ReticulumTelemetryHub 0.1.0__py3-none-any.whl → 0.143.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- reticulum_telemetry_hub/api/__init__.py +23 -0
- reticulum_telemetry_hub/api/models.py +323 -0
- reticulum_telemetry_hub/api/service.py +836 -0
- reticulum_telemetry_hub/api/storage.py +528 -0
- reticulum_telemetry_hub/api/storage_base.py +156 -0
- reticulum_telemetry_hub/api/storage_models.py +118 -0
- reticulum_telemetry_hub/atak_cot/__init__.py +49 -0
- reticulum_telemetry_hub/atak_cot/base.py +277 -0
- reticulum_telemetry_hub/atak_cot/chat.py +506 -0
- reticulum_telemetry_hub/atak_cot/detail.py +235 -0
- reticulum_telemetry_hub/atak_cot/event.py +181 -0
- reticulum_telemetry_hub/atak_cot/pytak_client.py +569 -0
- reticulum_telemetry_hub/atak_cot/tak_connector.py +848 -0
- reticulum_telemetry_hub/config/__init__.py +25 -0
- reticulum_telemetry_hub/config/constants.py +7 -0
- reticulum_telemetry_hub/config/manager.py +515 -0
- reticulum_telemetry_hub/config/models.py +215 -0
- reticulum_telemetry_hub/embedded_lxmd/__init__.py +5 -0
- reticulum_telemetry_hub/embedded_lxmd/embedded.py +418 -0
- reticulum_telemetry_hub/internal_api/__init__.py +21 -0
- reticulum_telemetry_hub/internal_api/bus.py +344 -0
- reticulum_telemetry_hub/internal_api/core.py +690 -0
- reticulum_telemetry_hub/internal_api/v1/__init__.py +74 -0
- reticulum_telemetry_hub/internal_api/v1/enums.py +109 -0
- reticulum_telemetry_hub/internal_api/v1/manifest.json +8 -0
- reticulum_telemetry_hub/internal_api/v1/schemas.py +478 -0
- reticulum_telemetry_hub/internal_api/versioning.py +63 -0
- reticulum_telemetry_hub/lxmf_daemon/Handlers.py +122 -0
- reticulum_telemetry_hub/lxmf_daemon/LXMF.py +252 -0
- reticulum_telemetry_hub/lxmf_daemon/LXMPeer.py +898 -0
- reticulum_telemetry_hub/lxmf_daemon/LXMRouter.py +4227 -0
- reticulum_telemetry_hub/lxmf_daemon/LXMessage.py +1006 -0
- reticulum_telemetry_hub/lxmf_daemon/LXStamper.py +490 -0
- reticulum_telemetry_hub/lxmf_daemon/__init__.py +10 -0
- reticulum_telemetry_hub/lxmf_daemon/_version.py +1 -0
- reticulum_telemetry_hub/lxmf_daemon/lxmd.py +1655 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/fields/field_telemetry_stream.py +6 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/__init__.py +3 -0
- {lxmf_telemetry → reticulum_telemetry_hub/lxmf_telemetry}/model/persistance/appearance.py +19 -19
- {lxmf_telemetry → reticulum_telemetry_hub/lxmf_telemetry}/model/persistance/peer.py +17 -13
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/__init__.py +65 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/acceleration.py +68 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/ambient_light.py +37 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/angular_velocity.py +68 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/battery.py +68 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/connection_map.py +258 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/generic.py +841 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/gravity.py +68 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/humidity.py +37 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/information.py +42 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/location.py +110 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/lxmf_propagation.py +429 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/magnetic_field.py +68 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/physical_link.py +53 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/pressure.py +37 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/proximity.py +37 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/received.py +75 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/rns_transport.py +209 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/sensor.py +65 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/sensor_enum.py +27 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/sensor_mapping.py +58 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/temperature.py +37 -0
- {lxmf_telemetry → reticulum_telemetry_hub/lxmf_telemetry}/model/persistance/sensors/time.py +36 -32
- {lxmf_telemetry → reticulum_telemetry_hub/lxmf_telemetry}/model/persistance/telemeter.py +26 -23
- reticulum_telemetry_hub/lxmf_telemetry/sampler.py +229 -0
- reticulum_telemetry_hub/lxmf_telemetry/telemeter_manager.py +409 -0
- reticulum_telemetry_hub/lxmf_telemetry/telemetry_controller.py +804 -0
- reticulum_telemetry_hub/northbound/__init__.py +5 -0
- reticulum_telemetry_hub/northbound/app.py +195 -0
- reticulum_telemetry_hub/northbound/auth.py +119 -0
- reticulum_telemetry_hub/northbound/gateway.py +310 -0
- reticulum_telemetry_hub/northbound/internal_adapter.py +302 -0
- reticulum_telemetry_hub/northbound/models.py +213 -0
- reticulum_telemetry_hub/northbound/routes_chat.py +123 -0
- reticulum_telemetry_hub/northbound/routes_files.py +119 -0
- reticulum_telemetry_hub/northbound/routes_rest.py +345 -0
- reticulum_telemetry_hub/northbound/routes_subscribers.py +150 -0
- reticulum_telemetry_hub/northbound/routes_topics.py +178 -0
- reticulum_telemetry_hub/northbound/routes_ws.py +107 -0
- reticulum_telemetry_hub/northbound/serializers.py +72 -0
- reticulum_telemetry_hub/northbound/services.py +373 -0
- reticulum_telemetry_hub/northbound/websocket.py +855 -0
- reticulum_telemetry_hub/reticulum_server/__main__.py +2237 -0
- reticulum_telemetry_hub/reticulum_server/command_manager.py +1268 -0
- reticulum_telemetry_hub/reticulum_server/command_text.py +399 -0
- reticulum_telemetry_hub/reticulum_server/constants.py +1 -0
- reticulum_telemetry_hub/reticulum_server/event_log.py +357 -0
- reticulum_telemetry_hub/reticulum_server/internal_adapter.py +358 -0
- reticulum_telemetry_hub/reticulum_server/outbound_queue.py +312 -0
- reticulum_telemetry_hub/reticulum_server/services.py +422 -0
- reticulumtelemetryhub-0.143.0.dist-info/METADATA +181 -0
- reticulumtelemetryhub-0.143.0.dist-info/RECORD +97 -0
- {reticulumtelemetryhub-0.1.0.dist-info → reticulumtelemetryhub-0.143.0.dist-info}/WHEEL +1 -1
- reticulumtelemetryhub-0.143.0.dist-info/licenses/LICENSE +277 -0
- lxmf_telemetry/model/fields/field_telemetry_stream.py +0 -7
- lxmf_telemetry/model/persistance/__init__.py +0 -3
- lxmf_telemetry/model/persistance/sensors/location.py +0 -69
- lxmf_telemetry/model/persistance/sensors/magnetic_field.py +0 -36
- lxmf_telemetry/model/persistance/sensors/sensor.py +0 -44
- lxmf_telemetry/model/persistance/sensors/sensor_enum.py +0 -24
- lxmf_telemetry/model/persistance/sensors/sensor_mapping.py +0 -9
- lxmf_telemetry/telemetry_controller.py +0 -124
- reticulum_server/main.py +0 -182
- reticulumtelemetryhub-0.1.0.dist-info/METADATA +0 -15
- reticulumtelemetryhub-0.1.0.dist-info/RECORD +0 -19
- {lxmf_telemetry → reticulum_telemetry_hub}/__init__.py +0 -0
- {lxmf_telemetry/model/persistance/sensors → reticulum_telemetry_hub/lxmf_telemetry}/__init__.py +0 -0
- {reticulum_server → reticulum_telemetry_hub/reticulum_server}/__init__.py +0 -0
|
@@ -0,0 +1,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
|