dronecot 2.3.0__tar.gz → 2.3.2__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.3.0 → dronecot-2.3.2}/CHANGELOG.md +23 -0
- {dronecot-2.3.0/dronecot.egg-info → dronecot-2.3.2}/PKG-INFO +1 -1
- {dronecot-2.3.0 → dronecot-2.3.2/dronecot.egg-info}/PKG-INFO +1 -1
- {dronecot-2.3.0 → dronecot-2.3.2}/dronecot.egg-info/SOURCES.txt +1 -0
- dronecot-2.3.2/src/dronecot/VERSION +1 -0
- {dronecot-2.3.0 → dronecot-2.3.2}/src/dronecot/classes.py +65 -6
- {dronecot-2.3.0 → dronecot-2.3.2}/src/dronecot/functions.py +81 -8
- {dronecot-2.3.0 → dronecot-2.3.2}/src/dronecot/rid_track.py +15 -2
- {dronecot-2.3.0 → dronecot-2.3.2}/tests/test_functions.py +20 -8
- dronecot-2.3.2/tests/test_mavlink_identity.py +145 -0
- {dronecot-2.3.0 → dronecot-2.3.2}/tests/test_rid_track.py +99 -0
- dronecot-2.3.0/src/dronecot/VERSION +0 -1
- {dronecot-2.3.0 → dronecot-2.3.2}/LICENSE +0 -0
- {dronecot-2.3.0 → dronecot-2.3.2}/MANIFEST.in +0 -0
- {dronecot-2.3.0 → dronecot-2.3.2}/README.md +0 -0
- {dronecot-2.3.0 → dronecot-2.3.2}/dronecot.egg-info/dependency_links.txt +0 -0
- {dronecot-2.3.0 → dronecot-2.3.2}/dronecot.egg-info/entry_points.txt +0 -0
- {dronecot-2.3.0 → dronecot-2.3.2}/dronecot.egg-info/requires.txt +0 -0
- {dronecot-2.3.0 → dronecot-2.3.2}/dronecot.egg-info/top_level.txt +0 -0
- {dronecot-2.3.0 → dronecot-2.3.2}/setup.cfg +0 -0
- {dronecot-2.3.0 → dronecot-2.3.2}/setup.py +0 -0
- {dronecot-2.3.0 → dronecot-2.3.2}/src/dronecot/__init__.py +0 -0
- {dronecot-2.3.0 → dronecot-2.3.2}/src/dronecot/ble_capture.py +0 -0
- {dronecot-2.3.0 → dronecot-2.3.2}/src/dronecot/ble_parse.py +0 -0
- {dronecot-2.3.0 → dronecot-2.3.2}/src/dronecot/bluez_capture.py +0 -0
- {dronecot-2.3.0 → dronecot-2.3.2}/src/dronecot/commands.py +0 -0
- {dronecot-2.3.0 → dronecot-2.3.2}/src/dronecot/constants.py +0 -0
- {dronecot-2.3.0 → dronecot-2.3.2}/src/dronecot/dji_exceptions.py +0 -0
- {dronecot-2.3.0 → dronecot-2.3.2}/src/dronecot/dji_functions.py +0 -0
- {dronecot-2.3.0 → dronecot-2.3.2}/src/dronecot/dji_text_parser.py +0 -0
- {dronecot-2.3.0 → dronecot-2.3.2}/src/dronecot/odid.py +0 -0
- {dronecot-2.3.0 → dronecot-2.3.2}/src/dronecot/open_drone_id.py +0 -0
- {dronecot-2.3.0 → dronecot-2.3.2}/src/dronecot/rid_normalize.py +0 -0
- {dronecot-2.3.0 → dronecot-2.3.2}/src/dronecot/udp_rid.py +0 -0
- {dronecot-2.3.0 → dronecot-2.3.2}/src/dronecot/wifi_capture.py +0 -0
- {dronecot-2.3.0 → dronecot-2.3.2}/src/dronecot/wifi_parse.py +0 -0
- {dronecot-2.3.0 → dronecot-2.3.2}/tests/test_adsb_vehicle.py +0 -0
- {dronecot-2.3.0 → dronecot-2.3.2}/tests/test_bluez_capture.py +0 -0
- {dronecot-2.3.0 → dronecot-2.3.2}/tests/test_rid_normalize.py +0 -0
- {dronecot-2.3.0 → dronecot-2.3.2}/tests/test_sensor_detail.py +0 -0
- {dronecot-2.3.0 → dronecot-2.3.2}/tests/test_wifi_parse.py +0 -0
|
@@ -1,3 +1,26 @@
|
|
|
1
|
+
## DroneCOT 2.3.1
|
|
2
|
+
|
|
3
|
+
- **Fix: CoT `ce`/`le` reported ODID accuracy ENUM CODES as if they were metres.**
|
|
4
|
+
ASTM F3411 accuracy fields are enum codes; CoT `<point ce= le=>` are metres.
|
|
5
|
+
Emitting the raw code claimed precision the aircraft never reported, and the
|
|
6
|
+
worst case inverted the meaning entirely: code `0` means *"unknown / >= 18.52
|
|
7
|
+
km"* but rendered as `ce="0"` — a perfect fix. Observed live on a DroneBeacon
|
|
8
|
+
DB120: `HorizAccuracy=9` (ODID *< 30 m*) was drawn on the map at **9 m**, and
|
|
9
|
+
`BaroAccuracy=0` (unknown) as **zero error**. Codes now decode to metres via
|
|
10
|
+
tables mirroring `decodeHorizontalAccuracy()`/`decodeVerticalAccuracy()` in
|
|
11
|
+
opendroneid-core-c; unknown and reserved codes emit the CoT unknown sentinel
|
|
12
|
+
rather than a fabricated number. Four existing tests asserted the old raw
|
|
13
|
+
codes and have been corrected.
|
|
14
|
+
- **Fix: absent altitude rendered as sea level.** `<height value>` defaulted to
|
|
15
|
+
`0`; an unknown altitude now uses the CoT unknown sentinel like `hae`/`ce`/`le`.
|
|
16
|
+
- **Fix: MAC-less feeds shared one placeholder UID.** Serial/MAVLink receivers
|
|
17
|
+
(DroneScout Bridge, SiK) report no MAC, so a Location- or System-only message
|
|
18
|
+
had neither MAC nor serial and rendered as `Unknown-BasicID_0`, colliding
|
|
19
|
+
across every such source. The UID now falls back to `FEED-<sensor_id>`.
|
|
20
|
+
Measured on a live DroneScout Bridge: 57 correctly identified events alongside
|
|
21
|
+
3 placeholders. Aggregation is deliberately NOT keyed on the feed — one
|
|
22
|
+
receiver reports many aircraft, so that would merge distinct drones.
|
|
23
|
+
|
|
1
24
|
## DroneCOT 2.3.0
|
|
2
25
|
|
|
3
26
|
Correctness release for single-message Remote ID. A BLE legacy transmitter fits
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
2.3.2
|
|
@@ -82,13 +82,64 @@ class SerialWorker(pytak.QueueWorker):
|
|
|
82
82
|
|
|
83
83
|
return serial_port, baud_rate
|
|
84
84
|
|
|
85
|
-
|
|
85
|
+
@staticmethod
|
|
86
|
+
def _id_or_mac_to_mac(id_or_mac) -> Optional[str]:
|
|
87
|
+
"""Render a MAVLink OpenDroneID ``id_or_mac`` field as an advertiser MAC.
|
|
88
|
+
|
|
89
|
+
ODID carries a 20-byte ``id_or_mac`` naming the TRANSMITTER, and it is the
|
|
90
|
+
only per-aircraft grouping key a MAVLink serial feed offers -- unlike a
|
|
91
|
+
BLE or Wi-Fi capture there is no radio MAC in the metadata.
|
|
92
|
+
|
|
93
|
+
Measured on a DroneScout DS110: populated on 58 of 58 packs, as
|
|
94
|
+
db:13:81:94:13:55 followed by 14 zero bytes.
|
|
95
|
+
|
|
96
|
+
Only a MAC-shaped value (6 significant bytes then padding) is returned.
|
|
97
|
+
The field may instead carry an opaque ID string, and calling that a MAC
|
|
98
|
+
would put a wrong-looking value in the CoT UID; such feeds keep the
|
|
99
|
+
previous behaviour until there is real hardware to test against.
|
|
100
|
+
"""
|
|
101
|
+
if id_or_mac is None:
|
|
102
|
+
return None
|
|
103
|
+
raw = bytes(bytearray(id_or_mac))
|
|
104
|
+
if not any(raw) or len(raw) < 6 or any(raw[6:]):
|
|
105
|
+
return None
|
|
106
|
+
return ":".join("%02X" % b for b in raw[:6])
|
|
107
|
+
|
|
108
|
+
def _mavlink_meta(self, id_or_mac=None, payload_type: Optional[str] = None) -> dict:
|
|
109
|
+
"""Sensor metadata for a MAVLink-sourced record.
|
|
110
|
+
|
|
111
|
+
The wireless path builds this via rid_normalize.bytes_to_rid_dict; the
|
|
112
|
+
MAVLink path used to skip it entirely and hand up a record with no
|
|
113
|
+
``data`` sub-dict at all. Two consequences, both seen on a live box:
|
|
114
|
+
|
|
115
|
+
* no transmitter identity, so a pack carrying only System/OperatorID
|
|
116
|
+
and no BasicID had nothing to aggregate on and rendered as
|
|
117
|
+
op-Unknown-BasicID_0 -- every operator in range on ONE track;
|
|
118
|
+
* no sensor_id, so CoT was attributed to the default
|
|
119
|
+
dronecot_<hostname> rather than the configured SENSOR_ID, making it
|
|
120
|
+
impossible to tell which receiver saw a contact.
|
|
121
|
+
"""
|
|
122
|
+
data = dronecot.rid_normalize.uas_meta_defaults(self.config)
|
|
123
|
+
if payload_type:
|
|
124
|
+
data["type"] = payload_type
|
|
125
|
+
mac = self._id_or_mac_to_mac(id_or_mac)
|
|
126
|
+
if mac:
|
|
127
|
+
data["MAC address"] = mac
|
|
128
|
+
return data
|
|
129
|
+
|
|
130
|
+
def _mavlink_pack_to_parse_payload_schema(
|
|
131
|
+
self, messages, pack_size, id_or_mac=None
|
|
132
|
+
) -> dict:
|
|
86
133
|
"""Convert MAVLink OPEN_DRONE_ID_MESSAGE_PACK to parse_payload-like schema."""
|
|
87
134
|
parsed = dronecot.odid.message_pack_to_dict(messages, pack_size)
|
|
88
|
-
|
|
135
|
+
rid = dronecot.rid_normalize.odid_parsed_to_rid_dict(parsed)
|
|
136
|
+
rid["data"] = self._mavlink_meta(
|
|
137
|
+
id_or_mac, payload_type="MAVLink OPEN_DRONE_ID_MESSAGE_PACK"
|
|
138
|
+
)
|
|
139
|
+
return rid
|
|
89
140
|
|
|
90
141
|
@staticmethod
|
|
91
|
-
def _adsb_vehicle_to_rid_dict(msg) -> dict:
|
|
142
|
+
def _adsb_vehicle_to_rid_dict(msg, meta: Optional[dict] = None) -> dict:
|
|
92
143
|
"""Convert a MAVLink ADSB_VEHICLE to a RIDWorker dict.
|
|
93
144
|
|
|
94
145
|
The DroneScout Bridge (and similar Remote ID receivers) can emit detected
|
|
@@ -113,7 +164,7 @@ class SerialWorker(pytak.QueueWorker):
|
|
|
113
164
|
"SpeedHorizontal": getattr(msg, "hor_velocity", 0) / 100.0, # cm/s -> m/s
|
|
114
165
|
"SpeedVertical": getattr(msg, "ver_velocity", 0) / 100.0,
|
|
115
166
|
"Direction": getattr(msg, "heading", 0) / 100.0, # cdeg -> deg
|
|
116
|
-
"data": {"type": "MAVLink ADSB_VEHICLE"},
|
|
167
|
+
"data": dict(meta) if meta else {"type": "MAVLink ADSB_VEHICLE"},
|
|
117
168
|
}
|
|
118
169
|
|
|
119
170
|
async def run(self, _=-1) -> None:
|
|
@@ -180,11 +231,19 @@ class SerialWorker(pytak.QueueWorker):
|
|
|
180
231
|
msg_type = msg.get_type()
|
|
181
232
|
if msg_type == "OPEN_DRONE_ID_MESSAGE_PACK":
|
|
182
233
|
parsed_payload = self._mavlink_pack_to_parse_payload_schema(
|
|
183
|
-
msg.messages,
|
|
234
|
+
msg.messages,
|
|
235
|
+
msg.msg_pack_size,
|
|
236
|
+
getattr(msg, "id_or_mac", None),
|
|
184
237
|
)
|
|
185
238
|
await self.put_queue(parsed_payload)
|
|
186
239
|
elif msg_type == "ADSB_VEHICLE":
|
|
187
|
-
rid = self._adsb_vehicle_to_rid_dict(
|
|
240
|
+
rid = self._adsb_vehicle_to_rid_dict(
|
|
241
|
+
msg,
|
|
242
|
+
self._mavlink_meta(
|
|
243
|
+
getattr(msg, "id_or_mac", None),
|
|
244
|
+
payload_type="MAVLink ADSB_VEHICLE",
|
|
245
|
+
),
|
|
246
|
+
)
|
|
188
247
|
if rid:
|
|
189
248
|
await self.put_queue(rid)
|
|
190
249
|
elif msg_type == "HEARTBEAT":
|
|
@@ -190,6 +190,60 @@ def _cot_event_with_detail(
|
|
|
190
190
|
)
|
|
191
191
|
|
|
192
192
|
|
|
193
|
+
# ASTM F3411 / Open Drone ID accuracy fields are ENUM CODES, not metres. CoT
|
|
194
|
+
# <point ce=/le=> are metres. Emitting the raw code claims a precision the
|
|
195
|
+
# aircraft never reported -- and the worst case inverts the meaning entirely:
|
|
196
|
+
# code 0 means "unknown / >= 18.52 km" but rendered as ce="0", i.e. a PERFECT
|
|
197
|
+
# fix. Observed live: a DroneBeacon reporting HorizAccuracy=9 (<30 m) was drawn
|
|
198
|
+
# on the map at 9 m, and BaroAccuracy=0 (unknown) as zero error.
|
|
199
|
+
#
|
|
200
|
+
# Values mirror decodeHorizontalAccuracy()/decodeVerticalAccuracy() in
|
|
201
|
+
# opendroneid-core-c. Each code is an upper bound ("less than X"), so using X is
|
|
202
|
+
# the conservative reading.
|
|
203
|
+
CE_LE_UNKNOWN = "9999999.0"
|
|
204
|
+
|
|
205
|
+
ODID_HORIZ_ACCURACY_M = {
|
|
206
|
+
1: 18520.0, # 10 NM
|
|
207
|
+
2: 7408.0, # 4 NM
|
|
208
|
+
3: 3704.0, # 2 NM
|
|
209
|
+
4: 1852.0, # 1 NM
|
|
210
|
+
5: 926.0, # 0.5 NM
|
|
211
|
+
6: 555.6, # 0.3 NM
|
|
212
|
+
7: 185.2, # 0.1 NM
|
|
213
|
+
8: 92.6, # 0.05 NM
|
|
214
|
+
9: 30.0,
|
|
215
|
+
10: 10.0,
|
|
216
|
+
11: 3.0,
|
|
217
|
+
12: 1.0,
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
ODID_VERT_ACCURACY_M = {1: 150.0, 2: 45.0, 3: 25.0, 4: 10.0, 5: 3.0, 6: 1.0}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _odid_accuracy_m(value, table) -> str:
|
|
224
|
+
"""Map an ODID accuracy enum code to metres for CoT ce/le.
|
|
225
|
+
|
|
226
|
+
Returns the CoT unknown sentinel for code 0 (explicitly "unknown"), for any
|
|
227
|
+
unlisted/reserved code, and for a missing value -- never a fabricated 0.
|
|
228
|
+
"""
|
|
229
|
+
if value is None:
|
|
230
|
+
return CE_LE_UNKNOWN
|
|
231
|
+
try:
|
|
232
|
+
code = int(value)
|
|
233
|
+
except (TypeError, ValueError):
|
|
234
|
+
return CE_LE_UNKNOWN
|
|
235
|
+
metres = table.get(code)
|
|
236
|
+
return CE_LE_UNKNOWN if metres is None else str(metres)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _odid_horiz_ce(data: dict) -> str:
|
|
240
|
+
return _odid_accuracy_m(data.get("HorizAccuracy"), ODID_HORIZ_ACCURACY_M)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _odid_vert_le(data: dict) -> str:
|
|
244
|
+
return _odid_accuracy_m(data.get("VertAccuracy"), ODID_VERT_ACCURACY_M)
|
|
245
|
+
|
|
246
|
+
|
|
193
247
|
def _rid_identity(data: dict) -> Tuple[str, Optional[str]]:
|
|
194
248
|
"""Resolve a stable UAS identifier and the advertiser MAC for CoT UIDs.
|
|
195
249
|
|
|
@@ -208,7 +262,24 @@ def _rid_identity(data: dict) -> Tuple[str, Optional[str]]:
|
|
|
208
262
|
uasid = data.get("BasicID") or data.get("BasicID_0")
|
|
209
263
|
uasid = str(uasid).strip() if uasid else ""
|
|
210
264
|
if not uasid:
|
|
211
|
-
|
|
265
|
+
if mac:
|
|
266
|
+
uasid = f"MAC-{mac.replace(':', '').upper()}"
|
|
267
|
+
else:
|
|
268
|
+
# Neither serial nor MAC: serial/MAVLink receivers report no MAC at
|
|
269
|
+
# all. Fall back to the feed that delivered it so two receivers do
|
|
270
|
+
# not collide, rather than the shared Unknown-BasicID_0 placeholder
|
|
271
|
+
# that put every such aircraft on one track.
|
|
272
|
+
# Checked at BOTH levels, for the same reason the MAC is: MQTT and
|
|
273
|
+
# serial feeds put this at the top level. Looking only in the
|
|
274
|
+
# sub-dict meant this rung silently never fired for them.
|
|
275
|
+
feed = (
|
|
276
|
+
src_data.get("sensor_id")
|
|
277
|
+
or src_data.get("sensor ID")
|
|
278
|
+
or data.get("sensor_id")
|
|
279
|
+
or data.get("sensor ID")
|
|
280
|
+
)
|
|
281
|
+
feed = str(feed).strip() if feed else ""
|
|
282
|
+
uasid = f"FEED-{feed}" if feed else "Unknown-BasicID_0"
|
|
212
283
|
|
|
213
284
|
return uasid, mac
|
|
214
285
|
|
|
@@ -298,8 +369,8 @@ def rid_op_to_cot_xml( # NOQA pylint: disable=too-many-locals,too-many-branches
|
|
|
298
369
|
stale=cot_stale,
|
|
299
370
|
lat=lat,
|
|
300
371
|
lon=lon,
|
|
301
|
-
ce=
|
|
302
|
-
le=
|
|
372
|
+
ce=_odid_horiz_ce(data),
|
|
373
|
+
le=_odid_vert_le(data),
|
|
303
374
|
hae=str(data.get("OperatorAltitudeGeo", "9999999.0")),
|
|
304
375
|
detail=detail,
|
|
305
376
|
config=config,
|
|
@@ -365,7 +436,9 @@ def rid_uas_to_cot_xml( # NOQA pylint: disable=too-many-locals,too-many-branche
|
|
|
365
436
|
track.set("course", str(data.get("Direction", 0)))
|
|
366
437
|
|
|
367
438
|
height: ET.Element = ET.Element("height")
|
|
368
|
-
|
|
439
|
+
# Never default to 0: an absent altitude is unknown, not sea level. Matches
|
|
440
|
+
# the CoT unknown sentinel used for hae/ce/le above.
|
|
441
|
+
height.set("value", str(data.get("AltitudeGeo", CE_LE_UNKNOWN)))
|
|
369
442
|
|
|
370
443
|
# link: ET.Element = ET.Element("link")
|
|
371
444
|
# link.set("uid", op_uid)
|
|
@@ -490,8 +563,8 @@ def rid_uas_to_cot_xml( # NOQA pylint: disable=too-many-locals,too-many-branche
|
|
|
490
563
|
stale=cot_stale,
|
|
491
564
|
lat=lat,
|
|
492
565
|
lon=lon,
|
|
493
|
-
ce=
|
|
494
|
-
le=
|
|
566
|
+
ce=_odid_horiz_ce(data),
|
|
567
|
+
le=_odid_vert_le(data),
|
|
495
568
|
hae=str(data.get("AltitudeGeo", "9999999.0")),
|
|
496
569
|
detail=detail,
|
|
497
570
|
config=config,
|
|
@@ -572,8 +645,8 @@ def sensor_status_to_cot( # NOQA pylint: disable=too-many-locals,too-many-branc
|
|
|
572
645
|
stale=cot_stale,
|
|
573
646
|
lat=lat,
|
|
574
647
|
lon=lon,
|
|
575
|
-
ce=
|
|
576
|
-
le=
|
|
648
|
+
ce=_odid_horiz_ce(data),
|
|
649
|
+
le=_odid_vert_le(data),
|
|
577
650
|
hae=hae,
|
|
578
651
|
detail=detail,
|
|
579
652
|
config=config,
|
|
@@ -94,8 +94,21 @@ def _is_empty(value: Any) -> bool:
|
|
|
94
94
|
def track_key(rid: dict) -> Optional[Tuple[str, str]]:
|
|
95
95
|
"""Return a ``(kind, value)`` aggregation key for a normalized RID dict.
|
|
96
96
|
|
|
97
|
-
|
|
98
|
-
|
|
97
|
+
Keyed on the advertiser MAC, falling back to the transmitter's own serial.
|
|
98
|
+
|
|
99
|
+
Deliberately NOT keyed on the feed/sensor when neither is present. It is
|
|
100
|
+
tempting -- a MAC-less serial receiver looks like a single stream -- but a
|
|
101
|
+
DroneScout-style receiver reports MANY aircraft over one port, so feeding
|
|
102
|
+
them all into one key would merge distinct drones into a single track: the
|
|
103
|
+
exact bug this module exists to fix, reintroduced by another route. It also
|
|
104
|
+
splits rather than merges, because a BasicID message would key on the serial
|
|
105
|
+
while a Location message from the same aircraft keyed on the feed.
|
|
106
|
+
|
|
107
|
+
So a record with neither MAC nor serial passes through unaggregated, and
|
|
108
|
+
only its rendered UID falls back to the feed (see functions._rid_identity)
|
|
109
|
+
so that unidentified contacts from different receivers stay distinct.
|
|
110
|
+
|
|
111
|
+
Returns ``None`` when the record identifies no transmitter at all.
|
|
99
112
|
"""
|
|
100
113
|
meta = rid.get(_META_KEY) or {}
|
|
101
114
|
mac = meta.get("MAC address")
|
|
@@ -73,8 +73,11 @@ class FunctionsTestCase(unittest.TestCase):
|
|
|
73
73
|
self.assertIsNotNone(point)
|
|
74
74
|
self.assertEqual(point.get("lat"), "37.7599")
|
|
75
75
|
self.assertEqual(point.get("lon"), "-122.4977")
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
# ODID accuracy codes, decoded to METRES for CoT: HorizAccuracy 12 is
|
|
77
|
+
# ODID_HOR_ACC_1_METER and VertAccuracy 5 is ODID_VER_ACC_3_METER. These
|
|
78
|
+
# previously asserted the raw codes "12"/"5", which claimed 12 m / 5 m.
|
|
79
|
+
self.assertEqual(point.get("ce"), "1.0")
|
|
80
|
+
self.assertEqual(point.get("le"), "3.0")
|
|
78
81
|
self.assertEqual(point.get("hae"), "28.0")
|
|
79
82
|
|
|
80
83
|
detail = cot_xml.find("detail")
|
|
@@ -113,8 +116,11 @@ class FunctionsTestCase(unittest.TestCase):
|
|
|
113
116
|
self.assertIsNotNone(point)
|
|
114
117
|
self.assertEqual(point.get("lat"), "37.7599")
|
|
115
118
|
self.assertEqual(point.get("lon"), "-122.4977")
|
|
116
|
-
|
|
117
|
-
|
|
119
|
+
# ODID accuracy codes, decoded to METRES for CoT: HorizAccuracy 12 is
|
|
120
|
+
# ODID_HOR_ACC_1_METER and VertAccuracy 5 is ODID_VER_ACC_3_METER. These
|
|
121
|
+
# previously asserted the raw codes "12"/"5", which claimed 12 m / 5 m.
|
|
122
|
+
self.assertEqual(point.get("ce"), "1.0")
|
|
123
|
+
self.assertEqual(point.get("le"), "3.0")
|
|
118
124
|
self.assertEqual(point.get("hae"), "28.0")
|
|
119
125
|
|
|
120
126
|
detail = cot_xml.find("detail")
|
|
@@ -153,8 +159,11 @@ class FunctionsTestCase(unittest.TestCase):
|
|
|
153
159
|
self.assertIsNotNone(point)
|
|
154
160
|
self.assertEqual(point.get("lat"), "37.7599")
|
|
155
161
|
self.assertEqual(point.get("lon"), "-122.4977")
|
|
156
|
-
|
|
157
|
-
|
|
162
|
+
# ODID accuracy codes, decoded to METRES for CoT: HorizAccuracy 12 is
|
|
163
|
+
# ODID_HOR_ACC_1_METER and VertAccuracy 5 is ODID_VER_ACC_3_METER. These
|
|
164
|
+
# previously asserted the raw codes "12"/"5", which claimed 12 m / 5 m.
|
|
165
|
+
self.assertEqual(point.get("ce"), "1.0")
|
|
166
|
+
self.assertEqual(point.get("le"), "3.0")
|
|
158
167
|
self.assertEqual(point.get("hae"), "28.0")
|
|
159
168
|
|
|
160
169
|
detail = cot_xml.find("detail")
|
|
@@ -193,8 +202,11 @@ class FunctionsTestCase(unittest.TestCase):
|
|
|
193
202
|
self.assertIsNotNone(point)
|
|
194
203
|
self.assertEqual(point.get("lat"), "37.7599")
|
|
195
204
|
self.assertEqual(point.get("lon"), "-122.4977")
|
|
196
|
-
|
|
197
|
-
|
|
205
|
+
# ODID accuracy codes, decoded to METRES for CoT: HorizAccuracy 12 is
|
|
206
|
+
# ODID_HOR_ACC_1_METER and VertAccuracy 5 is ODID_VER_ACC_3_METER. These
|
|
207
|
+
# previously asserted the raw codes "12"/"5", which claimed 12 m / 5 m.
|
|
208
|
+
self.assertEqual(point.get("ce"), "1.0")
|
|
209
|
+
self.assertEqual(point.get("le"), "3.0")
|
|
198
210
|
self.assertEqual(point.get("hae"), "28.0")
|
|
199
211
|
|
|
200
212
|
detail = cot_xml.find("detail")
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# Copyright Sensors & Signals LLC https://www.snstac.com/
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
"""Transmitter identity on the MAVLink serial feed (DroneScout Bridge).
|
|
5
|
+
|
|
6
|
+
Regression tests for operator tracks collapsing onto op-Unknown-BasicID_0.
|
|
7
|
+
|
|
8
|
+
Measured on a live box (aryaos-4f11, DroneScout DS110, dronecot 2.3.1), 30s of
|
|
9
|
+
CoT on the Mesh SA group, attributed by source IP:
|
|
10
|
+
|
|
11
|
+
op-Unknown-BasicID_0 x62 <- every operator on ONE track
|
|
12
|
+
RID.1787F04BM24010011195.uas x54 correct
|
|
13
|
+
op-1787F04BM24010011195 x54 correct
|
|
14
|
+
|
|
15
|
+
The aircraft UID resolved but the operator UID did not. Cause: the MAVLink path
|
|
16
|
+
called rid_normalize.odid_parsed_to_rid_dict directly and attached no ``data``
|
|
17
|
+
metadata at all, unlike the wireless path (bytes_to_rid_dict). With no MAC and no
|
|
18
|
+
sensor_id, a pack carrying only System/OperatorID had no transmitter identity to
|
|
19
|
+
key on, so functions._rid_identity fell all the way to Unknown-BasicID_0.
|
|
20
|
+
|
|
21
|
+
MAVLink OpenDroneID carries a 20-byte ``id_or_mac`` naming the transmitter. The
|
|
22
|
+
DS110 populates it on every pack -- measured 58 of 58, as
|
|
23
|
+
db:13:81:94:13:55 + 14 zero bytes -- and it is the only per-aircraft grouping key
|
|
24
|
+
a serial feed offers.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import types
|
|
28
|
+
import unittest
|
|
29
|
+
from unittest import mock
|
|
30
|
+
|
|
31
|
+
from dronecot.classes import SerialWorker
|
|
32
|
+
from dronecot.functions import _rid_identity
|
|
33
|
+
|
|
34
|
+
# Exactly what the DS110 sent, including the 14 bytes of padding.
|
|
35
|
+
DS110_ID_OR_MAC = bytes.fromhex("db13819413550000000000000000000000000000")
|
|
36
|
+
DS110_MAC = "DB:13:81:94:13:55"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _worker(sensor_id="dronescout"):
|
|
40
|
+
"""A SerialWorker with just enough state for the metadata helpers."""
|
|
41
|
+
w = SerialWorker.__new__(SerialWorker)
|
|
42
|
+
w.config = {"SENSOR_ID": sensor_id}
|
|
43
|
+
return w
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class IdOrMacTestCase(unittest.TestCase):
|
|
47
|
+
def test_real_ds110_value_renders_as_mac(self):
|
|
48
|
+
self.assertEqual(SerialWorker._id_or_mac_to_mac(DS110_ID_OR_MAC), DS110_MAC)
|
|
49
|
+
|
|
50
|
+
def test_all_zeros_is_no_identity(self):
|
|
51
|
+
self.assertIsNone(SerialWorker._id_or_mac_to_mac(bytes(20)))
|
|
52
|
+
|
|
53
|
+
def test_absent_is_no_identity(self):
|
|
54
|
+
self.assertIsNone(SerialWorker._id_or_mac_to_mac(None))
|
|
55
|
+
|
|
56
|
+
def test_opaque_id_is_not_claimed_to_be_a_mac(self):
|
|
57
|
+
"""Payload past byte 6 means this is an ID string, not a MAC.
|
|
58
|
+
|
|
59
|
+
Rendering it as one would put a wrong-looking value in the CoT UID, so it
|
|
60
|
+
is left alone rather than guessed at.
|
|
61
|
+
"""
|
|
62
|
+
self.assertIsNone(SerialWorker._id_or_mac_to_mac(b"SERIAL12345678901234"))
|
|
63
|
+
|
|
64
|
+
def test_accepts_a_bare_six_byte_field(self):
|
|
65
|
+
self.assertEqual(
|
|
66
|
+
SerialWorker._id_or_mac_to_mac(bytes.fromhex("db13819413 55".replace(" ", ""))),
|
|
67
|
+
DS110_MAC,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class MavlinkMetaTestCase(unittest.TestCase):
|
|
72
|
+
def test_meta_carries_identity_and_configured_sensor_id(self):
|
|
73
|
+
meta = _worker()._mavlink_meta(DS110_ID_OR_MAC, payload_type="pack")
|
|
74
|
+
self.assertEqual(meta["MAC address"], DS110_MAC)
|
|
75
|
+
# Previously absent, so CoT was attributed to dronecot_<hostname> and you
|
|
76
|
+
# could not tell which receiver saw a contact.
|
|
77
|
+
self.assertEqual(meta["sensor_id"], "dronescout")
|
|
78
|
+
self.assertEqual(meta["type"], "pack")
|
|
79
|
+
|
|
80
|
+
def test_no_identity_still_yields_sensor_id(self):
|
|
81
|
+
meta = _worker()._mavlink_meta(bytes(20))
|
|
82
|
+
self.assertNotIn("MAC address", meta)
|
|
83
|
+
self.assertEqual(meta["sensor_id"], "dronescout")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class PackIdentityTestCase(unittest.TestCase):
|
|
87
|
+
"""The actual regression: a pack with no BasicID must still identify itself."""
|
|
88
|
+
|
|
89
|
+
def _pack(self, parsed):
|
|
90
|
+
with mock.patch("dronecot.odid.message_pack_to_dict", return_value=parsed):
|
|
91
|
+
return _worker()._mavlink_pack_to_parse_payload_schema(
|
|
92
|
+
b"\x00" * 25, 1, DS110_ID_OR_MAC
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def test_pack_without_basicid_gets_a_distinct_uid(self):
|
|
96
|
+
rid = self._pack({"OperatorLatitude": 37.7, "OperatorLongitude": -122.4})
|
|
97
|
+
uasid, mac = _rid_identity(rid)
|
|
98
|
+
self.assertEqual(mac, DS110_MAC)
|
|
99
|
+
# The bug: this was "Unknown-BasicID_0" for every transmitter in range.
|
|
100
|
+
self.assertNotEqual(uasid, "Unknown-BasicID_0")
|
|
101
|
+
self.assertEqual(uasid, "MAC-DB1381941355")
|
|
102
|
+
|
|
103
|
+
def test_pack_with_basicid_prefers_the_real_serial(self):
|
|
104
|
+
rid = self._pack({"UASID": "1787F04BM24010011195"})
|
|
105
|
+
uasid, mac = _rid_identity(rid)
|
|
106
|
+
self.assertEqual(uasid, "1787F04BM24010011195")
|
|
107
|
+
self.assertEqual(mac, DS110_MAC)
|
|
108
|
+
|
|
109
|
+
def test_two_transmitters_do_not_collapse(self):
|
|
110
|
+
"""The DS110 reports many aircraft over one port; they must stay distinct."""
|
|
111
|
+
other = bytes.fromhex("aabbccddeeff" + "00" * 14)
|
|
112
|
+
with mock.patch("dronecot.odid.message_pack_to_dict", return_value={}):
|
|
113
|
+
a = _worker()._mavlink_pack_to_parse_payload_schema(
|
|
114
|
+
b"\x00" * 25, 1, DS110_ID_OR_MAC
|
|
115
|
+
)
|
|
116
|
+
b = _worker()._mavlink_pack_to_parse_payload_schema(
|
|
117
|
+
b"\x00" * 25, 1, other
|
|
118
|
+
)
|
|
119
|
+
self.assertNotEqual(_rid_identity(a)[0], _rid_identity(b)[0])
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class SensorIdFallbackTestCase(unittest.TestCase):
|
|
123
|
+
"""_rid_identity looked for sensor_id only in data['data'].
|
|
124
|
+
|
|
125
|
+
The MAC was already checked at both levels because "MQTT and serial feeds put
|
|
126
|
+
it at the top level" -- the same is true of sensor_id, so that rung of the
|
|
127
|
+
fallback silently never fired for those feeds.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
def test_top_level_sensor_id_is_used(self):
|
|
131
|
+
uasid, mac = _rid_identity({"sensor_id": "dronescout"})
|
|
132
|
+
self.assertIsNone(mac)
|
|
133
|
+
self.assertEqual(uasid, "FEED-dronescout")
|
|
134
|
+
|
|
135
|
+
def test_nested_sensor_id_still_works(self):
|
|
136
|
+
uasid, _ = _rid_identity({"data": {"sensor_id": "dronescout"}})
|
|
137
|
+
self.assertEqual(uasid, "FEED-dronescout")
|
|
138
|
+
|
|
139
|
+
def test_nothing_at_all_is_still_the_last_resort(self):
|
|
140
|
+
uasid, _ = _rid_identity({})
|
|
141
|
+
self.assertEqual(uasid, "Unknown-BasicID_0")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
if __name__ == "__main__":
|
|
145
|
+
unittest.main()
|
|
@@ -267,6 +267,56 @@ class TestFieldPadding(unittest.TestCase):
|
|
|
267
267
|
self.assertEqual(event.get("uid"), "RID.SHORTSN.uas")
|
|
268
268
|
|
|
269
269
|
|
|
270
|
+
class TestMaclessSources(unittest.TestCase):
|
|
271
|
+
"""Serial/MAVLink receivers report no MAC at all.
|
|
272
|
+
|
|
273
|
+
Measured on a live DroneScout Bridge: 57 correctly identified events
|
|
274
|
+
alongside 3 rendered under the shared ``Unknown-BasicID_0`` placeholder.
|
|
275
|
+
|
|
276
|
+
These records must NOT be aggregated on the feed -- one receiver reports
|
|
277
|
+
many aircraft, so a feed key would merge distinct drones. Instead they pass
|
|
278
|
+
through, and only the rendered UID falls back to the feed so contacts from
|
|
279
|
+
different receivers stay distinct.
|
|
280
|
+
"""
|
|
281
|
+
|
|
282
|
+
@staticmethod
|
|
283
|
+
def _serial_rid(msg, sensor="dronescout"):
|
|
284
|
+
"""Normalize a message the way a serial worker does: NO MAC address."""
|
|
285
|
+
return rid_normalize.bytes_to_rid_dict(
|
|
286
|
+
msg, {"sensor_id": sensor, "type": "MAVLink"}
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
def test_macless_without_serial_uses_feed_not_placeholder(self):
|
|
290
|
+
"""The fix: never render under the shared placeholder UID."""
|
|
291
|
+
rid = self._serial_rid(_system_message(51.5, -0.12))
|
|
292
|
+
uid, mac = functions._rid_identity(rid)
|
|
293
|
+
self.assertIsNone(mac)
|
|
294
|
+
self.assertEqual(uid, "FEED-dronescout")
|
|
295
|
+
self.assertNotEqual(uid, "Unknown-BasicID_0")
|
|
296
|
+
|
|
297
|
+
def test_different_receivers_stay_distinct(self):
|
|
298
|
+
a = functions._rid_identity(self._serial_rid(_system_message(1.0, 2.0), "rx-a"))[0]
|
|
299
|
+
b = functions._rid_identity(self._serial_rid(_system_message(3.0, 4.0), "rx-b"))[0]
|
|
300
|
+
self.assertNotEqual(a, b)
|
|
301
|
+
|
|
302
|
+
def test_serial_wins_over_feed_in_uid(self):
|
|
303
|
+
rid = self._serial_rid(_basic_id_message("SERIAL9"))
|
|
304
|
+
self.assertEqual(functions._rid_identity(rid)[0], "SERIAL9")
|
|
305
|
+
|
|
306
|
+
def test_feed_is_NOT_used_as_an_aggregation_key(self):
|
|
307
|
+
"""Guard against merging distinct aircraft from one multi-drone feed."""
|
|
308
|
+
rid = self._serial_rid(_location_message(51.5, -0.12))
|
|
309
|
+
self.assertIsNone(
|
|
310
|
+
rid_track.track_key(rid),
|
|
311
|
+
"a MAC-less, serial-less record must not be keyed on its feed",
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
def test_mac_still_wins_when_present(self):
|
|
315
|
+
rid = _rid(_location_message(1.0, 2.0), "AA:BB:CC:DD:EE:FF")
|
|
316
|
+
rid["data"]["sensor_id"] = "dronescout"
|
|
317
|
+
self.assertEqual(rid_track.track_key(rid)[0], "mac")
|
|
318
|
+
|
|
319
|
+
|
|
270
320
|
class TestRidIdentity(unittest.TestCase):
|
|
271
321
|
def test_missing_basic_id_falls_back_to_mac(self):
|
|
272
322
|
rid = _rid(_location_message(1.0, 2.0), "DF:72:11:D2:6B:95")
|
|
@@ -300,3 +350,52 @@ class TestRidIdentity(unittest.TestCase):
|
|
|
300
350
|
|
|
301
351
|
if __name__ == "__main__":
|
|
302
352
|
unittest.main()
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
class TestAccuracyEnums(unittest.TestCase):
|
|
356
|
+
"""ODID accuracy fields are enum CODES; CoT ce/le are METRES.
|
|
357
|
+
|
|
358
|
+
Emitting the raw code claims precision the aircraft never reported. Worst
|
|
359
|
+
case inverts the meaning: code 0 means "unknown / >= 18.52 km" but rendered
|
|
360
|
+
as ce="0" -- a perfect fix. Observed live on a DroneBeacon: HorizAccuracy=9
|
|
361
|
+
(<30 m) drawn at 9 m, BaroAccuracy=0 (unknown) as zero error.
|
|
362
|
+
"""
|
|
363
|
+
|
|
364
|
+
def test_horizontal_codes_map_to_metres(self):
|
|
365
|
+
# Mirrors decodeHorizontalAccuracy() in opendroneid-core-c.
|
|
366
|
+
for code, metres in ((9, "30.0"), (10, "10.0"), (11, "3.0"), (12, "1.0"),
|
|
367
|
+
(1, "18520.0"), (6, "555.6")):
|
|
368
|
+
self.assertEqual(functions._odid_horiz_ce({"HorizAccuracy": code}), metres)
|
|
369
|
+
|
|
370
|
+
def test_vertical_codes_map_to_metres(self):
|
|
371
|
+
for code, metres in ((1, "150.0"), (2, "45.0"), (4, "10.0"), (6, "1.0")):
|
|
372
|
+
self.assertEqual(functions._odid_vert_le({"VertAccuracy": code}), metres)
|
|
373
|
+
|
|
374
|
+
def test_unknown_code_zero_is_not_perfect_precision(self):
|
|
375
|
+
"""The dangerous inversion: 0 means UNKNOWN, never zero error."""
|
|
376
|
+
self.assertEqual(functions._odid_horiz_ce({"HorizAccuracy": 0}),
|
|
377
|
+
functions.CE_LE_UNKNOWN)
|
|
378
|
+
self.assertEqual(functions._odid_vert_le({"VertAccuracy": 0}),
|
|
379
|
+
functions.CE_LE_UNKNOWN)
|
|
380
|
+
|
|
381
|
+
def test_missing_or_reserved_codes_are_unknown(self):
|
|
382
|
+
for probe in ({}, {"HorizAccuracy": None}, {"HorizAccuracy": 15},
|
|
383
|
+
{"HorizAccuracy": "junk"}):
|
|
384
|
+
self.assertEqual(functions._odid_horiz_ce(probe), functions.CE_LE_UNKNOWN)
|
|
385
|
+
|
|
386
|
+
def test_end_to_end_cot_uses_metres_not_the_code(self):
|
|
387
|
+
pack = (bytes([0xF0, ODID_MESSAGE_SIZE, 2])
|
|
388
|
+
+ _basic_id_message("SERIAL1") + _location_message(37.7, -122.4))
|
|
389
|
+
rid = rid_normalize.bytes_to_rid_dict(pack, {"MAC address": "AA:BB:CC:DD:EE:FF"})
|
|
390
|
+
rid["HorizAccuracy"] = 9 # ODID: < 30 m
|
|
391
|
+
rid["VertAccuracy"] = 2 # ODID: < 45 m
|
|
392
|
+
point = functions.rid_uas_to_cot_xml(rid, {}).find("point")
|
|
393
|
+
self.assertEqual(point.get("ce"), "30.0")
|
|
394
|
+
self.assertEqual(point.get("le"), "45.0")
|
|
395
|
+
|
|
396
|
+
def test_absent_altitude_is_not_rendered_as_sea_level(self):
|
|
397
|
+
rid = _rid(_location_message(1.0, 2.0), "AA:BB:CC:DD:EE:FF")
|
|
398
|
+
rid.pop("AltitudeGeo", None)
|
|
399
|
+
event = functions.rid_uas_to_cot_xml(rid, {})
|
|
400
|
+
height = event.find(".//height")
|
|
401
|
+
self.assertEqual(height.get("value"), functions.CE_LE_UNKNOWN)
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
2.3.0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|