diff --git a/CHANGELOG.md b/CHANGELOG.md index e2530f4..09359ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,26 @@ +## DroneCOT 2.3.1 + +- **Fix: CoT `ce`/`le` reported ODID accuracy ENUM CODES as if they were metres.** + ASTM F3411 accuracy fields are enum codes; CoT `` are metres. + Emitting the raw code claimed precision the aircraft never reported, and the + worst case inverted the meaning entirely: code `0` means *"unknown / >= 18.52 + km"* but rendered as `ce="0"` — a perfect fix. Observed live on a DroneBeacon + DB120: `HorizAccuracy=9` (ODID *< 30 m*) was drawn on the map at **9 m**, and + `BaroAccuracy=0` (unknown) as **zero error**. Codes now decode to metres via + tables mirroring `decodeHorizontalAccuracy()`/`decodeVerticalAccuracy()` in + opendroneid-core-c; unknown and reserved codes emit the CoT unknown sentinel + rather than a fabricated number. Four existing tests asserted the old raw + codes and have been corrected. +- **Fix: absent altitude rendered as sea level.** `` defaulted to + `0`; an unknown altitude now uses the CoT unknown sentinel like `hae`/`ce`/`le`. +- **Fix: MAC-less feeds shared one placeholder UID.** Serial/MAVLink receivers + (DroneScout Bridge, SiK) report no MAC, so a Location- or System-only message + had neither MAC nor serial and rendered as `Unknown-BasicID_0`, colliding + across every such source. The UID now falls back to `FEED-`. + Measured on a live DroneScout Bridge: 57 correctly identified events alongside + 3 placeholders. Aggregation is deliberately NOT keyed on the feed — one + receiver reports many aircraft, so that would merge distinct drones. + ## DroneCOT 2.3.0 Correctness release for single-message Remote ID. A BLE legacy transmitter fits diff --git a/src/dronecot/VERSION b/src/dronecot/VERSION index cc6612c..a625450 100644 --- a/src/dronecot/VERSION +++ b/src/dronecot/VERSION @@ -1 +1 @@ -2.3.0 \ No newline at end of file +2.3.1 \ No newline at end of file diff --git a/src/dronecot/functions.py b/src/dronecot/functions.py index 17cf85a..a7dd5e3 100644 --- a/src/dronecot/functions.py +++ b/src/dronecot/functions.py @@ -190,6 +190,60 @@ def _cot_event_with_detail( ) +# ASTM F3411 / Open Drone ID accuracy fields are ENUM CODES, not metres. CoT +# are metres. Emitting the raw code claims a precision the +# aircraft never reported -- and the worst case inverts the meaning entirely: +# code 0 means "unknown / >= 18.52 km" but rendered as ce="0", i.e. a PERFECT +# fix. Observed live: a DroneBeacon reporting HorizAccuracy=9 (<30 m) was drawn +# on the map at 9 m, and BaroAccuracy=0 (unknown) as zero error. +# +# Values mirror decodeHorizontalAccuracy()/decodeVerticalAccuracy() in +# opendroneid-core-c. Each code is an upper bound ("less than X"), so using X is +# the conservative reading. +CE_LE_UNKNOWN = "9999999.0" + +ODID_HORIZ_ACCURACY_M = { + 1: 18520.0, # 10 NM + 2: 7408.0, # 4 NM + 3: 3704.0, # 2 NM + 4: 1852.0, # 1 NM + 5: 926.0, # 0.5 NM + 6: 555.6, # 0.3 NM + 7: 185.2, # 0.1 NM + 8: 92.6, # 0.05 NM + 9: 30.0, + 10: 10.0, + 11: 3.0, + 12: 1.0, +} + +ODID_VERT_ACCURACY_M = {1: 150.0, 2: 45.0, 3: 25.0, 4: 10.0, 5: 3.0, 6: 1.0} + + +def _odid_accuracy_m(value, table) -> str: + """Map an ODID accuracy enum code to metres for CoT ce/le. + + Returns the CoT unknown sentinel for code 0 (explicitly "unknown"), for any + unlisted/reserved code, and for a missing value -- never a fabricated 0. + """ + if value is None: + return CE_LE_UNKNOWN + try: + code = int(value) + except (TypeError, ValueError): + return CE_LE_UNKNOWN + metres = table.get(code) + return CE_LE_UNKNOWN if metres is None else str(metres) + + +def _odid_horiz_ce(data: dict) -> str: + return _odid_accuracy_m(data.get("HorizAccuracy"), ODID_HORIZ_ACCURACY_M) + + +def _odid_vert_le(data: dict) -> str: + return _odid_accuracy_m(data.get("VertAccuracy"), ODID_VERT_ACCURACY_M) + + def _rid_identity(data: dict) -> Tuple[str, Optional[str]]: """Resolve a stable UAS identifier and the advertiser MAC for CoT UIDs. @@ -208,7 +262,16 @@ def _rid_identity(data: dict) -> Tuple[str, Optional[str]]: uasid = data.get("BasicID") or data.get("BasicID_0") uasid = str(uasid).strip() if uasid else "" if not uasid: - uasid = f"MAC-{mac.replace(':', '').upper()}" if mac else "Unknown-BasicID_0" + if mac: + uasid = f"MAC-{mac.replace(':', '').upper()}" + else: + # Neither serial nor MAC: serial/MAVLink receivers report no MAC at + # all. Fall back to the feed that delivered it so two receivers do + # not collide, rather than the shared Unknown-BasicID_0 placeholder + # that put every such aircraft on one track. + feed = src_data.get("sensor_id") or src_data.get("sensor ID") + feed = str(feed).strip() if feed else "" + uasid = f"FEED-{feed}" if feed else "Unknown-BasicID_0" return uasid, mac @@ -298,8 +361,8 @@ def rid_op_to_cot_xml( # NOQA pylint: disable=too-many-locals,too-many-branches stale=cot_stale, lat=lat, lon=lon, - ce=str(data.get("HorizAccuracy", "9999999.0")), - le=str(data.get("VertAccuracy", "9999999.0")), + ce=_odid_horiz_ce(data), + le=_odid_vert_le(data), hae=str(data.get("OperatorAltitudeGeo", "9999999.0")), detail=detail, config=config, @@ -365,7 +428,9 @@ def rid_uas_to_cot_xml( # NOQA pylint: disable=too-many-locals,too-many-branche track.set("course", str(data.get("Direction", 0))) height: ET.Element = ET.Element("height") - height.set("value", str(data.get("AltitudeGeo", 0))) + # Never default to 0: an absent altitude is unknown, not sea level. Matches + # the CoT unknown sentinel used for hae/ce/le above. + height.set("value", str(data.get("AltitudeGeo", CE_LE_UNKNOWN))) # link: ET.Element = ET.Element("link") # link.set("uid", op_uid) @@ -490,8 +555,8 @@ def rid_uas_to_cot_xml( # NOQA pylint: disable=too-many-locals,too-many-branche stale=cot_stale, lat=lat, lon=lon, - ce=str(data.get("HorizAccuracy", "9999999.0")), - le=str(data.get("VertAccuracy", "9999999.0")), + ce=_odid_horiz_ce(data), + le=_odid_vert_le(data), hae=str(data.get("AltitudeGeo", "9999999.0")), detail=detail, config=config, @@ -572,8 +637,8 @@ def sensor_status_to_cot( # NOQA pylint: disable=too-many-locals,too-many-branc stale=cot_stale, lat=lat, lon=lon, - ce=str(data.get("HorizAccuracy", "9999999.0")), - le=str(data.get("VertAccuracy", "9999999.0")), + ce=_odid_horiz_ce(data), + le=_odid_vert_le(data), hae=hae, detail=detail, config=config, diff --git a/src/dronecot/rid_track.py b/src/dronecot/rid_track.py index c05a3b0..a6d41f8 100644 --- a/src/dronecot/rid_track.py +++ b/src/dronecot/rid_track.py @@ -94,8 +94,21 @@ def _is_empty(value: Any) -> bool: def track_key(rid: dict) -> Optional[Tuple[str, str]]: """Return a ``(kind, value)`` aggregation key for a normalized RID dict. - Returns ``None`` when the record identifies no transmitter at all, in which - case it cannot be safely merged with anything and should pass through. + Keyed on the advertiser MAC, falling back to the transmitter's own serial. + + Deliberately NOT keyed on the feed/sensor when neither is present. It is + tempting -- a MAC-less serial receiver looks like a single stream -- but a + DroneScout-style receiver reports MANY aircraft over one port, so feeding + them all into one key would merge distinct drones into a single track: the + exact bug this module exists to fix, reintroduced by another route. It also + splits rather than merges, because a BasicID message would key on the serial + while a Location message from the same aircraft keyed on the feed. + + So a record with neither MAC nor serial passes through unaggregated, and + only its rendered UID falls back to the feed (see functions._rid_identity) + so that unidentified contacts from different receivers stay distinct. + + Returns ``None`` when the record identifies no transmitter at all. """ meta = rid.get(_META_KEY) or {} mac = meta.get("MAC address") diff --git a/tests/test_functions.py b/tests/test_functions.py index d0169ce..e227faf 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -73,8 +73,11 @@ def test_wifi_nan_et(self): self.assertIsNotNone(point) self.assertEqual(point.get("lat"), "37.7599") self.assertEqual(point.get("lon"), "-122.4977") - self.assertEqual(point.get("ce"), "12") - self.assertEqual(point.get("le"), "5") + # ODID accuracy codes, decoded to METRES for CoT: HorizAccuracy 12 is + # ODID_HOR_ACC_1_METER and VertAccuracy 5 is ODID_VER_ACC_3_METER. These + # previously asserted the raw codes "12"/"5", which claimed 12 m / 5 m. + self.assertEqual(point.get("ce"), "1.0") + self.assertEqual(point.get("le"), "3.0") self.assertEqual(point.get("hae"), "28.0") detail = cot_xml.find("detail") @@ -113,8 +116,11 @@ def test_wifi_beacon_et(self): self.assertIsNotNone(point) self.assertEqual(point.get("lat"), "37.7599") self.assertEqual(point.get("lon"), "-122.4977") - self.assertEqual(point.get("ce"), "12") - self.assertEqual(point.get("le"), "5") + # ODID accuracy codes, decoded to METRES for CoT: HorizAccuracy 12 is + # ODID_HOR_ACC_1_METER and VertAccuracy 5 is ODID_VER_ACC_3_METER. These + # previously asserted the raw codes "12"/"5", which claimed 12 m / 5 m. + self.assertEqual(point.get("ce"), "1.0") + self.assertEqual(point.get("le"), "3.0") self.assertEqual(point.get("hae"), "28.0") detail = cot_xml.find("detail") @@ -153,8 +159,11 @@ def test_ble_legacy_et(self): self.assertIsNotNone(point) self.assertEqual(point.get("lat"), "37.7599") self.assertEqual(point.get("lon"), "-122.4977") - self.assertEqual(point.get("ce"), "12") - self.assertEqual(point.get("le"), "5") + # ODID accuracy codes, decoded to METRES for CoT: HorizAccuracy 12 is + # ODID_HOR_ACC_1_METER and VertAccuracy 5 is ODID_VER_ACC_3_METER. These + # previously asserted the raw codes "12"/"5", which claimed 12 m / 5 m. + self.assertEqual(point.get("ce"), "1.0") + self.assertEqual(point.get("le"), "3.0") self.assertEqual(point.get("hae"), "28.0") detail = cot_xml.find("detail") @@ -193,8 +202,11 @@ def test_ble_long_range_et(self): self.assertIsNotNone(point) self.assertEqual(point.get("lat"), "37.7599") self.assertEqual(point.get("lon"), "-122.4977") - self.assertEqual(point.get("ce"), "12") - self.assertEqual(point.get("le"), "5") + # ODID accuracy codes, decoded to METRES for CoT: HorizAccuracy 12 is + # ODID_HOR_ACC_1_METER and VertAccuracy 5 is ODID_VER_ACC_3_METER. These + # previously asserted the raw codes "12"/"5", which claimed 12 m / 5 m. + self.assertEqual(point.get("ce"), "1.0") + self.assertEqual(point.get("le"), "3.0") self.assertEqual(point.get("hae"), "28.0") detail = cot_xml.find("detail") diff --git a/tests/test_rid_track.py b/tests/test_rid_track.py index 168ac4b..8dd9628 100644 --- a/tests/test_rid_track.py +++ b/tests/test_rid_track.py @@ -267,6 +267,56 @@ def test_cot_xml_reparses(self): self.assertEqual(event.get("uid"), "RID.SHORTSN.uas") +class TestMaclessSources(unittest.TestCase): + """Serial/MAVLink receivers report no MAC at all. + + Measured on a live DroneScout Bridge: 57 correctly identified events + alongside 3 rendered under the shared ``Unknown-BasicID_0`` placeholder. + + These records must NOT be aggregated on the feed -- one receiver reports + many aircraft, so a feed key would merge distinct drones. Instead they pass + through, and only the rendered UID falls back to the feed so contacts from + different receivers stay distinct. + """ + + @staticmethod + def _serial_rid(msg, sensor="dronescout"): + """Normalize a message the way a serial worker does: NO MAC address.""" + return rid_normalize.bytes_to_rid_dict( + msg, {"sensor_id": sensor, "type": "MAVLink"} + ) + + def test_macless_without_serial_uses_feed_not_placeholder(self): + """The fix: never render under the shared placeholder UID.""" + rid = self._serial_rid(_system_message(51.5, -0.12)) + uid, mac = functions._rid_identity(rid) + self.assertIsNone(mac) + self.assertEqual(uid, "FEED-dronescout") + self.assertNotEqual(uid, "Unknown-BasicID_0") + + def test_different_receivers_stay_distinct(self): + a = functions._rid_identity(self._serial_rid(_system_message(1.0, 2.0), "rx-a"))[0] + b = functions._rid_identity(self._serial_rid(_system_message(3.0, 4.0), "rx-b"))[0] + self.assertNotEqual(a, b) + + def test_serial_wins_over_feed_in_uid(self): + rid = self._serial_rid(_basic_id_message("SERIAL9")) + self.assertEqual(functions._rid_identity(rid)[0], "SERIAL9") + + def test_feed_is_NOT_used_as_an_aggregation_key(self): + """Guard against merging distinct aircraft from one multi-drone feed.""" + rid = self._serial_rid(_location_message(51.5, -0.12)) + self.assertIsNone( + rid_track.track_key(rid), + "a MAC-less, serial-less record must not be keyed on its feed", + ) + + def test_mac_still_wins_when_present(self): + rid = _rid(_location_message(1.0, 2.0), "AA:BB:CC:DD:EE:FF") + rid["data"]["sensor_id"] = "dronescout" + self.assertEqual(rid_track.track_key(rid)[0], "mac") + + class TestRidIdentity(unittest.TestCase): def test_missing_basic_id_falls_back_to_mac(self): rid = _rid(_location_message(1.0, 2.0), "DF:72:11:D2:6B:95") @@ -300,3 +350,52 @@ def test_no_identity_at_all_keeps_legacy_placeholder(self): if __name__ == "__main__": unittest.main() + + +class TestAccuracyEnums(unittest.TestCase): + """ODID accuracy fields are enum CODES; CoT ce/le are METRES. + + Emitting the raw code claims precision the aircraft never reported. Worst + case inverts the meaning: code 0 means "unknown / >= 18.52 km" but rendered + as ce="0" -- a perfect fix. Observed live on a DroneBeacon: HorizAccuracy=9 + (<30 m) drawn at 9 m, BaroAccuracy=0 (unknown) as zero error. + """ + + def test_horizontal_codes_map_to_metres(self): + # Mirrors decodeHorizontalAccuracy() in opendroneid-core-c. + for code, metres in ((9, "30.0"), (10, "10.0"), (11, "3.0"), (12, "1.0"), + (1, "18520.0"), (6, "555.6")): + self.assertEqual(functions._odid_horiz_ce({"HorizAccuracy": code}), metres) + + def test_vertical_codes_map_to_metres(self): + for code, metres in ((1, "150.0"), (2, "45.0"), (4, "10.0"), (6, "1.0")): + self.assertEqual(functions._odid_vert_le({"VertAccuracy": code}), metres) + + def test_unknown_code_zero_is_not_perfect_precision(self): + """The dangerous inversion: 0 means UNKNOWN, never zero error.""" + self.assertEqual(functions._odid_horiz_ce({"HorizAccuracy": 0}), + functions.CE_LE_UNKNOWN) + self.assertEqual(functions._odid_vert_le({"VertAccuracy": 0}), + functions.CE_LE_UNKNOWN) + + def test_missing_or_reserved_codes_are_unknown(self): + for probe in ({}, {"HorizAccuracy": None}, {"HorizAccuracy": 15}, + {"HorizAccuracy": "junk"}): + self.assertEqual(functions._odid_horiz_ce(probe), functions.CE_LE_UNKNOWN) + + def test_end_to_end_cot_uses_metres_not_the_code(self): + pack = (bytes([0xF0, ODID_MESSAGE_SIZE, 2]) + + _basic_id_message("SERIAL1") + _location_message(37.7, -122.4)) + rid = rid_normalize.bytes_to_rid_dict(pack, {"MAC address": "AA:BB:CC:DD:EE:FF"}) + rid["HorizAccuracy"] = 9 # ODID: < 30 m + rid["VertAccuracy"] = 2 # ODID: < 45 m + point = functions.rid_uas_to_cot_xml(rid, {}).find("point") + self.assertEqual(point.get("ce"), "30.0") + self.assertEqual(point.get("le"), "45.0") + + def test_absent_altitude_is_not_rendered_as_sea_level(self): + rid = _rid(_location_message(1.0, 2.0), "AA:BB:CC:DD:EE:FF") + rid.pop("AltitudeGeo", None) + event = functions.rid_uas_to_cot_xml(rid, {}) + height = event.find(".//height") + self.assertEqual(height.get("value"), functions.CE_LE_UNKNOWN)