dronecot 2.3.2__tar.gz → 2.3.4__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.2 → dronecot-2.3.4}/CHANGELOG.md +15 -0
- {dronecot-2.3.2/dronecot.egg-info → dronecot-2.3.4}/PKG-INFO +2 -2
- {dronecot-2.3.2 → dronecot-2.3.4/dronecot.egg-info}/PKG-INFO +2 -2
- {dronecot-2.3.2 → dronecot-2.3.4}/dronecot.egg-info/SOURCES.txt +2 -0
- {dronecot-2.3.2 → dronecot-2.3.4}/dronecot.egg-info/requires.txt +1 -1
- {dronecot-2.3.2 → dronecot-2.3.4}/setup.cfg +4 -1
- dronecot-2.3.4/src/dronecot/VERSION +1 -0
- {dronecot-2.3.2 → dronecot-2.3.4}/src/dronecot/__init__.py +11 -0
- {dronecot-2.3.2 → dronecot-2.3.4}/src/dronecot/classes.py +234 -13
- dronecot-2.3.4/tests/test_dji_listener.py +108 -0
- dronecot-2.3.4/tests/test_status.py +268 -0
- dronecot-2.3.2/src/dronecot/VERSION +0 -1
- {dronecot-2.3.2 → dronecot-2.3.4}/LICENSE +0 -0
- {dronecot-2.3.2 → dronecot-2.3.4}/MANIFEST.in +0 -0
- {dronecot-2.3.2 → dronecot-2.3.4}/README.md +0 -0
- {dronecot-2.3.2 → dronecot-2.3.4}/dronecot.egg-info/dependency_links.txt +0 -0
- {dronecot-2.3.2 → dronecot-2.3.4}/dronecot.egg-info/entry_points.txt +0 -0
- {dronecot-2.3.2 → dronecot-2.3.4}/dronecot.egg-info/top_level.txt +0 -0
- {dronecot-2.3.2 → dronecot-2.3.4}/setup.py +0 -0
- {dronecot-2.3.2 → dronecot-2.3.4}/src/dronecot/ble_capture.py +0 -0
- {dronecot-2.3.2 → dronecot-2.3.4}/src/dronecot/ble_parse.py +0 -0
- {dronecot-2.3.2 → dronecot-2.3.4}/src/dronecot/bluez_capture.py +0 -0
- {dronecot-2.3.2 → dronecot-2.3.4}/src/dronecot/commands.py +0 -0
- {dronecot-2.3.2 → dronecot-2.3.4}/src/dronecot/constants.py +0 -0
- {dronecot-2.3.2 → dronecot-2.3.4}/src/dronecot/dji_exceptions.py +0 -0
- {dronecot-2.3.2 → dronecot-2.3.4}/src/dronecot/dji_functions.py +0 -0
- {dronecot-2.3.2 → dronecot-2.3.4}/src/dronecot/dji_text_parser.py +0 -0
- {dronecot-2.3.2 → dronecot-2.3.4}/src/dronecot/functions.py +0 -0
- {dronecot-2.3.2 → dronecot-2.3.4}/src/dronecot/odid.py +0 -0
- {dronecot-2.3.2 → dronecot-2.3.4}/src/dronecot/open_drone_id.py +0 -0
- {dronecot-2.3.2 → dronecot-2.3.4}/src/dronecot/rid_normalize.py +0 -0
- {dronecot-2.3.2 → dronecot-2.3.4}/src/dronecot/rid_track.py +0 -0
- {dronecot-2.3.2 → dronecot-2.3.4}/src/dronecot/udp_rid.py +0 -0
- {dronecot-2.3.2 → dronecot-2.3.4}/src/dronecot/wifi_capture.py +0 -0
- {dronecot-2.3.2 → dronecot-2.3.4}/src/dronecot/wifi_parse.py +0 -0
- {dronecot-2.3.2 → dronecot-2.3.4}/tests/test_adsb_vehicle.py +0 -0
- {dronecot-2.3.2 → dronecot-2.3.4}/tests/test_bluez_capture.py +0 -0
- {dronecot-2.3.2 → dronecot-2.3.4}/tests/test_functions.py +0 -0
- {dronecot-2.3.2 → dronecot-2.3.4}/tests/test_mavlink_identity.py +0 -0
- {dronecot-2.3.2 → dronecot-2.3.4}/tests/test_rid_normalize.py +0 -0
- {dronecot-2.3.2 → dronecot-2.3.4}/tests/test_rid_track.py +0 -0
- {dronecot-2.3.2 → dronecot-2.3.4}/tests/test_sensor_detail.py +0 -0
- {dronecot-2.3.2 → dronecot-2.3.4}/tests/test_wifi_parse.py +0 -0
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
## DroneCOT 2.3.4
|
|
2
|
+
|
|
3
|
+
- Include `dronecot/VERSION` in wheel and Debian installations. DroneCOT 2.3.3
|
|
4
|
+
omitted this runtime file and failed at import time after installation.
|
|
5
|
+
- Verify wheel and Debian archives contain the runtime version file before
|
|
6
|
+
publishing a release.
|
|
7
|
+
|
|
8
|
+
## DroneCOT 2.3.3
|
|
9
|
+
|
|
10
|
+
- Fixed the AntSDR scanner-push listener retaining active client tasks while
|
|
11
|
+
DroneCOT shut down after a TAK transport failure. The listener now owns and
|
|
12
|
+
closes its server, client tasks and writers with bounded waits, allowing the
|
|
13
|
+
PyTAK exception to reach systemd and trigger a reconnecting restart.
|
|
14
|
+
- Require PyTAK 7.4.1 for bounded first-exception worker shutdown.
|
|
15
|
+
|
|
1
16
|
## DroneCOT 2.3.1
|
|
2
17
|
|
|
3
18
|
- **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.4
|
|
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.4
|
|
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,9 +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
|
|
35
36
|
tests/test_mavlink_identity.py
|
|
36
37
|
tests/test_rid_normalize.py
|
|
37
38
|
tests/test_rid_track.py
|
|
38
39
|
tests/test_sensor_detail.py
|
|
40
|
+
tests/test_status.py
|
|
39
41
|
tests/test_wifi_parse.py
|
|
@@ -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.
|
|
51
|
+
pytak >= 7.4.1
|
|
52
52
|
paho-mqtt < 2.0.0
|
|
53
53
|
bitstruct
|
|
54
54
|
pymavlink
|
|
@@ -60,6 +60,9 @@ install_requires =
|
|
|
60
60
|
[options.packages.find]
|
|
61
61
|
where = src
|
|
62
62
|
|
|
63
|
+
[options.package_data]
|
|
64
|
+
dronecot = VERSION
|
|
65
|
+
|
|
63
66
|
[options.extras_require]
|
|
64
67
|
with_takproto = takproto >= 2.0.0
|
|
65
68
|
wifi = scapy >= 2.5.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
2.3.4
|
|
@@ -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
|
|
|
@@ -729,6 +769,19 @@ class RIDWorker(pytak.QueueWorker):
|
|
|
729
769
|
else None
|
|
730
770
|
)
|
|
731
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
|
+
|
|
732
785
|
async def handle_data(self, data: dict) -> None:
|
|
733
786
|
"""Handle Data from receiver: Render to CoT, put on TX queue.
|
|
734
787
|
|
|
@@ -739,39 +792,118 @@ class RIDWorker(pytak.QueueWorker):
|
|
|
739
792
|
"""
|
|
740
793
|
self._logger.debug("Handling data: %s", data)
|
|
741
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
|
+
|
|
742
803
|
if "status" in data and "position" not in data.get("topic", ""):
|
|
743
804
|
self._logger.info("Processing sensor status data")
|
|
744
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()
|
|
745
812
|
if event:
|
|
746
813
|
await self.put_queue(event)
|
|
747
814
|
return
|
|
748
815
|
|
|
749
816
|
if self.aggregator is not None:
|
|
750
817
|
merged = self.aggregator.update(data)
|
|
818
|
+
self.status.set(tracked=self._tracked())
|
|
751
819
|
if merged is None:
|
|
752
820
|
# Absorbed into a track that has no renderable position yet; it
|
|
753
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.
|
|
754
825
|
self._logger.debug("Merged RID message, awaiting position")
|
|
826
|
+
self.status.count("merged_awaiting_position")
|
|
827
|
+
self.status.write()
|
|
755
828
|
return
|
|
756
829
|
data = merged
|
|
757
830
|
|
|
758
831
|
self._logger.info("Processing RID data")
|
|
759
832
|
cot_funcs = ["rid_uas_to_cot_xml", "rid_op_to_cot_xml"]
|
|
833
|
+
events = []
|
|
760
834
|
for func in cot_funcs:
|
|
761
835
|
event = dronecot.cot_to_xml(data, self.config, func)
|
|
762
836
|
if event:
|
|
763
|
-
|
|
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)
|
|
764
864
|
|
|
765
865
|
async def run(self, _=-1) -> None:
|
|
766
866
|
"""Run the main process loop."""
|
|
767
867
|
self._logger.info("Running RIDWorker")
|
|
768
868
|
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
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)
|
|
773
880
|
|
|
774
|
-
|
|
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)
|
|
775
907
|
|
|
776
908
|
|
|
777
909
|
class RXMockWorker(pytak.RXWorker):
|
|
@@ -893,8 +1025,41 @@ class DJIFileWorker(_DJIFeedWorker):
|
|
|
893
1025
|
class DJIListenerWorker(pytak.QueueWorker):
|
|
894
1026
|
"""Accept incoming TCP connections from DJI RF scanners (server mode)."""
|
|
895
1027
|
|
|
896
|
-
|
|
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:
|
|
897
1061
|
peer = writer.get_extra_info("peername")
|
|
1062
|
+
self._client_writers.add(writer)
|
|
898
1063
|
self._logger.info("DJI client connected from %s", peer)
|
|
899
1064
|
try:
|
|
900
1065
|
while True:
|
|
@@ -914,19 +1079,75 @@ class DJIListenerWorker(pytak.QueueWorker):
|
|
|
914
1079
|
break
|
|
915
1080
|
self.queue.put_nowait(data)
|
|
916
1081
|
await asyncio.sleep(0.001)
|
|
1082
|
+
except asyncio.CancelledError:
|
|
1083
|
+
raise
|
|
917
1084
|
except Exception as exc:
|
|
918
1085
|
self._logger.error("DJI client error %s: %s", peer, exc)
|
|
919
1086
|
finally:
|
|
920
|
-
|
|
921
|
-
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()
|
|
922
1132
|
|
|
923
1133
|
async def run(self, _=-1) -> None:
|
|
924
1134
|
bind = self.config.get("DJI_BIND_ADDRESS", dronecot.DEFAULT_DJI_BIND_ADDRESS)
|
|
925
1135
|
port = self.config.get("DJI_TCP_PORT", dronecot.DEFAULT_DJI_TCP_PORT)
|
|
926
1136
|
self._logger.info("Running %s on %s:%s", self.__class__, bind, port)
|
|
927
|
-
server = await asyncio.start_server(
|
|
928
|
-
|
|
929
|
-
|
|
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()
|
|
930
1151
|
|
|
931
1152
|
|
|
932
1153
|
# Backward-compatible aliases matching djicot names
|
|
@@ -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,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.2
|
|
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
|
|
File without changes
|
|
File without changes
|