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,804 @@
1
+ from contextlib import contextmanager
2
+ from datetime import datetime
3
+ import json
4
+ from pathlib import Path
5
+ import string
6
+ import time
7
+ from typing import Callable, Optional
8
+
9
+ from reticulum_telemetry_hub.api.service import ReticulumTelemetryHubAPI
10
+ from reticulum_telemetry_hub.reticulum_server.event_log import EventLog
11
+
12
+ import LXMF
13
+ import RNS
14
+ from msgpack import packb, unpackb
15
+ from reticulum_telemetry_hub.lxmf_telemetry.model.persistance import Base
16
+ from reticulum_telemetry_hub.lxmf_telemetry.model.persistance.telemeter import (
17
+ Telemeter,
18
+ )
19
+ from reticulum_telemetry_hub.lxmf_telemetry.model.persistance.sensors.lxmf_propagation import (
20
+ LXMFPropagation,
21
+ )
22
+
23
+ from reticulum_telemetry_hub.lxmf_telemetry.model.persistance.sensors.sensor_mapping import (
24
+ sid_mapping,
25
+ )
26
+ from reticulum_telemetry_hub.lxmf_telemetry.model.persistance.sensors.sensor_enum import (
27
+ SID_ACCELERATION,
28
+ SID_AMBIENT_LIGHT,
29
+ SID_ANGULAR_VELOCITY,
30
+ SID_BATTERY,
31
+ SID_CONNECTION_MAP,
32
+ SID_CUSTOM,
33
+ SID_FUEL,
34
+ SID_GRAVITY,
35
+ SID_HUMIDITY,
36
+ SID_INFORMATION,
37
+ SID_LOCATION,
38
+ SID_LXMF_PROPAGATION,
39
+ SID_MAGNETIC_FIELD,
40
+ SID_NVM,
41
+ SID_PHYSICAL_LINK,
42
+ SID_POWER_CONSUMPTION,
43
+ SID_POWER_PRODUCTION,
44
+ SID_PRESSURE,
45
+ SID_PROCESSOR,
46
+ SID_PROXIMITY,
47
+ SID_RAM,
48
+ SID_RECEIVED,
49
+ SID_RNS_TRANSPORT,
50
+ SID_TANK,
51
+ SID_TEMPERATURE,
52
+ SID_TIME,
53
+ )
54
+ from sqlalchemy import create_engine
55
+ from sqlalchemy import func as sa_func
56
+ from sqlalchemy import text
57
+ from sqlalchemy.engine import Engine
58
+ from sqlalchemy.exc import OperationalError
59
+ from sqlalchemy.orm import Session, joinedload, sessionmaker
60
+ from sqlalchemy.pool import QueuePool
61
+
62
+
63
+ class TelemetryController:
64
+ """This class is responsible for managing the telemetry data."""
65
+
66
+ TELEMETRY_REQUEST = 1
67
+ SID_HUMAN_NAMES = {
68
+ SID_TIME: "time",
69
+ SID_LOCATION: "location",
70
+ SID_PRESSURE: "pressure",
71
+ SID_BATTERY: "battery",
72
+ SID_PHYSICAL_LINK: "physical_link",
73
+ SID_ACCELERATION: "acceleration",
74
+ SID_TEMPERATURE: "temperature",
75
+ SID_HUMIDITY: "humidity",
76
+ SID_MAGNETIC_FIELD: "magnetic_field",
77
+ SID_AMBIENT_LIGHT: "ambient_light",
78
+ SID_GRAVITY: "gravity",
79
+ SID_ANGULAR_VELOCITY: "angular_velocity",
80
+ SID_PROXIMITY: "proximity",
81
+ SID_INFORMATION: "information",
82
+ SID_RECEIVED: "received",
83
+ SID_POWER_CONSUMPTION: "power_consumption",
84
+ SID_POWER_PRODUCTION: "power_production",
85
+ SID_PROCESSOR: "processor",
86
+ SID_RAM: "ram",
87
+ SID_NVM: "nvm",
88
+ SID_TANK: "tank",
89
+ SID_FUEL: "fuel",
90
+ SID_LXMF_PROPAGATION: "lxmf_propagation",
91
+ SID_RNS_TRANSPORT: "rns_transport",
92
+ SID_CONNECTION_MAP: "connection_map",
93
+ SID_CUSTOM: "custom",
94
+ }
95
+
96
+ _POOL_SIZE = 30
97
+ _POOL_OVERFLOW = 60
98
+ _CONNECT_TIMEOUT_SECONDS = 30
99
+ _SESSION_RETRIES = 3
100
+ _SESSION_BACKOFF = 0.1
101
+
102
+ def __init__(
103
+ self,
104
+ *,
105
+ engine: Engine | None = None,
106
+ db_path: str | Path | None = None,
107
+ api: ReticulumTelemetryHubAPI | None = None,
108
+ event_log: EventLog | None = None,
109
+ ) -> None:
110
+ if engine is not None and db_path is not None:
111
+ raise ValueError("Provide either 'engine' or 'db_path', not both")
112
+
113
+ if engine is None:
114
+ db_location = Path(db_path) if db_path is not None else Path("telemetry.db")
115
+ engine = self._create_engine(db_location)
116
+
117
+ self._engine = engine
118
+ self._enable_wal_mode()
119
+ Base.metadata.create_all(self._engine)
120
+ self._session_cls = sessionmaker(bind=self._engine, expire_on_commit=False)
121
+ self._telemetry_listener: (
122
+ Callable[[dict, str | bytes | None, Optional[datetime]], None] | None
123
+ ) = None
124
+ self._api = api
125
+ self._event_log = event_log
126
+ self._ingest_count = 0
127
+ self._last_ingest_at: datetime | None = None
128
+
129
+ def set_api(self, api: ReticulumTelemetryHubAPI | None) -> None:
130
+ """Attach an API service for topic-aware telemetry filtering."""
131
+
132
+ self._api = api
133
+
134
+ def set_event_log(self, event_log: EventLog | None) -> None:
135
+ """Attach an event log for telemetry activity updates."""
136
+
137
+ self._event_log = event_log
138
+
139
+ def _create_engine(self, db_location: Path) -> Engine:
140
+ return create_engine(
141
+ f"sqlite:///{db_location}",
142
+ connect_args={
143
+ "check_same_thread": False,
144
+ "timeout": self._CONNECT_TIMEOUT_SECONDS,
145
+ },
146
+ poolclass=QueuePool,
147
+ pool_size=self._POOL_SIZE,
148
+ max_overflow=self._POOL_OVERFLOW,
149
+ pool_pre_ping=True,
150
+ )
151
+
152
+ def _enable_wal_mode(self) -> None:
153
+ if self._engine.url.get_backend_name() != "sqlite":
154
+ return
155
+ try:
156
+ with self._engine.connect().execution_options(
157
+ isolation_level="AUTOCOMMIT"
158
+ ) as conn:
159
+ conn.exec_driver_sql("PRAGMA journal_mode=WAL;")
160
+ except OperationalError as exc:
161
+ RNS.log(f"Failed enabling WAL mode: {exc}", RNS.LOG_WARNING)
162
+
163
+ @contextmanager
164
+ def _session_scope(self):
165
+ """Yield a telemetry DB session that always closes."""
166
+
167
+ session = self._acquire_session_with_retry()
168
+ try:
169
+ yield session
170
+ finally:
171
+ session.close()
172
+
173
+ def _acquire_session_with_retry(self):
174
+ """Return a database session, retrying on transient OperationalError."""
175
+
176
+ last_exc: OperationalError | None = None
177
+ for attempt in range(1, self._SESSION_RETRIES + 1):
178
+ session = None
179
+ try:
180
+ session = self._session_cls()
181
+ session.execute(text("SELECT 1"))
182
+ return session
183
+ except OperationalError as exc:
184
+ last_exc = exc
185
+ if session is not None:
186
+ session.close()
187
+ RNS.log(
188
+ (
189
+ "SQLite session acquisition failed "
190
+ f"(attempt {attempt}/{self._SESSION_RETRIES}): {exc}"
191
+ ),
192
+ RNS.LOG_WARNING,
193
+ )
194
+ time.sleep(self._SESSION_BACKOFF * attempt)
195
+ RNS.log(
196
+ "Unable to obtain telemetry database session after retries",
197
+ RNS.LOG_ERROR,
198
+ )
199
+ if last_exc:
200
+ raise last_exc
201
+ raise RuntimeError("Failed to acquire telemetry session")
202
+
203
+ def _load_telemetry(
204
+ self,
205
+ session: Session,
206
+ start_time: Optional[datetime] = None,
207
+ end_time: Optional[datetime] = None,
208
+ ) -> list[Telemeter]:
209
+ query = session.query(Telemeter)
210
+ if start_time:
211
+ query = query.filter(Telemeter.time >= start_time)
212
+ if end_time:
213
+ query = query.filter(Telemeter.time <= end_time)
214
+ query = query.order_by(Telemeter.time.desc())
215
+ tels = query.options(
216
+ joinedload(Telemeter.sensors),
217
+ joinedload(Telemeter.sensors.of_type(LXMFPropagation)).joinedload(
218
+ LXMFPropagation.peers
219
+ ),
220
+ ).all()
221
+ return tels
222
+
223
+ def get_telemetry(
224
+ self, start_time: Optional[datetime] = None, end_time: Optional[datetime] = None
225
+ ) -> list[Telemeter]:
226
+ """Get the telemetry data."""
227
+ with self._session_scope() as ses:
228
+ return self._load_telemetry(ses, start_time, end_time)
229
+
230
+ def list_telemetry_entries(
231
+ self, *, since: int, topic_id: str | None = None
232
+ ) -> list[dict[str, object]]:
233
+ """Return telemetry entries as JSON-friendly dictionaries.
234
+
235
+ Args:
236
+ since (int): Unix timestamp (seconds) for the earliest telemetry
237
+ records to include.
238
+ topic_id (str | None): Optional topic identifier for filtering.
239
+
240
+ Returns:
241
+ list[dict[str, object]]: Telemetry entries formatted for the
242
+ northbound API.
243
+
244
+ Raises:
245
+ KeyError: If ``topic_id`` is provided but does not exist.
246
+ ValueError: If topic filtering is requested without an API service.
247
+ """
248
+
249
+ timebase_dt = datetime.fromtimestamp(int(since))
250
+ with self._session_scope() as ses:
251
+ telemeters = self._load_telemetry(ses, start_time=timebase_dt)
252
+ telemeters = self._latest_by_peer(telemeters)
253
+ if topic_id:
254
+ if self._api is None:
255
+ raise ValueError("Topic filtering requires an API service")
256
+ subscribers = self._api.list_subscribers_for_topic(topic_id)
257
+ allowed = {sub.destination for sub in subscribers}
258
+ telemeters = [
259
+ telemeter
260
+ for telemeter in telemeters
261
+ if telemeter.peer_dest in allowed
262
+ ]
263
+
264
+ entries: list[dict[str, object]] = []
265
+ for telemeter in telemeters:
266
+ timestamp = int(telemeter.time.timestamp()) if telemeter.time else 0
267
+ payload = self._serialize_telemeter(telemeter)
268
+ readable_payload = self._humanize_telemetry(payload)
269
+ display_name = None
270
+ if self._api is not None and hasattr(
271
+ self._api, "resolve_identity_display_name"
272
+ ):
273
+ try:
274
+ display_name = self._api.resolve_identity_display_name(
275
+ telemeter.peer_dest
276
+ )
277
+ except Exception: # pragma: no cover - defensive
278
+ display_name = None
279
+ entries.append(
280
+ {
281
+ "peer_destination": telemeter.peer_dest,
282
+ "timestamp": timestamp,
283
+ "telemetry": self._json_safe(readable_payload),
284
+ "display_name": display_name,
285
+ "identity_label": display_name,
286
+ }
287
+ )
288
+ return entries
289
+
290
+ def register_listener(
291
+ self,
292
+ listener: Callable[[dict, str | bytes | None, Optional[datetime]], None],
293
+ ) -> None:
294
+ """Register a callback invoked when telemetry is ingested."""
295
+
296
+ self._telemetry_listener = listener
297
+
298
+ def save_telemetry(
299
+ self,
300
+ telemetry_data: dict | bytes,
301
+ peer_dest,
302
+ timestamp: Optional[datetime] = None,
303
+ ) -> None:
304
+ """Save the telemetry data."""
305
+ tel = self._deserialize_telemeter(telemetry_data, peer_dest)
306
+
307
+ payload = telemetry_data
308
+ if isinstance(payload, (bytes, bytearray)):
309
+ try:
310
+ payload = unpackb(payload, strict_map_key=False)
311
+ except Exception: # pragma: no cover - defensive decoding
312
+ payload = None
313
+
314
+ has_sensor_timestamp = False
315
+ if isinstance(payload, dict):
316
+ time_value = payload.get(SID_TIME)
317
+ has_sensor_timestamp = isinstance(time_value, (int, float))
318
+
319
+ if not has_sensor_timestamp and timestamp is not None:
320
+ tel.time = timestamp
321
+ with self._session_scope() as ses:
322
+ ses.add(tel)
323
+ ses.commit()
324
+ self._record_ingest(tel)
325
+
326
+ def clear_telemetry(self) -> int:
327
+ """Remove all telemetry entries from storage.
328
+
329
+ Returns:
330
+ int: Number of rows removed from the telemetry table.
331
+ """
332
+
333
+ with self._session_scope() as ses:
334
+ deleted = ses.query(Telemeter).delete()
335
+ ses.commit()
336
+ return int(deleted or 0)
337
+
338
+ def telemetry_stats(self) -> dict:
339
+ """Return basic telemetry ingestion statistics."""
340
+
341
+ total = self._ingest_count
342
+ last_ingest_at = self._last_ingest_at
343
+ try:
344
+ with self._session_scope() as ses:
345
+ total = int(ses.query(sa_func.count(Telemeter.id)).scalar() or 0)
346
+ last_ingest_at = ses.query(sa_func.max(Telemeter.time)).scalar()
347
+ if isinstance(last_ingest_at, str):
348
+ last_ingest_at = datetime.fromisoformat(last_ingest_at)
349
+ except Exception: # pragma: no cover - defensive fallback
350
+ pass
351
+
352
+ last_ingest = last_ingest_at.isoformat() if last_ingest_at else None
353
+ return {
354
+ "ingest_count": total,
355
+ "last_ingest_at": last_ingest,
356
+ }
357
+
358
+ def ingest_local_payload(
359
+ self,
360
+ payload: dict,
361
+ *,
362
+ peer_dest: str,
363
+ ) -> bytes | None:
364
+ """Persist ``payload`` and return a msgpack encoded snapshot.
365
+
366
+ The telemetry sampler uses this helper to ensure locally collected
367
+ sensor data flows through the same persistence pipeline as incoming
368
+ LXMF telemetry before broadcasting it to connected peers.
369
+ """
370
+
371
+ if not payload:
372
+ return None
373
+
374
+ self.save_telemetry(payload, peer_dest)
375
+ return packb(payload, use_bin_type=True)
376
+
377
+ def handle_message(self, message: LXMF.LXMessage) -> bool:
378
+ """Handle the incoming message."""
379
+ handled = False
380
+ if LXMF.FIELD_TELEMETRY in message.fields:
381
+ tel_data: dict = unpackb(
382
+ message.fields[LXMF.FIELD_TELEMETRY], strict_map_key=False
383
+ )
384
+ readable = self._humanize_telemetry(tel_data)
385
+ timestamp = self._extract_timestamp(readable)
386
+ peer_dest = RNS.hexrep(message.source_hash, False)
387
+ display_name, label = self._resolve_peer_label(peer_dest)
388
+ RNS.log(f"Telemetry received from {label}")
389
+ RNS.log(f"Telemetry decoded: {readable}")
390
+ self.save_telemetry(tel_data, peer_dest)
391
+ self._notify_listener(readable, message.source_hash, timestamp)
392
+ self._record_event(
393
+ "telemetry_received",
394
+ f"Telemetry received from {label}",
395
+ metadata={"identity": peer_dest, "display_name": display_name},
396
+ )
397
+ handled = True
398
+ if LXMF.FIELD_TELEMETRY_STREAM in message.fields:
399
+ tels_data = message.fields[LXMF.FIELD_TELEMETRY_STREAM]
400
+ if isinstance(tels_data, (bytes, bytearray)):
401
+ # Sideband sends telemetry streams as raw lists; decode msgpack
402
+ # if a sender pre-encodes the field.
403
+ tels_data = unpackb(tels_data, strict_map_key=False)
404
+ for tel_data in tels_data:
405
+ if not isinstance(tel_data, (list, tuple)) or len(tel_data) < 3:
406
+ RNS.log(
407
+ "Telemetry stream entries must include peer hash, timestamp, and payload; skipping"
408
+ )
409
+ continue
410
+
411
+ peer_hash, raw_timestamp, payload = tel_data[:3]
412
+ if not isinstance(peer_hash, (bytes, bytearray)):
413
+ RNS.log("Telemetry stream entry missing peer hash bytes; skipping")
414
+ continue
415
+
416
+ peer_dest = RNS.hexrep(peer_hash, False)
417
+
418
+ timestamp = None
419
+ if isinstance(raw_timestamp, (int, float)):
420
+ timestamp = datetime.fromtimestamp(raw_timestamp)
421
+ elif raw_timestamp is not None:
422
+ RNS.log(
423
+ "Telemetry stream timestamp must be numeric or null; skipping entry"
424
+ )
425
+ continue
426
+
427
+ if not payload:
428
+ RNS.log("Telemetry payload missing; skipping entry")
429
+ continue
430
+
431
+ readable = self._humanize_telemetry(payload)
432
+ display_name, label = self._resolve_peer_label(peer_dest)
433
+ RNS.log(f"Telemetry stream from {label} at {timestamp}: {readable}")
434
+ self.save_telemetry(payload, peer_dest, timestamp)
435
+ stream_timestamp = timestamp or self._extract_timestamp(readable)
436
+ self._notify_listener(readable, peer_hash, stream_timestamp)
437
+ self._record_event(
438
+ "telemetry_stream",
439
+ f"Telemetry stream entry from {label}",
440
+ metadata={"identity": peer_dest, "display_name": display_name},
441
+ )
442
+ handled = True
443
+
444
+ return handled
445
+
446
+ def handle_command(
447
+ self, command: dict, message: LXMF.LXMessage, my_lxm_dest
448
+ ) -> Optional[LXMF.LXMessage]:
449
+ """Handle the incoming command."""
450
+ request_key = self._numeric_command_key(
451
+ command, TelemetryController.TELEMETRY_REQUEST
452
+ )
453
+ if request_key is not None:
454
+ request_value = command[request_key]
455
+ topic_id = self._extract_topic_id(command)
456
+
457
+ # Sideband (and compatible clients) send telemetry requests either as a
458
+ # standalone timestamp or as ``[timestamp, collector_flag]``. The
459
+ # hub currently ignores the optional collector flag, but we still
460
+ # need to unpack the timestamp so ``datetime.fromtimestamp`` doesn't
461
+ # receive a list and raise ``TypeError``.
462
+ if isinstance(request_value, (list, tuple)):
463
+ if not request_value:
464
+ return None
465
+ timebase_raw = request_value[0]
466
+ else:
467
+ timebase_raw = request_value
468
+
469
+ if not isinstance(timebase_raw, (int, float)):
470
+ raise TypeError(
471
+ "Telemetry request timestamp must be numeric; "
472
+ f"received {type(timebase_raw)!r}"
473
+ )
474
+
475
+ timebase = int(timebase_raw)
476
+ human_readable_entries: list[dict[str, object]] = []
477
+ with self._session_scope() as ses:
478
+ timebase_dt = datetime.fromtimestamp(timebase)
479
+ teles = self._load_telemetry(ses, start_time=timebase_dt)
480
+ # Return one snapshot per peer using the most recent entry.
481
+ teles = self._latest_by_peer(teles)
482
+ teles = self._filter_by_topic(teles, topic_id, message)
483
+ if teles is None:
484
+ return self._reply(
485
+ message,
486
+ my_lxm_dest,
487
+ "Telemetry request denied: sender is not subscribed to the topic.",
488
+ )
489
+ packed_tels = []
490
+ dest = RNS.Destination(
491
+ message.source.identity,
492
+ RNS.Destination.OUT,
493
+ RNS.Destination.SINGLE,
494
+ "lxmf",
495
+ "delivery",
496
+ )
497
+ for tel in teles:
498
+ peer_hash = self._peer_hash_bytes(tel)
499
+ if peer_hash is None:
500
+ continue
501
+ tel_data = self._serialize_telemeter(tel)
502
+ human_readable_entries.append(
503
+ {
504
+ "peer_destination": tel.peer_dest,
505
+ "timestamp": round(tel.time.timestamp()),
506
+ "telemetry": self._humanize_telemetry(tel_data),
507
+ }
508
+ )
509
+ packed_tels.append(
510
+ [
511
+ peer_hash,
512
+ round(tel.time.timestamp()),
513
+ packb(tel_data, use_bin_type=True),
514
+ None,
515
+ ]
516
+ )
517
+ message = LXMF.LXMessage(
518
+ dest,
519
+ my_lxm_dest,
520
+ desired_method=LXMF.LXMessage.DIRECT,
521
+ )
522
+ # Sideband expects telemetry streams as plain lists; avoid
523
+ # double-encoding the field so clients can iterate entries directly.
524
+ message.fields[LXMF.FIELD_TELEMETRY_STREAM] = packed_tels
525
+ readable_json = json.dumps(
526
+ self._json_safe(human_readable_entries), default=str
527
+ )
528
+ print(f"Sending telemetry of {len(human_readable_entries)} clients")
529
+ print("Telemetry response in human readeble format: " f"{readable_json}")
530
+ self._record_event(
531
+ "telemetry_request",
532
+ f"Telemetry request served ({len(human_readable_entries)} entries)",
533
+ metadata={"topic_id": topic_id} if topic_id else None,
534
+ )
535
+ return message
536
+ else:
537
+ return None
538
+
539
+ def _serialize_telemeter(self, telemeter: Telemeter) -> dict:
540
+ """Serialize the telemeter data."""
541
+ telemeter_data = {}
542
+ for sensor in telemeter.sensors:
543
+ sensor_data = sensor.pack()
544
+ telemeter_data[sensor.sid] = sensor_data
545
+
546
+ # Ensure the timestamp sensor is always present so downstream
547
+ # consumers (e.g. Sideband) can reconstitute the Telemeter.
548
+ timestamp = int(telemeter.time.timestamp())
549
+ time_payload = telemeter_data.get(SID_TIME)
550
+ if not isinstance(time_payload, (int, float)):
551
+ telemeter_data[SID_TIME] = timestamp
552
+ else:
553
+ telemeter_data[SID_TIME] = int(time_payload)
554
+
555
+ return telemeter_data
556
+
557
+ def _deserialize_telemeter(self, tel_data, peer_dest: str = "") -> Telemeter:
558
+ """Deserialize the telemeter data.
559
+
560
+ The method accepts either already unpacked telemetry dictionaries or
561
+ raw msgpack-encoded bytes. The optional ``peer_dest`` parameter is
562
+ primarily used when storing data received from the network.
563
+ """
564
+ if isinstance(tel_data, (bytes, bytearray)):
565
+ tel_data = unpackb(tel_data, strict_map_key=False)
566
+
567
+ tel = Telemeter(peer_dest)
568
+ # Iterate in the order defined by ``sid_mapping`` so tests relying on
569
+ # specific sensor ordering remain stable.
570
+ for sid in sid_mapping:
571
+ if sid in tel_data:
572
+ if tel_data[sid] is None:
573
+ RNS.log(f"Sensor data for {sid} is None")
574
+ continue
575
+ sensor = sid_mapping[sid]()
576
+ sensor.unpack(tel_data[sid])
577
+ tel.sensors.append(sensor)
578
+ time_value = tel_data.get(SID_TIME)
579
+ if isinstance(time_value, (int, float)):
580
+ tel.time = datetime.fromtimestamp(int(time_value))
581
+ return tel
582
+
583
+ def _humanize_telemetry(self, tel_data: dict) -> dict:
584
+ """Return a friendly dict mapping sensor names to decoded readings."""
585
+ if isinstance(tel_data, (bytes, bytearray)):
586
+ tel_data = unpackb(tel_data, strict_map_key=False)
587
+
588
+ readable: dict[str, object] = {}
589
+ for sid, payload in tel_data.items():
590
+ name = self.SID_HUMAN_NAMES.get(sid, f"sid_{sid}")
591
+ sensor_cls = sid_mapping.get(sid)
592
+ if sensor_cls is None:
593
+ readable[name] = payload
594
+ continue
595
+ sensor = sensor_cls()
596
+ try:
597
+ decoded = sensor.unpack(payload)
598
+ except Exception as exc: # pragma: no cover - defensive logging
599
+ RNS.log(f"Failed decoding telemetry sensor {name}: {exc}")
600
+ decoded = payload
601
+ readable[name] = decoded
602
+ return readable
603
+
604
+ def _latest_by_peer(self, telemeters: list[Telemeter]) -> list[Telemeter]:
605
+ """Return the most recent telemetry entry per peer."""
606
+ latest: dict[str, Telemeter] = {}
607
+ for tel in telemeters:
608
+ # The list is already ordered newest->oldest, so first wins.
609
+ if tel.peer_dest not in latest:
610
+ latest[tel.peer_dest] = tel
611
+ return list(latest.values())
612
+
613
+ def _notify_listener(
614
+ self,
615
+ telemetry: dict,
616
+ peer_hash: str | bytes | None,
617
+ timestamp: Optional[datetime],
618
+ ) -> None:
619
+ """Invoke the registered telemetry listener when present."""
620
+
621
+ if self._telemetry_listener is None:
622
+ return
623
+ try:
624
+ self._telemetry_listener(telemetry, peer_hash, timestamp)
625
+ except Exception as exc: # pragma: no cover - defensive logging
626
+ RNS.log(f"Telemetry listener raised an exception: {exc}", RNS.LOG_WARNING)
627
+
628
+ def _record_event(
629
+ self,
630
+ event_type: str,
631
+ message: str,
632
+ *,
633
+ metadata: Optional[dict] = None,
634
+ ) -> None:
635
+ """Emit a telemetry event to the shared event log."""
636
+
637
+ if self._event_log is None:
638
+ return
639
+ self._event_log.add_event(event_type, message, metadata=metadata)
640
+
641
+ def _resolve_peer_label(self, peer_dest: str) -> tuple[str | None, str]:
642
+ """Return display name and label for a peer destination."""
643
+
644
+ display_name = None
645
+ if self._api is not None and hasattr(self._api, "resolve_identity_display_name"):
646
+ try:
647
+ display_name = self._api.resolve_identity_display_name(peer_dest)
648
+ except Exception: # pragma: no cover - defensive
649
+ display_name = None
650
+ if display_name:
651
+ return display_name, f"{display_name} ({peer_dest})"
652
+ return None, peer_dest
653
+
654
+ def _record_ingest(self, telemeter: Telemeter) -> None:
655
+ """Update telemetry ingestion statistics."""
656
+
657
+ self._ingest_count += 1
658
+ if telemeter.time:
659
+ self._last_ingest_at = telemeter.time
660
+
661
+ @staticmethod
662
+ def _reply(
663
+ message: LXMF.LXMessage, my_lxm_dest, content: str
664
+ ) -> LXMF.LXMessage:
665
+ """Return an LXMF reply message to the sender."""
666
+
667
+ dest = RNS.Destination(
668
+ message.source.identity,
669
+ RNS.Destination.OUT,
670
+ RNS.Destination.SINGLE,
671
+ "lxmf",
672
+ "delivery",
673
+ )
674
+ return LXMF.LXMessage(
675
+ dest,
676
+ my_lxm_dest,
677
+ content,
678
+ desired_method=LXMF.LXMessage.DIRECT,
679
+ )
680
+
681
+ @staticmethod
682
+ def _extract_topic_id(command: dict) -> Optional[str]:
683
+ """Return a topic id from a telemetry command payload."""
684
+
685
+ return (
686
+ command.get("TopicID")
687
+ or command.get("topic_id")
688
+ or command.get("topicId")
689
+ )
690
+
691
+ @staticmethod
692
+ def _numeric_command_key(command: dict, index: int) -> int | str | None:
693
+ """Return the numeric command key matching the provided index.
694
+
695
+ Args:
696
+ command (dict): Incoming command payload.
697
+ index (int): Numeric index to locate.
698
+
699
+ Returns:
700
+ int | str | None: The matching key when present.
701
+ """
702
+
703
+ for key in command:
704
+ try:
705
+ if str(key).isdigit() and int(str(key)) == index:
706
+ return key
707
+ except ValueError:
708
+ continue
709
+ return None
710
+
711
+ def _filter_by_topic(
712
+ self,
713
+ telemeters: list[Telemeter],
714
+ topic_id: str | None,
715
+ message: LXMF.LXMessage,
716
+ ) -> list[Telemeter] | None:
717
+ """Filter telemetry entries to those subscribed to a topic."""
718
+
719
+ if topic_id is None:
720
+ return telemeters
721
+ if self._api is None:
722
+ return telemeters
723
+ try:
724
+ subscribers = self._api.list_subscribers_for_topic(topic_id)
725
+ except KeyError:
726
+ return []
727
+ destination = self._identity_hex(message.source.identity)
728
+ allowed = {sub.destination for sub in subscribers}
729
+ if destination not in allowed:
730
+ return None
731
+ return [tel for tel in telemeters if tel.peer_dest in allowed]
732
+
733
+ @staticmethod
734
+ def _identity_hex(identity: RNS.Identity) -> str:
735
+ """Return the identity hash as a lowercase hex string."""
736
+
737
+ hash_bytes = getattr(identity, "hash", b"") or b""
738
+ return hash_bytes.hex()
739
+
740
+ def _extract_timestamp(self, telemetry: dict) -> Optional[datetime]:
741
+ """Return a datetime parsed from a telemetry payload when available."""
742
+
743
+ time_payload = telemetry.get("time")
744
+ if isinstance(time_payload, dict):
745
+ raw_timestamp = time_payload.get("timestamp")
746
+ if isinstance(raw_timestamp, (int, float)):
747
+ return datetime.fromtimestamp(int(raw_timestamp))
748
+ iso_value = time_payload.get("iso")
749
+ if isinstance(iso_value, str):
750
+ try:
751
+ return datetime.fromisoformat(iso_value)
752
+ except ValueError:
753
+ return None
754
+ if isinstance(time_payload, (int, float)):
755
+ return datetime.fromtimestamp(int(time_payload))
756
+ return None
757
+
758
+ def _peer_hash_bytes(self, telemeter: Telemeter) -> Optional[bytes]:
759
+ """Return the peer hash for ``telemeter`` as bytes or ``None`` on failure."""
760
+
761
+ peer_dest = (telemeter.peer_dest or "").strip()
762
+ if not peer_dest:
763
+ RNS.log("Telemetry entry missing peer destination; skipping")
764
+ return None
765
+
766
+ normalized = "".join(ch for ch in peer_dest if ch in string.hexdigits)
767
+ if not normalized:
768
+ RNS.log(
769
+ f"Telemetry entry peer destination missing hex characters: {peer_dest!r}"
770
+ )
771
+ return None
772
+ if len(normalized) % 2 != 0:
773
+ RNS.log(
774
+ f"Telemetry entry peer destination has odd length after normalization: {peer_dest!r}"
775
+ )
776
+ return None
777
+
778
+ try:
779
+ return bytes.fromhex(normalized)
780
+ except ValueError as exc:
781
+ RNS.log(
782
+ f"Skipping telemetry entry with invalid peer destination {peer_dest!r}: {exc}"
783
+ )
784
+ return None
785
+
786
+ def _json_safe(self, value):
787
+ """Return ``value`` converted into a JSON-safe structure."""
788
+
789
+ if isinstance(value, dict):
790
+ return {self._json_key(k): self._json_safe(v) for k, v in value.items()}
791
+ if isinstance(value, (list, tuple, set)):
792
+ return [self._json_safe(v) for v in value]
793
+ if isinstance(value, (bytes, bytearray)):
794
+ return value.hex()
795
+ return value
796
+
797
+ def _json_key(self, key):
798
+ """Return a JSON-safe dict key representation."""
799
+
800
+ if isinstance(key, (str, int, float, bool)) or key is None:
801
+ return key
802
+ if isinstance(key, (bytes, bytearray)):
803
+ return key.hex()
804
+ return str(key)