#!/usr/bin/env python3 """Open Drone ID (ASTM F3411-22a) Bluetooth LE receiver → dronesight.cc. Standalone script for any Linux box (Raspberry Pi, laptop, mini-PC) with Bluetooth LE. Scans for drone Remote ID broadcasts and pushes detections to a dronesight server. Why this matters: Since 2024, EU-mandated Remote ID requires all drones >250g to broadcast their ID + position via Bluetooth LE or WiFi every second. This script listens for those broadcasts and aggregates them into the dronesight network. Each running receiver = a ~1-2 km radius drone-detection node. Install: pip install bleak httpx pydantic Usage: export DRONESIGHT_URL=https://dronesight.65.21.56.196.nip.io export DRONESIGHT_SENSOR_KEY= export SENSOR_ID=$(hostname) export SENSOR_LAT=55.6181 # your installation point export SENSOR_LON=12.6508 sudo python3 remote_id_receiver.py (sudo is typically needed for raw BT LE access on Linux) Wire up as systemd service: /etc/systemd/system/dronesight-remote-id.service [Service] Environment=DRONESIGHT_URL=... Environment=DRONESIGHT_SENSOR_KEY=... ExecStart=/usr/bin/python3 /opt/dronesight/tools/remote_id_receiver.py References: - ASTM F3411-22a "Standard Specification for Remote ID and Tracking" - opendroneid.org documentation - EU regulation 2019/945 (Direct Remote ID requirement) """ from __future__ import annotations import asyncio import logging import os import struct import sys from datetime import datetime, timezone from typing import Optional # Note: bleak + httpx imports are deferred to runtime in main() so this module # can be imported (e.g. for parser unit tests) without those deps installed. # Open Drone ID service UUID (16-bit) — 0xFFFA OPEN_DRONE_ID_UUID_16 = "0000fffa-0000-1000-8000-00805f9b34fb" # Message types per ASTM F3411 MSG_BASIC_ID = 0x0 MSG_LOCATION = 0x1 MSG_AUTH = 0x2 MSG_SELF_ID = 0x3 MSG_SYSTEM = 0x4 MSG_OPERATOR_ID = 0x5 MSG_PACK = 0xF # Configuration via env DRONESIGHT_URL = os.environ.get( "DRONESIGHT_URL", "https://dronesight.65.21.56.196.nip.io" ) SENSOR_KEY = os.environ.get("DRONESIGHT_SENSOR_KEY") SENSOR_ID = os.environ.get("SENSOR_ID", "remote-id-receiver-unknown") SENSOR_LAT = float(os.environ.get("SENSOR_LAT", "0") or "0") SENSOR_LON = float(os.environ.get("SENSOR_LON", "0") or "0") POST_TIMEOUT = float(os.environ.get("POST_TIMEOUT", "5.0")) DRY_RUN = os.environ.get("DRY_RUN", "").lower() in {"1", "true", "yes"} logging.basicConfig( level=os.environ.get("LOG_LEVEL", "INFO"), format="%(asctime)s [%(levelname)s] %(message)s", ) log = logging.getLogger("remote-id-receiver") def _parse_location_message(payload: bytes) -> Optional[dict]: """Parse ASTM F3411 Location message (type 0x1). Returns dict with lat, lon, altitude_m, speed_ms, heading_deg. """ if len(payload) < 23: return None # Skip status/speed-mult byte (offset 1) track_direction = payload[2] # × 1 deg speed_raw = payload[3] # × 0.25 m/s # Lat/Lon: signed int32 LE, divide by 1e7 try: lat = struct.unpack_from(" 90 or abs(lon) > 180: return None return { "lat": lat, "lon": lon, "altitude_m": altitude_m, "speed_ms": speed_raw * 0.25, "heading_deg": track_direction, } def _parse_basic_id_message(payload: bytes) -> Optional[dict]: """Parse ASTM F3411 Basic ID message (type 0x0). Returns UAS ID string.""" if len(payload) < 23: return None # Byte 1: 4-bit ID type, 4-bit UA type id_type = (payload[1] >> 4) & 0x0F ua_type = payload[1] & 0x0F # Bytes 2-22: 20-byte UAS ID (ASCII, null-padded) try: uas_id = payload[2:22].rstrip(b"\x00").decode("ascii", errors="replace") except Exception: uas_id = "" id_type_names = { 0: "none", 1: "serial", 2: "caa-reg", 3: "utm-uuid", 4: "session-id", } ua_type_names = { 0: "undeclared", 1: "aeroplane", 2: "helicopter", 3: "gyroplane", 4: "hybrid-lift", 5: "ornithopter", 6: "glider", 7: "kite", 8: "free-balloon", 9: "captive-balloon", 10: "airship", 11: "parachute", 12: "rocket", 13: "tethered", 14: "ground-obstacle", 15: "other", } return { "id_type": id_type_names.get(id_type, f"type_{id_type}"), "ua_type": ua_type_names.get(ua_type, f"type_{ua_type}"), "uas_id": uas_id, } def _decode_message(msg: bytes) -> dict: """Decode a bare 25-byte ASTM F3411 message (no counter prefix). Used for both standalone messages and pack sub-messages. """ if not msg or len(msg) < 23: return {} msg_type = (msg[0] >> 4) & 0x0F result = {"msg_type": msg_type} if msg_type == MSG_LOCATION: loc = _parse_location_message(msg) if loc: result.update(loc) elif msg_type == MSG_BASIC_ID: bid = _parse_basic_id_message(msg) if bid: result.update(bid) elif msg_type == MSG_PACK: # Pack header: msg[0] = type+ver, msg[1] = msg_size (25), msg[2] = n_msgs # Sub-messages start at byte 3, each 25 bytes sub_messages = [] for i in range(9): start = 3 + (i * 25) sub = msg[start : start + 25] if len(sub) < 23: break sub_decoded = _decode_message(sub) if sub_decoded: sub_messages.append(sub_decoded) if sub_messages: result["pack"] = sub_messages # Hoist location/basic-id from pack to top-level for easy use for sm in sub_messages: if "lat" in sm: result.update({k: v for k, v in sm.items() if k != "msg_type"}) if "uas_id" in sm: result["uas_id"] = sm["uas_id"] result["ua_type"] = sm.get("ua_type", "?") return result def _decode_advertisement(service_data: bytes) -> dict: """Decode a BT LE service-data advertisement (counter byte + ASTM message). Advertisement format on the wire: byte 0: app-specific counter (rolling) bytes 1..: ASTM F3411 message """ if not service_data or len(service_data) < 25: return {} return _decode_message(service_data[1:]) # Cache of recent UAS IDs to avoid spamming dronesight with the same drone _recent_post: dict[str, float] = {} _DEDUP_SECONDS = 30 # post the same UAS ID at most once per 30s async def _post_detection(http, decoded: dict) -> None: """POST a decoded Remote ID detection to dronesight.""" if not SENSOR_KEY: log.warning("DRONESIGHT_SENSOR_KEY not set — skipping POST") return uas_id = decoded.get("uas_id") or "anonymous" now = asyncio.get_event_loop().time() last = _recent_post.get(uas_id, 0) if now - last < _DEDUP_SECONDS: return _recent_post[uas_id] = now # Prefer drone-reported lat/lon, fall back to receiver location lat = decoded.get("lat") or SENSOR_LAT or None lon = decoded.get("lon") or SENSOR_LON or None ua_type = decoded.get("ua_type", "") label = f"remote_id: {uas_id} ({ua_type})" if uas_id else f"remote_id: {ua_type}" payload = { "sensor_type": "remote_id", "sensor_id": SENSOR_ID, "lat": lat, "lon": lon, "confidence": 0.99, # Remote ID self-reported = effectively certain "target_label": label, "evidence_url": None, "raw_json": str(decoded), } if DRY_RUN: log.info("DRY_RUN — would post: %s", payload) return try: r = await http.post( f"{DRONESIGHT_URL}/api/detections", json=payload, headers={"X-Sensor-Key": SENSOR_KEY}, timeout=POST_TIMEOUT, ) if r.status_code == 201: log.info("posted detection id=%s uas_id=%s", r.json().get("id"), uas_id) else: log.warning("post failed status=%d body=%s", r.status_code, r.text[:200]) except Exception as exc: log.warning("post error: %s", exc) async def _on_advertisement( http: httpx.AsyncClient, device, adv_data: AdvertisementData ): """Callback for each BT LE advertisement.""" # Filter for OpenDroneID service data (UUID 0xFFFA) svc_data = adv_data.service_data or {} for uuid, data in svc_data.items(): if uuid.lower() == OPEN_DRONE_ID_UUID_16: decoded = _decode_advertisement(data) if decoded: log.debug("remote-id adv from %s: %s", device.address, decoded) if "lat" in decoded or "uas_id" in decoded: await _post_detection(http, decoded) return async def main(): # Defer imports to runtime so module is importable without deps try: from bleak import BleakScanner # noqa: F401 import httpx except ImportError as exc: print( f"ERROR: missing dep ({exc}). Run: pip install bleak httpx", file=sys.stderr ) sys.exit(1) if not SENSOR_KEY: log.error("DRONESIGHT_SENSOR_KEY env var required") sys.exit(1) log.info("starting Open Drone ID receiver — sensor_id=%s", SENSOR_ID) log.info("posting detections to %s", DRONESIGHT_URL) if DRY_RUN: log.info("DRY_RUN mode — no detections will be POSTed") async with httpx.AsyncClient() as http: async def cb(device, adv_data): try: await _on_advertisement(http, device, adv_data) except Exception as exc: log.exception("callback error: %s", exc) scanner = BleakScanner(detection_callback=cb) await scanner.start() log.info("BT LE scanner started — listening for Open Drone ID broadcasts") try: while True: await asyncio.sleep(60) log.debug( "alive — %d distinct UAS IDs seen in last 30min", sum( 1 for t in _recent_post.values() if asyncio.get_event_loop().time() - t < 1800 ), ) except KeyboardInterrupt: pass finally: await scanner.stop() if __name__ == "__main__": asyncio.run(main())