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.
Files changed (108) hide show
  1. reticulum_telemetry_hub/api/__init__.py +23 -0
  2. reticulum_telemetry_hub/api/models.py +323 -0
  3. reticulum_telemetry_hub/api/service.py +836 -0
  4. reticulum_telemetry_hub/api/storage.py +528 -0
  5. reticulum_telemetry_hub/api/storage_base.py +156 -0
  6. reticulum_telemetry_hub/api/storage_models.py +118 -0
  7. reticulum_telemetry_hub/atak_cot/__init__.py +49 -0
  8. reticulum_telemetry_hub/atak_cot/base.py +277 -0
  9. reticulum_telemetry_hub/atak_cot/chat.py +506 -0
  10. reticulum_telemetry_hub/atak_cot/detail.py +235 -0
  11. reticulum_telemetry_hub/atak_cot/event.py +181 -0
  12. reticulum_telemetry_hub/atak_cot/pytak_client.py +569 -0
  13. reticulum_telemetry_hub/atak_cot/tak_connector.py +848 -0
  14. reticulum_telemetry_hub/config/__init__.py +25 -0
  15. reticulum_telemetry_hub/config/constants.py +7 -0
  16. reticulum_telemetry_hub/config/manager.py +515 -0
  17. reticulum_telemetry_hub/config/models.py +215 -0
  18. reticulum_telemetry_hub/embedded_lxmd/__init__.py +5 -0
  19. reticulum_telemetry_hub/embedded_lxmd/embedded.py +418 -0
  20. reticulum_telemetry_hub/internal_api/__init__.py +21 -0
  21. reticulum_telemetry_hub/internal_api/bus.py +344 -0
  22. reticulum_telemetry_hub/internal_api/core.py +690 -0
  23. reticulum_telemetry_hub/internal_api/v1/__init__.py +74 -0
  24. reticulum_telemetry_hub/internal_api/v1/enums.py +109 -0
  25. reticulum_telemetry_hub/internal_api/v1/manifest.json +8 -0
  26. reticulum_telemetry_hub/internal_api/v1/schemas.py +478 -0
  27. reticulum_telemetry_hub/internal_api/versioning.py +63 -0
  28. reticulum_telemetry_hub/lxmf_daemon/Handlers.py +122 -0
  29. reticulum_telemetry_hub/lxmf_daemon/LXMF.py +252 -0
  30. reticulum_telemetry_hub/lxmf_daemon/LXMPeer.py +898 -0
  31. reticulum_telemetry_hub/lxmf_daemon/LXMRouter.py +4227 -0
  32. reticulum_telemetry_hub/lxmf_daemon/LXMessage.py +1006 -0
  33. reticulum_telemetry_hub/lxmf_daemon/LXStamper.py +490 -0
  34. reticulum_telemetry_hub/lxmf_daemon/__init__.py +10 -0
  35. reticulum_telemetry_hub/lxmf_daemon/_version.py +1 -0
  36. reticulum_telemetry_hub/lxmf_daemon/lxmd.py +1655 -0
  37. reticulum_telemetry_hub/lxmf_telemetry/model/fields/field_telemetry_stream.py +6 -0
  38. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/__init__.py +3 -0
  39. {lxmf_telemetry → reticulum_telemetry_hub/lxmf_telemetry}/model/persistance/appearance.py +19 -19
  40. {lxmf_telemetry → reticulum_telemetry_hub/lxmf_telemetry}/model/persistance/peer.py +17 -13
  41. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/__init__.py +65 -0
  42. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/acceleration.py +68 -0
  43. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/ambient_light.py +37 -0
  44. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/angular_velocity.py +68 -0
  45. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/battery.py +68 -0
  46. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/connection_map.py +258 -0
  47. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/generic.py +841 -0
  48. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/gravity.py +68 -0
  49. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/humidity.py +37 -0
  50. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/information.py +42 -0
  51. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/location.py +110 -0
  52. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/lxmf_propagation.py +429 -0
  53. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/magnetic_field.py +68 -0
  54. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/physical_link.py +53 -0
  55. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/pressure.py +37 -0
  56. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/proximity.py +37 -0
  57. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/received.py +75 -0
  58. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/rns_transport.py +209 -0
  59. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/sensor.py +65 -0
  60. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/sensor_enum.py +27 -0
  61. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/sensor_mapping.py +58 -0
  62. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/temperature.py +37 -0
  63. {lxmf_telemetry → reticulum_telemetry_hub/lxmf_telemetry}/model/persistance/sensors/time.py +36 -32
  64. {lxmf_telemetry → reticulum_telemetry_hub/lxmf_telemetry}/model/persistance/telemeter.py +26 -23
  65. reticulum_telemetry_hub/lxmf_telemetry/sampler.py +229 -0
  66. reticulum_telemetry_hub/lxmf_telemetry/telemeter_manager.py +409 -0
  67. reticulum_telemetry_hub/lxmf_telemetry/telemetry_controller.py +804 -0
  68. reticulum_telemetry_hub/northbound/__init__.py +5 -0
  69. reticulum_telemetry_hub/northbound/app.py +195 -0
  70. reticulum_telemetry_hub/northbound/auth.py +119 -0
  71. reticulum_telemetry_hub/northbound/gateway.py +310 -0
  72. reticulum_telemetry_hub/northbound/internal_adapter.py +302 -0
  73. reticulum_telemetry_hub/northbound/models.py +213 -0
  74. reticulum_telemetry_hub/northbound/routes_chat.py +123 -0
  75. reticulum_telemetry_hub/northbound/routes_files.py +119 -0
  76. reticulum_telemetry_hub/northbound/routes_rest.py +345 -0
  77. reticulum_telemetry_hub/northbound/routes_subscribers.py +150 -0
  78. reticulum_telemetry_hub/northbound/routes_topics.py +178 -0
  79. reticulum_telemetry_hub/northbound/routes_ws.py +107 -0
  80. reticulum_telemetry_hub/northbound/serializers.py +72 -0
  81. reticulum_telemetry_hub/northbound/services.py +373 -0
  82. reticulum_telemetry_hub/northbound/websocket.py +855 -0
  83. reticulum_telemetry_hub/reticulum_server/__main__.py +2237 -0
  84. reticulum_telemetry_hub/reticulum_server/command_manager.py +1268 -0
  85. reticulum_telemetry_hub/reticulum_server/command_text.py +399 -0
  86. reticulum_telemetry_hub/reticulum_server/constants.py +1 -0
  87. reticulum_telemetry_hub/reticulum_server/event_log.py +357 -0
  88. reticulum_telemetry_hub/reticulum_server/internal_adapter.py +358 -0
  89. reticulum_telemetry_hub/reticulum_server/outbound_queue.py +312 -0
  90. reticulum_telemetry_hub/reticulum_server/services.py +422 -0
  91. reticulumtelemetryhub-0.143.0.dist-info/METADATA +181 -0
  92. reticulumtelemetryhub-0.143.0.dist-info/RECORD +97 -0
  93. {reticulumtelemetryhub-0.1.0.dist-info → reticulumtelemetryhub-0.143.0.dist-info}/WHEEL +1 -1
  94. reticulumtelemetryhub-0.143.0.dist-info/licenses/LICENSE +277 -0
  95. lxmf_telemetry/model/fields/field_telemetry_stream.py +0 -7
  96. lxmf_telemetry/model/persistance/__init__.py +0 -3
  97. lxmf_telemetry/model/persistance/sensors/location.py +0 -69
  98. lxmf_telemetry/model/persistance/sensors/magnetic_field.py +0 -36
  99. lxmf_telemetry/model/persistance/sensors/sensor.py +0 -44
  100. lxmf_telemetry/model/persistance/sensors/sensor_enum.py +0 -24
  101. lxmf_telemetry/model/persistance/sensors/sensor_mapping.py +0 -9
  102. lxmf_telemetry/telemetry_controller.py +0 -124
  103. reticulum_server/main.py +0 -182
  104. reticulumtelemetryhub-0.1.0.dist-info/METADATA +0 -15
  105. reticulumtelemetryhub-0.1.0.dist-info/RECORD +0 -19
  106. {lxmf_telemetry → reticulum_telemetry_hub}/__init__.py +0 -0
  107. {lxmf_telemetry/model/persistance/sensors → reticulum_telemetry_hub/lxmf_telemetry}/__init__.py +0 -0
  108. {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