dronecot 2.2.4__tar.gz → 2.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. {dronecot-2.2.4 → dronecot-2.3.0}/CHANGELOG.md +63 -0
  2. {dronecot-2.2.4/dronecot.egg-info → dronecot-2.3.0}/PKG-INFO +1 -1
  3. {dronecot-2.2.4 → dronecot-2.3.0/dronecot.egg-info}/PKG-INFO +1 -1
  4. {dronecot-2.2.4 → dronecot-2.3.0}/dronecot.egg-info/SOURCES.txt +5 -0
  5. dronecot-2.3.0/src/dronecot/VERSION +1 -0
  6. {dronecot-2.2.4 → dronecot-2.3.0}/src/dronecot/__init__.py +8 -0
  7. dronecot-2.3.0/src/dronecot/bluez_capture.py +521 -0
  8. {dronecot-2.2.4 → dronecot-2.3.0}/src/dronecot/classes.py +101 -7
  9. {dronecot-2.2.4 → dronecot-2.3.0}/src/dronecot/constants.py +16 -0
  10. {dronecot-2.2.4 → dronecot-2.3.0}/src/dronecot/functions.py +60 -7
  11. {dronecot-2.2.4 → dronecot-2.3.0}/src/dronecot/odid.py +18 -14
  12. dronecot-2.3.0/src/dronecot/rid_track.py +238 -0
  13. dronecot-2.3.0/tests/test_bluez_capture.py +264 -0
  14. dronecot-2.3.0/tests/test_rid_track.py +302 -0
  15. dronecot-2.3.0/tests/test_sensor_detail.py +54 -0
  16. dronecot-2.2.4/src/dronecot/VERSION +0 -1
  17. {dronecot-2.2.4 → dronecot-2.3.0}/LICENSE +0 -0
  18. {dronecot-2.2.4 → dronecot-2.3.0}/MANIFEST.in +0 -0
  19. {dronecot-2.2.4 → dronecot-2.3.0}/README.md +0 -0
  20. {dronecot-2.2.4 → dronecot-2.3.0}/dronecot.egg-info/dependency_links.txt +0 -0
  21. {dronecot-2.2.4 → dronecot-2.3.0}/dronecot.egg-info/entry_points.txt +0 -0
  22. {dronecot-2.2.4 → dronecot-2.3.0}/dronecot.egg-info/requires.txt +0 -0
  23. {dronecot-2.2.4 → dronecot-2.3.0}/dronecot.egg-info/top_level.txt +0 -0
  24. {dronecot-2.2.4 → dronecot-2.3.0}/setup.cfg +0 -0
  25. {dronecot-2.2.4 → dronecot-2.3.0}/setup.py +0 -0
  26. {dronecot-2.2.4 → dronecot-2.3.0}/src/dronecot/ble_capture.py +0 -0
  27. {dronecot-2.2.4 → dronecot-2.3.0}/src/dronecot/ble_parse.py +0 -0
  28. {dronecot-2.2.4 → dronecot-2.3.0}/src/dronecot/commands.py +0 -0
  29. {dronecot-2.2.4 → dronecot-2.3.0}/src/dronecot/dji_exceptions.py +0 -0
  30. {dronecot-2.2.4 → dronecot-2.3.0}/src/dronecot/dji_functions.py +0 -0
  31. {dronecot-2.2.4 → dronecot-2.3.0}/src/dronecot/dji_text_parser.py +0 -0
  32. {dronecot-2.2.4 → dronecot-2.3.0}/src/dronecot/open_drone_id.py +0 -0
  33. {dronecot-2.2.4 → dronecot-2.3.0}/src/dronecot/rid_normalize.py +0 -0
  34. {dronecot-2.2.4 → dronecot-2.3.0}/src/dronecot/udp_rid.py +0 -0
  35. {dronecot-2.2.4 → dronecot-2.3.0}/src/dronecot/wifi_capture.py +0 -0
  36. {dronecot-2.2.4 → dronecot-2.3.0}/src/dronecot/wifi_parse.py +0 -0
  37. {dronecot-2.2.4 → dronecot-2.3.0}/tests/test_adsb_vehicle.py +0 -0
  38. {dronecot-2.2.4 → dronecot-2.3.0}/tests/test_functions.py +0 -0
  39. {dronecot-2.2.4 → dronecot-2.3.0}/tests/test_rid_normalize.py +0 -0
  40. {dronecot-2.2.4 → dronecot-2.3.0}/tests/test_wifi_parse.py +0 -0
@@ -1,3 +1,66 @@
1
+ ## DroneCOT 2.3.0
2
+
3
+ Correctness release for single-message Remote ID. A BLE legacy transmitter fits
4
+ only ONE 25-byte ODID message per advertisement and rotates through message
5
+ types, so the serial, the UAS position and the operator location arrive in
6
+ *different* frames. DroneCOT rendered each frame independently, which broke
7
+ badly in the field.
8
+
9
+ - **Fix: CoT XML could contain NUL bytes and fail to parse.** ODID pads its
10
+ fixed-width ASCII fields (20-byte serial, 23-byte text) with NUL, and
11
+ `clean_SN()` / `clean_string()` stripped only whitespace. Any UAS whose serial
12
+ was shorter than the field emitted a CoT UID such as
13
+ `RID.SHORTSN\0\0\0....uas` — 82 NUL bytes in the document, which is illegal in
14
+ XML 1.0 and will not reparse. Both cleaners now strip NUL and all C0 control
15
+ characters. Affected every source (Wi-Fi, BLE, serial, MQTT); went unnoticed
16
+ because every committed fixture uses a full-width 20-character serial.
17
+ - **Fix: every drone collapsed onto one track.** A Location message carries a
18
+ position but no serial, so it rendered with the constant UID
19
+ `RID.Unknown-BasicID_0.uas` — all aircraft in range shared one track. The UID
20
+ now falls back to the advertiser MAC (`MAC-<addr>`) so distinct transmitters
21
+ stay distinct.
22
+ - **Fix: BasicID and System messages were silently dropped**, having no position
23
+ of their own.
24
+ - **New `rid_track.ODIDAggregator`**: merges successive single ODID messages per
25
+ transmitter (keyed on the advertiser MAC, which ASTM F3411 requires to be
26
+ stable for a flight session) so a CoT carries the serial from one
27
+ advertisement and the position from another. TTL-expired and hard-capped to
28
+ bound memory. Wired into `RIDWorker.handle_data()`, the single choke point all
29
+ workers feed, so Wi-Fi, BLE, serial, MQTT and UDP all benefit. Full `0xF`
30
+ message packs are unaffected — they already carry everything in one frame.
31
+ Tunable via `RID_TRACK_TTL` (default 120 s; set 0 to disable),
32
+ `RID_TRACK_MAX` (default 512) and `RID_TRACK_ID_GRACE` (default 5 s).
33
+ `RID_TRACK_ID_GRACE` briefly holds a position-only track while waiting for
34
+ its serial, so one aircraft does not appear as two TAK markers (first under a
35
+ MAC-derived UID, then under the real serial); after the grace period an
36
+ unidentified drone renders anyway.
37
+
38
+ **Measured against a live BlueMark DroneBeacon DB120** over 234 real BLE
39
+ advertisements: without aggregation only 57 (24%) rendered as CoT and the
40
+ real serial NEVER appeared — every event used the MAC fallback UID. With
41
+ aggregation 230 (98%) rendered, all under the correct serial
42
+ `RID.1787F04BM24010011195.uas`. The transmitter sent BasicID, Location and
43
+ System as separate advertisements and no `0xF` message packs at all,
44
+ confirming the single-message rotation this release exists to handle.
45
+ - **Fix: operator CoT UID ignored the MAC.** `rid_op_to_cot_xml()` read
46
+ `data["MAC address"]`, but wireless captures put it in `data["data"]`, so the
47
+ Drone-Hone-style `op-<mac>` UID never actually used a MAC.
48
+ - Replace deprecated `datetime.utcfromtimestamp()` (Python 3.12+ warning).
49
+ - Tests: 16 new, including a real ASTM F3411 advertisement captured off the air
50
+ from a BlueMark DroneBeacon DB120 — the existing fixtures are all message
51
+ packs or CUAS blobs and never exercised the single-message path.
52
+
53
+ ## DroneCOT 2.2.5
54
+
55
+ - Enrich RID CoT `<__cuas>` detail and remarks with `sensor_model`,
56
+ `sensor_method` and `band`. New `SENSOR_MODEL` / `SENSOR_TYPE` config.
57
+
58
+ ## DroneCOT 2.2.4
59
+
60
+ - `wifi_parse`: fix `extract_odid_from_scapy_packet()`, which decoded zero
61
+ beacons because `bytes(elt.oui)` on an int produced a 16 MB zero buffer. Now
62
+ delegates to `extract_odid_from_dot11(bytes(dot11))`.
63
+
1
64
  ## DroneCOT 2.2.3
2
65
 
3
66
  - SerialWorker: handle MAVLink **ADSB_VEHICLE** messages (in addition to
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dronecot
3
- Version: 2.2.4
3
+ Version: 2.3.0
4
4
  Summary: Display Drones in Team Awareness Kit (TAK).
5
5
  Home-page: https://github.com/snstac/dronecot
6
6
  Maintainer: Greg Albrecht <oss@undef.net>
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dronecot
3
- Version: 2.2.4
3
+ Version: 2.3.0
4
4
  Summary: Display Drones in Team Awareness Kit (TAK).
5
5
  Home-page: https://github.com/snstac/dronecot
6
6
  Maintainer: Greg Albrecht <oss@undef.net>
@@ -14,6 +14,7 @@ src/dronecot/VERSION
14
14
  src/dronecot/__init__.py
15
15
  src/dronecot/ble_capture.py
16
16
  src/dronecot/ble_parse.py
17
+ src/dronecot/bluez_capture.py
17
18
  src/dronecot/classes.py
18
19
  src/dronecot/commands.py
19
20
  src/dronecot/constants.py
@@ -24,10 +25,14 @@ src/dronecot/functions.py
24
25
  src/dronecot/odid.py
25
26
  src/dronecot/open_drone_id.py
26
27
  src/dronecot/rid_normalize.py
28
+ src/dronecot/rid_track.py
27
29
  src/dronecot/udp_rid.py
28
30
  src/dronecot/wifi_capture.py
29
31
  src/dronecot/wifi_parse.py
30
32
  tests/test_adsb_vehicle.py
33
+ tests/test_bluez_capture.py
31
34
  tests/test_functions.py
32
35
  tests/test_rid_normalize.py
36
+ tests/test_rid_track.py
37
+ tests/test_sensor_detail.py
33
38
  tests/test_wifi_parse.py
@@ -0,0 +1 @@
1
+ 2.3.0
@@ -32,10 +32,16 @@ from .constants import ( # NOQA
32
32
  DEFAULT_GPS_INFO_CMD,
33
33
  DEFAULT_SENSOR_ID,
34
34
  DEFAULT_SENSOR_PAYLOAD_TYPE,
35
+ DEFAULT_SENSOR_MODEL,
36
+ DEFAULT_SENSOR_TYPE,
35
37
  DEFAULT_SENSOR_COT_TYPE,
36
38
  DEFAULT_OP_COT_TYPE,
37
39
  DEFAULT_HOME_COT_TYPE,
38
40
  DEFAULT_UAS_COT_TYPE,
41
+ # Per-transmitter RID track aggregation constants
42
+ DEFAULT_RID_TRACK_TTL,
43
+ DEFAULT_RID_TRACK_MAX,
44
+ DEFAULT_RID_TRACK_ID_GRACE,
39
45
  # UDP pre-decoded Remote ID constants
40
46
  DEFAULT_UDP_RID_PORT,
41
47
  DEFAULT_UDP_RID_HOST,
@@ -84,6 +90,7 @@ from .udp_rid import ( # NOQA
84
90
 
85
91
  from .classes import ( # NOQA
86
92
  BleWorker,
93
+ BlueZWorker,
87
94
  MQTTWorker,
88
95
  RIDWorker,
89
96
  RXMockWorker,
@@ -115,6 +122,7 @@ from .dji_exceptions import ( # NOQA
115
122
 
116
123
  from . import odid # NOQA
117
124
  from . import rid_normalize # NOQA
125
+ from . import rid_track # NOQA
118
126
 
119
127
  from .open_drone_id import (
120
128
  ODIDValidBlocks,
@@ -0,0 +1,521 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ #
4
+ # Copyright Sensors & Signals LLC https://www.snstac.com
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+ #
18
+
19
+ """Bluetooth Remote ID capture on a stock BlueZ adapter -- no Sniffle dongle.
20
+
21
+ ``ble_capture`` needs a Sniffle nRF52 dongle, which means a box with no extra
22
+ hardware decodes no Bluetooth Remote ID at all. Every Raspberry Pi already has a
23
+ Bluetooth radio that can hear ASTM F3411 advertisements, so this module reads
24
+ them through BlueZ instead.
25
+
26
+ Two cooperating pieces
27
+ ----------------------
28
+ **Scan driver** (always): BlueZ D-Bus ``SetDiscoveryFilter`` + ``StartDiscovery``
29
+ puts the controller into a continuous LE scan. This is a normal D-Bus client, so
30
+ it coexists with ``bluetoothd`` and with an active Bluetooth PAN rather than
31
+ seizing the adapter.
32
+
33
+ **Reader** (selectable):
34
+
35
+ ``monitor``
36
+ An ``HCI_CHANNEL_MONITOR`` socket -- the same passive tap ``btmon`` uses.
37
+ Delivers every LE Advertising Report the controller receives, with the full
38
+ advertisement bytes and per-frame RSSI, and nothing is coalesced. Needs
39
+ ``CAP_NET_RAW``. It is purely passive, so it only sees traffic while the
40
+ scan driver (or anything else) is scanning.
41
+
42
+ ``dbus``
43
+ Reads the ``ServiceData`` property off ``org.bluez.Device1``. Requires no
44
+ elevated capability, but BlueZ may coalesce repeated advertisements from one
45
+ device, which for Remote ID means dropped messages -- a transmitter rotates
46
+ through message types, so a dropped repeat is a lost message *type*, not a
47
+ duplicate.
48
+
49
+ ``auto`` (default)
50
+ Try ``monitor``; fall back to ``dbus`` if the socket cannot be opened.
51
+
52
+ Known limitation
53
+ ----------------
54
+ This captures **legacy** advertisements. The ASTM long-range profile uses BT5
55
+ extended advertising on the Coded PHY, which the Pi's CYW43455 is not known to
56
+ receive. Treat onboard capture as complementary to a Sniffle dongle or a
57
+ DroneScout receiver, not a replacement.
58
+ """
59
+
60
+ import logging
61
+ import os
62
+ import socket
63
+ import struct
64
+ import threading
65
+
66
+ from typing import Callable, Dict, List, Optional, Tuple
67
+ from urllib.parse import parse_qs, urlparse
68
+
69
+ _logger = logging.getLogger(__name__)
70
+
71
+ # --- BlueZ / HCI constants ------------------------------------------------
72
+
73
+ AF_BLUETOOTH = 31
74
+ BTPROTO_HCI = 1
75
+ HCI_CHANNEL_MONITOR = 2
76
+ HCI_DEV_NONE = 0xFFFF
77
+
78
+ # struct hci_mon_hdr { __le16 opcode; __le16 index; __le16 len; }
79
+ HCI_MON_HDR_LEN = 6
80
+ HCI_MON_EVENT_PKT = 0x0003
81
+
82
+ HCI_EVENT_LE_META = 0x3E
83
+ LE_ADVERTISING_REPORT = 0x02
84
+ LE_EXTENDED_ADVERTISING_REPORT = 0x0D
85
+
86
+ BLUEZ_SERVICE = "org.bluez"
87
+ ADAPTER_IFACE = "org.bluez.Adapter1"
88
+ DEVICE_IFACE = "org.bluez.Device1"
89
+
90
+ # 16-bit ASTM UUID 0xFFFA as BlueZ spells it on D-Bus.
91
+ ASTM_UUID_DBUS = "0000fffa-0000-1000-8000-00805f9b34fb"
92
+
93
+ DEFAULT_ADAPTER = "hci0"
94
+
95
+
96
+ def parse_bluez_feed_url(feed_url: str) -> dict:
97
+ """Parse a ``ble+hci://`` FEED_URL.
98
+
99
+ Examples
100
+ --------
101
+ ``ble+hci://hci0``
102
+ ``ble+hci://hci0?reader=monitor&rssi=-95``
103
+ """
104
+ parsed = urlparse(feed_url)
105
+ path = (parsed.path or "").strip()
106
+ if path.startswith("//"):
107
+ path = path[2:]
108
+ adapter = parsed.hostname or path.strip("/") or DEFAULT_ADAPTER
109
+ if adapter == "auto":
110
+ adapter = DEFAULT_ADAPTER
111
+
112
+ qs = parse_qs(parsed.query or "")
113
+ reader = qs.get("reader", ["auto"])[0].lower()
114
+ if reader not in {"auto", "monitor", "dbus"}:
115
+ _logger.warning("Unknown BLE reader %r, using 'auto'", reader)
116
+ reader = "auto"
117
+
118
+ rssi_raw = qs.get("rssi", [None])[0]
119
+ duplicates = qs.get("duplicates", ["1"])[0].lower() in {"1", "true", "yes"}
120
+
121
+ return {
122
+ "adapter": adapter,
123
+ "reader": reader,
124
+ "rssi_threshold": int(rssi_raw) if rssi_raw is not None else None,
125
+ "duplicates": duplicates,
126
+ }
127
+
128
+
129
+ def adapter_index(adapter: str) -> int:
130
+ """``hci0`` -> ``0``. Used to filter monitor traffic to one controller."""
131
+ digits = "".join(ch for ch in adapter if ch.isdigit())
132
+ return int(digits) if digits else 0
133
+
134
+
135
+ def format_mac(addr_le: bytes) -> str:
136
+ """HCI reports addresses little-endian; render them the human way."""
137
+ return ":".join(f"{b:02X}" for b in reversed(addr_le[:6]))
138
+
139
+
140
+ def bind_hci_monitor(sock: socket.socket) -> None:
141
+ """Bind ``sock`` to the HCI monitor channel.
142
+
143
+ CPython's ``socket.bind()`` for ``BTPROTO_HCI`` accepts only ``(device_id,)``
144
+ on current builds -- passing a channel raises "bind(): wrong format" -- so it
145
+ cannot select ``HCI_CHANNEL_MONITOR`` at all. Try the two-tuple first in case
146
+ the interpreter does support it, then fall back to calling ``bind(2)``
147
+ directly with a proper ``struct sockaddr_hci``.
148
+
149
+ Raises ``OSError`` if the bind fails (typically missing ``CAP_NET_RAW``).
150
+ """
151
+ try:
152
+ sock.bind((HCI_DEV_NONE, HCI_CHANNEL_MONITOR))
153
+ return
154
+ except OSError:
155
+ pass # interpreter lacks channel support; fall through to ctypes
156
+
157
+ import ctypes # pylint: disable=import-outside-toplevel
158
+
159
+ libc = ctypes.CDLL("libc.so.6", use_errno=True)
160
+ # struct sockaddr_hci { sa_family_t family; unsigned short dev, channel; }
161
+ addr = struct.pack("<HHH", AF_BLUETOOTH, HCI_DEV_NONE, HCI_CHANNEL_MONITOR)
162
+ if libc.bind(sock.fileno(), addr, len(addr)) != 0:
163
+ err = ctypes.get_errno()
164
+ raise OSError(err, os.strerror(err))
165
+
166
+
167
+ # --- HCI advertising report parsing ---------------------------------------
168
+
169
+
170
+ def parse_le_advertising_report(params: bytes) -> List[Tuple[str, bytes, Optional[int]]]:
171
+ """Decode an LE Advertising Report subevent body.
172
+
173
+ Returns ``[(mac, adv_data, rssi), ...]``. Malformed or truncated reports
174
+ yield nothing rather than raising -- this runs on radio input.
175
+ """
176
+ out: List[Tuple[str, bytes, Optional[int]]] = []
177
+ if len(params) < 2:
178
+ return out
179
+
180
+ num_reports = params[1]
181
+ pos = 2
182
+ for _ in range(num_reports):
183
+ # event_type(1) addr_type(1) addr(6) data_len(1)
184
+ if pos + 9 > len(params):
185
+ break
186
+ addr = params[pos + 2 : pos + 8]
187
+ data_len = params[pos + 8]
188
+ pos += 9
189
+ if pos + data_len > len(params):
190
+ break
191
+ adv_data = params[pos : pos + data_len]
192
+ pos += data_len
193
+ rssi = None
194
+ if pos < len(params):
195
+ rssi = struct.unpack("b", params[pos : pos + 1])[0]
196
+ pos += 1
197
+ out.append((format_mac(addr), adv_data, rssi))
198
+ return out
199
+
200
+
201
+ def parse_le_extended_advertising_report(
202
+ params: bytes,
203
+ ) -> List[Tuple[str, bytes, Optional[int]]]:
204
+ """Decode an LE Extended Advertising Report subevent body (BT5)."""
205
+ out: List[Tuple[str, bytes, Optional[int]]] = []
206
+ if len(params) < 2:
207
+ return out
208
+
209
+ num_reports = params[1]
210
+ pos = 2
211
+ for _ in range(num_reports):
212
+ # event_type(2) addr_type(1) addr(6) primary_phy(1) secondary_phy(1)
213
+ # adv_sid(1) tx_power(1) rssi(1) periodic_interval(2)
214
+ # direct_addr_type(1) direct_addr(6) data_len(1)
215
+ if pos + 24 > len(params):
216
+ break
217
+ addr = params[pos + 3 : pos + 9]
218
+ rssi = struct.unpack("b", params[pos + 13 : pos + 14])[0]
219
+ data_len = params[pos + 23]
220
+ pos += 24
221
+ if pos + data_len > len(params):
222
+ break
223
+ adv_data = params[pos : pos + data_len]
224
+ pos += data_len
225
+ out.append((format_mac(addr), adv_data, rssi))
226
+ return out
227
+
228
+
229
+ def parse_monitor_packet(packet: bytes, index: int) -> List[Tuple[str, bytes, Optional[int]]]:
230
+ """Extract advertising reports from one HCI monitor frame.
231
+
232
+ ``index`` restricts decoding to a single controller, so a box with more than
233
+ one adapter does not attribute another radio's traffic to this sensor.
234
+ """
235
+ if len(packet) < HCI_MON_HDR_LEN:
236
+ return []
237
+
238
+ opcode, pkt_index, length = struct.unpack_from("<HHH", packet, 0)
239
+ if opcode != HCI_MON_EVENT_PKT or pkt_index != index:
240
+ return []
241
+
242
+ body = packet[HCI_MON_HDR_LEN : HCI_MON_HDR_LEN + length]
243
+ # HCI event: event_code(1) param_len(1) params...
244
+ if len(body) < 3 or body[0] != HCI_EVENT_LE_META:
245
+ return []
246
+
247
+ params = body[2:]
248
+ if not params:
249
+ return []
250
+
251
+ subevent = params[0]
252
+ if subevent == LE_ADVERTISING_REPORT:
253
+ return parse_le_advertising_report(params)
254
+ if subevent == LE_EXTENDED_ADVERTISING_REPORT:
255
+ return parse_le_extended_advertising_report(params)
256
+ return []
257
+
258
+
259
+ # --- Capture ---------------------------------------------------------------
260
+
261
+
262
+ class BlueZSniffer:
263
+ """Capture ASTM F3411 Remote ID advertisements via a stock BlueZ adapter."""
264
+
265
+ def __init__(
266
+ self,
267
+ on_packet: Callable,
268
+ adapter: str = DEFAULT_ADAPTER,
269
+ reader: str = "auto",
270
+ rssi_threshold: Optional[int] = None,
271
+ duplicates: bool = True,
272
+ ):
273
+ self.on_packet = on_packet
274
+ self.adapter = adapter or DEFAULT_ADAPTER
275
+ self.reader = reader
276
+ self.rssi_threshold = rssi_threshold
277
+ self.duplicates = duplicates
278
+
279
+ self._stop = threading.Event()
280
+ self._threads: List[threading.Thread] = []
281
+ self._sock: Optional[socket.socket] = None
282
+ self._loop = None
283
+ self._active_reader: Optional[str] = None
284
+
285
+ # -- reader: HCI monitor tap -------------------------------------------
286
+
287
+ def _open_monitor_socket(self) -> Optional[socket.socket]:
288
+ """Open a passive HCI monitor socket, or return None if not permitted."""
289
+ try:
290
+ sock = socket.socket(
291
+ AF_BLUETOOTH, socket.SOCK_RAW | socket.SOCK_CLOEXEC, BTPROTO_HCI
292
+ )
293
+ except (OSError, AttributeError) as exc:
294
+ _logger.debug("HCI monitor socket unavailable: %s", exc)
295
+ return None
296
+
297
+ try:
298
+ bind_hci_monitor(sock)
299
+ except OSError as exc:
300
+ sock.close()
301
+ _logger.debug(
302
+ "Cannot bind HCI monitor channel (%s); CAP_NET_RAW is required", exc
303
+ )
304
+ return None
305
+
306
+ return sock
307
+
308
+ def _run_monitor(self) -> None:
309
+ from dronecot import ble_parse # pylint: disable=import-outside-toplevel
310
+
311
+ index = adapter_index(self.adapter)
312
+ sock = self._sock
313
+ if sock is None:
314
+ return
315
+ sock.settimeout(1.0)
316
+
317
+ while not self._stop.is_set():
318
+ try:
319
+ packet = sock.recv(4096)
320
+ except socket.timeout:
321
+ continue
322
+ except OSError as exc:
323
+ if self._stop.is_set():
324
+ break
325
+ _logger.debug("HCI monitor read error: %s", exc)
326
+ continue
327
+
328
+ for mac, adv_data, rssi in parse_monitor_packet(packet, index):
329
+ self._emit(ble_parse, adv_data, mac, rssi)
330
+
331
+ def _emit(self, ble_parse, adv_data: bytes, mac: str, rssi: Optional[int]) -> None:
332
+ """Extract ODID from an advertisement and hand it upstream."""
333
+ if self.rssi_threshold is not None and rssi is not None:
334
+ if rssi < self.rssi_threshold:
335
+ return
336
+
337
+ pack = ble_parse.extract_odid_from_adv_data(adv_data)
338
+ if not pack:
339
+ return
340
+
341
+ meta: Dict = {"type": "BLE legacy (BlueZ)", "MAC address": mac}
342
+ if rssi is not None:
343
+ meta["RSSI"] = rssi
344
+ self.on_packet(pack, meta)
345
+
346
+ # -- reader: BlueZ D-Bus ServiceData ------------------------------------
347
+
348
+ def _handle_dbus_properties(self, address: Optional[str], props: dict) -> None:
349
+ from dronecot import ble_parse # pylint: disable=import-outside-toplevel
350
+
351
+ service_data = props.get("ServiceData")
352
+ if not service_data:
353
+ return
354
+
355
+ raw = None
356
+ for uuid, value in service_data.items():
357
+ if str(uuid).lower() == ASTM_UUID_DBUS:
358
+ raw = bytes(bytearray(value))
359
+ break
360
+ if raw is None:
361
+ return
362
+
363
+ rssi = props.get("RSSI")
364
+ rssi = int(rssi) if rssi is not None else None
365
+ if self.rssi_threshold is not None and rssi is not None:
366
+ if rssi < self.rssi_threshold:
367
+ return
368
+
369
+ # BlueZ hands back the service-data payload with the 16-bit UUID already
370
+ # stripped; put it back so the shared AD parser sees its usual layout.
371
+ pack = ble_parse.parse_odid_service_data(ble_parse.ASTM_BLE_UUID + raw)
372
+ if not pack:
373
+ return
374
+
375
+ meta: Dict = {"type": "BLE legacy (BlueZ)"}
376
+ if address:
377
+ meta["MAC address"] = str(address).upper()
378
+ if rssi is not None:
379
+ meta["RSSI"] = rssi
380
+ self.on_packet(pack, meta)
381
+
382
+ def _run_dbus(self) -> None:
383
+ """Drive a continuous LE scan and, on the dbus reader, decode from it."""
384
+ try:
385
+ import dbus # pylint: disable=import-outside-toplevel
386
+ import dbus.mainloop.glib # pylint: disable=import-outside-toplevel
387
+ from gi.repository import GLib # pylint: disable=import-outside-toplevel
388
+ except ImportError as exc:
389
+ raise ImportError(
390
+ "BlueZ capture requires python3-dbus and python3-gi "
391
+ "(Debian: apt install python3-dbus python3-gi)"
392
+ ) from exc
393
+
394
+ dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
395
+ bus = dbus.SystemBus()
396
+ adapter_path = f"/org/bluez/{self.adapter}"
397
+
398
+ adapter = dbus.Interface(
399
+ bus.get_object(BLUEZ_SERVICE, adapter_path), ADAPTER_IFACE
400
+ )
401
+
402
+ # Transport 'le' keeps Classic (and therefore an active PAN) untouched.
403
+ # DuplicateData asks BlueZ to report repeated advertisement payloads
404
+ # rather than coalescing them -- essential when each repeat may carry a
405
+ # different ODID message type.
406
+ discovery_filter = {
407
+ "Transport": "le",
408
+ "DuplicateData": dbus.Boolean(self.duplicates),
409
+ }
410
+ if self.rssi_threshold is not None:
411
+ discovery_filter["RSSI"] = dbus.Int16(self.rssi_threshold)
412
+
413
+ try:
414
+ adapter.SetDiscoveryFilter(discovery_filter)
415
+ except dbus.DBusException as exc:
416
+ _logger.warning("SetDiscoveryFilter failed (%s); scanning unfiltered", exc)
417
+
418
+ try:
419
+ adapter.StartDiscovery()
420
+ except dbus.DBusException as exc:
421
+ # Another client may already be discovering, which is fine -- the
422
+ # radio is scanning either way and that is all the tap needs.
423
+ _logger.info("StartDiscovery: %s (continuing)", exc)
424
+
425
+ if self._active_reader == "dbus":
426
+ self._connect_dbus_signals(bus)
427
+
428
+ self._loop = GLib.MainLoop()
429
+ try:
430
+ self._loop.run()
431
+ finally:
432
+ try:
433
+ adapter.StopDiscovery()
434
+ except Exception: # pylint: disable=broad-except
435
+ pass
436
+
437
+ def _connect_dbus_signals(self, bus) -> None:
438
+ """Subscribe to advertisement properties for the D-Bus reader."""
439
+
440
+ def on_interfaces_added(_path, interfaces):
441
+ props = interfaces.get(DEVICE_IFACE)
442
+ if props:
443
+ self._handle_dbus_properties(props.get("Address"), props)
444
+
445
+ def on_properties_changed(interface, changed, _invalidated, path=None):
446
+ if interface != DEVICE_IFACE:
447
+ return
448
+ address = None
449
+ if path:
450
+ # /org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF
451
+ tail = str(path).rsplit("/", 1)[-1]
452
+ if tail.startswith("dev_"):
453
+ address = tail[4:].replace("_", ":")
454
+ self._handle_dbus_properties(address, changed)
455
+
456
+ bus.add_signal_receiver(
457
+ on_interfaces_added,
458
+ dbus_interface="org.freedesktop.DBus.ObjectManager",
459
+ signal_name="InterfacesAdded",
460
+ )
461
+ bus.add_signal_receiver(
462
+ on_properties_changed,
463
+ dbus_interface="org.freedesktop.DBus.Properties",
464
+ signal_name="PropertiesChanged",
465
+ arg0=DEVICE_IFACE,
466
+ path_keyword="path",
467
+ )
468
+
469
+ # -- lifecycle ----------------------------------------------------------
470
+
471
+ def start(self) -> None:
472
+ self._stop.clear()
473
+
474
+ if self.reader in {"auto", "monitor"}:
475
+ self._sock = self._open_monitor_socket()
476
+ if self._sock is None and self.reader == "monitor":
477
+ raise IOError(
478
+ "HCI monitor socket unavailable: dronecot needs CAP_NET_RAW "
479
+ "for reader=monitor (systemd: AmbientCapabilities=CAP_NET_RAW)"
480
+ )
481
+
482
+ self._active_reader = "monitor" if self._sock is not None else "dbus"
483
+ if self.reader == "dbus":
484
+ self._active_reader = "dbus"
485
+ if self._sock is not None:
486
+ self._sock.close()
487
+ self._sock = None
488
+
489
+ # The scan driver runs either way: the monitor tap is passive and sees
490
+ # nothing unless the controller is actually scanning.
491
+ self._spawn(self._run_dbus, "dronecot-ble-scan")
492
+ if self._active_reader == "monitor":
493
+ self._spawn(self._run_monitor, "dronecot-ble-monitor")
494
+
495
+ _logger.info(
496
+ "BlueZ Remote ID capture started on %s (reader=%s)",
497
+ self.adapter,
498
+ self._active_reader,
499
+ )
500
+
501
+ def _spawn(self, target: Callable, name: str) -> None:
502
+ thread = threading.Thread(target=target, daemon=True, name=name)
503
+ thread.start()
504
+ self._threads.append(thread)
505
+
506
+ def stop(self) -> None:
507
+ self._stop.set()
508
+ if self._loop is not None:
509
+ try:
510
+ self._loop.quit()
511
+ except Exception: # pylint: disable=broad-except
512
+ pass
513
+ for thread in self._threads:
514
+ thread.join(timeout=3)
515
+ self._threads = []
516
+ if self._sock is not None:
517
+ try:
518
+ self._sock.close()
519
+ except OSError:
520
+ pass
521
+ self._sock = None