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,690 @@
1
+ """Internal API core command handling and in-memory state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import deque
6
+ from dataclasses import dataclass
7
+ from datetime import datetime
8
+ from datetime import timezone
9
+ import logging
10
+ import time
11
+ from typing import Deque
12
+ from typing import Dict
13
+ from typing import Optional
14
+ from typing import Set
15
+ from typing import Tuple
16
+ from uuid import UUID
17
+ from uuid import uuid4
18
+
19
+ from reticulum_telemetry_hub.internal_api.bus import EventBus
20
+ from reticulum_telemetry_hub.internal_api.v1.enums import CommandStatus
21
+ from reticulum_telemetry_hub.internal_api.v1.enums import CommandType
22
+ from reticulum_telemetry_hub.internal_api.v1.enums import ErrorCode
23
+ from reticulum_telemetry_hub.internal_api.v1.enums import EventType
24
+ from reticulum_telemetry_hub.internal_api.v1.enums import IssuerType
25
+ from reticulum_telemetry_hub.internal_api.v1.enums import MessageType
26
+ from reticulum_telemetry_hub.internal_api.v1.enums import NodeType
27
+ from reticulum_telemetry_hub.internal_api.v1.enums import QueryType
28
+ from reticulum_telemetry_hub.internal_api.v1.enums import RetentionPolicy
29
+ from reticulum_telemetry_hub.internal_api.v1.enums import SubscriberAction
30
+ from reticulum_telemetry_hub.internal_api.v1.enums import Visibility
31
+ from reticulum_telemetry_hub.internal_api.v1.schemas import CommandEnvelope
32
+ from reticulum_telemetry_hub.internal_api.v1.schemas import CommandResult
33
+ from reticulum_telemetry_hub.internal_api.v1.schemas import CreateTopicPayload
34
+ from reticulum_telemetry_hub.internal_api.v1.schemas import EventEnvelope
35
+ from reticulum_telemetry_hub.internal_api.v1.schemas import GetNodeStatusPayload
36
+ from reticulum_telemetry_hub.internal_api.v1.schemas import GetSubscribersPayload
37
+ from reticulum_telemetry_hub.internal_api.v1.schemas import MessagePublishedPayload
38
+ from reticulum_telemetry_hub.internal_api.v1.schemas import NodeRegisteredPayload
39
+ from reticulum_telemetry_hub.internal_api.v1.schemas import PublishMessagePayload
40
+ from reticulum_telemetry_hub.internal_api.v1.schemas import RegisterNodeMetadata
41
+ from reticulum_telemetry_hub.internal_api.v1.schemas import RegisterNodePayload
42
+ from reticulum_telemetry_hub.internal_api.v1.schemas import QueryEnvelope
43
+ from reticulum_telemetry_hub.internal_api.v1.schemas import QueryError
44
+ from reticulum_telemetry_hub.internal_api.v1.schemas import QueryResult
45
+ from reticulum_telemetry_hub.internal_api.v1.schemas import QueryResultPayload
46
+ from reticulum_telemetry_hub.internal_api.v1.schemas import SubscribeTopicPayload
47
+ from reticulum_telemetry_hub.internal_api.v1.schemas import SubscriberUpdatedPayload
48
+ from reticulum_telemetry_hub.internal_api.v1.schemas import SUPPORTED_API_VERSION
49
+ from reticulum_telemetry_hub.internal_api.v1.schemas import TopicCreatedPayload
50
+
51
+
52
+ _LOGGER = logging.getLogger(__name__)
53
+
54
+
55
+ def _utc_now() -> datetime:
56
+ """Return the current UTC time."""
57
+
58
+ return datetime.now(timezone.utc)
59
+
60
+
61
+ def _epoch_now() -> float:
62
+ """Return current time as epoch seconds."""
63
+
64
+ return time.time()
65
+
66
+
67
+ @dataclass
68
+ class NodeRecord:
69
+ """In-memory record for registered nodes."""
70
+
71
+ node_id: str
72
+ node_type: NodeType
73
+ metadata: Optional[RegisterNodeMetadata]
74
+
75
+
76
+ @dataclass
77
+ class TopicRecord:
78
+ """In-memory record for topics."""
79
+
80
+ topic_path: str
81
+ retention: RetentionPolicy
82
+ visibility: Visibility
83
+ created_ts: float
84
+ last_activity_ts: float
85
+
86
+
87
+ @dataclass
88
+ class NodeStats:
89
+ """In-memory telemetry and activity tracking for nodes."""
90
+
91
+ first_seen_ts: float
92
+ last_seen_ts: float
93
+ telemetry_timestamps: Deque[float]
94
+ message_timestamps: Deque[float]
95
+ battery_pct: Optional[float] = None
96
+ signal_quality: Optional[float] = None
97
+
98
+
99
+ class CommandRejection(Exception):
100
+ """Raise when a command must be rejected with a specific error code."""
101
+
102
+ def __init__(self, error_code: ErrorCode) -> None:
103
+ """Initialize with a typed error code."""
104
+
105
+ super().__init__(error_code.value)
106
+ self.error_code = error_code
107
+
108
+
109
+ MESSAGE_WINDOW_SECONDS = 60.0
110
+ NODE_ONLINE_THRESHOLD_SECONDS = 30.0
111
+ NODE_STALE_THRESHOLD_SECONDS = 300.0
112
+ CACHE_TTL_SECONDS = 5
113
+
114
+
115
+ def _prune_timestamps(timestamps: Deque[float], now: float) -> None:
116
+ """Remove timestamps outside the sliding window."""
117
+
118
+ cutoff = now - MESSAGE_WINDOW_SECONDS
119
+ while timestamps and timestamps[0] < cutoff:
120
+ timestamps.popleft()
121
+
122
+
123
+ def _rate_from_timestamps(timestamps: Deque[float], now: float) -> float:
124
+ """Return the per-second rate for the sliding window."""
125
+
126
+ _prune_timestamps(timestamps, now)
127
+ return len(timestamps) / MESSAGE_WINDOW_SECONDS
128
+
129
+
130
+ def _coerce_metric_value(value: object, fallback: Optional[float]) -> Optional[float]:
131
+ """Convert telemetry metric values to floats when possible."""
132
+
133
+ try:
134
+ return float(value)
135
+ except (TypeError, ValueError):
136
+ return fallback
137
+
138
+
139
+ def _command_log_context(command: CommandEnvelope) -> Dict[str, object]:
140
+ """Return structured logging context for commands."""
141
+
142
+ return {
143
+ "command_id": str(command.command_id),
144
+ "command_type": command.command_type.value,
145
+ "issuer_id": command.issuer.id,
146
+ "correlation_id": str(command.command_id),
147
+ }
148
+
149
+
150
+ def _event_log_context(event: EventEnvelope, correlation_id: Optional[str]) -> Dict[str, object]:
151
+ """Return structured logging context for events."""
152
+
153
+ context = {
154
+ "event_id": str(event.event_id),
155
+ "event_type": event.event_type.value,
156
+ }
157
+ if correlation_id is not None:
158
+ context["correlation_id"] = correlation_id
159
+ return context
160
+
161
+
162
+ def _query_log_context(query: QueryEnvelope) -> Dict[str, object]:
163
+ """Return structured logging context for queries."""
164
+
165
+ return {
166
+ "query_id": str(query.query_id),
167
+ "query_type": query.query_type.value,
168
+ "correlation_id": str(query.query_id),
169
+ }
170
+
171
+
172
+ class InternalApiCore:
173
+ """Handle internal API commands and emit events."""
174
+
175
+ _AUTHORIZATION: Dict[CommandType, Set[IssuerType]] = {
176
+ CommandType.REGISTER_NODE: {
177
+ IssuerType.RETICULUM,
178
+ IssuerType.INTERNAL,
179
+ },
180
+ CommandType.CREATE_TOPIC: {
181
+ IssuerType.API,
182
+ IssuerType.INTERNAL,
183
+ },
184
+ CommandType.SUBSCRIBE_TOPIC: {
185
+ IssuerType.API,
186
+ IssuerType.RETICULUM,
187
+ IssuerType.INTERNAL,
188
+ },
189
+ CommandType.PUBLISH_MESSAGE: {
190
+ IssuerType.API,
191
+ IssuerType.RETICULUM,
192
+ IssuerType.INTERNAL,
193
+ },
194
+ }
195
+
196
+ def __init__(self, event_bus: EventBus) -> None:
197
+ """Initialize the core with an event bus."""
198
+
199
+ self._event_bus = event_bus
200
+ self._nodes: Dict[str, NodeRecord] = {}
201
+ self._topics: Dict[str, TopicRecord] = {}
202
+ self._subscriptions: Set[Tuple[str, str]] = set()
203
+ self._command_results: Dict[UUID, CommandResult] = {}
204
+ self._node_stats: Dict[str, NodeStats] = {}
205
+ self._topic_messages: Dict[str, Deque[float]] = {}
206
+ self._blackholed: Set[str] = set()
207
+
208
+ async def handle_command(self, command: CommandEnvelope) -> CommandResult:
209
+ """Process a command and emit the corresponding event."""
210
+
211
+ _LOGGER.info("Command received", extra=_command_log_context(command))
212
+ cached = self._command_results.get(command.command_id)
213
+ if cached is not None:
214
+ _LOGGER.info("Command replayed", extra=_command_log_context(command))
215
+ return cached
216
+
217
+ if not self._is_authorized(command):
218
+ _LOGGER.info(
219
+ "Command rejected",
220
+ extra={
221
+ **_command_log_context(command),
222
+ "reason": ErrorCode.UNAUTHORIZED_COMMAND.value,
223
+ },
224
+ )
225
+ return self._cache_result(
226
+ command,
227
+ CommandStatus.REJECTED,
228
+ ErrorCode.UNAUTHORIZED_COMMAND.value,
229
+ )
230
+
231
+ self._record_last_seen(self._node_id_for_seen(command))
232
+
233
+ try:
234
+ event = await self._apply_command(command)
235
+ except CommandRejection as exc:
236
+ _LOGGER.info(
237
+ "Command rejected",
238
+ extra={**_command_log_context(command), "reason": exc.error_code.value},
239
+ )
240
+ return self._cache_result(
241
+ command,
242
+ CommandStatus.REJECTED,
243
+ exc.error_code.value,
244
+ )
245
+
246
+ await self._event_bus.publish(event)
247
+ _LOGGER.info(
248
+ "Event emitted",
249
+ extra=_event_log_context(event, str(command.command_id)),
250
+ )
251
+ _LOGGER.info("Command accepted", extra=_command_log_context(command))
252
+ return self._cache_result(command, CommandStatus.ACCEPTED, None)
253
+
254
+ def _cache_result(
255
+ self,
256
+ command: CommandEnvelope,
257
+ status: CommandStatus,
258
+ reason: Optional[str],
259
+ ) -> CommandResult:
260
+ """Store and return a command result."""
261
+
262
+ result = CommandResult(
263
+ command_id=command.command_id,
264
+ status=status,
265
+ reason=reason,
266
+ )
267
+ self._command_results[command.command_id] = result
268
+ return result
269
+
270
+ def _is_authorized(self, command: CommandEnvelope) -> bool:
271
+ """Return ``True`` when the issuer may run the command."""
272
+
273
+ allowed = self._AUTHORIZATION.get(command.command_type, set())
274
+ return command.issuer.type in allowed
275
+
276
+ def _node_id_for_seen(self, command: CommandEnvelope) -> str:
277
+ """Return the node identifier to update for last-seen."""
278
+
279
+ if (
280
+ command.command_type == CommandType.REGISTER_NODE
281
+ and isinstance(command.payload, RegisterNodePayload)
282
+ ):
283
+ return command.payload.node_id
284
+ return command.issuer.id
285
+
286
+ def _record_last_seen(self, node_id: str) -> None:
287
+ """Update node last-seen timestamps."""
288
+
289
+ now = _epoch_now()
290
+ stats = self._node_stats.get(node_id)
291
+ if stats is None:
292
+ stats = NodeStats(
293
+ first_seen_ts=now,
294
+ last_seen_ts=now,
295
+ telemetry_timestamps=deque(),
296
+ message_timestamps=deque(),
297
+ )
298
+ self._node_stats[node_id] = stats
299
+ else:
300
+ stats.last_seen_ts = now
301
+
302
+ def touch_node(self, node_id: str) -> None:
303
+ """Update last-seen timestamps without emitting events."""
304
+
305
+ if node_id:
306
+ self._record_last_seen(node_id)
307
+
308
+ async def _apply_command(self, command: CommandEnvelope) -> EventEnvelope:
309
+ """Apply command state changes and return the emitted event."""
310
+
311
+ if command.command_type == CommandType.REGISTER_NODE:
312
+ payload = command.payload
313
+ if not isinstance(payload, RegisterNodePayload):
314
+ raise CommandRejection(ErrorCode.UNAUTHORIZED_COMMAND)
315
+ return self._register_node(payload)
316
+ if command.command_type == CommandType.CREATE_TOPIC:
317
+ payload = command.payload
318
+ if not isinstance(payload, CreateTopicPayload):
319
+ raise CommandRejection(ErrorCode.UNAUTHORIZED_COMMAND)
320
+ return self._create_topic(payload)
321
+ if command.command_type == CommandType.SUBSCRIBE_TOPIC:
322
+ payload = command.payload
323
+ if not isinstance(payload, SubscribeTopicPayload):
324
+ raise CommandRejection(ErrorCode.UNAUTHORIZED_COMMAND)
325
+ return self._subscribe_topic(payload)
326
+ if command.command_type == CommandType.PUBLISH_MESSAGE:
327
+ payload = command.payload
328
+ if not isinstance(payload, PublishMessagePayload):
329
+ raise CommandRejection(ErrorCode.UNAUTHORIZED_COMMAND)
330
+ return self._publish_message(command, payload)
331
+
332
+ raise CommandRejection(ErrorCode.UNAUTHORIZED_COMMAND)
333
+
334
+ def _register_node(self, payload: RegisterNodePayload) -> EventEnvelope:
335
+ """Register or update a node."""
336
+
337
+ self._nodes[payload.node_id] = NodeRecord(
338
+ node_id=payload.node_id,
339
+ node_type=payload.node_type,
340
+ metadata=payload.metadata,
341
+ )
342
+ event_payload = NodeRegisteredPayload(
343
+ node_id=payload.node_id,
344
+ node_type=payload.node_type,
345
+ )
346
+ return EventEnvelope(
347
+ api_version=SUPPORTED_API_VERSION,
348
+ event_id=uuid4(),
349
+ event_type=EventType.NODE_REGISTERED,
350
+ occurred_at=_utc_now(),
351
+ origin="hub-core",
352
+ payload=event_payload,
353
+ )
354
+
355
+ def _create_topic(self, payload: CreateTopicPayload) -> EventEnvelope:
356
+ """Create or update a topic."""
357
+
358
+ now = _epoch_now()
359
+ self._topics[payload.topic_path] = TopicRecord(
360
+ topic_path=payload.topic_path,
361
+ retention=payload.retention,
362
+ visibility=payload.visibility,
363
+ created_ts=now,
364
+ last_activity_ts=now,
365
+ )
366
+ self._topic_messages.setdefault(payload.topic_path, deque())
367
+ event_payload = TopicCreatedPayload(topic_path=payload.topic_path)
368
+ return EventEnvelope(
369
+ api_version=SUPPORTED_API_VERSION,
370
+ event_id=uuid4(),
371
+ event_type=EventType.TOPIC_CREATED,
372
+ occurred_at=_utc_now(),
373
+ origin="hub-core",
374
+ payload=event_payload,
375
+ )
376
+
377
+ def _subscribe_topic(self, payload: SubscribeTopicPayload) -> EventEnvelope:
378
+ """Subscribe a destination to a topic."""
379
+
380
+ if payload.topic_path not in self._topics:
381
+ raise CommandRejection(ErrorCode.TOPIC_NOT_FOUND)
382
+ self._subscriptions.add((payload.subscriber_id, payload.topic_path))
383
+ event_payload = SubscriberUpdatedPayload(
384
+ subscriber_id=payload.subscriber_id,
385
+ topic_path=payload.topic_path,
386
+ action=SubscriberAction.SUBSCRIBED,
387
+ )
388
+ return EventEnvelope(
389
+ api_version=SUPPORTED_API_VERSION,
390
+ event_id=uuid4(),
391
+ event_type=EventType.SUBSCRIBER_UPDATED,
392
+ occurred_at=_utc_now(),
393
+ origin="hub-core",
394
+ payload=event_payload,
395
+ )
396
+
397
+ def _publish_message(
398
+ self, command: CommandEnvelope, payload: PublishMessagePayload
399
+ ) -> EventEnvelope:
400
+ """Publish a message to a topic."""
401
+
402
+ if payload.topic_path not in self._topics:
403
+ raise CommandRejection(ErrorCode.TOPIC_NOT_FOUND)
404
+ now = _epoch_now()
405
+ self._record_topic_message(payload.topic_path, now)
406
+ self._record_node_message(command.issuer.id, payload, now)
407
+ event_payload = MessagePublishedPayload(
408
+ topic_path=payload.topic_path,
409
+ message_id=command.command_id.hex,
410
+ originator=command.issuer.id,
411
+ )
412
+ return EventEnvelope(
413
+ api_version=SUPPORTED_API_VERSION,
414
+ event_id=uuid4(),
415
+ event_type=EventType.MESSAGE_PUBLISHED,
416
+ occurred_at=_utc_now(),
417
+ origin="hub-core",
418
+ payload=event_payload,
419
+ )
420
+
421
+ def _record_topic_message(self, topic_path: str, now: float) -> None:
422
+ """Update topic message activity."""
423
+
424
+ stats = self._topics.get(topic_path)
425
+ if stats is not None:
426
+ stats.last_activity_ts = now
427
+ timestamps = self._topic_messages.setdefault(topic_path, deque())
428
+ timestamps.append(now)
429
+ _prune_timestamps(timestamps, now)
430
+
431
+ def _record_node_message(
432
+ self, node_id: str, payload: PublishMessagePayload, now: float
433
+ ) -> None:
434
+ """Update node message activity and metrics."""
435
+
436
+ stats = self._node_stats.get(node_id)
437
+ if stats is None:
438
+ stats = NodeStats(
439
+ first_seen_ts=now,
440
+ last_seen_ts=now,
441
+ telemetry_timestamps=deque(),
442
+ message_timestamps=deque(),
443
+ )
444
+ self._node_stats[node_id] = stats
445
+ stats.last_seen_ts = now
446
+ stats.message_timestamps.append(now)
447
+ _prune_timestamps(stats.message_timestamps, now)
448
+
449
+ if payload.message_type == MessageType.TELEMETRY:
450
+ stats.telemetry_timestamps.append(now)
451
+ _prune_timestamps(stats.telemetry_timestamps, now)
452
+ self._update_metrics_from_telemetry(stats, payload)
453
+
454
+ def _update_metrics_from_telemetry(
455
+ self, stats: NodeStats, payload: PublishMessagePayload
456
+ ) -> None:
457
+ """Map telemetry metrics to node status metrics."""
458
+
459
+ data = getattr(payload.content, "data", None)
460
+ if not isinstance(data, dict):
461
+ return
462
+ if "battery" in data:
463
+ stats.battery_pct = _coerce_metric_value(data.get("battery"), stats.battery_pct)
464
+ if "battery_pct" in data:
465
+ stats.battery_pct = _coerce_metric_value(data.get("battery_pct"), stats.battery_pct)
466
+ if "rssi" in data:
467
+ stats.signal_quality = _coerce_metric_value(data.get("rssi"), stats.signal_quality)
468
+ if "snr" in data:
469
+ stats.signal_quality = _coerce_metric_value(data.get("snr"), stats.signal_quality)
470
+
471
+ async def handle_query(self, query: QueryEnvelope) -> QueryResult:
472
+ """Process a query without mutating state or emitting events."""
473
+
474
+ _LOGGER.info("Query received", extra=_query_log_context(query))
475
+ if query.query_type == QueryType.GET_TOPICS:
476
+ result = self._build_query_result(
477
+ query, {"topics": self._get_topics_snapshot()}, scope="hub"
478
+ )
479
+ _LOGGER.info("Query completed", extra=_query_log_context(query))
480
+ return result
481
+ if query.query_type == QueryType.GET_SUBSCRIBERS:
482
+ result = self._handle_get_subscribers(query)
483
+ _LOGGER.info("Query completed", extra=_query_log_context(query))
484
+ return result
485
+ if query.query_type == QueryType.GET_NODE_STATUS:
486
+ result = self._handle_get_node_status(query)
487
+ _LOGGER.info("Query completed", extra=_query_log_context(query))
488
+ return result
489
+ result = self._build_query_error(
490
+ query,
491
+ ErrorCode.INVALID_QUERY,
492
+ "Query type is not supported",
493
+ )
494
+ _LOGGER.info("Query completed", extra=_query_log_context(query))
495
+ return result
496
+
497
+ def _handle_get_subscribers(self, query: QueryEnvelope) -> QueryResult:
498
+ """Handle GetSubscribers queries."""
499
+
500
+ payload = query.payload
501
+ if not isinstance(payload, GetSubscribersPayload):
502
+ return self._build_query_error(
503
+ query,
504
+ ErrorCode.INVALID_QUERY,
505
+ "Invalid query payload",
506
+ )
507
+ topic_id = payload.topic_path
508
+ if topic_id not in self._topics:
509
+ return self._build_query_error(
510
+ query,
511
+ ErrorCode.TOPIC_NOT_FOUND,
512
+ "Topic does not exist",
513
+ )
514
+ subscribers = self._get_subscribers_snapshot(topic_id)
515
+ return self._build_query_result(
516
+ query,
517
+ {"topic_id": topic_id, "subscribers": subscribers},
518
+ scope="hub",
519
+ )
520
+
521
+ def _handle_get_node_status(self, query: QueryEnvelope) -> QueryResult:
522
+ """Handle GetNodeStatus queries."""
523
+
524
+ payload = query.payload
525
+ if not isinstance(payload, GetNodeStatusPayload):
526
+ return self._build_query_error(
527
+ query,
528
+ ErrorCode.INVALID_QUERY,
529
+ "Invalid query payload",
530
+ )
531
+ node_id = payload.node_id
532
+ status = self._get_node_status_snapshot(node_id)
533
+ return self._build_query_result(query, status, scope="node")
534
+
535
+ def _build_query_result(
536
+ self,
537
+ query: QueryEnvelope,
538
+ data: Dict[str, object],
539
+ *,
540
+ scope: str,
541
+ ) -> QueryResult:
542
+ """Build a successful query result with cache hints."""
543
+
544
+ cache = {
545
+ "ttl_seconds": CACHE_TTL_SECONDS,
546
+ "scope": scope,
547
+ "stale_while_revalidate": True,
548
+ }
549
+ payload = QueryResultPayload(data=data, _cache=cache)
550
+ return QueryResult(
551
+ query_id=query.query_id,
552
+ ok=True,
553
+ result=payload,
554
+ error=None,
555
+ )
556
+
557
+ def _build_query_error(
558
+ self, query: QueryEnvelope, code: ErrorCode, message: str
559
+ ) -> QueryResult:
560
+ """Build an error query result."""
561
+
562
+ return QueryResult(
563
+ query_id=query.query_id,
564
+ ok=False,
565
+ result=None,
566
+ error=QueryError(code=code, message=message),
567
+ )
568
+
569
+ def _get_topics_snapshot(self) -> list[Dict[str, object]]:
570
+ """Return topic summaries with live statistics."""
571
+
572
+ now = _epoch_now()
573
+ summaries: list[Dict[str, object]] = []
574
+ for topic_path, topic in sorted(self._topics.items()):
575
+ timestamps = self._topic_messages.get(topic_path, deque())
576
+ message_rate = _rate_from_timestamps(timestamps, now)
577
+ subscriber_count = sum(
578
+ 1 for subscriber, tpath in self._subscriptions if tpath == topic_path
579
+ )
580
+ visibility = "public"
581
+ if topic.visibility == Visibility.RESTRICTED:
582
+ visibility = "private"
583
+ summaries.append(
584
+ {
585
+ "topic_id": topic.topic_path,
586
+ "visibility": visibility,
587
+ "subscriber_count": subscriber_count,
588
+ "message_rate": message_rate,
589
+ "last_activity_ts": int(topic.last_activity_ts),
590
+ "created_ts": int(topic.created_ts),
591
+ }
592
+ )
593
+ return summaries
594
+
595
+ def _get_subscribers_snapshot(self, topic_id: str) -> list[Dict[str, object]]:
596
+ """Return subscriber summaries for a topic."""
597
+
598
+ summaries: list[Dict[str, object]] = []
599
+ for subscriber_id, topic_path in sorted(self._subscriptions):
600
+ if topic_path != topic_id:
601
+ continue
602
+ status = self._subscriber_status(subscriber_id)
603
+ if status is None:
604
+ continue
605
+ stats = self._node_stats.get(subscriber_id)
606
+ if stats is None:
607
+ continue
608
+ summaries.append(
609
+ {
610
+ "node_id": subscriber_id,
611
+ "first_seen_ts": int(stats.first_seen_ts),
612
+ "last_seen_ts": int(stats.last_seen_ts),
613
+ "status": status,
614
+ }
615
+ )
616
+ return summaries
617
+
618
+ def get_subscriber_ids(self, topic_id: str) -> Set[str]:
619
+ """Return subscriber IDs for a topic without status filtering."""
620
+
621
+ return {
622
+ subscriber_id
623
+ for subscriber_id, topic_path in self._subscriptions
624
+ if topic_path == topic_id
625
+ }
626
+
627
+ def _get_node_status_snapshot(self, node_id: str) -> Dict[str, object]:
628
+ """Return a node status snapshot."""
629
+
630
+ stats = self._node_stats.get(node_id)
631
+ if stats is None:
632
+ return {
633
+ "node_id": node_id,
634
+ "status": "unknown",
635
+ "topics": [],
636
+ "last_seen_ts": None,
637
+ "metrics": {},
638
+ }
639
+ status = self._node_status(node_id)
640
+ topics = sorted(
641
+ topic_path
642
+ for subscriber_id, topic_path in self._subscriptions
643
+ if subscriber_id == node_id
644
+ )
645
+ metrics: Dict[str, object] = {}
646
+ telemetry_rate = _rate_from_timestamps(stats.telemetry_timestamps, _epoch_now())
647
+ if telemetry_rate > 0:
648
+ metrics["telemetry_rate"] = telemetry_rate
649
+ lxmf_rate = _rate_from_timestamps(stats.message_timestamps, _epoch_now())
650
+ if lxmf_rate > 0:
651
+ metrics["lxmf_rate"] = lxmf_rate
652
+ if stats.battery_pct is not None:
653
+ metrics["battery_pct"] = stats.battery_pct
654
+ if stats.signal_quality is not None:
655
+ metrics["signal_quality"] = stats.signal_quality
656
+
657
+ return {
658
+ "node_id": node_id,
659
+ "status": status,
660
+ "topics": topics,
661
+ "last_seen_ts": int(stats.last_seen_ts),
662
+ "metrics": metrics,
663
+ }
664
+
665
+ def _node_status(self, node_id: str) -> str:
666
+ """Return the node status string."""
667
+
668
+ if node_id in self._blackholed:
669
+ return "blackholed"
670
+ stats = self._node_stats.get(node_id)
671
+ if stats is None:
672
+ return "unknown"
673
+ age = _epoch_now() - stats.last_seen_ts
674
+ if age <= NODE_ONLINE_THRESHOLD_SECONDS:
675
+ return "online"
676
+ if age <= NODE_STALE_THRESHOLD_SECONDS:
677
+ return "stale"
678
+ return "offline"
679
+
680
+ def _subscriber_status(self, node_id: str) -> Optional[str]:
681
+ """Return subscriber status or None when inactive."""
682
+
683
+ status = self._node_status(node_id)
684
+ if status == "blackholed":
685
+ return "blackholed"
686
+ if status == "online":
687
+ return "active"
688
+ if status == "stale":
689
+ return "stale"
690
+ return None