BLE & NMEA Protocol Overview
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 formFFE0
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
- Scan for BLE devices advertising the service UUID
- Connect to the device
- Discover services and characteristics
- Subscribe to the RX characteristic to receive data
- 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
};
}