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,855 @@
1
+ """WebSocket helpers for the northbound API."""
2
+ # pylint: disable=import-error
3
+
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ import json
8
+ import uuid
9
+ from dataclasses import dataclass
10
+ from datetime import datetime
11
+ from datetime import timezone
12
+ from typing import Any
13
+ from typing import Awaitable
14
+ from typing import Callable
15
+ from typing import Dict
16
+ from typing import Optional
17
+
18
+ from fastapi import WebSocket
19
+
20
+ from reticulum_telemetry_hub.api.service import ReticulumTelemetryHubAPI
21
+ from reticulum_telemetry_hub.lxmf_telemetry.telemetry_controller import (
22
+ TelemetryController,
23
+ )
24
+ from reticulum_telemetry_hub.reticulum_server.event_log import EventLog
25
+
26
+ from .auth import ApiAuth
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class _TelemetrySubscriber:
31
+ """Configuration for a telemetry WebSocket subscriber."""
32
+
33
+ callback: Callable[[Dict[str, object]], Awaitable[None]]
34
+ allowed_destinations: Optional[frozenset[str]]
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class _MessageSubscriber:
39
+ """Configuration for a message WebSocket subscriber."""
40
+
41
+ callback: Callable[[Dict[str, object]], Awaitable[None]]
42
+ topic_id: Optional[str]
43
+ source_hash: Optional[str]
44
+
45
+
46
+ class EventBroadcaster:
47
+ """Fan out events to active WebSocket subscribers."""
48
+
49
+ def __init__(self, event_log: EventLog) -> None:
50
+ """Initialize the event broadcaster.
51
+
52
+ Args:
53
+ event_log (EventLog): Event log used for event updates.
54
+ """
55
+
56
+ self._event_log = event_log
57
+ self._subscribers: set[Callable[[Dict[str, object]], Awaitable[None]]] = set()
58
+ self._loop: Optional[asyncio.AbstractEventLoop] = None
59
+ self._event_log.add_listener(self._handle_event)
60
+
61
+ def subscribe(
62
+ self, callback: Callable[[Dict[str, object]], Awaitable[None]]
63
+ ) -> Callable[[], None]:
64
+ """Register an async event callback.
65
+
66
+ Args:
67
+ callback (Callable[[Dict[str, object]], Awaitable[None]]): Callback
68
+ invoked for each new event.
69
+
70
+ Returns:
71
+ Callable[[], None]: Unsubscribe callback.
72
+ """
73
+
74
+ self._subscribers.add(callback)
75
+ self._capture_loop()
76
+
77
+ def _unsubscribe() -> None:
78
+ """Remove the event callback subscription.
79
+
80
+ Returns:
81
+ None: Removes the callback.
82
+ """
83
+
84
+ self._subscribers.discard(callback)
85
+
86
+ return _unsubscribe
87
+
88
+ def _capture_loop(self) -> None:
89
+ """Capture the running event loop for cross-thread dispatch."""
90
+
91
+ if self._loop is not None and self._loop.is_running():
92
+ return
93
+ try:
94
+ self._loop = asyncio.get_running_loop()
95
+ except RuntimeError:
96
+ self._loop = None
97
+
98
+ def _create_task(
99
+ self,
100
+ callback: Callable[[Dict[str, object]], Awaitable[None]],
101
+ entry: Dict[str, object],
102
+ ) -> None:
103
+ """Create an asyncio task for a callback on the current loop."""
104
+
105
+ loop = asyncio.get_running_loop()
106
+ loop.create_task(callback(entry))
107
+
108
+ def _handle_event(self, entry: Dict[str, object]) -> None:
109
+ """Dispatch a new event to subscribers.
110
+
111
+ Args:
112
+ entry (Dict[str, object]): Recorded event entry.
113
+ """
114
+
115
+ for callback in list(self._subscribers):
116
+ try:
117
+ loop = asyncio.get_running_loop()
118
+ loop.create_task(callback(entry))
119
+ continue
120
+ except RuntimeError:
121
+ pass
122
+ if self._loop is None or not self._loop.is_running():
123
+ # Reason: skip async dispatch when no loop is running.
124
+ continue
125
+ self._loop.call_soon_threadsafe(self._create_task, callback, entry)
126
+
127
+
128
+ class TelemetryBroadcaster:
129
+ """Fan out telemetry updates to WebSocket subscribers."""
130
+
131
+ def __init__(
132
+ self,
133
+ controller: TelemetryController,
134
+ api: Optional[ReticulumTelemetryHubAPI],
135
+ ) -> None:
136
+ """Initialize the telemetry broadcaster.
137
+
138
+ Args:
139
+ controller (TelemetryController): Telemetry controller instance.
140
+ api (Optional[ReticulumTelemetryHubAPI]): API service for topic
141
+ filtering.
142
+ """
143
+
144
+ self._controller = controller
145
+ self._api = api
146
+ self._subscribers: set[_TelemetrySubscriber] = set()
147
+ self._loop: Optional[asyncio.AbstractEventLoop] = None
148
+ self._controller.register_listener(self._handle_telemetry)
149
+
150
+ def subscribe(
151
+ self,
152
+ callback: Callable[[Dict[str, object]], Awaitable[None]],
153
+ *,
154
+ topic_id: Optional[str] = None,
155
+ ) -> Callable[[], None]:
156
+ """Register a telemetry callback.
157
+
158
+ Args:
159
+ callback (Callable[[Dict[str, object]], Awaitable[None]]): Callback
160
+ invoked with telemetry entries.
161
+ topic_id (Optional[str]): Optional topic filter for telemetry.
162
+
163
+ Returns:
164
+ Callable[[], None]: Unsubscribe callback.
165
+ """
166
+
167
+ allowed = None
168
+ if topic_id:
169
+ if self._api is None:
170
+ raise ValueError("Topic filtering requires an API service")
171
+ subscribers = self._api.list_subscribers_for_topic(topic_id)
172
+ allowed = frozenset(
173
+ subscriber.destination for subscriber in subscribers
174
+ )
175
+ subscriber = _TelemetrySubscriber(callback=callback, allowed_destinations=allowed)
176
+ self._subscribers.add(subscriber)
177
+ self._capture_loop()
178
+
179
+ def _unsubscribe() -> None:
180
+ """Remove the telemetry callback subscription.
181
+
182
+ Returns:
183
+ None: Removes the callback.
184
+ """
185
+
186
+ self._subscribers.discard(subscriber)
187
+
188
+ return _unsubscribe
189
+
190
+ def _capture_loop(self) -> None:
191
+ """Capture the running event loop for cross-thread dispatch."""
192
+
193
+ if self._loop is not None and self._loop.is_running():
194
+ return
195
+ try:
196
+ self._loop = asyncio.get_running_loop()
197
+ except RuntimeError:
198
+ self._loop = None
199
+
200
+ def _create_task(
201
+ self,
202
+ callback: Callable[[Dict[str, object]], Awaitable[None]],
203
+ entry: Dict[str, object],
204
+ ) -> None:
205
+ """Create an asyncio task for a callback on the current loop."""
206
+
207
+ loop = asyncio.get_running_loop()
208
+ loop.create_task(callback(entry))
209
+
210
+ def _handle_telemetry(
211
+ self,
212
+ telemetry: dict,
213
+ peer_hash: str | bytes | None,
214
+ timestamp: Optional[datetime],
215
+ ) -> None:
216
+ """Dispatch telemetry updates to subscribers.
217
+
218
+ Args:
219
+ telemetry (dict): Telemetry payload.
220
+ peer_hash (str | bytes | None): Peer identifier.
221
+ timestamp (Optional[datetime]): Telemetry timestamp.
222
+ """
223
+
224
+ peer_dest = _normalize_peer(peer_hash)
225
+ display_name = None
226
+ if self._api is not None and hasattr(
227
+ self._api, "resolve_identity_display_name"
228
+ ):
229
+ try:
230
+ display_name = self._api.resolve_identity_display_name(peer_dest)
231
+ except Exception: # pragma: no cover - defensive
232
+ display_name = None
233
+ entry = {
234
+ "peer_destination": peer_dest,
235
+ "timestamp": int(timestamp.timestamp()) if timestamp else 0,
236
+ "telemetry": telemetry,
237
+ "display_name": display_name,
238
+ "identity_label": display_name,
239
+ }
240
+ for subscriber in list(self._subscribers):
241
+ if subscriber.allowed_destinations is not None:
242
+ if peer_dest not in subscriber.allowed_destinations:
243
+ continue
244
+ try:
245
+ loop = asyncio.get_running_loop()
246
+ loop.create_task(subscriber.callback(entry))
247
+ continue
248
+ except RuntimeError:
249
+ pass
250
+ if self._loop is None or not self._loop.is_running():
251
+ # Reason: skip async dispatch when no loop is running.
252
+ continue
253
+ self._loop.call_soon_threadsafe(
254
+ self._create_task,
255
+ subscriber.callback,
256
+ entry,
257
+ )
258
+
259
+
260
+ class MessageBroadcaster:
261
+ """Fan out inbound messages to WebSocket subscribers."""
262
+
263
+ def __init__(
264
+ self,
265
+ register_listener: Optional[
266
+ Callable[[Callable[[Dict[str, object]], None]], Callable[[], None]]
267
+ ] = None,
268
+ ) -> None:
269
+ """Initialize the message broadcaster."""
270
+
271
+ self._subscribers: set[_MessageSubscriber] = set()
272
+ self._loop: Optional[asyncio.AbstractEventLoop] = None
273
+ self._unsubscribe_source: Optional[Callable[[], None]] = None
274
+ if register_listener is not None:
275
+ self._unsubscribe_source = register_listener(self._handle_message)
276
+
277
+ def subscribe(
278
+ self,
279
+ callback: Callable[[Dict[str, object]], Awaitable[None]],
280
+ *,
281
+ topic_id: Optional[str] = None,
282
+ source_hash: Optional[str] = None,
283
+ ) -> Callable[[], None]:
284
+ """Register a message callback."""
285
+
286
+ subscriber = _MessageSubscriber(
287
+ callback=callback,
288
+ topic_id=topic_id,
289
+ source_hash=_normalize_peer(source_hash) if source_hash else None,
290
+ )
291
+ self._subscribers.add(subscriber)
292
+ self._capture_loop()
293
+
294
+ def _unsubscribe() -> None:
295
+ """Remove the message callback subscription."""
296
+
297
+ self._subscribers.discard(subscriber)
298
+
299
+ return _unsubscribe
300
+
301
+ def _capture_loop(self) -> None:
302
+ """Capture the running event loop for cross-thread dispatch."""
303
+
304
+ if self._loop is not None and self._loop.is_running():
305
+ return
306
+ try:
307
+ self._loop = asyncio.get_running_loop()
308
+ except RuntimeError:
309
+ self._loop = None
310
+
311
+ def _create_task(
312
+ self,
313
+ callback: Callable[[Dict[str, object]], Awaitable[None]],
314
+ entry: Dict[str, object],
315
+ ) -> None:
316
+ """Create an asyncio task for a callback on the current loop."""
317
+
318
+ loop = asyncio.get_running_loop()
319
+ loop.create_task(callback(entry))
320
+
321
+ def _handle_message(self, entry: Dict[str, object]) -> None:
322
+ """Dispatch inbound messages to subscribers."""
323
+
324
+ entry_topic = entry.get("topic_id")
325
+ entry_source = _normalize_peer(entry.get("source_hash"))
326
+ for subscriber in list(self._subscribers):
327
+ if subscriber.topic_id and subscriber.topic_id != entry_topic:
328
+ continue
329
+ if subscriber.source_hash and subscriber.source_hash != entry_source:
330
+ continue
331
+ try:
332
+ loop = asyncio.get_running_loop()
333
+ loop.create_task(subscriber.callback(entry))
334
+ continue
335
+ except RuntimeError:
336
+ pass
337
+ if self._loop is None or not self._loop.is_running():
338
+ # Reason: skip async dispatch when no loop is running.
339
+ continue
340
+ self._loop.call_soon_threadsafe(
341
+ self._create_task,
342
+ subscriber.callback,
343
+ entry,
344
+ )
345
+
346
+
347
+ def _normalize_peer(peer_hash: str | bytes | None) -> str:
348
+ """Return a normalized peer destination string.
349
+
350
+ Args:
351
+ peer_hash (str | bytes | None): Peer hash input.
352
+
353
+ Returns:
354
+ str: Normalized peer destination.
355
+ """
356
+
357
+ if peer_hash is None:
358
+ return ""
359
+ if isinstance(peer_hash, (bytes, bytearray)):
360
+ return peer_hash.hex()
361
+ return str(peer_hash)
362
+
363
+
364
+ def _utcnow_iso() -> str:
365
+ """Return an RFC3339 timestamp string in UTC.
366
+
367
+ Returns:
368
+ str: Current timestamp string.
369
+ """
370
+
371
+ return datetime.now(timezone.utc).isoformat()
372
+
373
+
374
+ def build_ws_message(message_type: str, data: Dict[str, Any]) -> Dict[str, Any]:
375
+ """Create a WebSocket envelope payload.
376
+
377
+ Args:
378
+ message_type (str): Message type identifier.
379
+ data (Dict[str, Any]): Payload data.
380
+
381
+ Returns:
382
+ Dict[str, Any]: Envelope payload.
383
+ """
384
+
385
+ return {"type": message_type, "ts": _utcnow_iso(), "data": data}
386
+
387
+
388
+ def build_error_message(code: str, message: str) -> Dict[str, Any]:
389
+ """Create a standardized error message envelope.
390
+
391
+ Args:
392
+ code (str): Error code.
393
+ message (str): Error message.
394
+
395
+ Returns:
396
+ Dict[str, Any]: Error message envelope.
397
+ """
398
+
399
+ return build_ws_message("error", {"code": code, "message": message})
400
+
401
+
402
+ def build_ping_message() -> Dict[str, Any]:
403
+ """Create a ping message envelope.
404
+
405
+ Returns:
406
+ Dict[str, Any]: Ping message payload.
407
+ """
408
+
409
+ return build_ws_message("ping", {"nonce": uuid.uuid4().hex})
410
+
411
+
412
+ def parse_ws_message(payload: str) -> Dict[str, Any]:
413
+ """Parse a WebSocket JSON payload.
414
+
415
+ Args:
416
+ payload (str): JSON message string.
417
+
418
+ Returns:
419
+ Dict[str, Any]: Parsed JSON payload.
420
+
421
+ Raises:
422
+ ValueError: If parsing fails or payload is not a JSON object.
423
+ """
424
+
425
+ data = json.loads(payload)
426
+ if not isinstance(data, dict):
427
+ raise ValueError("WebSocket payload must be a JSON object")
428
+ return data
429
+
430
+
431
+ def _extract_auth_data(message: Dict[str, Any]) -> tuple[Optional[str], Optional[str]]:
432
+ """Extract auth credentials from a message.
433
+
434
+ Args:
435
+ message (Dict[str, Any]): Parsed message payload.
436
+
437
+ Returns:
438
+ tuple[Optional[str], Optional[str]]: Token and API key values.
439
+ """
440
+
441
+ data = message.get("data")
442
+ if not isinstance(data, dict):
443
+ return None, None
444
+ token = data.get("token")
445
+ api_key = data.get("api_key")
446
+ return token, api_key
447
+
448
+
449
+ def _is_auth_message(message: Dict[str, Any]) -> bool:
450
+ """Return ``True`` when the message is an auth payload.
451
+
452
+ Args:
453
+ message (Dict[str, Any]): Parsed message payload.
454
+
455
+ Returns:
456
+ bool: ``True`` when the message is an auth payload.
457
+ """
458
+
459
+ return message.get("type") == "auth"
460
+
461
+
462
+ def _get_message_type(message: Dict[str, Any]) -> str:
463
+ """Return the message type string.
464
+
465
+ Args:
466
+ message (Dict[str, Any]): Parsed message payload.
467
+
468
+ Returns:
469
+ str: Message type string.
470
+ """
471
+
472
+ msg_type = message.get("type")
473
+ return str(msg_type) if msg_type is not None else ""
474
+
475
+
476
+ def _get_message_data(message: Dict[str, Any]) -> Dict[str, Any]:
477
+ """Return the message data dict.
478
+
479
+ Args:
480
+ message (Dict[str, Any]): Parsed message payload.
481
+
482
+ Returns:
483
+ Dict[str, Any]: Payload data.
484
+ """
485
+
486
+ data = message.get("data")
487
+ return data if isinstance(data, dict) else {}
488
+
489
+
490
+ def _validated_auth(
491
+ auth: ApiAuth, token: Optional[str], api_key: Optional[str]
492
+ ) -> bool:
493
+ """Return ``True`` when auth credentials are valid.
494
+
495
+ Args:
496
+ auth (ApiAuth): Auth validator.
497
+ token (Optional[str]): Bearer token.
498
+ api_key (Optional[str]): API key header.
499
+
500
+ Returns:
501
+ bool: ``True`` when credentials are valid.
502
+ """
503
+
504
+ return auth.validate_credentials(api_key, token)
505
+
506
+
507
+ def _get_subscribe_flags(data: Dict[str, Any]) -> tuple[bool, bool, int]:
508
+ """Return subscription flags for system events.
509
+
510
+ Args:
511
+ data (Dict[str, Any]): Subscription payload.
512
+
513
+ Returns:
514
+ tuple[bool, bool, int]: include_status, include_events, events_limit.
515
+ """
516
+
517
+ include_status = bool(data.get("include_status", True))
518
+ include_events = bool(data.get("include_events", True))
519
+ events_limit = data.get("events_limit")
520
+ if not isinstance(events_limit, int) or events_limit <= 0:
521
+ events_limit = 50
522
+ return include_status, include_events, events_limit
523
+
524
+
525
+ def _get_telemetry_subscription(data: Dict[str, Any]) -> tuple[int, Optional[str], bool]:
526
+ """Return telemetry subscription settings.
527
+
528
+ Args:
529
+ data (Dict[str, Any]): Subscription payload.
530
+
531
+ Returns:
532
+ tuple[int, Optional[str], bool]: since timestamp, topic ID, follow flag.
533
+
534
+ Raises:
535
+ ValueError: If required fields are missing.
536
+ """
537
+
538
+ since = data.get("since")
539
+ if not isinstance(since, int):
540
+ raise ValueError("Telemetry subscription requires a numeric 'since' field")
541
+ topic_id = data.get("topic_id")
542
+ follow = data.get("follow")
543
+ follow_flag = True if follow is None else bool(follow)
544
+ return since, topic_id, follow_flag
545
+
546
+
547
+ def _get_message_subscription(data: Dict[str, Any]) -> tuple[Optional[str], Optional[str], bool]:
548
+ """Return message subscription settings."""
549
+
550
+ topic_id = data.get("topic_id")
551
+ source_hash = data.get("source_hash") or data.get("source")
552
+ follow = data.get("follow")
553
+ follow_flag = True if follow is None else bool(follow)
554
+ return topic_id, source_hash, follow_flag
555
+
556
+
557
+ def _get_message_send_payload(data: Dict[str, Any]) -> tuple[str, Optional[str], Optional[str]]:
558
+ """Return message send parameters from the payload."""
559
+
560
+ content = data.get("content")
561
+ if not isinstance(content, str) or not content.strip():
562
+ raise ValueError("Message send requires non-empty 'content'")
563
+ topic_id = data.get("topic_id")
564
+ destination = data.get("destination")
565
+ if destination is not None and not isinstance(destination, str):
566
+ raise ValueError("Message destination must be a string")
567
+ return content, topic_id, destination
568
+
569
+
570
+ async def authenticate_websocket(
571
+ websocket: WebSocket,
572
+ *,
573
+ auth: ApiAuth,
574
+ timeout_seconds: float = 5.0,
575
+ ) -> bool:
576
+ """Authenticate a WebSocket connection.
577
+
578
+ Args:
579
+ websocket (WebSocket): WebSocket connection.
580
+ auth (ApiAuth): Auth validator.
581
+ timeout_seconds (float): Timeout for the auth message.
582
+
583
+ Returns:
584
+ bool: ``True`` when authentication succeeds.
585
+ """
586
+
587
+ try:
588
+ payload = await asyncio.wait_for(websocket.receive_text(), timeout=timeout_seconds)
589
+ except asyncio.TimeoutError:
590
+ await websocket.send_json(build_error_message("timeout", "Authentication timed out"))
591
+ await websocket.close(code=4001)
592
+ return False
593
+
594
+ try:
595
+ message = parse_ws_message(payload)
596
+ except ValueError as exc:
597
+ await websocket.send_json(build_error_message("bad_request", str(exc)))
598
+ await websocket.close(code=4002)
599
+ return False
600
+
601
+ if not _is_auth_message(message):
602
+ await websocket.send_json(build_error_message("bad_request", "Auth message required"))
603
+ await websocket.close(code=4002)
604
+ return False
605
+
606
+ token, api_key = _extract_auth_data(message)
607
+ if not _validated_auth(auth, token, api_key):
608
+ await websocket.send_json(build_error_message("unauthorized", "Unauthorized"))
609
+ await websocket.close(code=4003)
610
+ return False
611
+
612
+ await websocket.send_json(build_ws_message("auth.ok", {}))
613
+ return True
614
+
615
+
616
+ async def ping_loop(websocket: WebSocket, *, interval_seconds: float = 30.0) -> None:
617
+ """Send periodic ping messages to a WebSocket.
618
+
619
+ Args:
620
+ websocket (WebSocket): WebSocket connection.
621
+ interval_seconds (float): Ping interval in seconds.
622
+ """
623
+
624
+ while True:
625
+ await asyncio.sleep(interval_seconds)
626
+ await websocket.send_json(build_ping_message())
627
+
628
+
629
+ async def handle_system_socket(
630
+ websocket: WebSocket,
631
+ *,
632
+ auth: ApiAuth,
633
+ event_broadcaster: EventBroadcaster,
634
+ status_provider: Callable[[], Dict[str, object]],
635
+ event_list_provider: Callable[[int], list[Dict[str, object]]],
636
+ ) -> None:
637
+ """Handle the system events WebSocket.
638
+
639
+ Args:
640
+ websocket (WebSocket): WebSocket connection.
641
+ auth (ApiAuth): Auth validator.
642
+ event_broadcaster (EventBroadcaster): Event broadcaster.
643
+ status_provider (Callable[[], Dict[str, object]]): Status snapshot provider.
644
+ event_list_provider (Callable[[int], list[Dict[str, object]]]): Event list provider.
645
+ """
646
+
647
+ await websocket.accept()
648
+ if not await authenticate_websocket(websocket, auth=auth):
649
+ return
650
+
651
+ include_status = True
652
+ include_events = True
653
+ events_limit = 50
654
+
655
+ async def _send_event(entry: Dict[str, object]) -> None:
656
+ """Send event updates to the WebSocket client.
657
+
658
+ Args:
659
+ entry (Dict[str, object]): Event entry payload.
660
+
661
+ Returns:
662
+ None: Sends messages to the WebSocket client.
663
+ """
664
+
665
+ if include_events:
666
+ await websocket.send_json(build_ws_message("system.event", entry))
667
+ if include_status:
668
+ await websocket.send_json(build_ws_message("system.status", status_provider()))
669
+
670
+ unsubscribe = event_broadcaster.subscribe(_send_event)
671
+ ping_task = asyncio.create_task(ping_loop(websocket))
672
+
673
+ try:
674
+ if include_status:
675
+ await websocket.send_json(build_ws_message("system.status", status_provider()))
676
+ if include_events:
677
+ for event in event_list_provider(events_limit):
678
+ await websocket.send_json(build_ws_message("system.event", event))
679
+
680
+ while True:
681
+ payload = await websocket.receive_text()
682
+ message = parse_ws_message(payload)
683
+ msg_type = _get_message_type(message)
684
+ if msg_type == "system.subscribe":
685
+ data = _get_message_data(message)
686
+ include_status, include_events, events_limit = _get_subscribe_flags(data)
687
+ if include_status:
688
+ await websocket.send_json(build_ws_message("system.status", status_provider()))
689
+ elif msg_type == "pong":
690
+ continue
691
+ else:
692
+ await websocket.send_json(build_error_message("bad_request", "Unsupported message"))
693
+ except Exception: # pragma: no cover - websocket disconnects vary
694
+ return
695
+ finally:
696
+ unsubscribe()
697
+ ping_task.cancel()
698
+
699
+
700
+ async def handle_telemetry_socket(
701
+ websocket: WebSocket,
702
+ *,
703
+ auth: ApiAuth,
704
+ telemetry_broadcaster: TelemetryBroadcaster,
705
+ telemetry_snapshot: Callable[[int, Optional[str]], list[Dict[str, object]]],
706
+ ) -> None:
707
+ """Handle the telemetry WebSocket.
708
+
709
+ Args:
710
+ websocket (WebSocket): WebSocket connection.
711
+ auth (ApiAuth): Auth validator.
712
+ telemetry_broadcaster (TelemetryBroadcaster): Telemetry broadcaster.
713
+ telemetry_snapshot (Callable[[int, Optional[str]], list[Dict[str, object]]]): Snapshot provider.
714
+ """
715
+
716
+ await websocket.accept()
717
+ if not await authenticate_websocket(websocket, auth=auth):
718
+ return
719
+
720
+ ping_task = asyncio.create_task(ping_loop(websocket))
721
+ unsubscribe = None
722
+
723
+ try:
724
+ while True:
725
+ payload = await websocket.receive_text()
726
+ message = parse_ws_message(payload)
727
+ msg_type = _get_message_type(message)
728
+ if msg_type == "telemetry.subscribe":
729
+ data = _get_message_data(message)
730
+ try:
731
+ since, topic_id, follow = _get_telemetry_subscription(data)
732
+ except ValueError as exc:
733
+ await websocket.send_json(build_error_message("bad_request", str(exc)))
734
+ continue
735
+ entries = telemetry_snapshot(since, topic_id)
736
+ await websocket.send_json(
737
+ build_ws_message("telemetry.snapshot", {"entries": entries})
738
+ )
739
+ if follow:
740
+ if unsubscribe:
741
+ unsubscribe()
742
+ try:
743
+ async def _send_update(entry: Dict[str, object]) -> None:
744
+ """Send telemetry updates to the WebSocket client.
745
+
746
+ Args:
747
+ entry (Dict[str, object]): Telemetry entry payload.
748
+
749
+ Returns:
750
+ None: Sends messages to the WebSocket client.
751
+ """
752
+
753
+ await websocket.send_json(
754
+ build_ws_message("telemetry.update", {"entry": entry})
755
+ )
756
+
757
+ unsubscribe = telemetry_broadcaster.subscribe(
758
+ _send_update,
759
+ topic_id=topic_id,
760
+ )
761
+ except KeyError:
762
+ await websocket.send_json(
763
+ build_error_message("not_found", "Topic not found")
764
+ )
765
+ except ValueError as exc:
766
+ await websocket.send_json(build_error_message("bad_request", str(exc)))
767
+ elif msg_type == "pong":
768
+ continue
769
+ else:
770
+ await websocket.send_json(build_error_message("bad_request", "Unsupported message"))
771
+ except Exception: # pragma: no cover - websocket disconnects vary
772
+ return
773
+ finally:
774
+ if unsubscribe:
775
+ unsubscribe()
776
+ ping_task.cancel()
777
+
778
+
779
+ async def handle_message_socket(
780
+ websocket: WebSocket,
781
+ *,
782
+ auth: ApiAuth,
783
+ message_broadcaster: MessageBroadcaster,
784
+ message_sender: Callable[[str, Optional[str], Optional[str]], None],
785
+ ) -> None:
786
+ """Handle the messages WebSocket."""
787
+
788
+ await websocket.accept()
789
+ if not await authenticate_websocket(websocket, auth=auth):
790
+ return
791
+
792
+ ping_task = asyncio.create_task(ping_loop(websocket))
793
+ unsubscribe = None
794
+
795
+ try:
796
+ while True:
797
+ payload = await websocket.receive_text()
798
+ message = parse_ws_message(payload)
799
+ msg_type = _get_message_type(message)
800
+ if msg_type == "message.subscribe":
801
+ data = _get_message_data(message)
802
+ topic_id, source_hash, follow = _get_message_subscription(data)
803
+ if follow:
804
+ if unsubscribe:
805
+ unsubscribe()
806
+
807
+ async def _send_update(entry: Dict[str, object]) -> None:
808
+ """Send message updates to the WebSocket client."""
809
+
810
+ await websocket.send_json(
811
+ build_ws_message("message.receive", {"entry": entry})
812
+ )
813
+
814
+ unsubscribe = message_broadcaster.subscribe(
815
+ _send_update,
816
+ topic_id=topic_id,
817
+ source_hash=source_hash,
818
+ )
819
+ await websocket.send_json(
820
+ build_ws_message(
821
+ "message.subscribed",
822
+ {
823
+ "topic_id": topic_id,
824
+ "source_hash": source_hash,
825
+ "follow": follow,
826
+ },
827
+ )
828
+ )
829
+ elif msg_type == "message.send":
830
+ data = _get_message_data(message)
831
+ try:
832
+ content, topic_id, destination = _get_message_send_payload(data)
833
+ message_sender(content, topic_id=topic_id, destination=destination)
834
+ except RuntimeError as exc:
835
+ await websocket.send_json(
836
+ build_error_message("service_unavailable", str(exc))
837
+ )
838
+ except ValueError as exc:
839
+ await websocket.send_json(build_error_message("bad_request", str(exc)))
840
+ else:
841
+ await websocket.send_json(
842
+ build_ws_message("message.sent", {"ok": True})
843
+ )
844
+ elif msg_type == "pong":
845
+ continue
846
+ else:
847
+ await websocket.send_json(
848
+ build_error_message("bad_request", "Unsupported message")
849
+ )
850
+ except Exception: # pragma: no cover - websocket disconnects vary
851
+ return
852
+ finally:
853
+ if unsubscribe:
854
+ unsubscribe()
855
+ ping_task.cancel()