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.
Files changed (43) hide show
  1. {dronecot-2.3.1 → dronecot-2.3.3}/CHANGELOG.md +8 -0
  2. {dronecot-2.3.1/dronecot.egg-info → dronecot-2.3.3}/PKG-INFO +2 -2
  3. {dronecot-2.3.1 → dronecot-2.3.3/dronecot.egg-info}/PKG-INFO +2 -2
  4. {dronecot-2.3.1 → dronecot-2.3.3}/dronecot.egg-info/SOURCES.txt +3 -0
  5. {dronecot-2.3.1 → dronecot-2.3.3}/dronecot.egg-info/requires.txt +1 -1
  6. {dronecot-2.3.1 → dronecot-2.3.3}/setup.cfg +1 -1
  7. dronecot-2.3.3/src/dronecot/VERSION +1 -0
  8. {dronecot-2.3.1 → dronecot-2.3.3}/src/dronecot/__init__.py +11 -0
  9. {dronecot-2.3.1 → dronecot-2.3.3}/src/dronecot/classes.py +299 -19
  10. {dronecot-2.3.1 → dronecot-2.3.3}/src/dronecot/functions.py +9 -1
  11. dronecot-2.3.3/tests/test_dji_listener.py +108 -0
  12. dronecot-2.3.3/tests/test_mavlink_identity.py +145 -0
  13. dronecot-2.3.3/tests/test_status.py +268 -0
  14. dronecot-2.3.1/src/dronecot/VERSION +0 -1
  15. {dronecot-2.3.1 → dronecot-2.3.3}/LICENSE +0 -0
  16. {dronecot-2.3.1 → dronecot-2.3.3}/MANIFEST.in +0 -0
  17. {dronecot-2.3.1 → dronecot-2.3.3}/README.md +0 -0
  18. {dronecot-2.3.1 → dronecot-2.3.3}/dronecot.egg-info/dependency_links.txt +0 -0
  19. {dronecot-2.3.1 → dronecot-2.3.3}/dronecot.egg-info/entry_points.txt +0 -0
  20. {dronecot-2.3.1 → dronecot-2.3.3}/dronecot.egg-info/top_level.txt +0 -0
  21. {dronecot-2.3.1 → dronecot-2.3.3}/setup.py +0 -0
  22. {dronecot-2.3.1 → dronecot-2.3.3}/src/dronecot/ble_capture.py +0 -0
  23. {dronecot-2.3.1 → dronecot-2.3.3}/src/dronecot/ble_parse.py +0 -0
  24. {dronecot-2.3.1 → dronecot-2.3.3}/src/dronecot/bluez_capture.py +0 -0
  25. {dronecot-2.3.1 → dronecot-2.3.3}/src/dronecot/commands.py +0 -0
  26. {dronecot-2.3.1 → dronecot-2.3.3}/src/dronecot/constants.py +0 -0
  27. {dronecot-2.3.1 → dronecot-2.3.3}/src/dronecot/dji_exceptions.py +0 -0
  28. {dronecot-2.3.1 → dronecot-2.3.3}/src/dronecot/dji_functions.py +0 -0
  29. {dronecot-2.3.1 → dronecot-2.3.3}/src/dronecot/dji_text_parser.py +0 -0
  30. {dronecot-2.3.1 → dronecot-2.3.3}/src/dronecot/odid.py +0 -0
  31. {dronecot-2.3.1 → dronecot-2.3.3}/src/dronecot/open_drone_id.py +0 -0
  32. {dronecot-2.3.1 → dronecot-2.3.3}/src/dronecot/rid_normalize.py +0 -0
  33. {dronecot-2.3.1 → dronecot-2.3.3}/src/dronecot/rid_track.py +0 -0
  34. {dronecot-2.3.1 → dronecot-2.3.3}/src/dronecot/udp_rid.py +0 -0
  35. {dronecot-2.3.1 → dronecot-2.3.3}/src/dronecot/wifi_capture.py +0 -0
  36. {dronecot-2.3.1 → dronecot-2.3.3}/src/dronecot/wifi_parse.py +0 -0
  37. {dronecot-2.3.1 → dronecot-2.3.3}/tests/test_adsb_vehicle.py +0 -0
  38. {dronecot-2.3.1 → dronecot-2.3.3}/tests/test_bluez_capture.py +0 -0
  39. {dronecot-2.3.1 → dronecot-2.3.3}/tests/test_functions.py +0 -0
  40. {dronecot-2.3.1 → dronecot-2.3.3}/tests/test_rid_normalize.py +0 -0
  41. {dronecot-2.3.1 → dronecot-2.3.3}/tests/test_rid_track.py +0 -0
  42. {dronecot-2.3.1 → dronecot-2.3.3}/tests/test_sensor_detail.py +0 -0
  43. {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.1
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.3.12
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.1
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.3.12
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
@@ -1,4 +1,4 @@
1
- pytak>=7.3.12
1
+ pytak>=7.4.1
2
2
  paho-mqtt<2.0.0
3
3
  bitstruct
4
4
  pymavlink
@@ -48,7 +48,7 @@ package_dir =
48
48
  dronecot = src/dronecot
49
49
  python_requires = >=3.9, <4
50
50
  install_requires =
51
- pytak >= 7.3.12
51
+ pytak >= 7.4.1
52
52
  paho-mqtt < 2.0.0
53
53
  bitstruct
54
54
  pymavlink
@@ -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
- def _mavlink_pack_to_parse_payload_schema(self, messages, pack_size) -> dict:
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
- return dronecot.rid_normalize.odid_parsed_to_rid_dict(parsed)
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, msg.msg_pack_size
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(msg)
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
- await self.put_queue(event)
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
- while True:
711
- data = await self.net_queue.get()
712
- if not data:
713
- continue
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
- await self.handle_data(data)
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
- async def _handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
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
- writer.close()
862
- await writer.wait_closed()
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(self._handle_client, bind, port)
869
- async with server:
870
- await server.serve_forever()
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
- feed = src_data.get("sensor_id") or src_data.get("sensor ID")
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