ReticulumTelemetryHub 0.1.0__py3-none-any.whl → 0.143.0__py3-none-any.whl
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.
- reticulum_telemetry_hub/api/__init__.py +23 -0
- reticulum_telemetry_hub/api/models.py +323 -0
- reticulum_telemetry_hub/api/service.py +836 -0
- reticulum_telemetry_hub/api/storage.py +528 -0
- reticulum_telemetry_hub/api/storage_base.py +156 -0
- reticulum_telemetry_hub/api/storage_models.py +118 -0
- reticulum_telemetry_hub/atak_cot/__init__.py +49 -0
- reticulum_telemetry_hub/atak_cot/base.py +277 -0
- reticulum_telemetry_hub/atak_cot/chat.py +506 -0
- reticulum_telemetry_hub/atak_cot/detail.py +235 -0
- reticulum_telemetry_hub/atak_cot/event.py +181 -0
- reticulum_telemetry_hub/atak_cot/pytak_client.py +569 -0
- reticulum_telemetry_hub/atak_cot/tak_connector.py +848 -0
- reticulum_telemetry_hub/config/__init__.py +25 -0
- reticulum_telemetry_hub/config/constants.py +7 -0
- reticulum_telemetry_hub/config/manager.py +515 -0
- reticulum_telemetry_hub/config/models.py +215 -0
- reticulum_telemetry_hub/embedded_lxmd/__init__.py +5 -0
- reticulum_telemetry_hub/embedded_lxmd/embedded.py +418 -0
- reticulum_telemetry_hub/internal_api/__init__.py +21 -0
- reticulum_telemetry_hub/internal_api/bus.py +344 -0
- reticulum_telemetry_hub/internal_api/core.py +690 -0
- reticulum_telemetry_hub/internal_api/v1/__init__.py +74 -0
- reticulum_telemetry_hub/internal_api/v1/enums.py +109 -0
- reticulum_telemetry_hub/internal_api/v1/manifest.json +8 -0
- reticulum_telemetry_hub/internal_api/v1/schemas.py +478 -0
- reticulum_telemetry_hub/internal_api/versioning.py +63 -0
- reticulum_telemetry_hub/lxmf_daemon/Handlers.py +122 -0
- reticulum_telemetry_hub/lxmf_daemon/LXMF.py +252 -0
- reticulum_telemetry_hub/lxmf_daemon/LXMPeer.py +898 -0
- reticulum_telemetry_hub/lxmf_daemon/LXMRouter.py +4227 -0
- reticulum_telemetry_hub/lxmf_daemon/LXMessage.py +1006 -0
- reticulum_telemetry_hub/lxmf_daemon/LXStamper.py +490 -0
- reticulum_telemetry_hub/lxmf_daemon/__init__.py +10 -0
- reticulum_telemetry_hub/lxmf_daemon/_version.py +1 -0
- reticulum_telemetry_hub/lxmf_daemon/lxmd.py +1655 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/fields/field_telemetry_stream.py +6 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/__init__.py +3 -0
- {lxmf_telemetry → reticulum_telemetry_hub/lxmf_telemetry}/model/persistance/appearance.py +19 -19
- {lxmf_telemetry → reticulum_telemetry_hub/lxmf_telemetry}/model/persistance/peer.py +17 -13
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/__init__.py +65 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/acceleration.py +68 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/ambient_light.py +37 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/angular_velocity.py +68 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/battery.py +68 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/connection_map.py +258 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/generic.py +841 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/gravity.py +68 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/humidity.py +37 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/information.py +42 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/location.py +110 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/lxmf_propagation.py +429 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/magnetic_field.py +68 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/physical_link.py +53 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/pressure.py +37 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/proximity.py +37 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/received.py +75 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/rns_transport.py +209 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/sensor.py +65 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/sensor_enum.py +27 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/sensor_mapping.py +58 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/temperature.py +37 -0
- {lxmf_telemetry → reticulum_telemetry_hub/lxmf_telemetry}/model/persistance/sensors/time.py +36 -32
- {lxmf_telemetry → reticulum_telemetry_hub/lxmf_telemetry}/model/persistance/telemeter.py +26 -23
- reticulum_telemetry_hub/lxmf_telemetry/sampler.py +229 -0
- reticulum_telemetry_hub/lxmf_telemetry/telemeter_manager.py +409 -0
- reticulum_telemetry_hub/lxmf_telemetry/telemetry_controller.py +804 -0
- reticulum_telemetry_hub/northbound/__init__.py +5 -0
- reticulum_telemetry_hub/northbound/app.py +195 -0
- reticulum_telemetry_hub/northbound/auth.py +119 -0
- reticulum_telemetry_hub/northbound/gateway.py +310 -0
- reticulum_telemetry_hub/northbound/internal_adapter.py +302 -0
- reticulum_telemetry_hub/northbound/models.py +213 -0
- reticulum_telemetry_hub/northbound/routes_chat.py +123 -0
- reticulum_telemetry_hub/northbound/routes_files.py +119 -0
- reticulum_telemetry_hub/northbound/routes_rest.py +345 -0
- reticulum_telemetry_hub/northbound/routes_subscribers.py +150 -0
- reticulum_telemetry_hub/northbound/routes_topics.py +178 -0
- reticulum_telemetry_hub/northbound/routes_ws.py +107 -0
- reticulum_telemetry_hub/northbound/serializers.py +72 -0
- reticulum_telemetry_hub/northbound/services.py +373 -0
- reticulum_telemetry_hub/northbound/websocket.py +855 -0
- reticulum_telemetry_hub/reticulum_server/__main__.py +2237 -0
- reticulum_telemetry_hub/reticulum_server/command_manager.py +1268 -0
- reticulum_telemetry_hub/reticulum_server/command_text.py +399 -0
- reticulum_telemetry_hub/reticulum_server/constants.py +1 -0
- reticulum_telemetry_hub/reticulum_server/event_log.py +357 -0
- reticulum_telemetry_hub/reticulum_server/internal_adapter.py +358 -0
- reticulum_telemetry_hub/reticulum_server/outbound_queue.py +312 -0
- reticulum_telemetry_hub/reticulum_server/services.py +422 -0
- reticulumtelemetryhub-0.143.0.dist-info/METADATA +181 -0
- reticulumtelemetryhub-0.143.0.dist-info/RECORD +97 -0
- {reticulumtelemetryhub-0.1.0.dist-info → reticulumtelemetryhub-0.143.0.dist-info}/WHEEL +1 -1
- reticulumtelemetryhub-0.143.0.dist-info/licenses/LICENSE +277 -0
- lxmf_telemetry/model/fields/field_telemetry_stream.py +0 -7
- lxmf_telemetry/model/persistance/__init__.py +0 -3
- lxmf_telemetry/model/persistance/sensors/location.py +0 -69
- lxmf_telemetry/model/persistance/sensors/magnetic_field.py +0 -36
- lxmf_telemetry/model/persistance/sensors/sensor.py +0 -44
- lxmf_telemetry/model/persistance/sensors/sensor_enum.py +0 -24
- lxmf_telemetry/model/persistance/sensors/sensor_mapping.py +0 -9
- lxmf_telemetry/telemetry_controller.py +0 -124
- reticulum_server/main.py +0 -182
- reticulumtelemetryhub-0.1.0.dist-info/METADATA +0 -15
- reticulumtelemetryhub-0.1.0.dist-info/RECORD +0 -19
- {lxmf_telemetry → reticulum_telemetry_hub}/__init__.py +0 -0
- {lxmf_telemetry/model/persistance/sensors → reticulum_telemetry_hub/lxmf_telemetry}/__init__.py +0 -0
- {reticulum_server → reticulum_telemetry_hub/reticulum_server}/__init__.py +0 -0
|
@@ -0,0 +1,848 @@
|
|
|
1
|
+
"""Utilities for building and transmitting ATAK Cursor-on-Target events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import uuid
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import datetime, timedelta, timezone
|
|
9
|
+
from typing import TYPE_CHECKING, Any, Callable, Mapping
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
|
|
12
|
+
import RNS
|
|
13
|
+
from sqlalchemy.orm.exc import DetachedInstanceError
|
|
14
|
+
from reticulum_telemetry_hub.atak_cot import Chat
|
|
15
|
+
from reticulum_telemetry_hub.atak_cot import ChatGroup
|
|
16
|
+
from reticulum_telemetry_hub.atak_cot import Contact
|
|
17
|
+
from reticulum_telemetry_hub.atak_cot import Detail
|
|
18
|
+
from reticulum_telemetry_hub.atak_cot import Event
|
|
19
|
+
from reticulum_telemetry_hub.atak_cot import Group
|
|
20
|
+
from reticulum_telemetry_hub.atak_cot import Link
|
|
21
|
+
from reticulum_telemetry_hub.atak_cot import Marti
|
|
22
|
+
from reticulum_telemetry_hub.atak_cot import MartiDest
|
|
23
|
+
from reticulum_telemetry_hub.atak_cot import Remarks
|
|
24
|
+
from reticulum_telemetry_hub.atak_cot import Status
|
|
25
|
+
from reticulum_telemetry_hub.atak_cot import Takv
|
|
26
|
+
from reticulum_telemetry_hub.atak_cot import Track
|
|
27
|
+
from reticulum_telemetry_hub.atak_cot import Uid
|
|
28
|
+
from reticulum_telemetry_hub.config.models import TakConnectionConfig
|
|
29
|
+
from reticulum_telemetry_hub.lxmf_telemetry.model.persistance.sensors import (
|
|
30
|
+
sensor_enum,
|
|
31
|
+
)
|
|
32
|
+
from reticulum_telemetry_hub.lxmf_telemetry.telemeter_manager import (
|
|
33
|
+
TelemeterManager,
|
|
34
|
+
)
|
|
35
|
+
from reticulum_telemetry_hub.lxmf_telemetry.telemetry_controller import (
|
|
36
|
+
TelemetryController,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
SID_LOCATION = sensor_enum.SID_LOCATION
|
|
40
|
+
|
|
41
|
+
if TYPE_CHECKING:
|
|
42
|
+
from reticulum_telemetry_hub.atak_cot.pytak_client import PytakClient
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class LocationSnapshot: # pylint: disable=too-many-instance-attributes
|
|
47
|
+
"""Represents the latest known position of the hub."""
|
|
48
|
+
|
|
49
|
+
latitude: float
|
|
50
|
+
longitude: float
|
|
51
|
+
altitude: float
|
|
52
|
+
speed: float
|
|
53
|
+
bearing: float
|
|
54
|
+
accuracy: float
|
|
55
|
+
updated_at: datetime
|
|
56
|
+
peer_hash: str | None = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _utc_iso(dt: datetime) -> str:
|
|
60
|
+
"""Format a ``datetime`` in UTC without microseconds.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
dt (datetime): Datetime to normalise.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
str: ISO-8601 timestamp suffixed with ``Z``.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
normalized = _normalize_utc(dt).replace(microsecond=0)
|
|
70
|
+
return normalized.isoformat() + "Z"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _utc_iso_millis(dt: datetime) -> str:
|
|
74
|
+
"""Format a ``datetime`` in UTC with millisecond precision.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
dt (datetime): Datetime to normalise.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
str: ISO-8601 timestamp with milliseconds and a ``Z`` suffix.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
normalized = _normalize_utc(dt)
|
|
84
|
+
normalized = normalized.replace(microsecond=int(normalized.microsecond / 1000) * 1000)
|
|
85
|
+
return normalized.isoformat(timespec="milliseconds") + "Z"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _normalize_utc(dt: datetime) -> datetime:
|
|
89
|
+
if dt.tzinfo is None:
|
|
90
|
+
return dt
|
|
91
|
+
return dt.astimezone(timezone.utc).replace(tzinfo=None)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _utcnow() -> datetime:
|
|
95
|
+
return datetime.now(timezone.utc).replace(tzinfo=None)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class TakConnector: # pylint: disable=too-many-instance-attributes
|
|
99
|
+
"""Build and transmit CoT events describing the hub's location."""
|
|
100
|
+
|
|
101
|
+
EVENT_TYPE = "a-f-G-U-C"
|
|
102
|
+
EVENT_HOW = "h-g-i-g-o"
|
|
103
|
+
CHAT_LINK_TYPE = "a-f-G-U-C-I"
|
|
104
|
+
CHAT_EVENT_TYPE = "b-t-f"
|
|
105
|
+
CHAT_EVENT_HOW = "h-g-i-g-o"
|
|
106
|
+
TAKV_VERSION = "0.44.0"
|
|
107
|
+
TAKV_PLATFORM = "RetTAK"
|
|
108
|
+
TAKV_OS = "ubuntu"
|
|
109
|
+
TAKV_DEVICE = "not your business"
|
|
110
|
+
GROUP_NAME = "Yellow"
|
|
111
|
+
GROUP_ROLE = "Team Member"
|
|
112
|
+
STATUS_BATTERY = 0.0
|
|
113
|
+
|
|
114
|
+
def __init__( # pylint: disable=too-many-arguments
|
|
115
|
+
self,
|
|
116
|
+
*,
|
|
117
|
+
config: TakConnectionConfig | None = None,
|
|
118
|
+
pytak_client: PytakClient | None = None,
|
|
119
|
+
telemeter_manager: TelemeterManager | None = None,
|
|
120
|
+
telemetry_controller: TelemetryController | None = None,
|
|
121
|
+
identity_lookup: Callable[[str | bytes | None], str] | None = None,
|
|
122
|
+
) -> None:
|
|
123
|
+
"""Initialize the connector with optional collaborators.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
config (TakConnectionConfig | None): Connection parameters for
|
|
127
|
+
PyTAK. Defaults to a new :class:`TakConnectionConfig` when
|
|
128
|
+
omitted.
|
|
129
|
+
pytak_client (PytakClient | None): Client used to create and send
|
|
130
|
+
messages. A default client is created when not provided.
|
|
131
|
+
telemeter_manager (TelemeterManager | None): Manager that exposes
|
|
132
|
+
live sensor data.
|
|
133
|
+
telemetry_controller (TelemetryController | None): Controller used
|
|
134
|
+
for fallback location lookups.
|
|
135
|
+
identity_lookup (Callable[[str | bytes | None], str] | None):
|
|
136
|
+
Optional lookup used to resolve destination hashes into human
|
|
137
|
+
readable labels.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
self._config = config or TakConnectionConfig()
|
|
141
|
+
if pytak_client is None:
|
|
142
|
+
from reticulum_telemetry_hub.atak_cot.pytak_client import PytakClient
|
|
143
|
+
|
|
144
|
+
pytak_client = PytakClient(self._config.to_config_parser())
|
|
145
|
+
self._pytak_client = pytak_client
|
|
146
|
+
self._config_parser = self._config.to_config_parser()
|
|
147
|
+
self._telemeter_manager = telemeter_manager
|
|
148
|
+
self._telemetry_controller = telemetry_controller
|
|
149
|
+
self._identity_lookup = identity_lookup
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def config(self) -> TakConnectionConfig:
|
|
153
|
+
"""Return the current TAK connection configuration.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
TakConnectionConfig: Active configuration for outbound CoT events.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
return self._config
|
|
160
|
+
|
|
161
|
+
async def send_latest_location(self) -> bool:
|
|
162
|
+
"""Send the most recent location snapshot if one is available.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
bool: ``True`` when a message was dispatched, ``False`` when no
|
|
166
|
+
location was available.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
snapshots = self._latest_location_snapshots()
|
|
170
|
+
if not snapshots:
|
|
171
|
+
RNS.log(
|
|
172
|
+
"TAK connector skipped CoT send because no location is available",
|
|
173
|
+
RNS.LOG_WARNING,
|
|
174
|
+
)
|
|
175
|
+
return False
|
|
176
|
+
|
|
177
|
+
dispatched = False
|
|
178
|
+
for snapshot in snapshots:
|
|
179
|
+
uid = self._uid_from_hash(snapshot.peer_hash)
|
|
180
|
+
callsign = self._callsign_from_hash(snapshot.peer_hash)
|
|
181
|
+
event = self._build_event_from_snapshot(
|
|
182
|
+
snapshot, uid=uid, callsign=callsign
|
|
183
|
+
)
|
|
184
|
+
event_payload = json.dumps(event.to_dict())
|
|
185
|
+
RNS.log(
|
|
186
|
+
"TAK connector sending event type "
|
|
187
|
+
f"{event.type} with payload: {event_payload}",
|
|
188
|
+
RNS.LOG_INFO,
|
|
189
|
+
)
|
|
190
|
+
await self._pytak_client.create_and_send_message(
|
|
191
|
+
event, config=self._config_parser, parse_inbound=False
|
|
192
|
+
)
|
|
193
|
+
dispatched = True
|
|
194
|
+
return dispatched
|
|
195
|
+
|
|
196
|
+
def build_event(self) -> Event | None:
|
|
197
|
+
"""Construct a CoT :class:`Event` from available telemetry.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
Event | None: Populated CoT event or ``None`` when no location
|
|
201
|
+
snapshot exists.
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
snapshot = self._latest_location()
|
|
205
|
+
if snapshot is None:
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
uid = self._uid_from_hash(snapshot.peer_hash)
|
|
209
|
+
callsign = self._callsign_from_hash(snapshot.peer_hash)
|
|
210
|
+
return self._build_event_from_snapshot(snapshot, uid=uid, callsign=callsign)
|
|
211
|
+
|
|
212
|
+
def build_event_from_telemetry(
|
|
213
|
+
self,
|
|
214
|
+
telemetry: Mapping[str, Any],
|
|
215
|
+
*,
|
|
216
|
+
peer_hash: str | bytes | None,
|
|
217
|
+
timestamp: datetime | None = None,
|
|
218
|
+
) -> Event | None:
|
|
219
|
+
"""Build a CoT event directly from telemetry payloads.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
telemetry (Mapping[str, Any]): Human-readable telemetry payload as
|
|
223
|
+
decoded by :class:`TelemetryController`.
|
|
224
|
+
peer_hash (str | bytes | None): LXMF destination hash identifying
|
|
225
|
+
the telemetry sender.
|
|
226
|
+
timestamp (datetime | None): Optional timestamp associated with the
|
|
227
|
+
payload.
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
Event | None: A populated CoT event when a location sensor exists,
|
|
231
|
+
otherwise ``None``.
|
|
232
|
+
"""
|
|
233
|
+
|
|
234
|
+
snapshot = self._snapshot_from_telemetry(telemetry, timestamp)
|
|
235
|
+
if snapshot is None:
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
uid = self._uid_from_hash(peer_hash)
|
|
239
|
+
callsign = self._callsign_from_hash(peer_hash)
|
|
240
|
+
snapshot.peer_hash = peer_hash if peer_hash is not None else snapshot.peer_hash
|
|
241
|
+
return self._build_event_from_snapshot(snapshot, uid=uid, callsign=callsign)
|
|
242
|
+
|
|
243
|
+
async def send_telemetry_event(
|
|
244
|
+
self,
|
|
245
|
+
telemetry: Mapping[str, Any],
|
|
246
|
+
*,
|
|
247
|
+
peer_hash: str | bytes | None,
|
|
248
|
+
timestamp: datetime | None = None,
|
|
249
|
+
) -> bool:
|
|
250
|
+
"""Send a CoT event derived from telemetry data.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
telemetry (Mapping[str, Any]): Telemetry payload to convert.
|
|
254
|
+
peer_hash (str | bytes | None): LXMF destination hash identifying
|
|
255
|
+
the telemetry sender.
|
|
256
|
+
timestamp (datetime | None): Optional timestamp associated with the
|
|
257
|
+
payload.
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
bool: ``True`` when an event is transmitted, ``False`` when
|
|
261
|
+
location data is missing.
|
|
262
|
+
"""
|
|
263
|
+
|
|
264
|
+
event = self.build_event_from_telemetry(
|
|
265
|
+
telemetry, peer_hash=peer_hash, timestamp=timestamp
|
|
266
|
+
)
|
|
267
|
+
if event is None:
|
|
268
|
+
RNS.log(
|
|
269
|
+
"TAK connector skipped CoT send because telemetry lacked location data",
|
|
270
|
+
RNS.LOG_WARNING,
|
|
271
|
+
)
|
|
272
|
+
return False
|
|
273
|
+
|
|
274
|
+
event_payload = json.dumps(event.to_dict())
|
|
275
|
+
RNS.log(
|
|
276
|
+
f"TAK connector sending event type {event.type} with payload: {event_payload}",
|
|
277
|
+
RNS.LOG_INFO,
|
|
278
|
+
)
|
|
279
|
+
await self._pytak_client.create_and_send_message(
|
|
280
|
+
event, config=self._config_parser, parse_inbound=False
|
|
281
|
+
)
|
|
282
|
+
RNS.log("TAK connector dispatched telemetry CoT event", RNS.LOG_INFO)
|
|
283
|
+
return True
|
|
284
|
+
|
|
285
|
+
async def send_keepalive(self) -> bool:
|
|
286
|
+
"""Transmit a takPong CoT event to keep the TAK session alive.
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
bool: ``True`` when the keepalive is dispatched.
|
|
290
|
+
"""
|
|
291
|
+
|
|
292
|
+
from pytak.functions import tak_pong
|
|
293
|
+
|
|
294
|
+
RNS.log("TAK connector sending keepalive takPong", RNS.LOG_DEBUG)
|
|
295
|
+
await self._pytak_client.create_and_send_message(
|
|
296
|
+
tak_pong(), config=self._config_parser, parse_inbound=False
|
|
297
|
+
)
|
|
298
|
+
return True
|
|
299
|
+
|
|
300
|
+
async def send_ping(self) -> bool:
|
|
301
|
+
"""Send a TAK hello/ping keepalive event."""
|
|
302
|
+
|
|
303
|
+
from pytak import hello_event
|
|
304
|
+
|
|
305
|
+
RNS.log("TAK connector sending ping", RNS.LOG_DEBUG)
|
|
306
|
+
await self._pytak_client.create_and_send_message(
|
|
307
|
+
hello_event(), config=self._config_parser, parse_inbound=False
|
|
308
|
+
)
|
|
309
|
+
return True
|
|
310
|
+
|
|
311
|
+
def build_chat_event( # pylint: disable=too-many-arguments,too-many-locals
|
|
312
|
+
self,
|
|
313
|
+
*,
|
|
314
|
+
content: str,
|
|
315
|
+
sender_label: str,
|
|
316
|
+
topic_id: str | None = None,
|
|
317
|
+
source_hash: str | None = None,
|
|
318
|
+
timestamp: datetime | None = None,
|
|
319
|
+
message_uuid: str | None = None,
|
|
320
|
+
) -> Event:
|
|
321
|
+
"""Construct a CoT chat :class:`Event` for LXMF message content.
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
content (str): Plaintext chat body to relay.
|
|
325
|
+
sender_label (str): Human-readable label for the sender.
|
|
326
|
+
topic_id (str | None): Optional topic identifier for routing.
|
|
327
|
+
source_hash (str | None): Optional sender hash used to derive the
|
|
328
|
+
UID.
|
|
329
|
+
timestamp (datetime | None): Time the LXMF message was created.
|
|
330
|
+
message_uuid (str | None): Optional UUID for the chat message
|
|
331
|
+
allowing deterministic testing.
|
|
332
|
+
|
|
333
|
+
Returns:
|
|
334
|
+
Event: Populated CoT chat event ready for transmission.
|
|
335
|
+
"""
|
|
336
|
+
|
|
337
|
+
if not content:
|
|
338
|
+
raise ValueError("Chat content is required to build a CoT event.")
|
|
339
|
+
|
|
340
|
+
event_time = timestamp or _utcnow()
|
|
341
|
+
stale = event_time + timedelta(hours=24)
|
|
342
|
+
chatroom = str(topic_id) if topic_id else "All Chat Rooms"
|
|
343
|
+
sender_uid = self._normalize_hash(source_hash) or self._config.callsign
|
|
344
|
+
message_id = message_uuid or str(uuid.uuid4())
|
|
345
|
+
event_uid = f"GeoChat.{sender_uid}.{chatroom}.{message_id}"
|
|
346
|
+
|
|
347
|
+
chat_group = ChatGroup(
|
|
348
|
+
chatroom=None,
|
|
349
|
+
chat_id=chatroom,
|
|
350
|
+
uid0=sender_uid,
|
|
351
|
+
uid1=chatroom,
|
|
352
|
+
)
|
|
353
|
+
chat = Chat(
|
|
354
|
+
id=chatroom,
|
|
355
|
+
chatroom=chatroom,
|
|
356
|
+
sender_callsign=sender_label,
|
|
357
|
+
group_owner="false",
|
|
358
|
+
message_id=message_id,
|
|
359
|
+
chat_group=chat_group,
|
|
360
|
+
)
|
|
361
|
+
link = Link(
|
|
362
|
+
uid=sender_uid,
|
|
363
|
+
type=self.CHAT_LINK_TYPE,
|
|
364
|
+
relation="p-p",
|
|
365
|
+
)
|
|
366
|
+
remarks_source = f"LXMF.CLIENT.{sender_uid}" if sender_uid else "LXMF.CLIENT"
|
|
367
|
+
remarks = Remarks(
|
|
368
|
+
text=content.strip(),
|
|
369
|
+
source=remarks_source,
|
|
370
|
+
source_id=sender_uid,
|
|
371
|
+
to=chatroom,
|
|
372
|
+
time=_utc_iso_millis(event_time),
|
|
373
|
+
)
|
|
374
|
+
detail = Detail(
|
|
375
|
+
chat=chat,
|
|
376
|
+
links=[link],
|
|
377
|
+
remarks=remarks,
|
|
378
|
+
marti=Marti(dest=MartiDest(callsign=None)),
|
|
379
|
+
server_destination=True,
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
event_dict = {
|
|
383
|
+
"version": "2.0",
|
|
384
|
+
"uid": event_uid,
|
|
385
|
+
"type": self.CHAT_EVENT_TYPE,
|
|
386
|
+
"how": self.CHAT_EVENT_HOW,
|
|
387
|
+
"access": "Undefined",
|
|
388
|
+
"time": _utc_iso_millis(event_time),
|
|
389
|
+
"start": _utc_iso_millis(event_time),
|
|
390
|
+
"stale": _utc_iso_millis(stale),
|
|
391
|
+
"point": {
|
|
392
|
+
"lat": 0.0,
|
|
393
|
+
"lon": 0.0,
|
|
394
|
+
"hae": 9999999.0,
|
|
395
|
+
"ce": 9999999.0,
|
|
396
|
+
"le": 9999999.0,
|
|
397
|
+
},
|
|
398
|
+
"detail": detail.to_dict(),
|
|
399
|
+
}
|
|
400
|
+
return Event.from_dict(event_dict)
|
|
401
|
+
|
|
402
|
+
async def send_chat_event( # pylint: disable=too-many-arguments
|
|
403
|
+
self,
|
|
404
|
+
*,
|
|
405
|
+
content: str,
|
|
406
|
+
sender_label: str,
|
|
407
|
+
topic_id: str | None = None,
|
|
408
|
+
source_hash: str | None = None,
|
|
409
|
+
timestamp: datetime | None = None,
|
|
410
|
+
message_uuid: str | None = None,
|
|
411
|
+
) -> bool:
|
|
412
|
+
"""Send a CoT chat event derived from LXMF payloads.
|
|
413
|
+
|
|
414
|
+
Args:
|
|
415
|
+
content (str): Plaintext chat body to relay.
|
|
416
|
+
sender_label (str): Human-readable label for the sender.
|
|
417
|
+
topic_id (str | None): Optional topic identifier for routing.
|
|
418
|
+
source_hash (str | None): Optional sender hash used to derive the
|
|
419
|
+
UID.
|
|
420
|
+
timestamp (datetime | None): Time the LXMF message was created.
|
|
421
|
+
message_uuid (str | None): Optional UUID for the chat message to
|
|
422
|
+
allow deterministic testing.
|
|
423
|
+
|
|
424
|
+
Returns:
|
|
425
|
+
bool: ``True`` when a message was dispatched.
|
|
426
|
+
"""
|
|
427
|
+
|
|
428
|
+
event = self.build_chat_event(
|
|
429
|
+
content=content,
|
|
430
|
+
sender_label=sender_label,
|
|
431
|
+
topic_id=topic_id,
|
|
432
|
+
source_hash=source_hash,
|
|
433
|
+
timestamp=timestamp,
|
|
434
|
+
message_uuid=message_uuid,
|
|
435
|
+
)
|
|
436
|
+
event_payload = json.dumps(event.to_dict())
|
|
437
|
+
RNS.log(
|
|
438
|
+
f"TAK connector sending event type {event.type} with payload: {event_payload}",
|
|
439
|
+
RNS.LOG_INFO,
|
|
440
|
+
)
|
|
441
|
+
await self._pytak_client.create_and_send_message(
|
|
442
|
+
event, config=self._config_parser, parse_inbound=False
|
|
443
|
+
)
|
|
444
|
+
return True
|
|
445
|
+
|
|
446
|
+
def _latest_location(self) -> LocationSnapshot | None:
|
|
447
|
+
"""Return the freshest location snapshot available.
|
|
448
|
+
|
|
449
|
+
Returns:
|
|
450
|
+
LocationSnapshot | None: Most recent location if available.
|
|
451
|
+
"""
|
|
452
|
+
|
|
453
|
+
snapshots = self._latest_location_snapshots()
|
|
454
|
+
if not snapshots:
|
|
455
|
+
return None
|
|
456
|
+
return snapshots[0]
|
|
457
|
+
|
|
458
|
+
def _latest_location_snapshots(self) -> list[LocationSnapshot]:
|
|
459
|
+
"""Return location snapshots for the latest telemetry per peer.
|
|
460
|
+
|
|
461
|
+
The returned list is sorted by ``updated_at`` with the newest snapshot
|
|
462
|
+
first.
|
|
463
|
+
|
|
464
|
+
Returns:
|
|
465
|
+
list[LocationSnapshot]: Unique location snapshots keyed by peer.
|
|
466
|
+
"""
|
|
467
|
+
|
|
468
|
+
snapshots: list[LocationSnapshot] = []
|
|
469
|
+
seen_hashes: set[str] = set()
|
|
470
|
+
|
|
471
|
+
manager_snapshot = self._latest_location_from_manager()
|
|
472
|
+
if manager_snapshot is not None:
|
|
473
|
+
normalized = self._normalize_hash(manager_snapshot.peer_hash)
|
|
474
|
+
seen_hashes.add(normalized)
|
|
475
|
+
snapshots.append(manager_snapshot)
|
|
476
|
+
|
|
477
|
+
controller_snapshots = self._latest_locations_from_controller(seen_hashes)
|
|
478
|
+
snapshots.extend(controller_snapshots)
|
|
479
|
+
|
|
480
|
+
snapshots.sort(key=lambda snapshot: snapshot.updated_at, reverse=True)
|
|
481
|
+
return snapshots
|
|
482
|
+
|
|
483
|
+
def _cot_endpoint(self) -> str | None:
|
|
484
|
+
"""Return the contact endpoint derived from the configured COT URL."""
|
|
485
|
+
|
|
486
|
+
parsed = urlparse(self._config.cot_url)
|
|
487
|
+
if not parsed.scheme or not parsed.hostname:
|
|
488
|
+
return None
|
|
489
|
+
port = f":{parsed.port}" if parsed.port else ""
|
|
490
|
+
return f"{parsed.hostname}{port}:{parsed.scheme}"
|
|
491
|
+
|
|
492
|
+
def _latest_locations_from_controller(
|
|
493
|
+
self, seen_hashes: set[str]
|
|
494
|
+
) -> list[LocationSnapshot]:
|
|
495
|
+
"""Return unique snapshots derived from stored telemetry entries.
|
|
496
|
+
|
|
497
|
+
Args:
|
|
498
|
+
seen_hashes (set[str]): Normalized peer hashes already captured.
|
|
499
|
+
|
|
500
|
+
Returns:
|
|
501
|
+
list[LocationSnapshot]: Unique snapshots sorted newest to oldest.
|
|
502
|
+
"""
|
|
503
|
+
|
|
504
|
+
if self._telemetry_controller is None:
|
|
505
|
+
return []
|
|
506
|
+
|
|
507
|
+
telemetry_controller = self._telemetry_controller
|
|
508
|
+
snapshots: list[LocationSnapshot] = []
|
|
509
|
+
# pylint: disable=protected-access
|
|
510
|
+
with telemetry_controller._session_cls() as session: # type: ignore[attr-defined]
|
|
511
|
+
telemetry = telemetry_controller._load_telemetry(session)
|
|
512
|
+
for telemeter in telemetry:
|
|
513
|
+
peer_hash = getattr(telemeter, "peer_dest", None)
|
|
514
|
+
normalized_peer = self._normalize_hash(peer_hash)
|
|
515
|
+
if normalized_peer in seen_hashes:
|
|
516
|
+
continue
|
|
517
|
+
snapshot = self._snapshot_from_telemeter(telemeter)
|
|
518
|
+
if snapshot is None:
|
|
519
|
+
continue
|
|
520
|
+
snapshot.peer_hash = (
|
|
521
|
+
peer_hash if peer_hash is not None else snapshot.peer_hash
|
|
522
|
+
)
|
|
523
|
+
snapshots.append(snapshot)
|
|
524
|
+
seen_hashes.add(normalized_peer)
|
|
525
|
+
snapshots.sort(key=lambda snap: snap.updated_at, reverse=True)
|
|
526
|
+
return snapshots
|
|
527
|
+
|
|
528
|
+
def _latest_location_from_manager(self) -> LocationSnapshot | None:
|
|
529
|
+
"""Extract the latest location data from the telemeter manager.
|
|
530
|
+
|
|
531
|
+
Returns:
|
|
532
|
+
LocationSnapshot | None: Location snapshot when a location sensor
|
|
533
|
+
exists.
|
|
534
|
+
"""
|
|
535
|
+
|
|
536
|
+
if self._telemeter_manager is None:
|
|
537
|
+
return None
|
|
538
|
+
|
|
539
|
+
sensor = self._telemeter_manager.get_sensor("location")
|
|
540
|
+
if sensor is None:
|
|
541
|
+
return None
|
|
542
|
+
|
|
543
|
+
latitude = getattr(sensor, "latitude", None)
|
|
544
|
+
longitude = getattr(sensor, "longitude", None)
|
|
545
|
+
if latitude is None or longitude is None:
|
|
546
|
+
return None
|
|
547
|
+
|
|
548
|
+
altitude = getattr(sensor, "altitude", 0.0) or 0.0
|
|
549
|
+
speed = getattr(sensor, "speed", 0.0) or 0.0
|
|
550
|
+
bearing = getattr(sensor, "bearing", 0.0) or 0.0
|
|
551
|
+
accuracy = getattr(sensor, "accuracy", 0.0) or 0.0
|
|
552
|
+
updated_at = getattr(sensor, "last_update", None) or _utcnow()
|
|
553
|
+
peer_hash = getattr(
|
|
554
|
+
getattr(self._telemeter_manager, "telemeter", None),
|
|
555
|
+
"peer_dest",
|
|
556
|
+
None,
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
return LocationSnapshot(
|
|
560
|
+
latitude=float(latitude),
|
|
561
|
+
longitude=float(longitude),
|
|
562
|
+
altitude=float(altitude),
|
|
563
|
+
speed=float(speed),
|
|
564
|
+
bearing=float(bearing),
|
|
565
|
+
accuracy=float(accuracy),
|
|
566
|
+
updated_at=updated_at,
|
|
567
|
+
peer_hash=str(peer_hash) if peer_hash is not None else None,
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
def _latest_location_from_controller(self) -> LocationSnapshot | None:
|
|
571
|
+
"""Extract the latest location data from the telemetry controller.
|
|
572
|
+
|
|
573
|
+
Returns:
|
|
574
|
+
LocationSnapshot | None: Location snapshot derived from stored
|
|
575
|
+
telemetry.
|
|
576
|
+
"""
|
|
577
|
+
|
|
578
|
+
if self._telemetry_controller is None:
|
|
579
|
+
return None
|
|
580
|
+
|
|
581
|
+
telemetry = self._telemetry_controller.get_telemetry()
|
|
582
|
+
if not telemetry:
|
|
583
|
+
return None
|
|
584
|
+
|
|
585
|
+
snapshot = self._snapshot_from_telemeter(telemetry[0])
|
|
586
|
+
if snapshot is None:
|
|
587
|
+
return None
|
|
588
|
+
snapshot.peer_hash = getattr(telemetry[0], "peer_dest", None)
|
|
589
|
+
return snapshot
|
|
590
|
+
|
|
591
|
+
def _snapshot_from_telemeter(self, telemeter: Any) -> LocationSnapshot | None:
|
|
592
|
+
"""Convert a stored telemeter entry into a location snapshot.
|
|
593
|
+
|
|
594
|
+
Args:
|
|
595
|
+
telemeter (Any): Telemeter ORM instance containing sensors.
|
|
596
|
+
|
|
597
|
+
Returns:
|
|
598
|
+
LocationSnapshot | None: Snapshot when location data exists.
|
|
599
|
+
"""
|
|
600
|
+
|
|
601
|
+
location_sensor = None
|
|
602
|
+
for sensor in getattr(telemeter, "sensors", []):
|
|
603
|
+
if getattr(sensor, "sid", None) == SID_LOCATION:
|
|
604
|
+
location_sensor = sensor
|
|
605
|
+
break
|
|
606
|
+
|
|
607
|
+
if location_sensor is None:
|
|
608
|
+
return None
|
|
609
|
+
|
|
610
|
+
try:
|
|
611
|
+
latitude = getattr(location_sensor, "latitude", None)
|
|
612
|
+
longitude = getattr(location_sensor, "longitude", None)
|
|
613
|
+
altitude = getattr(location_sensor, "altitude", 0.0) or 0.0
|
|
614
|
+
speed = getattr(location_sensor, "speed", 0.0) or 0.0
|
|
615
|
+
bearing = getattr(location_sensor, "bearing", 0.0) or 0.0
|
|
616
|
+
accuracy = getattr(location_sensor, "accuracy", 0.0) or 0.0
|
|
617
|
+
updated_at = getattr(location_sensor, "last_update", None)
|
|
618
|
+
except DetachedInstanceError:
|
|
619
|
+
sensor_state = getattr(location_sensor, "__dict__", {}) or {}
|
|
620
|
+
latitude = sensor_state.get("latitude")
|
|
621
|
+
longitude = sensor_state.get("longitude")
|
|
622
|
+
altitude = sensor_state.get("altitude", 0.0) or 0.0
|
|
623
|
+
speed = sensor_state.get("speed", 0.0) or 0.0
|
|
624
|
+
bearing = sensor_state.get("bearing", 0.0) or 0.0
|
|
625
|
+
accuracy = sensor_state.get("accuracy", 0.0) or 0.0
|
|
626
|
+
updated_at = sensor_state.get("last_update")
|
|
627
|
+
|
|
628
|
+
if (latitude is None or longitude is None) and hasattr(
|
|
629
|
+
location_sensor, "unpack"
|
|
630
|
+
):
|
|
631
|
+
packed_payload = getattr(location_sensor, "data", None)
|
|
632
|
+
if packed_payload is not None:
|
|
633
|
+
try:
|
|
634
|
+
location_sensor.unpack(packed_payload)
|
|
635
|
+
latitude = getattr(location_sensor, "latitude", latitude)
|
|
636
|
+
longitude = getattr(location_sensor, "longitude", longitude)
|
|
637
|
+
altitude = getattr(location_sensor, "altitude", altitude)
|
|
638
|
+
speed = getattr(location_sensor, "speed", speed)
|
|
639
|
+
bearing = getattr(location_sensor, "bearing", bearing)
|
|
640
|
+
accuracy = getattr(location_sensor, "accuracy", accuracy)
|
|
641
|
+
updated_at = getattr(location_sensor, "last_update", updated_at)
|
|
642
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
643
|
+
return None
|
|
644
|
+
|
|
645
|
+
if latitude is None or longitude is None:
|
|
646
|
+
return None
|
|
647
|
+
|
|
648
|
+
updated_at = updated_at or getattr(telemeter, "time", _utcnow())
|
|
649
|
+
|
|
650
|
+
return LocationSnapshot(
|
|
651
|
+
latitude=float(latitude),
|
|
652
|
+
longitude=float(longitude),
|
|
653
|
+
altitude=float(altitude),
|
|
654
|
+
speed=float(speed),
|
|
655
|
+
bearing=float(bearing),
|
|
656
|
+
accuracy=float(accuracy),
|
|
657
|
+
updated_at=updated_at,
|
|
658
|
+
peer_hash=getattr(telemeter, "peer_dest", None),
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
def _build_event_from_snapshot(
|
|
662
|
+
self, snapshot: LocationSnapshot, *, uid: str, callsign: str
|
|
663
|
+
) -> Event:
|
|
664
|
+
"""Return a CoT event populated from a location snapshot.
|
|
665
|
+
|
|
666
|
+
Args:
|
|
667
|
+
snapshot (LocationSnapshot): Position and movement metadata.
|
|
668
|
+
uid (str): UID assigned to the CoT event.
|
|
669
|
+
callsign (str): Callsign used for the contact detail.
|
|
670
|
+
|
|
671
|
+
Returns:
|
|
672
|
+
Event: A populated CoT event ready for serialization.
|
|
673
|
+
"""
|
|
674
|
+
|
|
675
|
+
now = _utcnow()
|
|
676
|
+
stale_delta = max(self._config.poll_interval_seconds, 1.0)
|
|
677
|
+
stale = now + timedelta(seconds=stale_delta * 2)
|
|
678
|
+
|
|
679
|
+
contact = Contact(callsign=callsign, endpoint=self._cot_endpoint())
|
|
680
|
+
group = Group(name=self.GROUP_NAME, role=self.GROUP_ROLE)
|
|
681
|
+
track = Track(course=snapshot.bearing, speed=snapshot.speed)
|
|
682
|
+
takv = Takv(
|
|
683
|
+
version=self.TAKV_VERSION,
|
|
684
|
+
platform=self.TAKV_PLATFORM,
|
|
685
|
+
os=self.TAKV_OS,
|
|
686
|
+
device=self.TAKV_DEVICE,
|
|
687
|
+
)
|
|
688
|
+
detail = Detail(
|
|
689
|
+
contact=contact,
|
|
690
|
+
group=group,
|
|
691
|
+
track=track,
|
|
692
|
+
takv=takv,
|
|
693
|
+
uid=Uid(droid=callsign),
|
|
694
|
+
status=Status(battery=self.STATUS_BATTERY),
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
event_dict = {
|
|
698
|
+
"version": "2.0",
|
|
699
|
+
"uid": uid,
|
|
700
|
+
"type": self.EVENT_TYPE,
|
|
701
|
+
"how": self.EVENT_HOW,
|
|
702
|
+
"time": _utc_iso(now),
|
|
703
|
+
"start": _utc_iso(snapshot.updated_at),
|
|
704
|
+
"stale": _utc_iso(stale),
|
|
705
|
+
"point": {
|
|
706
|
+
"lat": snapshot.latitude,
|
|
707
|
+
"lon": snapshot.longitude,
|
|
708
|
+
"hae": snapshot.altitude,
|
|
709
|
+
"ce": snapshot.accuracy,
|
|
710
|
+
"le": snapshot.accuracy,
|
|
711
|
+
},
|
|
712
|
+
"detail": detail.to_dict(),
|
|
713
|
+
}
|
|
714
|
+
return Event.from_dict(event_dict)
|
|
715
|
+
|
|
716
|
+
def _snapshot_from_telemetry(
|
|
717
|
+
self, telemetry: Mapping[str, Any], timestamp: datetime | None
|
|
718
|
+
) -> LocationSnapshot | None:
|
|
719
|
+
"""Convert a telemetry payload into a location snapshot.
|
|
720
|
+
|
|
721
|
+
Args:
|
|
722
|
+
telemetry (Mapping[str, Any]): Human-readable telemetry payload.
|
|
723
|
+
timestamp (datetime | None): Optional timestamp to use when sensor
|
|
724
|
+
timestamps are absent.
|
|
725
|
+
|
|
726
|
+
Returns:
|
|
727
|
+
LocationSnapshot | None: Snapshot when location data exists.
|
|
728
|
+
"""
|
|
729
|
+
|
|
730
|
+
location = telemetry.get("location")
|
|
731
|
+
if not isinstance(location, Mapping):
|
|
732
|
+
return None
|
|
733
|
+
|
|
734
|
+
latitude = self._coerce_float(location.get("latitude"))
|
|
735
|
+
longitude = self._coerce_float(location.get("longitude"))
|
|
736
|
+
if latitude is None or longitude is None:
|
|
737
|
+
return None
|
|
738
|
+
|
|
739
|
+
altitude = self._coerce_float(location.get("altitude"), default=0.0)
|
|
740
|
+
speed = self._coerce_float(location.get("speed"), default=0.0)
|
|
741
|
+
bearing = self._coerce_float(location.get("bearing"), default=0.0)
|
|
742
|
+
accuracy = self._coerce_float(location.get("accuracy"), default=0.0)
|
|
743
|
+
|
|
744
|
+
updated_at = self._coerce_datetime(location.get("last_update_iso"))
|
|
745
|
+
if updated_at is None:
|
|
746
|
+
updated_at = self._coerce_datetime(location.get("last_update_timestamp"))
|
|
747
|
+
if updated_at is None:
|
|
748
|
+
updated_at = timestamp or _utcnow()
|
|
749
|
+
|
|
750
|
+
return LocationSnapshot(
|
|
751
|
+
latitude=latitude,
|
|
752
|
+
longitude=longitude,
|
|
753
|
+
altitude=altitude or 0.0,
|
|
754
|
+
speed=speed or 0.0,
|
|
755
|
+
bearing=bearing or 0.0,
|
|
756
|
+
accuracy=accuracy or 0.0,
|
|
757
|
+
updated_at=updated_at,
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
def _uid_from_hash(self, peer_hash: str | bytes | None) -> str:
|
|
761
|
+
"""Return a CoT UID derived from an LXMF destination hash."""
|
|
762
|
+
|
|
763
|
+
normalized = self._normalize_hash(peer_hash)
|
|
764
|
+
return normalized or self._config.callsign
|
|
765
|
+
|
|
766
|
+
def _callsign_from_hash(self, peer_hash: str | bytes | None) -> str:
|
|
767
|
+
"""Return a callsign preferring identity labels when available."""
|
|
768
|
+
|
|
769
|
+
label = self._label_from_identity(peer_hash)
|
|
770
|
+
if label:
|
|
771
|
+
return label
|
|
772
|
+
normalized = self._normalize_hash(peer_hash)
|
|
773
|
+
return normalized or self._config.callsign
|
|
774
|
+
|
|
775
|
+
def _identifier_from_hash(self, peer_hash: str | bytes | None) -> str:
|
|
776
|
+
"""Return a short identifier suitable for chat UIDs."""
|
|
777
|
+
|
|
778
|
+
label = self._label_from_identity(peer_hash)
|
|
779
|
+
if label:
|
|
780
|
+
return label
|
|
781
|
+
normalized = self._normalize_hash(peer_hash) or self._config.callsign
|
|
782
|
+
if len(normalized) > 12:
|
|
783
|
+
return normalized[-12:]
|
|
784
|
+
return normalized
|
|
785
|
+
|
|
786
|
+
def _normalize_hash(self, peer_hash: str | bytes | None) -> str:
|
|
787
|
+
"""Normalize LXMF destination hashes for use in UIDs."""
|
|
788
|
+
|
|
789
|
+
if peer_hash is None:
|
|
790
|
+
return ""
|
|
791
|
+
if isinstance(peer_hash, (bytes, bytearray)):
|
|
792
|
+
normalized = peer_hash.hex()
|
|
793
|
+
else:
|
|
794
|
+
normalized = str(peer_hash).strip()
|
|
795
|
+
normalized = normalized.replace(":", "")
|
|
796
|
+
return normalized
|
|
797
|
+
|
|
798
|
+
def _label_from_identity(self, peer_hash: str | bytes | None) -> str | None:
|
|
799
|
+
"""Return a display label for ``peer_hash`` when a lookup is available.
|
|
800
|
+
|
|
801
|
+
Args:
|
|
802
|
+
peer_hash (str | bytes | None): Destination hash supplied by the
|
|
803
|
+
telemetry source.
|
|
804
|
+
|
|
805
|
+
Returns:
|
|
806
|
+
str | None: A human-friendly label if the lookup yields one.
|
|
807
|
+
"""
|
|
808
|
+
|
|
809
|
+
if self._identity_lookup is None:
|
|
810
|
+
return None
|
|
811
|
+
if peer_hash is None:
|
|
812
|
+
return None
|
|
813
|
+
try:
|
|
814
|
+
label = self._identity_lookup(peer_hash)
|
|
815
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
816
|
+
return None
|
|
817
|
+
if label is None:
|
|
818
|
+
return None
|
|
819
|
+
cleaned = str(label).strip()
|
|
820
|
+
return cleaned or None
|
|
821
|
+
|
|
822
|
+
def _coerce_float(
|
|
823
|
+
self, value: Any, *, default: float | None = None
|
|
824
|
+
) -> float | None:
|
|
825
|
+
"""Safely cast a value to ``float`` when possible."""
|
|
826
|
+
|
|
827
|
+
if value is None:
|
|
828
|
+
return default
|
|
829
|
+
try:
|
|
830
|
+
return float(value)
|
|
831
|
+
except (TypeError, ValueError):
|
|
832
|
+
return default
|
|
833
|
+
|
|
834
|
+
def _coerce_datetime(self, value: Any) -> datetime | None:
|
|
835
|
+
"""Parse ISO or timestamp inputs into :class:`datetime` objects."""
|
|
836
|
+
|
|
837
|
+
if value is None:
|
|
838
|
+
return None
|
|
839
|
+
if isinstance(value, datetime):
|
|
840
|
+
return value
|
|
841
|
+
if isinstance(value, (int, float)):
|
|
842
|
+
return datetime.fromtimestamp(float(value))
|
|
843
|
+
if isinstance(value, str):
|
|
844
|
+
try:
|
|
845
|
+
return datetime.fromisoformat(value)
|
|
846
|
+
except ValueError:
|
|
847
|
+
return None
|
|
848
|
+
return None
|