Skip to main content

Full Guide

This guide explains how to connect to a SafeSky-compatible Aero-Tracker over Bluetooth Low Energy (BLE) and receive NMEA 0183 data.

It is intended for developers who need to consume NMEA streams from Aero-Tracker devices without SafeSky-specific logic.


Device Discovery

Aero-Tracker devices typically advertise with names following the pattern: AEROXXXXXX where XXXXXX is a unique identifier.


Bluetooth notes

  • On Apple (iOS) devices, UUIDs may appear with a 0x prefix. Do not include this prefix when scanning or subscribing.
  • On Android, short-form UUIDs like FFE0 are internally represented as full 128-bit UUIDs (e.g. 0000ffe0-0000-1000-8000-00805f9b34fb), but you can still pass the short form FFE0 in scan or connect calls.
  • On Android, updating the MTU (Maximum Transmission Unit) is mostly useful for sending larger messages, particularly when using the proprietary SafeSky protocol (TX_UUID). For receiving NMEA sentences via notifications, it is not mandatory, as messages are short and BLE handles fragmentation.

BLE Services

Aero-Tracker devices expose one of the following BLE profiles:

1. Generic NMEA BLE Service

Read-only access to NMEA data.

  • Service UUID: FFE0
  • RX Characteristic (Notify): FFE1

2. SafeSky Proprietary BLE Service

Use if your integration supports SafeSky's full protocol (pairing, configuration, etc.).

  • Service UUID: 3F6AECC7-5406-47AB-9A75-0F5CF12EAF8E
  • RX Characteristic (Notify): E8A6196B-2B02-4EFE-ADC7-6702CBBA3605
  • TX Characteristic (Write): ABBDFD33-B2EF-4E78-8D7C-A7427FC441DB

Connection Flow

  1. Scan for BLE devices advertising the service UUID
  2. Connect to the device
  3. Discover services and characteristics
  4. Subscribe to the RX characteristic to receive data
  5. Set MTU to 500 bytes (Android recommended)

Implementation Example

// Initialize Bluetooth
await bluetoothLE.initialize();

// Scan for devices
await bluetoothLE.startScan({ 
  services: ['FFE0'] // Generic NMEA service
});

// Connect to device (replace with your device discovery logic)
const device = /* your device discovery implementation */;
await bluetoothLE.connect({ address: device.address });
await bluetoothLE.discover({ address: device.address });

// Set MTU for better performance (Android)
if (platform.is('android')) {
  await bluetoothLE.mtu({ address: device.address, mtu: 500 });
}

// Subscribe to NMEA data
const subscription = bluetoothLE.subscribe({
  address: device.address,
  service: 'FFE0',
  characteristic: 'FFE1'
}).subscribe(data => {
  const nmeaChunk = bluetoothLE.bytesToString(
    bluetoothLE.encodedStringToBytes(data.value)
  );
  
  // Buffer and process NMEA sentences
  processNMEAData(nmeaChunk);
});

Data Processing

NMEA Sentence Buffering

NMEA data may arrive in fragments. Use buffering to reassemble complete sentences:

let buffer = '';

function processNMEAData(chunk: string) {
  buffer += chunk;
  
  // Extract complete NMEA sentences ($ to checksum)
  const nmeaRegex = /\$(?:[^$*]+)\*[0-9A-Fa-f]{2}/g;
  const sentences = buffer.match(nmeaRegex) || [];
  
  // Process each complete sentence
  sentences.forEach(sentence => {
    parseNMEASentence(sentence);
  });
  
  // Keep remaining incomplete data
  const lastIndex = buffer.lastIndexOf(sentences[sentences.length - 1] || '');
  buffer = lastIndex >= 0 ? buffer.slice(lastIndex + sentences[sentences.length - 1]?.length || 0) : buffer;
}

NMEA Message Types

Aero-Tracker devices output standard NMEA 0183 sentences based on the FLARM data port specification: https://www.flarm.com/wp-content/uploads/2024/04/FTD-012-Data-Port-Interface-Control-Document-ICD-7.19.pdf

Standard GPS/GNSS Messages:

  • $GPGGA / $GNGGA - Global Positioning System Fix Data (time, position, fix quality)
  • $GNRMC - Recommended Minimum Navigation Information (position, velocity, time)
  • $GNVTG - Track Made Good and Ground Speed (course and speed information)
  • $GNGLL - Geographic Position - Latitude/Longitude (position and time)
  • $GRMZ - Altitude Information (altitude above mean sea level in feet)

FLARM-Specific Traffic Messages:

  • $PFLAA - Traffic Data (relative positions of nearby aircraft)
  • $PFLAU - Operating Status and Priority Intruder (traffic summary and system status)

FLARM Traffic Parsing

The $PFLAA sentence contains relative traffic positions that must be converted to absolute coordinates using your current GPS position as reference:

function parsePFLAA(sentence: string, referencePosition: {lat: number, lon: number, alt: number}) {
  const parts = sentence.split(',');
  
  // Extract relative distances in meters
  const northDistance = parseFloat(parts[2]);  // North-South distance
  const eastDistance = parseFloat(parts[3]);   // East-West distance  
  const verticalDistance = parseFloat(parts[4]); // Vertical separation
  
  // Convert to absolute position using Earth's radius
  const earthRadius = 6378137.0;
  const latOffset = (northDistance / earthRadius) * (180.0 / Math.PI);
  const lonOffset = (eastDistance / (earthRadius * Math.cos(referencePosition.lat * Math.PI / 180.0))) * (180.0 / Math.PI);
  
  // Parse additional traffic information
  const addressType = parts[5];           // Address type (0=FLARM, 1=ADS-B, etc.)
  const address = parts[6];               // Aircraft address/ID
  const track = parseInt(parts[7]) || 0;  // Track/heading in degrees
  const turnRate = parseInt(parts[8]) || 0; // Turn rate in degrees/second
  const groundSpeed = parseInt(parts[9]) || 0; // Ground speed in km/h
  const climbRate = parseInt(parts[10]) || 0;  // Climb rate in m/s
  const aircraftType = parts[11].split('*')[0]; // Aircraft type (hex)
  
  return {
    id: address,
    latitude: referencePosition.lat + latOffset,
    longitude: referencePosition.lon + lonOffset,
    altitude: referencePosition.alt + verticalDistance,
    ground_speed: groundSpeed,
    course: track,
    vertical_rate: climbRate,
    turn_rate: turnRate,
    aircraft_type: aircraftType,
    address_type: addressType
  };
}

The $PFLAU sentence provides system status and priority intruder information:

function parsePFLAU(sentence: string) {
  const parts = sentence.split(',');
  
  return {
    rx_count: parseInt(parts[1]) || 0,        // Number of received FLARM devices
    tx_status: parts[2],                      // Transmission status
    gps_status: parts[3],                     // GPS status (0=no GPS, 2=3D fix)
    power_status: parts[4],                   // Power status
    alarm_level: parseInt(parts[5]) || 0,     // Alarm level (0-3)
    relative_bearing: parseInt(parts[6]) || 0, // Relative bearing to priority target
    alarm_type: parts[7],                     // Type of alarm
    relative_vertical: parseInt(parts[8]) || 0, // Relative vertical distance
    relative_distance: parseInt(parts[9]) || 0, // Relative horizontal distance
    id: parts[10]?.split('*')[0]             // ID of priority target
  };
}