Skip to content

BlueZ capture backend: Bluetooth Remote ID with no extra hardware - #8

Merged
ampledata merged 1 commit into
mainfrom
bluez-ble-rid
Jul 27, 2026
Merged

BlueZ capture backend: Bluetooth Remote ID with no extra hardware#8
ampledata merged 1 commit into
mainfrom
bluez-ble-rid

Conversation

@ampledata

Copy link
Copy Markdown
Contributor

Stacked on #7 — review that first. Base will retarget to main once #7 merges.

Why

ble_capture requires a Sniffle nRF52 dongle, so a box with no add-on hardware decodes no Bluetooth Remote ID at all. Every Raspberry Pi already has a radio that can hear ASTM F3411 advertisements — verified by capturing real DroneBeacon DB120 frames with btmon on an AryaOS box:

1e 16 faff 0d 22 42 00 8e b7 81 ...
   ^^ ^^^^ ^^ ^^ ^^ ODID System message, version 2
   |  |    |  message counter
   |  |    ASTM app code
   |  UUID 0xFFFA
   AD type 0x16 (Service Data)

Wi-Fi RID is not an alternative on that radio — iw list on brcmfmac shows IBSS, managed, AP, P2P-* and no monitor mode. BLE is the only onboard path.

Design

New bluez_capture module selected by the ble+hci://hci0 scheme. It reuses ble_parse for ODID extraction and emits the same (pack, meta) contract BleWorker already uses, so the entire downstream chain is untouched.

Scan driver (always): BlueZ D-Bus SetDiscoveryFilter(Transport=le) + StartDiscovery. An ordinary D-Bus client, so it coexists with bluetoothd and an active Bluetooth PAN instead of seizing the adapter.

Reader (selectable):

Reader Needs Trade-off
monitor CAP_NET_RAW HCI_CHANNEL_MONITOR socket — the passive tap btmon uses. Every advertising report, full AD bytes, per-frame RSSI, nothing coalesced.
dbus nothing Reads ServiceData off org.bluez.Device1. BlueZ may coalesce repeats — and since a transmitter rotates message types, a coalesced repeat is a lost message type, not a duplicate.
auto (default) Try monitor, fall back to dbus.

Two traps worth flagging

CPython cannot bind the monitor channel. socket.bind() for BTPROTO_HCI accepts only (device_id,); passing a channel raises bind(): wrong format. So bind_hci_monitor() falls back to calling bind(2) via ctypes with a real struct sockaddr_hci. Confirmed correct against a live adapter: the kernel returns EPERM (the CAP_NET_RAW check) rather than EINVAL, which means the channel was parsed as MONITOR.

"ble+hci" contains "ble", so its branch must precede the substring test in create_tasks(). Covered by a test.

Limitation

Captures legacy advertising only. The ASTM long-range profile uses BT5 extended advertising on the Coded PHY, which the Pi's CYW43455 is not known to receive. Documented as complementary to a Sniffle dongle or DroneScout DS101, not a replacement.

Tests

18 new (57 total, zero warnings). Real captured advertisement bytes are replayed through synthesized HCI monitor frames, so they run in CI with no Bluetooth adapter present. Covers legacy + extended reports, multi-report events, wrong-adapter filtering, RSSI thresholding, D-Bus ServiceData re-wrapping, and truncated input at every offset.

Not yet verified

No hardware validation yet. The parser is exercised against real captured bytes, but end-to-end capture on a live adapter needs CAP_NET_RAW and a transmitting drone. No catch-rate number is claimed anywhere until it is measured — deliberately, since a single radio hopping 3 advertising channels will do worse than a dedicated 3-channel sniffer.

🤖 Generated with Claude Code

https://claude.ai/code/session_0197da7dhvcPoHxYKamrYqyM

@ampledata

Copy link
Copy Markdown
Contributor Author

Validated against live hardware ✅

The "no hardware validation yet" caveat is resolved for the dbus reader. A BlueMark DroneBeacon DB120 was transmitting in range and this backend decoded it end to end — capture → ble_parserid_normalize → aggregator → CoT:

advertisements decoded : 199 in 25s   (~480/min from one transmitter)
ODID message types     : BasicID 41, Location 44, System 114   (no message packs)
rssi                   : -46 .. -40 dBm
tracks held            : 1
CoT events rendered    : 199

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

The advertiser MAC DF:72:11:D2:6B:95 matches the frame captured with btmon that motivated this work, and the serial 1787F04BM24010011195 matches the one in the repo's existing WiFi-beacon.json fixture — same device, two independent capture paths agreeing.

What this does and does not prove

Proven: the BlueZ D-Bus scan driver works and coexists with a running bluetoothd; SetDiscoveryFilter(Transport=le, DuplicateData=True) is accepted; BlueZ does not destructively coalesce here — 199 distinct frames with all three message types arrived; the ServiceData re-wrapping into parse_odid_service_data is correct; RSSI is delivered per advertisement.

Still unproven:

  • The monitor reader has not run against live traffic — binding HCI_CHANNEL_MONITOR needs CAP_NET_RAW. The sockaddr_hci layout is confirmed correct (the kernel returns EPERM, the capability check, rather than EINVAL), and the decoder is unit-tested against real captured bytes wrapped in synthesized monitor frames, but the socket has not actually delivered a frame yet.
  • BT5 Coded PHY reception on a Pi's CYW43455 — unmeasured, and still expected to be absent.
  • BT PAN coexistence cost — not measured in either direction.
  • This ran on an x86 workstation with a btusb adapter, not a Pi's hci_uart/btbcm radio. The BlueZ/ODID path is identical, but the radio is not.

Given the dbus reader alone achieved ~480 advertisements/min with every needed message type, reader=auto is safe as the default: if monitor turns out to be unavailable or unnecessary on the Pi, the fallback is already demonstrated to work.

@ampledata
ampledata changed the base branch from rid-track-aggregation to main July 27, 2026 16:44
ble_capture requires a Sniffle nRF52 dongle, so a box with no add-on
hardware decodes no Bluetooth Remote ID at all. Every Raspberry Pi
already has a radio that can hear ASTM F3411 advertisements -- verified
by capturing real DroneBeacon DB120 frames with btmon on an AryaOS box.

New bluez_capture module, selected by FEED_URL scheme ble+hci://hci0.
It reuses ble_parse for ODID extraction and emits the same (pack, meta)
contract BleWorker uses, so the whole downstream chain is unchanged.

Two cooperating pieces:

* Scan driver (always): BlueZ D-Bus SetDiscoveryFilter(Transport=le) +
  StartDiscovery. An ordinary D-Bus client, so it coexists with
  bluetoothd and an active Bluetooth PAN instead of seizing the adapter.

* Reader, selectable. 'monitor' uses an HCI_CHANNEL_MONITOR socket --
  the passive tap btmon uses -- for every advertising report with full
  AD bytes and per-frame RSSI, nothing coalesced; needs CAP_NET_RAW.
  'dbus' reads ServiceData off org.bluez.Device1, needs no capability
  but BlueZ may coalesce repeats, and for Remote ID a coalesced repeat
  is a lost message TYPE rather than a duplicate. 'auto' tries monitor
  then falls back.

CPython's socket.bind() for BTPROTO_HCI accepts only (device_id,) and
raises "bind(): wrong format" for a channel, so it cannot select the
monitor channel at all; bind_hci_monitor() falls back to calling bind(2)
via ctypes with a real struct sockaddr_hci. Confirmed correct against a
live adapter: the kernel returns EPERM (the CAP_NET_RAW check) rather
than EINVAL, so the channel is parsed as MONITOR.

Routing note: "ble+hci" contains "ble", so its branch must precede the
substring test in create_tasks(). Covered by a test.

Captures LEGACY advertising only -- the ASTM long-range profile uses BT5
extended advertising on the Coded PHY, which the Pi's CYW43455 is not
known to receive. Documented as complementary to a Sniffle dongle or
DroneScout receiver, not a replacement.

18 new tests, decoding real captured advertisement bytes through
synthesized HCI monitor frames so they run in CI with no adapter.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197da7dhvcPoHxYKamrYqyM
@ampledata
ampledata merged commit a15daf5 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