Skip to content

Merge single ODID messages per transmitter; fix NUL bytes in CoT UIDs — v2.3.0 - #7

Merged
ampledata merged 2 commits into
mainfrom
rid-track-aggregation
Jul 27, 2026
Merged

Merge single ODID messages per transmitter; fix NUL bytes in CoT UIDs — v2.3.0#7
ampledata merged 2 commits into
mainfrom
rid-track-aggregation

Conversation

@ampledata

Copy link
Copy Markdown
Contributor

Why

A BLE legacy Remote ID transmitter fits only one 25-byte ODID message per advertisement, so it rotates through message types — the serial (BasicID), the aircraft position (Location) and the operator location (System) each arrive in a different frame. DroneCOT rendered every frame independently. Three things went wrong in the field.

1. CoT XML could contain NUL bytes and fail to parse

ODID pads its fixed-width ASCII fields (20-byte serial, 23-byte text) with NUL, and clean_SN() / clean_string() stripped only whitespace. Any UAS whose serial was shorter than the field produced:

UID  = 'RID.SHORTSN\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00.uas'
NULs = 82
reparse FAILED: not well-formed (invalid token): line 1, column 56

NUL is illegal in XML 1.0, so TAK would reject the event. This affected every source (Wi-Fi, BLE, serial, MQTT). It stayed hidden because every committed fixture happens to use a full-width 20-character serial.

2. Every drone collapsed onto a single track

A Location message carries a position but no serial, so it rendered with the constant UID RID.Unknown-BasicID_0.uas — every aircraft in range shared one track. The UID now falls back to the advertiser MAC (MAC-<addr>).

3. BasicID and System messages were silently dropped

They have no position of their own, so rid_uas_to_cot_xml() returned None.

What changed

  • New rid_track.ODIDAggregator — merges successive single ODID messages per transmitter, keyed on the advertiser MAC (ASTM F3411 requires it to stay constant for a flight session, and it is the only field present on every message type). TTL-expired and hard-capped so the table can't grow without bound.
  • Wired into RIDWorker.handle_data() — the single choke point every worker feeds via net_queue — so Wi-Fi, BLE, serial, MQTT and UDP all benefit from one call site. Sensor-status beacons bypass it.
  • Full 0xF message packs pass through unchanged; they already carry everything in one frame.
  • Fixed rid_op_to_cot_xml() reading data["MAC address"] when wireless captures put it in data["data"], so the op-<mac> UID never actually used a MAC.
  • Replaced deprecated datetime.utcfromtimestamp().

Config

Key Default Description
RID_TRACK_TTL 120 Seconds of silence before a track expires. 0 disables aggregation.
RID_TRACK_MAX 512 Cap on tracked transmitters; least-recently-heard evicted first.

Tests

38 pass, zero warnings (-W error::DeprecationWarning). 16 new, including a real ASTM F3411 advertisement captured off the air with btmon from a BlueMark DroneBeacon DB120 — the existing fixtures are all message packs or CUAS blobs and never exercised the single-message path at all.

Key regressions covered: two MACs stay two tracks; a lone Location yields a MAC-derived UID rather than Unknown-BasicID_0; short serials produce CoT that reparses; TTL and max-tracks bound the table; packs are unchanged.

Not yet verified

Aggregation is validated against synthetic and captured frames, but not yet against a live multi-drone flight. Worth a HIL run on the existing Sniffle/DS101 boxes before this goes to the fleet apt repo.

🤖 Generated with Claude Code

https://claude.ai/code/session_0197da7dhvcPoHxYKamrYqyM

A BLE legacy transmitter fits only ONE 25-byte ODID message per
advertisement and rotates through message types, so the serial, the UAS
position and the operator location arrive in different frames. DroneCOT
rendered each frame independently, with three consequences in the field:

* CoT XML could contain NUL bytes and fail to parse. ODID pads its
  fixed-width ASCII fields with NUL and clean_SN()/clean_string()
  stripped only whitespace, so any UAS with a serial shorter than the
  20-byte field emitted a UID like RID.SHORTSN\0\0\0....uas — 82 NUL
  bytes in the document, illegal in XML 1.0. Affected every source; hid
  because every committed fixture uses a full-width 20-char serial.

* Every drone collapsed onto one track. A Location message has a
  position but no serial, so it rendered as RID.Unknown-BasicID_0.uas
  for all aircraft in range. The UID now falls back to the advertiser
  MAC so distinct transmitters stay distinct.

* BasicID and System messages were dropped, having no position.

Adds rid_track.ODIDAggregator, keyed on the advertiser MAC (ASTM F3411
requires it to be stable for a flight session), TTL-expired and
hard-capped to bound memory. Wired into RIDWorker.handle_data(), the one
choke point every worker feeds, so Wi-Fi, BLE, serial, MQTT and UDP all
benefit. Full 0xF message packs pass through unchanged.

Also fixes rid_op_to_cot_xml() reading the MAC from the wrong nesting
level, and replaces deprecated datetime.utcfromtimestamp().

16 new tests, including a real ASTM F3411 advertisement captured off the
air from a BlueMark DroneBeacon DB120 — the existing fixtures are all
message packs or CUAS blobs and never exercised the single-message path.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197da7dhvcPoHxYKamrYqyM
Validated against a live BlueMark DroneBeacon DB120 over 234 real BLE
advertisements captured through the BlueZ backend. The transmitter sent
BasicID, Location and System as SEPARATE advertisements and no 0xF
message packs at all, confirming the single-message rotation this work
exists to handle:

  without aggregation  57/234 (24%) rendered; real serial NEVER appeared
                       -- every event used the MAC fallback UID
  with aggregation    230/234 (98%) rendered, all under the correct
                       serial RID.1787F04BM24010011195.uas

Two fixes the live run exposed:

* RID_TRACK_ID_GRACE (default 5s). Rendering a Location before its
  BasicID arrives emits CoT under a MAC-derived UID and then switches to
  the real serial, so ONE aircraft became TWO TAK markers -- observed
  directly: both RID.MAC-DF7211D26B95.uas and
  RID.1787F04BM24010011195.uas for the same DB120. A position-only track
  is now held briefly while waiting for the serial; after the grace
  period it renders anyway, because an unidentified drone still needs to
  be on the map. Set 0 to render immediately.

* ODIDAggregator.__bool__. __len__ made a freshly-constructed aggregator
  FALSY, so the natural `if aggregator:` skipped aggregation until the
  first track existed -- which never happens, because tracks are only
  created by aggregating. Truthiness now means "there is an aggregator",
  not "it is holding something". This silently defeated a comparison
  harness before it was caught.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197da7dhvcPoHxYKamrYqyM
@ampledata

Copy link
Copy Markdown
Contributor Author

Validated against live hardware ✅

The "not yet verified" caveat in the description is now resolved. A BlueMark DroneBeacon DB120 was transmitting in range, and I captured 234 real BLE advertisements through the BlueZ backend (#8) and replayed the same frames through both code paths.

The transmitter sent BasicID, Location and System as separate advertisements and ZERO 0xF message packs — exactly the single-message rotation this PR exists to handle, and precisely the case no existing fixture covered.

CoT rendered Track UID
Without aggregation (today) 57/234 (24%) RID.MAC-DF7211D26B95.uasthe real serial never appears
With aggregation, no grace 231/234 (99%) two UIDs — one aircraft, two markers
With aggregation + id_grace 230/234 (98%) RID.1787F04BM24010011195.uas — correct serial, single track

So on real traffic the fix takes a source that produced no correctly-identified track at all and turns it into a clean one, while raising usable CoT yield from 24% to 98%.

Two fixes the live run exposed (2174fdf)

RID_TRACK_ID_GRACE (default 5s). Rendering a Location before its BasicID arrives emits CoT under a MAC-derived UID, then switches to the real serial moments later — I watched one DB120 produce both RID.MAC-DF7211D26B95.uas and RID.1787F04BM24010011195.uas. A position-only track is now held briefly while waiting for the serial. After the grace period it renders anyway, because an unidentified drone still needs to be on the map.

ODIDAggregator.__bool__. __len__ made a freshly-constructed aggregator falsy, so the natural if aggregator: skipped aggregation until the first track existed — which never happens, because tracks are only created by aggregating. This silently defeated my first comparison harness and made the A/B show no difference at all; worth removing the trap rather than relying on every caller writing is not None. (The production call site in classes.py was already correct.)

Sample CoT off the wire, end to end:

uid   : RID.1787F04BM24010011195.uas
type  : a-n-A-M-H-Q
point : lat=37.76 lon=-122.4977 hae=16.5
__cuas: rid_uas=1787F04BM24010011195  mac_address=DF:72:11:D2:6B:95
        rssi=-40  type='BLE legacy (BlueZ)'
NUL bytes in XML: 0    reparse: OK

61 tests pass, zero warnings.

@ampledata
ampledata merged commit 8c24e47 into main Jul 27, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant