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.
- {dronecot-2.2.4 → dronecot-2.3.0}/CHANGELOG.md +63 -0
- {dronecot-2.2.4/dronecot.egg-info → dronecot-2.3.0}/PKG-INFO +1 -1
- {dronecot-2.2.4 → dronecot-2.3.0/dronecot.egg-info}/PKG-INFO +1 -1
- {dronecot-2.2.4 → dronecot-2.3.0}/dronecot.egg-info/SOURCES.txt +5 -0
- dronecot-2.3.0/src/dronecot/VERSION +1 -0
- {dronecot-2.2.4 → dronecot-2.3.0}/src/dronecot/__init__.py +8 -0
- dronecot-2.3.0/src/dronecot/bluez_capture.py +521 -0
- {dronecot-2.2.4 → dronecot-2.3.0}/src/dronecot/classes.py +101 -7
- {dronecot-2.2.4 → dronecot-2.3.0}/src/dronecot/constants.py +16 -0
- {dronecot-2.2.4 → dronecot-2.3.0}/src/dronecot/functions.py +60 -7
- {dronecot-2.2.4 → dronecot-2.3.0}/src/dronecot/odid.py +18 -14
- dronecot-2.3.0/src/dronecot/rid_track.py +238 -0
- dronecot-2.3.0/tests/test_bluez_capture.py +264 -0
- dronecot-2.3.0/tests/test_rid_track.py +302 -0
- dronecot-2.3.0/tests/test_sensor_detail.py +54 -0
- dronecot-2.2.4/src/dronecot/VERSION +0 -1
- {dronecot-2.2.4 → dronecot-2.3.0}/LICENSE +0 -0
- {dronecot-2.2.4 → dronecot-2.3.0}/MANIFEST.in +0 -0
- {dronecot-2.2.4 → dronecot-2.3.0}/README.md +0 -0
- {dronecot-2.2.4 → dronecot-2.3.0}/dronecot.egg-info/dependency_links.txt +0 -0
- {dronecot-2.2.4 → dronecot-2.3.0}/dronecot.egg-info/entry_points.txt +0 -0
- {dronecot-2.2.4 → dronecot-2.3.0}/dronecot.egg-info/requires.txt +0 -0
- {dronecot-2.2.4 → dronecot-2.3.0}/dronecot.egg-info/top_level.txt +0 -0
- {dronecot-2.2.4 → dronecot-2.3.0}/setup.cfg +0 -0
- {dronecot-2.2.4 → dronecot-2.3.0}/setup.py +0 -0
- {dronecot-2.2.4 → dronecot-2.3.0}/src/dronecot/ble_capture.py +0 -0
- {dronecot-2.2.4 → dronecot-2.3.0}/src/dronecot/ble_parse.py +0 -0
- {dronecot-2.2.4 → dronecot-2.3.0}/src/dronecot/commands.py +0 -0
- {dronecot-2.2.4 → dronecot-2.3.0}/src/dronecot/dji_exceptions.py +0 -0
- {dronecot-2.2.4 → dronecot-2.3.0}/src/dronecot/dji_functions.py +0 -0
- {dronecot-2.2.4 → dronecot-2.3.0}/src/dronecot/dji_text_parser.py +0 -0
- {dronecot-2.2.4 → dronecot-2.3.0}/src/dronecot/open_drone_id.py +0 -0
- {dronecot-2.2.4 → dronecot-2.3.0}/src/dronecot/rid_normalize.py +0 -0
- {dronecot-2.2.4 → dronecot-2.3.0}/src/dronecot/udp_rid.py +0 -0
- {dronecot-2.2.4 → dronecot-2.3.0}/src/dronecot/wifi_capture.py +0 -0
- {dronecot-2.2.4 → dronecot-2.3.0}/src/dronecot/wifi_parse.py +0 -0
- {dronecot-2.2.4 → dronecot-2.3.0}/tests/test_adsb_vehicle.py +0 -0
- {dronecot-2.2.4 → dronecot-2.3.0}/tests/test_functions.py +0 -0
- {dronecot-2.2.4 → dronecot-2.3.0}/tests/test_rid_normalize.py +0 -0
- {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
|
|
@@ -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
|