Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 `<point ce= le=>` 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.** `<height value>` 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-<sensor_id>`.
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
Expand Down
2 changes: 1 addition & 1 deletion src/dronecot/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.3.0
2.3.1
81 changes: 73 additions & 8 deletions src/dronecot/functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,60 @@ def _cot_event_with_detail(
)


# ASTM F3411 / Open Drone ID accuracy fields are ENUM CODES, not metres. CoT
# <point ce=/le=> 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.

Expand All @@ -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

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
17 changes: 15 additions & 2 deletions src/dronecot/rid_track.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
28 changes: 20 additions & 8 deletions tests/test_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down
99 changes: 99 additions & 0 deletions tests/test_rid_track.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Loading