dronecot 2.3.1__tar.gz → 2.3.3__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.1 → dronecot-2.3.3}/CHANGELOG.md +8 -0
- {dronecot-2.3.1/dronecot.egg-info → dronecot-2.3.3}/PKG-INFO +2 -2
- {dronecot-2.3.1 → dronecot-2.3.3/dronecot.egg-info}/PKG-INFO +2 -2
- {dronecot-2.3.1 → dronecot-2.3.3}/dronecot.egg-info/SOURCES.txt +3 -0
- {dronecot-2.3.1 → dronecot-2.3.3}/dronecot.egg-info/requires.txt +1 -1
- {dronecot-2.3.1 → dronecot-2.3.3}/setup.cfg +1 -1
- dronecot-2.3.3/src/dronecot/VERSION +1 -0
- {dronecot-2.3.1 → dronecot-2.3.3}/src/dronecot/__init__.py +11 -0
- {dronecot-2.3.1 → dronecot-2.3.3}/src/dronecot/classes.py +299 -19
- {dronecot-2.3.1 → dronecot-2.3.3}/src/dronecot/functions.py +9 -1
- dronecot-2.3.3/tests/test_dji_listener.py +108 -0
- dronecot-2.3.3/tests/test_mavlink_identity.py +145 -0
- dronecot-2.3.3/tests/test_status.py +268 -0
- dronecot-2.3.1/src/dronecot/VERSION +0 -1
- {dronecot-2.3.1 → dronecot-2.3.3}/LICENSE +0 -0
- {dronecot-2.3.1 → dronecot-2.3.3}/MANIFEST.in +0 -0
- {dronecot-2.3.1 → dronecot-2.3.3}/README.md +0 -0
- {dronecot-2.3.1 → dronecot-2.3.3}/dronecot.egg-info/dependency_links.txt +0 -0
- {dronecot-2.3.1 → dronecot-2.3.3}/dronecot.egg-info/entry_points.txt +0 -0
- {dronecot-2.3.1 → dronecot-2.3.3}/dronecot.egg-info/top_level.txt +0 -0
- {dronecot-2.3.1 → dronecot-2.3.3}/setup.py +0 -0
- {dronecot-2.3.1 → dronecot-2.3.3}/src/dronecot/ble_capture.py +0 -0
- {dronecot-2.3.1 → dronecot-2.3.3}/src/dronecot/ble_parse.py +0 -0
- {dronecot-2.3.1 → dronecot-2.3.3}/src/dronecot/bluez_capture.py +0 -0
- {dronecot-2.3.1 → dronecot-2.3.3}/src/dronecot/commands.py +0 -0
- {dronecot-2.3.1 → dronecot-2.3.3}/src/dronecot/constants.py +0 -0
- {dronecot-2.3.1 → dronecot-2.3.3}/src/dronecot/dji_exceptions.py +0 -0
- {dronecot-2.3.1 → dronecot-2.3.3}/src/dronecot/dji_functions.py +0 -0
- {dronecot-2.3.1 → dronecot-2.3.3}/src/dronecot/dji_text_parser.py +0 -0
- {dronecot-2.3.1 → dronecot-2.3.3}/src/dronecot/odid.py +0 -0
- {dronecot-2.3.1 → dronecot-2.3.3}/src/dronecot/open_drone_id.py +0 -0
- {dronecot-2.3.1 → dronecot-2.3.3}/src/dronecot/rid_normalize.py +0 -0
- {dronecot-2.3.1 → dronecot-2.3.3}/src/dronecot/rid_track.py +0 -0
- {dronecot-2.3.1 → dronecot-2.3.3}/src/dronecot/udp_rid.py +0 -0
- {dronecot-2.3.1 → dronecot-2.3.3}/src/dronecot/wifi_capture.py +0 -0
- {dronecot-2.3.1 → dronecot-2.3.3}/src/dronecot/wifi_parse.py +0 -0
- {dronecot-2.3.1 → dronecot-2.3.3}/tests/test_adsb_vehicle.py +0 -0
- {dronecot-2.3.1 → dronecot-2.3.3}/tests/test_bluez_capture.py +0 -0
- {dronecot-2.3.1 → dronecot-2.3.3}/tests/test_functions.py +0 -0
- {dronecot-2.3.1 → dronecot-2.3.3}/tests/test_rid_normalize.py +0 -0
- {dronecot-2.3.1 → dronecot-2.3.3}/tests/test_rid_track.py +0 -0
- {dronecot-2.3.1 → dronecot-2.3.3}/tests/test_sensor_detail.py +0 -0
- {dronecot-2.3.1 → dronecot-2.3.3}/tests/test_wifi_parse.py +0 -0
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
## DroneCOT 2.3.3
|
|
2
|
+
|
|
3
|
+
- Fixed the AntSDR scanner-push listener retaining active client tasks while
|
|
4
|
+
DroneCOT shut down after a TAK transport failure. The listener now owns and
|
|
5
|
+
closes its server, client tasks and writers with bounded waits, allowing the
|
|
6
|
+
PyTAK exception to reach systemd and trigger a reconnecting restart.
|
|
7
|
+
- Require PyTAK 7.4.1 for bounded first-exception worker shutdown.
|
|
8
|
+
|
|
1
9
|
## DroneCOT 2.3.1
|
|
2
10
|
|
|
3
11
|
- **Fix: CoT `ce`/`le` reported ODID accuracy ENUM CODES as if they were metres.**
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dronecot
|
|
3
|
-
Version: 2.3.
|
|
3
|
+
Version: 2.3.3
|
|
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>
|
|
@@ -22,7 +22,7 @@ Classifier: Operating System :: OS Independent
|
|
|
22
22
|
Requires-Python: <4,>=3.9
|
|
23
23
|
Description-Content-Type: text/markdown
|
|
24
24
|
License-File: LICENSE
|
|
25
|
-
Requires-Dist: pytak>=7.
|
|
25
|
+
Requires-Dist: pytak>=7.4.1
|
|
26
26
|
Requires-Dist: paho-mqtt<2.0.0
|
|
27
27
|
Requires-Dist: bitstruct
|
|
28
28
|
Requires-Dist: pymavlink
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dronecot
|
|
3
|
-
Version: 2.3.
|
|
3
|
+
Version: 2.3.3
|
|
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>
|
|
@@ -22,7 +22,7 @@ Classifier: Operating System :: OS Independent
|
|
|
22
22
|
Requires-Python: <4,>=3.9
|
|
23
23
|
Description-Content-Type: text/markdown
|
|
24
24
|
License-File: LICENSE
|
|
25
|
-
Requires-Dist: pytak>=7.
|
|
25
|
+
Requires-Dist: pytak>=7.4.1
|
|
26
26
|
Requires-Dist: paho-mqtt<2.0.0
|
|
27
27
|
Requires-Dist: bitstruct
|
|
28
28
|
Requires-Dist: pymavlink
|
|
@@ -31,8 +31,11 @@ src/dronecot/wifi_capture.py
|
|
|
31
31
|
src/dronecot/wifi_parse.py
|
|
32
32
|
tests/test_adsb_vehicle.py
|
|
33
33
|
tests/test_bluez_capture.py
|
|
34
|
+
tests/test_dji_listener.py
|
|
34
35
|
tests/test_functions.py
|
|
36
|
+
tests/test_mavlink_identity.py
|
|
35
37
|
tests/test_rid_normalize.py
|
|
36
38
|
tests/test_rid_track.py
|
|
37
39
|
tests/test_sensor_detail.py
|
|
40
|
+
tests/test_status.py
|
|
38
41
|
tests/test_wifi_parse.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
2.3.3
|
|
@@ -18,6 +18,17 @@
|
|
|
18
18
|
|
|
19
19
|
"""Drone Open Remote ID to TAK Gateway (with DJI Drone ID and UDP Remote ID support)."""
|
|
20
20
|
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
# Read from the same VERSION file setup.cfg packages, so the version an operator
|
|
24
|
+
# sees in the management UI cannot drift from the version of the installed .deb.
|
|
25
|
+
__version__ = (
|
|
26
|
+
Path(__file__)
|
|
27
|
+
.resolve()
|
|
28
|
+
.parent.joinpath("VERSION")
|
|
29
|
+
.read_text(encoding="utf-8")
|
|
30
|
+
.strip()
|
|
31
|
+
)
|
|
21
32
|
|
|
22
33
|
from .constants import ( # NOQA
|
|
23
34
|
DEFAULT_MQTT_TOPIC,
|
|
@@ -27,7 +27,7 @@ import ssl
|
|
|
27
27
|
from pathlib import Path
|
|
28
28
|
|
|
29
29
|
from socket import socket
|
|
30
|
-
from typing import Optional, Union
|
|
30
|
+
from typing import Optional, Set, Union
|
|
31
31
|
from urllib.parse import urlparse
|
|
32
32
|
|
|
33
33
|
import lzma
|
|
@@ -42,6 +42,46 @@ import pytak
|
|
|
42
42
|
import dronecot
|
|
43
43
|
|
|
44
44
|
|
|
45
|
+
class _NoStatus:
|
|
46
|
+
"""Stand-in for pytak.StatusWriter on a pytak too old to have one.
|
|
47
|
+
|
|
48
|
+
AryaOS boxes are updated as packages, so dronecot can land on a host whose
|
|
49
|
+
pytak predates StatusWriter (added in 7.4.0) -- 7.3.13 is still in the field.
|
|
50
|
+
A hard reference would raise AttributeError at import and take the gateway
|
|
51
|
+
down over its telemetry helper, which is exactly backwards: moving CoT is
|
|
52
|
+
the job, reporting on it is not.
|
|
53
|
+
|
|
54
|
+
Degrading here is safe because it is VISIBLE. With nothing writing
|
|
55
|
+
/run/dronecot/status.json, the Cockpit plugin reports "No status from this
|
|
56
|
+
gateway ... may be running a pytak too old to report status" rather than
|
|
57
|
+
rendering an empty decode feed as though the sky were empty.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def count(self, *args, **kwargs) -> None:
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
def record(self, *args, **kwargs) -> None:
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
def set(self, *args, **kwargs) -> None:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
def write(self, *args, **kwargs) -> bool:
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# Resolved at import so a missing StatusWriter is a startup-time decision
|
|
74
|
+
# rather than an AttributeError on the first Remote ID advertisement.
|
|
75
|
+
_StatusWriter = getattr(pytak, "StatusWriter", None)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def make_status(app_name: str, version: str):
|
|
79
|
+
"""Return a status writer, or a no-op if this pytak has none."""
|
|
80
|
+
if _StatusWriter is None:
|
|
81
|
+
return _NoStatus()
|
|
82
|
+
return _StatusWriter(app_name, version=version)
|
|
83
|
+
|
|
84
|
+
|
|
45
85
|
class SerialWorker(pytak.QueueWorker):
|
|
46
86
|
"""Queue Worker for MAVLink serial Open Drone ID."""
|
|
47
87
|
|
|
@@ -82,13 +122,64 @@ class SerialWorker(pytak.QueueWorker):
|
|
|
82
122
|
|
|
83
123
|
return serial_port, baud_rate
|
|
84
124
|
|
|
85
|
-
|
|
125
|
+
@staticmethod
|
|
126
|
+
def _id_or_mac_to_mac(id_or_mac) -> Optional[str]:
|
|
127
|
+
"""Render a MAVLink OpenDroneID ``id_or_mac`` field as an advertiser MAC.
|
|
128
|
+
|
|
129
|
+
ODID carries a 20-byte ``id_or_mac`` naming the TRANSMITTER, and it is the
|
|
130
|
+
only per-aircraft grouping key a MAVLink serial feed offers -- unlike a
|
|
131
|
+
BLE or Wi-Fi capture there is no radio MAC in the metadata.
|
|
132
|
+
|
|
133
|
+
Measured on a DroneScout DS110: populated on 58 of 58 packs, as
|
|
134
|
+
db:13:81:94:13:55 followed by 14 zero bytes.
|
|
135
|
+
|
|
136
|
+
Only a MAC-shaped value (6 significant bytes then padding) is returned.
|
|
137
|
+
The field may instead carry an opaque ID string, and calling that a MAC
|
|
138
|
+
would put a wrong-looking value in the CoT UID; such feeds keep the
|
|
139
|
+
previous behaviour until there is real hardware to test against.
|
|
140
|
+
"""
|
|
141
|
+
if id_or_mac is None:
|
|
142
|
+
return None
|
|
143
|
+
raw = bytes(bytearray(id_or_mac))
|
|
144
|
+
if not any(raw) or len(raw) < 6 or any(raw[6:]):
|
|
145
|
+
return None
|
|
146
|
+
return ":".join("%02X" % b for b in raw[:6])
|
|
147
|
+
|
|
148
|
+
def _mavlink_meta(self, id_or_mac=None, payload_type: Optional[str] = None) -> dict:
|
|
149
|
+
"""Sensor metadata for a MAVLink-sourced record.
|
|
150
|
+
|
|
151
|
+
The wireless path builds this via rid_normalize.bytes_to_rid_dict; the
|
|
152
|
+
MAVLink path used to skip it entirely and hand up a record with no
|
|
153
|
+
``data`` sub-dict at all. Two consequences, both seen on a live box:
|
|
154
|
+
|
|
155
|
+
* no transmitter identity, so a pack carrying only System/OperatorID
|
|
156
|
+
and no BasicID had nothing to aggregate on and rendered as
|
|
157
|
+
op-Unknown-BasicID_0 -- every operator in range on ONE track;
|
|
158
|
+
* no sensor_id, so CoT was attributed to the default
|
|
159
|
+
dronecot_<hostname> rather than the configured SENSOR_ID, making it
|
|
160
|
+
impossible to tell which receiver saw a contact.
|
|
161
|
+
"""
|
|
162
|
+
data = dronecot.rid_normalize.uas_meta_defaults(self.config)
|
|
163
|
+
if payload_type:
|
|
164
|
+
data["type"] = payload_type
|
|
165
|
+
mac = self._id_or_mac_to_mac(id_or_mac)
|
|
166
|
+
if mac:
|
|
167
|
+
data["MAC address"] = mac
|
|
168
|
+
return data
|
|
169
|
+
|
|
170
|
+
def _mavlink_pack_to_parse_payload_schema(
|
|
171
|
+
self, messages, pack_size, id_or_mac=None
|
|
172
|
+
) -> dict:
|
|
86
173
|
"""Convert MAVLink OPEN_DRONE_ID_MESSAGE_PACK to parse_payload-like schema."""
|
|
87
174
|
parsed = dronecot.odid.message_pack_to_dict(messages, pack_size)
|
|
88
|
-
|
|
175
|
+
rid = dronecot.rid_normalize.odid_parsed_to_rid_dict(parsed)
|
|
176
|
+
rid["data"] = self._mavlink_meta(
|
|
177
|
+
id_or_mac, payload_type="MAVLink OPEN_DRONE_ID_MESSAGE_PACK"
|
|
178
|
+
)
|
|
179
|
+
return rid
|
|
89
180
|
|
|
90
181
|
@staticmethod
|
|
91
|
-
def _adsb_vehicle_to_rid_dict(msg) -> dict:
|
|
182
|
+
def _adsb_vehicle_to_rid_dict(msg, meta: Optional[dict] = None) -> dict:
|
|
92
183
|
"""Convert a MAVLink ADSB_VEHICLE to a RIDWorker dict.
|
|
93
184
|
|
|
94
185
|
The DroneScout Bridge (and similar Remote ID receivers) can emit detected
|
|
@@ -113,7 +204,7 @@ class SerialWorker(pytak.QueueWorker):
|
|
|
113
204
|
"SpeedHorizontal": getattr(msg, "hor_velocity", 0) / 100.0, # cm/s -> m/s
|
|
114
205
|
"SpeedVertical": getattr(msg, "ver_velocity", 0) / 100.0,
|
|
115
206
|
"Direction": getattr(msg, "heading", 0) / 100.0, # cdeg -> deg
|
|
116
|
-
"data": {"type": "MAVLink ADSB_VEHICLE"},
|
|
207
|
+
"data": dict(meta) if meta else {"type": "MAVLink ADSB_VEHICLE"},
|
|
117
208
|
}
|
|
118
209
|
|
|
119
210
|
async def run(self, _=-1) -> None:
|
|
@@ -180,11 +271,19 @@ class SerialWorker(pytak.QueueWorker):
|
|
|
180
271
|
msg_type = msg.get_type()
|
|
181
272
|
if msg_type == "OPEN_DRONE_ID_MESSAGE_PACK":
|
|
182
273
|
parsed_payload = self._mavlink_pack_to_parse_payload_schema(
|
|
183
|
-
msg.messages,
|
|
274
|
+
msg.messages,
|
|
275
|
+
msg.msg_pack_size,
|
|
276
|
+
getattr(msg, "id_or_mac", None),
|
|
184
277
|
)
|
|
185
278
|
await self.put_queue(parsed_payload)
|
|
186
279
|
elif msg_type == "ADSB_VEHICLE":
|
|
187
|
-
rid = self._adsb_vehicle_to_rid_dict(
|
|
280
|
+
rid = self._adsb_vehicle_to_rid_dict(
|
|
281
|
+
msg,
|
|
282
|
+
self._mavlink_meta(
|
|
283
|
+
getattr(msg, "id_or_mac", None),
|
|
284
|
+
payload_type="MAVLink ADSB_VEHICLE",
|
|
285
|
+
),
|
|
286
|
+
)
|
|
188
287
|
if rid:
|
|
189
288
|
await self.put_queue(rid)
|
|
190
289
|
elif msg_type == "HEARTBEAT":
|
|
@@ -670,6 +769,19 @@ class RIDWorker(pytak.QueueWorker):
|
|
|
670
769
|
else None
|
|
671
770
|
)
|
|
672
771
|
|
|
772
|
+
# Runtime status for Cockpit. Systemd gives us /run/dronecot via
|
|
773
|
+
# RuntimeDirectory=, so this lands where the plugin looks for it.
|
|
774
|
+
#
|
|
775
|
+
# RIDWorker owns the status file rather than the capture workers because
|
|
776
|
+
# every RF source (Wi-Fi, BLE, BlueZ, serial MAVLink, MQTT, UDP) funnels
|
|
777
|
+
# through here. Instrumenting the capture workers instead would give N
|
|
778
|
+
# writers racing on one path, and each would see only its own slice.
|
|
779
|
+
self.status = make_status("dronecot", dronecot.__version__)
|
|
780
|
+
|
|
781
|
+
def _tracked(self) -> int:
|
|
782
|
+
"""Number of transmitters the aggregator is currently holding."""
|
|
783
|
+
return len(self.aggregator) if self.aggregator is not None else 0
|
|
784
|
+
|
|
673
785
|
async def handle_data(self, data: dict) -> None:
|
|
674
786
|
"""Handle Data from receiver: Render to CoT, put on TX queue.
|
|
675
787
|
|
|
@@ -680,39 +792,118 @@ class RIDWorker(pytak.QueueWorker):
|
|
|
680
792
|
"""
|
|
681
793
|
self._logger.debug("Handling data: %s", data)
|
|
682
794
|
|
|
795
|
+
if not data:
|
|
796
|
+
# A decoder upstream produced nothing. Deliberately NOT counted as
|
|
797
|
+
# received: inflating rx with non-messages would make a receiver
|
|
798
|
+
# that hears only noise look like one hearing drones.
|
|
799
|
+
return
|
|
800
|
+
|
|
801
|
+
self.status.count("rx")
|
|
802
|
+
|
|
683
803
|
if "status" in data and "position" not in data.get("topic", ""):
|
|
684
804
|
self._logger.info("Processing sensor status data")
|
|
685
805
|
event = dronecot.cot_to_xml(data, self.config, "sensor_status_to_cot")
|
|
806
|
+
if event:
|
|
807
|
+
self.status.count("emitted")
|
|
808
|
+
self.status.count("sensor_status")
|
|
809
|
+
else:
|
|
810
|
+
self.status.count("no_cot")
|
|
811
|
+
self.status.write()
|
|
686
812
|
if event:
|
|
687
813
|
await self.put_queue(event)
|
|
688
814
|
return
|
|
689
815
|
|
|
690
816
|
if self.aggregator is not None:
|
|
691
817
|
merged = self.aggregator.update(data)
|
|
818
|
+
self.status.set(tracked=self._tracked())
|
|
692
819
|
if merged is None:
|
|
693
820
|
# Absorbed into a track that has no renderable position yet; it
|
|
694
821
|
# will contribute to the CoT rendered by a later advertisement.
|
|
822
|
+
# This is the COMMON case on BLE legacy, where a transmitter
|
|
823
|
+
# rotates BasicID -> Location -> System across advertisements,
|
|
824
|
+
# so it is counted rather than logged per-message.
|
|
695
825
|
self._logger.debug("Merged RID message, awaiting position")
|
|
826
|
+
self.status.count("merged_awaiting_position")
|
|
827
|
+
self.status.write()
|
|
696
828
|
return
|
|
697
829
|
data = merged
|
|
698
830
|
|
|
699
831
|
self._logger.info("Processing RID data")
|
|
700
832
|
cot_funcs = ["rid_uas_to_cot_xml", "rid_op_to_cot_xml"]
|
|
833
|
+
events = []
|
|
701
834
|
for func in cot_funcs:
|
|
702
835
|
event = dronecot.cot_to_xml(data, self.config, func)
|
|
703
836
|
if event:
|
|
704
|
-
|
|
837
|
+
events.append(event)
|
|
838
|
+
|
|
839
|
+
# Record EVERY message that survives aggregation, not only the ones that
|
|
840
|
+
# produce a marker. A drone heard without a position still proves the
|
|
841
|
+
# receiver is working, and a feed that showed only plotted aircraft
|
|
842
|
+
# would sit empty on a perfectly healthy box -- which reads as a fault.
|
|
843
|
+
# The `placed` flag distinguishes the two without hiding either.
|
|
844
|
+
meta = data.get("data") or {}
|
|
845
|
+
self.status.record(
|
|
846
|
+
uas=data.get("BasicID") or data.get("BasicID_0"),
|
|
847
|
+
operator=data.get("OperatorID"),
|
|
848
|
+
mac=meta.get("MAC address"),
|
|
849
|
+
rssi=meta.get("RSSI"),
|
|
850
|
+
# "WiFi beacon", "WiFi NaN", "BLE", "BLE legacy (BlueZ)",
|
|
851
|
+
# "MAVLink ADSB_VEHICLE", or the configured SENSOR_PAYLOAD_TYPE.
|
|
852
|
+
source=meta.get("type"),
|
|
853
|
+
placed=bool(events),
|
|
854
|
+
)
|
|
855
|
+
|
|
856
|
+
if events:
|
|
857
|
+
self.status.count("emitted", len(events))
|
|
858
|
+
else:
|
|
859
|
+
self.status.count("no_cot")
|
|
860
|
+
self.status.write()
|
|
861
|
+
|
|
862
|
+
for event in events:
|
|
863
|
+
await self.put_queue(event)
|
|
705
864
|
|
|
706
865
|
async def run(self, _=-1) -> None:
|
|
707
866
|
"""Run the main process loop."""
|
|
708
867
|
self._logger.info("Running RIDWorker")
|
|
709
868
|
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
869
|
+
# Write immediately, before any traffic arrives. A quiet sky is normal
|
|
870
|
+
# and can last hours; without this the management UI would report "no
|
|
871
|
+
# status from this gateway" -- indistinguishable from a gateway that
|
|
872
|
+
# failed to start -- for that entire time.
|
|
873
|
+
self.status.set(
|
|
874
|
+
tracked=self._tracked(),
|
|
875
|
+
# Scheme only. FEED_URL can carry MQTT credentials and status.json
|
|
876
|
+
# is world-readable on tmpfs.
|
|
877
|
+
feed=urlparse(str(self.config.get("FEED_URL", ""))).scheme or None,
|
|
878
|
+
)
|
|
879
|
+
self.status.write(force=True)
|
|
714
880
|
|
|
715
|
-
|
|
881
|
+
# Heartbeat runs as its own task because the loop below blocks on
|
|
882
|
+
# net_queue.get(). Folding it into that await (via wait_for) would make
|
|
883
|
+
# every idle timeout look like an exception path; a separate task keeps
|
|
884
|
+
# the receive path unchanged.
|
|
885
|
+
heartbeat = asyncio.ensure_future(self._heartbeat())
|
|
886
|
+
try:
|
|
887
|
+
while True:
|
|
888
|
+
data = await self.net_queue.get()
|
|
889
|
+
if not data:
|
|
890
|
+
continue
|
|
891
|
+
|
|
892
|
+
await self.handle_data(data)
|
|
893
|
+
finally:
|
|
894
|
+
heartbeat.cancel()
|
|
895
|
+
|
|
896
|
+
async def _heartbeat(self, interval: float = 5.0) -> None:
|
|
897
|
+
"""Keep the status file fresh while no drones are in range.
|
|
898
|
+
|
|
899
|
+
The UI decides liveness from whether this file keeps changing, so an
|
|
900
|
+
idle-but-healthy gateway MUST keep writing; otherwise an empty sky would
|
|
901
|
+
be reported as a wedged service.
|
|
902
|
+
"""
|
|
903
|
+
while True:
|
|
904
|
+
await asyncio.sleep(interval)
|
|
905
|
+
self.status.set(tracked=self._tracked())
|
|
906
|
+
self.status.write(force=True)
|
|
716
907
|
|
|
717
908
|
|
|
718
909
|
class RXMockWorker(pytak.RXWorker):
|
|
@@ -834,8 +1025,41 @@ class DJIFileWorker(_DJIFeedWorker):
|
|
|
834
1025
|
class DJIListenerWorker(pytak.QueueWorker):
|
|
835
1026
|
"""Accept incoming TCP connections from DJI RF scanners (server mode)."""
|
|
836
1027
|
|
|
837
|
-
|
|
1028
|
+
_CLOSE_TIMEOUT = 1.0
|
|
1029
|
+
|
|
1030
|
+
def __init__(self, queue: asyncio.Queue, config) -> None:
|
|
1031
|
+
super().__init__(queue, config)
|
|
1032
|
+
self._server: Optional[asyncio.AbstractServer] = None
|
|
1033
|
+
self._client_tasks: Set[asyncio.Task] = set()
|
|
1034
|
+
self._client_writers: Set[asyncio.StreamWriter] = set()
|
|
1035
|
+
self._close_lock = asyncio.Lock()
|
|
1036
|
+
self._closed = False
|
|
1037
|
+
|
|
1038
|
+
def _client_connected(
|
|
1039
|
+
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
|
1040
|
+
) -> None:
|
|
1041
|
+
"""Start and retain ownership of a scanner client task."""
|
|
1042
|
+
task = asyncio.ensure_future(self._handle_client(reader, writer))
|
|
1043
|
+
self._client_tasks.add(task)
|
|
1044
|
+
task.add_done_callback(self._client_tasks.discard)
|
|
1045
|
+
|
|
1046
|
+
async def _close_writer(self, writer: asyncio.StreamWriter) -> None:
|
|
1047
|
+
"""Close one scanner connection without allowing shutdown to hang."""
|
|
1048
|
+
writer.close()
|
|
1049
|
+
try:
|
|
1050
|
+
await asyncio.wait_for(
|
|
1051
|
+
writer.wait_closed(), timeout=self._CLOSE_TIMEOUT
|
|
1052
|
+
)
|
|
1053
|
+
except (asyncio.TimeoutError, ConnectionError, OSError):
|
|
1054
|
+
transport = getattr(writer, "transport", None)
|
|
1055
|
+
if transport is not None:
|
|
1056
|
+
transport.abort()
|
|
1057
|
+
|
|
1058
|
+
async def _handle_client(
|
|
1059
|
+
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
|
1060
|
+
) -> None:
|
|
838
1061
|
peer = writer.get_extra_info("peername")
|
|
1062
|
+
self._client_writers.add(writer)
|
|
839
1063
|
self._logger.info("DJI client connected from %s", peer)
|
|
840
1064
|
try:
|
|
841
1065
|
while True:
|
|
@@ -855,19 +1079,75 @@ class DJIListenerWorker(pytak.QueueWorker):
|
|
|
855
1079
|
break
|
|
856
1080
|
self.queue.put_nowait(data)
|
|
857
1081
|
await asyncio.sleep(0.001)
|
|
1082
|
+
except asyncio.CancelledError:
|
|
1083
|
+
raise
|
|
858
1084
|
except Exception as exc:
|
|
859
1085
|
self._logger.error("DJI client error %s: %s", peer, exc)
|
|
860
1086
|
finally:
|
|
861
|
-
|
|
862
|
-
await
|
|
1087
|
+
self._client_writers.discard(writer)
|
|
1088
|
+
await self._close_writer(writer)
|
|
1089
|
+
|
|
1090
|
+
async def close(self) -> None:
|
|
1091
|
+
"""Stop accepting clients and release every owned scanner connection."""
|
|
1092
|
+
async with self._close_lock:
|
|
1093
|
+
if self._closed:
|
|
1094
|
+
return
|
|
1095
|
+
self._closed = True
|
|
1096
|
+
|
|
1097
|
+
if self._server is not None:
|
|
1098
|
+
self._server.close()
|
|
1099
|
+
try:
|
|
1100
|
+
await asyncio.wait_for(
|
|
1101
|
+
self._server.wait_closed(), timeout=self._CLOSE_TIMEOUT
|
|
1102
|
+
)
|
|
1103
|
+
except asyncio.TimeoutError:
|
|
1104
|
+
self._logger.warning("Timed out closing DJI listener socket")
|
|
1105
|
+
self._server = None
|
|
1106
|
+
|
|
1107
|
+
client_tasks = {
|
|
1108
|
+
task
|
|
1109
|
+
for task in self._client_tasks
|
|
1110
|
+
if task is not asyncio.current_task()
|
|
1111
|
+
}
|
|
1112
|
+
for task in client_tasks:
|
|
1113
|
+
task.cancel()
|
|
1114
|
+
|
|
1115
|
+
writers = list(self._client_writers)
|
|
1116
|
+
if writers:
|
|
1117
|
+
await asyncio.gather(
|
|
1118
|
+
*(self._close_writer(writer) for writer in writers),
|
|
1119
|
+
return_exceptions=True,
|
|
1120
|
+
)
|
|
1121
|
+
|
|
1122
|
+
if client_tasks:
|
|
1123
|
+
_, stalled = await asyncio.wait(
|
|
1124
|
+
client_tasks, timeout=self._CLOSE_TIMEOUT
|
|
1125
|
+
)
|
|
1126
|
+
for task in stalled:
|
|
1127
|
+
self._logger.warning(
|
|
1128
|
+
"DJI client task did not stop within %.1fs",
|
|
1129
|
+
self._CLOSE_TIMEOUT,
|
|
1130
|
+
)
|
|
1131
|
+
task.cancel()
|
|
863
1132
|
|
|
864
1133
|
async def run(self, _=-1) -> None:
|
|
865
1134
|
bind = self.config.get("DJI_BIND_ADDRESS", dronecot.DEFAULT_DJI_BIND_ADDRESS)
|
|
866
1135
|
port = self.config.get("DJI_TCP_PORT", dronecot.DEFAULT_DJI_TCP_PORT)
|
|
867
1136
|
self._logger.info("Running %s on %s:%s", self.__class__, bind, port)
|
|
868
|
-
server = await asyncio.start_server(
|
|
869
|
-
|
|
870
|
-
|
|
1137
|
+
server = await asyncio.start_server(
|
|
1138
|
+
self._client_connected, bind, port
|
|
1139
|
+
)
|
|
1140
|
+
async with self._close_lock:
|
|
1141
|
+
if self._closed:
|
|
1142
|
+
server.close()
|
|
1143
|
+
await server.wait_closed()
|
|
1144
|
+
raise asyncio.CancelledError
|
|
1145
|
+
self._server = server
|
|
1146
|
+
try:
|
|
1147
|
+
async with self._server:
|
|
1148
|
+
await self._server.serve_forever()
|
|
1149
|
+
finally:
|
|
1150
|
+
await self.close()
|
|
871
1151
|
|
|
872
1152
|
|
|
873
1153
|
# Backward-compatible aliases matching djicot names
|
|
@@ -269,7 +269,15 @@ def _rid_identity(data: dict) -> Tuple[str, Optional[str]]:
|
|
|
269
269
|
# all. Fall back to the feed that delivered it so two receivers do
|
|
270
270
|
# not collide, rather than the shared Unknown-BasicID_0 placeholder
|
|
271
271
|
# that put every such aircraft on one track.
|
|
272
|
-
|
|
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
|
+
)
|
|
273
281
|
feed = str(feed).strip() if feed else ""
|
|
274
282
|
uasid = f"FEED-{feed}" if feed else "Unknown-BasicID_0"
|
|
275
283
|
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# Copyright Sensors & Signals LLC https://www.snstac.com/
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
"""Lifecycle regression tests for the AntSDR scanner-push listener."""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
import pytak
|
|
11
|
+
from dronecot.classes import DJIListenerWorker
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def _wait_for_listener(worker):
|
|
15
|
+
for _ in range(100):
|
|
16
|
+
if worker._server is not None: # pylint: disable=protected-access
|
|
17
|
+
return worker._server # pylint: disable=protected-access
|
|
18
|
+
await asyncio.sleep(0.01)
|
|
19
|
+
raise AssertionError("DJI listener did not start")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.mark.asyncio
|
|
23
|
+
async def test_listener_close_releases_active_client():
|
|
24
|
+
"""An active AntSDR connection must not keep DroneCOT alive on shutdown."""
|
|
25
|
+
queue = asyncio.Queue()
|
|
26
|
+
worker = DJIListenerWorker(
|
|
27
|
+
queue,
|
|
28
|
+
{"DJI_BIND_ADDRESS": "127.0.0.1", "DJI_TCP_PORT": 0},
|
|
29
|
+
)
|
|
30
|
+
run_task = asyncio.create_task(worker.run())
|
|
31
|
+
writer = None
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
server = await _wait_for_listener(worker)
|
|
35
|
+
port = server.sockets[0].getsockname()[1]
|
|
36
|
+
reader, writer = await asyncio.open_connection("127.0.0.1", port)
|
|
37
|
+
writer.write(b"dji_O,test-frame\n")
|
|
38
|
+
await writer.drain()
|
|
39
|
+
assert await asyncio.wait_for(queue.get(), timeout=1.0) == b"dji_O,test-frame"
|
|
40
|
+
|
|
41
|
+
await asyncio.wait_for(worker.close(), timeout=2.0)
|
|
42
|
+
result = await asyncio.wait_for(
|
|
43
|
+
asyncio.gather(run_task, return_exceptions=True), timeout=2.0
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
assert isinstance(result[0], asyncio.CancelledError)
|
|
47
|
+
assert await asyncio.wait_for(reader.read(), timeout=1.0) == b""
|
|
48
|
+
assert not worker._client_tasks # pylint: disable=protected-access
|
|
49
|
+
assert not worker._client_writers # pylint: disable=protected-access
|
|
50
|
+
|
|
51
|
+
# Cleanup is intentionally idempotent because PyTAK and run() can call
|
|
52
|
+
# it concurrently during first-exception shutdown.
|
|
53
|
+
await worker.close()
|
|
54
|
+
finally:
|
|
55
|
+
if writer is not None:
|
|
56
|
+
writer.close()
|
|
57
|
+
if not run_task.done():
|
|
58
|
+
run_task.cancel()
|
|
59
|
+
await asyncio.gather(run_task, return_exceptions=True)
|
|
60
|
+
await worker.close()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@pytest.mark.asyncio
|
|
64
|
+
async def test_transport_failure_exits_with_active_listener_client():
|
|
65
|
+
"""A TAK failure must escape CLITool while an AntSDR remains connected."""
|
|
66
|
+
fail = asyncio.Event()
|
|
67
|
+
|
|
68
|
+
class _FailingTransportWorker:
|
|
69
|
+
async def run(self):
|
|
70
|
+
await fail.wait()
|
|
71
|
+
raise ConnectionAbortedError("WebSocket closed by server")
|
|
72
|
+
|
|
73
|
+
async def close(self):
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
config = {
|
|
77
|
+
"COT_URL": "wss://takserver.example.com:8443/takproto/1",
|
|
78
|
+
"PYTAK_NO_HELLO": True,
|
|
79
|
+
}
|
|
80
|
+
listener = DJIListenerWorker(
|
|
81
|
+
asyncio.Queue(),
|
|
82
|
+
{"DJI_BIND_ADDRESS": "127.0.0.1", "DJI_TCP_PORT": 0},
|
|
83
|
+
)
|
|
84
|
+
clitool = pytak.CLITool(config)
|
|
85
|
+
clitool.add_task(listener)
|
|
86
|
+
clitool.add_task(_FailingTransportWorker())
|
|
87
|
+
run_task = asyncio.create_task(clitool.run())
|
|
88
|
+
writer = None
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
server = await _wait_for_listener(listener)
|
|
92
|
+
port = server.sockets[0].getsockname()[1]
|
|
93
|
+
reader, writer = await asyncio.open_connection("127.0.0.1", port)
|
|
94
|
+
|
|
95
|
+
fail.set()
|
|
96
|
+
with pytest.raises(ConnectionAbortedError, match="closed by server"):
|
|
97
|
+
await asyncio.wait_for(run_task, timeout=2.0)
|
|
98
|
+
|
|
99
|
+
assert await asyncio.wait_for(reader.read(), timeout=1.0) == b""
|
|
100
|
+
assert not listener._client_tasks # pylint: disable=protected-access
|
|
101
|
+
assert not listener._client_writers # pylint: disable=protected-access
|
|
102
|
+
finally:
|
|
103
|
+
if writer is not None:
|
|
104
|
+
writer.close()
|
|
105
|
+
if not run_task.done():
|
|
106
|
+
run_task.cancel()
|
|
107
|
+
await asyncio.gather(run_task, return_exceptions=True)
|
|
108
|
+
await listener.close()
|
|
@@ -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()
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
"""Tests for the runtime status surface RIDWorker writes for Cockpit.
|
|
5
|
+
|
|
6
|
+
These drive the coroutine with ``asyncio.run()`` rather than declaring bare
|
|
7
|
+
``async def`` tests. That is not a style choice: pytest-asyncio is not installed
|
|
8
|
+
in the build/CI environment for this package, and pytest SKIPS bare async tests
|
|
9
|
+
while still reporting the run as passed -- i.e. tests that cannot fail. Calling
|
|
10
|
+
asyncio.run() explicitly keeps them real regardless of plugins.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import struct
|
|
18
|
+
|
|
19
|
+
import pytest
|
|
20
|
+
|
|
21
|
+
import pytak
|
|
22
|
+
|
|
23
|
+
from dronecot import classes, rid_normalize, rid_track
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
ODID_MESSAGE_SIZE = 25
|
|
27
|
+
PROTO_VERSION = 2
|
|
28
|
+
|
|
29
|
+
TEST_MAC = "AA:BB:CC:DD:EE:01"
|
|
30
|
+
TEST_SERIAL = "1596F3AD8E2B4C5D6E7F"
|
|
31
|
+
|
|
32
|
+
needs_statuswriter = pytest.mark.skipif(
|
|
33
|
+
not hasattr(pytak, "StatusWriter"),
|
|
34
|
+
reason="installed pytak has no StatusWriter (pre-7.4.0)",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _basic_id_message(serial: str = TEST_SERIAL) -> bytes:
|
|
39
|
+
"""A single ODID BasicID message (type 0): serial, no position."""
|
|
40
|
+
msg = bytearray(ODID_MESSAGE_SIZE)
|
|
41
|
+
msg[0] = (0 << 4) | PROTO_VERSION
|
|
42
|
+
msg[1] = (1 << 4) | 2
|
|
43
|
+
msg[2:22] = serial.encode("ascii").ljust(20, b"\x00")
|
|
44
|
+
return bytes(msg)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _location_message(lat: float = 37.76, lon: float = -122.4) -> bytes:
|
|
48
|
+
"""A single ODID Location message (type 1): position, no serial."""
|
|
49
|
+
msg = bytearray(ODID_MESSAGE_SIZE)
|
|
50
|
+
msg[0] = (1 << 4) | PROTO_VERSION
|
|
51
|
+
msg[1] = 0x20 # Status=2 (Airborne)
|
|
52
|
+
msg[2] = 90
|
|
53
|
+
msg[3] = 10
|
|
54
|
+
msg[5:9] = struct.pack("i", int(lat * 1e7))
|
|
55
|
+
msg[9:13] = struct.pack("i", int(lon * 1e7))
|
|
56
|
+
msg[13:15] = struct.pack("H", 2000 + 200)
|
|
57
|
+
msg[15:17] = struct.pack("H", 2000 + 200)
|
|
58
|
+
msg[17:19] = struct.pack("H", 2000 + 100)
|
|
59
|
+
return bytes(msg)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _rid(msg: bytes, mac: str = TEST_MAC, rssi: int = -71) -> dict:
|
|
63
|
+
"""Normalize one ODID message the way a BLE capture worker would."""
|
|
64
|
+
return rid_normalize.bytes_to_rid_dict(
|
|
65
|
+
msg, {"MAC address": mac, "RSSI": rssi, "type": "BLE legacy (BlueZ)"}
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def _noop_put(event):
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@needs_statuswriter
|
|
74
|
+
class TestStatusSurface:
|
|
75
|
+
"""What the Cockpit plugin reads out of /run/dronecot/status.json.
|
|
76
|
+
|
|
77
|
+
RIDWorker already made these decisions -- merge, render, drop -- they just
|
|
78
|
+
went nowhere an operator could see. The risk with a status surface is that
|
|
79
|
+
it reports the shape of the code rather than what actually happened, so
|
|
80
|
+
these assert against real ODID advertisements pushed through the worker.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def _worker(self, tmp_path, ttl=120.0, id_grace=5.0):
|
|
84
|
+
worker = classes.RIDWorker.__new__(classes.RIDWorker)
|
|
85
|
+
worker.config = {}
|
|
86
|
+
worker.net_queue = None
|
|
87
|
+
worker.aggregator = rid_track.ODIDAggregator(ttl=ttl, id_grace=id_grace)
|
|
88
|
+
worker._logger = logging.getLogger("test")
|
|
89
|
+
worker.status = pytak.StatusWriter(
|
|
90
|
+
"dronecot-test", path=str(tmp_path / "status.json")
|
|
91
|
+
)
|
|
92
|
+
worker.put_queue = _noop_put
|
|
93
|
+
return worker
|
|
94
|
+
|
|
95
|
+
def _handle(self, worker, data):
|
|
96
|
+
asyncio.run(worker.handle_data(data))
|
|
97
|
+
|
|
98
|
+
def _doc(self, worker):
|
|
99
|
+
with open(worker.status.path) as handle:
|
|
100
|
+
return json.load(handle)
|
|
101
|
+
|
|
102
|
+
def test_id_only_fragment_is_counted_not_silently_dropped(self, tmp_path):
|
|
103
|
+
"""A BasicID advertisement carries no position, so it renders no CoT.
|
|
104
|
+
|
|
105
|
+
On BLE legacy this is the majority of traffic. Before this surface it
|
|
106
|
+
vanished at debug level, so a receiver hearing a drone loud and clear
|
|
107
|
+
looked identical to one hearing nothing at all.
|
|
108
|
+
"""
|
|
109
|
+
worker = self._worker(tmp_path)
|
|
110
|
+
self._handle(worker, _rid(_basic_id_message()))
|
|
111
|
+
|
|
112
|
+
doc = self._doc(worker)
|
|
113
|
+
assert doc["counters"]["rx"] == 1
|
|
114
|
+
assert doc["counters"]["merged_awaiting_position"] == 1
|
|
115
|
+
assert "emitted" not in doc["counters"]
|
|
116
|
+
# Nothing renderable yet, so nothing claims to be a contact.
|
|
117
|
+
assert doc["recent"] == []
|
|
118
|
+
assert doc["tracked"] == 1
|
|
119
|
+
|
|
120
|
+
def test_merged_track_emits_and_appears_in_the_feed(self, tmp_path):
|
|
121
|
+
"""Serial from one advertisement, position from the next -> one contact."""
|
|
122
|
+
worker = self._worker(tmp_path)
|
|
123
|
+
self._handle(worker, _rid(_basic_id_message()))
|
|
124
|
+
self._handle(worker, _rid(_location_message()))
|
|
125
|
+
|
|
126
|
+
# Writes are rate-limited to 1/sec, so two messages in the same second
|
|
127
|
+
# leave the file holding the first one's figures. That is by design --
|
|
128
|
+
# a gateway taking hundreds of advertisements a second must not spend
|
|
129
|
+
# its time serialising JSON -- and run()'s 5s heartbeat is what
|
|
130
|
+
# reconciles it. Forcing here stands in for that heartbeat.
|
|
131
|
+
worker.status.write(force=True)
|
|
132
|
+
|
|
133
|
+
doc = self._doc(worker)
|
|
134
|
+
assert doc["counters"]["rx"] == 2
|
|
135
|
+
assert doc["counters"]["emitted"] >= 1
|
|
136
|
+
entry = doc["recent"][-1]
|
|
137
|
+
assert entry["uas"] == TEST_SERIAL
|
|
138
|
+
assert entry["placed"] is True
|
|
139
|
+
assert entry["rssi"] == -71
|
|
140
|
+
assert entry["source"] == "BLE legacy (BlueZ)"
|
|
141
|
+
assert entry["mac"] == TEST_MAC
|
|
142
|
+
|
|
143
|
+
def test_two_transmitters_are_tracked_separately(self, tmp_path):
|
|
144
|
+
"""tracked is the aggregator's real size, not a message count."""
|
|
145
|
+
worker = self._worker(tmp_path)
|
|
146
|
+
self._handle(worker, _rid(_basic_id_message(), mac="AA:BB:CC:DD:EE:01"))
|
|
147
|
+
self._handle(worker, _rid(_basic_id_message("OTHER"), mac="AA:BB:CC:DD:EE:02"))
|
|
148
|
+
worker.status.write(force=True)
|
|
149
|
+
|
|
150
|
+
assert self._doc(worker)["tracked"] == 2
|
|
151
|
+
|
|
152
|
+
def test_empty_payload_is_not_counted_as_received(self, tmp_path):
|
|
153
|
+
"""A decoder that produced nothing is not a drone.
|
|
154
|
+
|
|
155
|
+
Counting it would make a receiver hearing only noise indistinguishable
|
|
156
|
+
from one hearing traffic, which is the exact confusion this file exists
|
|
157
|
+
to remove.
|
|
158
|
+
"""
|
|
159
|
+
worker = self._worker(tmp_path)
|
|
160
|
+
self._handle(worker, {})
|
|
161
|
+
assert not os.path.exists(worker.status.path)
|
|
162
|
+
|
|
163
|
+
def test_unrenderable_record_is_counted_as_no_cot(self, tmp_path):
|
|
164
|
+
"""Aggregation off: a position-less record reaches the renderer and fails.
|
|
165
|
+
|
|
166
|
+
With RID_TRACK_TTL=0 there is no aggregator, so the fragment goes
|
|
167
|
+
straight to cot_to_xml, which cannot place it. That is a distinct
|
|
168
|
+
outcome from "held for merging" and gets a distinct counter.
|
|
169
|
+
"""
|
|
170
|
+
worker = self._worker(tmp_path)
|
|
171
|
+
worker.aggregator = None
|
|
172
|
+
self._handle(worker, _rid(_basic_id_message()))
|
|
173
|
+
|
|
174
|
+
doc = self._doc(worker)
|
|
175
|
+
assert doc["counters"]["rx"] == 1
|
|
176
|
+
assert doc["counters"]["no_cot"] == 1
|
|
177
|
+
assert "merged_awaiting_position" not in doc["counters"]
|
|
178
|
+
# Still shown in the feed: it WAS decoded, it just could not be placed.
|
|
179
|
+
assert doc["recent"][-1]["placed"] is False
|
|
180
|
+
assert doc["recent"][-1]["uas"] == TEST_SERIAL
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@needs_statuswriter
|
|
184
|
+
class TestStatusHeartbeat:
|
|
185
|
+
"""An idle-but-healthy gateway must keep writing."""
|
|
186
|
+
|
|
187
|
+
def test_heartbeat_refreshes_the_file(self, tmp_path):
|
|
188
|
+
worker = classes.RIDWorker.__new__(classes.RIDWorker)
|
|
189
|
+
worker.config = {}
|
|
190
|
+
worker.aggregator = rid_track.ODIDAggregator()
|
|
191
|
+
worker._logger = logging.getLogger("test")
|
|
192
|
+
worker.status = pytak.StatusWriter(
|
|
193
|
+
"dronecot-test", path=str(tmp_path / "status.json")
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
async def _drive():
|
|
197
|
+
task = asyncio.ensure_future(worker._heartbeat(interval=0.01))
|
|
198
|
+
await asyncio.sleep(0.05)
|
|
199
|
+
task.cancel()
|
|
200
|
+
|
|
201
|
+
asyncio.run(_drive())
|
|
202
|
+
|
|
203
|
+
with open(worker.status.path) as handle:
|
|
204
|
+
doc = json.load(handle)
|
|
205
|
+
# No traffic at all, yet the file exists and reports a wall clock the
|
|
206
|
+
# UI can use to tell "quiet" from "stopped".
|
|
207
|
+
assert doc["counters"] == {}
|
|
208
|
+
assert doc["wall_t"] > 0
|
|
209
|
+
assert doc["tracked"] == 0
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class TestStatusDegradesVisibly:
|
|
213
|
+
"""A pytak without StatusWriter must not take the gateway down.
|
|
214
|
+
|
|
215
|
+
Fleet boxes run pytak 7.3.13, which has no StatusWriter at all.
|
|
216
|
+
"""
|
|
217
|
+
|
|
218
|
+
def test_no_op_status_when_pytak_is_too_old(self, monkeypatch):
|
|
219
|
+
monkeypatch.setattr(classes, "_StatusWriter", None)
|
|
220
|
+
status = classes.make_status("dronecot", "0.1.0")
|
|
221
|
+
|
|
222
|
+
# Every call RIDWorker makes must be safe on the stand-in.
|
|
223
|
+
status.count("rx")
|
|
224
|
+
status.count("emitted", 2)
|
|
225
|
+
status.record(uas="X", placed=True)
|
|
226
|
+
status.set(tracked=1)
|
|
227
|
+
assert status.write() is False
|
|
228
|
+
assert status.write(force=True) is False
|
|
229
|
+
|
|
230
|
+
def test_worker_still_handles_data_with_no_statuswriter(self, monkeypatch):
|
|
231
|
+
"""The whole point: no StatusWriter must not break the data path.
|
|
232
|
+
|
|
233
|
+
The renderer is stubbed so this test asserts what it claims to --
|
|
234
|
+
that RIDWorker still aggregates and still enqueues -- rather than
|
|
235
|
+
incidentally re-testing dronecot's CoT rendering against whatever
|
|
236
|
+
pytak happens to be installed.
|
|
237
|
+
"""
|
|
238
|
+
monkeypatch.setattr(classes, "_StatusWriter", None)
|
|
239
|
+
monkeypatch.setattr(
|
|
240
|
+
classes.dronecot, "cot_to_xml", lambda data, config, func: b"<event/>"
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
worker = classes.RIDWorker.__new__(classes.RIDWorker)
|
|
244
|
+
worker.config = {}
|
|
245
|
+
worker.aggregator = rid_track.ODIDAggregator()
|
|
246
|
+
worker._logger = logging.getLogger("test")
|
|
247
|
+
worker.status = classes.make_status("dronecot", "0.1.0")
|
|
248
|
+
|
|
249
|
+
emitted = []
|
|
250
|
+
|
|
251
|
+
async def _put(event):
|
|
252
|
+
emitted.append(event)
|
|
253
|
+
|
|
254
|
+
worker.put_queue = _put
|
|
255
|
+
|
|
256
|
+
asyncio.run(worker.handle_data(_rid(_basic_id_message())))
|
|
257
|
+
# Held for merging: no position yet, so nothing to send.
|
|
258
|
+
assert emitted == []
|
|
259
|
+
|
|
260
|
+
asyncio.run(worker.handle_data(_rid(_location_message())))
|
|
261
|
+
|
|
262
|
+
assert isinstance(worker.status, classes._NoStatus)
|
|
263
|
+
assert emitted, "CoT must still be produced when status is unavailable"
|
|
264
|
+
|
|
265
|
+
def test_real_writer_used_when_available(self):
|
|
266
|
+
if classes._StatusWriter is None:
|
|
267
|
+
pytest.skip("installed pytak has no StatusWriter")
|
|
268
|
+
assert not isinstance(classes.make_status("x", "0"), classes._NoStatus)
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
2.3.1
|
|
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
|