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,358 @@
|
|
|
1
|
+
"""Reticulum-facing adapter for the internal API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import copy
|
|
7
|
+
import logging
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Awaitable
|
|
11
|
+
from typing import Callable
|
|
12
|
+
from typing import Optional
|
|
13
|
+
from typing import Union
|
|
14
|
+
from uuid import uuid4
|
|
15
|
+
|
|
16
|
+
import LXMF
|
|
17
|
+
from msgpack import unpackb
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
from datetime import timezone
|
|
20
|
+
|
|
21
|
+
from reticulum_telemetry_hub.internal_api.core import InternalApiCore
|
|
22
|
+
from reticulum_telemetry_hub.internal_api.v1.enums import CommandStatus
|
|
23
|
+
from reticulum_telemetry_hub.internal_api.v1.enums import CommandType
|
|
24
|
+
from reticulum_telemetry_hub.internal_api.v1.enums import EventType
|
|
25
|
+
from reticulum_telemetry_hub.internal_api.v1.enums import IssuerType
|
|
26
|
+
from reticulum_telemetry_hub.internal_api.v1.enums import MessageType
|
|
27
|
+
from reticulum_telemetry_hub.internal_api.v1.enums import NodeType
|
|
28
|
+
from reticulum_telemetry_hub.internal_api.v1.enums import Qos
|
|
29
|
+
from reticulum_telemetry_hub.internal_api.v1.schemas import CommandEnvelope
|
|
30
|
+
from reticulum_telemetry_hub.internal_api.v1.schemas import EventEnvelope
|
|
31
|
+
from reticulum_telemetry_hub.internal_api.versioning import select_api_version
|
|
32
|
+
from reticulum_telemetry_hub.reticulum_server.constants import PLUGIN_COMMAND
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_DEDUP_TTL_SECONDS = 600.0
|
|
36
|
+
_LOGGER = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _maybe_await(result: Union[None, Awaitable[None]]) -> Awaitable[None]:
|
|
40
|
+
if asyncio.iscoroutine(result):
|
|
41
|
+
return result
|
|
42
|
+
return asyncio.sleep(0)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class InlineEventBus:
|
|
46
|
+
"""Inline event bus for synchronous Reticulum adapters."""
|
|
47
|
+
|
|
48
|
+
def __init__(self) -> None:
|
|
49
|
+
self._subscribers: list[Callable[[EventEnvelope], Union[None, Awaitable[None]]]] = []
|
|
50
|
+
|
|
51
|
+
def subscribe(self, handler):
|
|
52
|
+
"""Subscribe to events and return an unsubscribe callback."""
|
|
53
|
+
|
|
54
|
+
self._subscribers.append(handler)
|
|
55
|
+
|
|
56
|
+
def _unsubscribe() -> None:
|
|
57
|
+
if handler in self._subscribers:
|
|
58
|
+
self._subscribers.remove(handler)
|
|
59
|
+
|
|
60
|
+
return _unsubscribe
|
|
61
|
+
|
|
62
|
+
async def start(self) -> None:
|
|
63
|
+
"""No-op start."""
|
|
64
|
+
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
async def stop(self) -> None:
|
|
68
|
+
"""No-op stop."""
|
|
69
|
+
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
async def publish(self, event: EventEnvelope) -> None:
|
|
73
|
+
"""Publish events to subscribers immediately."""
|
|
74
|
+
|
|
75
|
+
for handler in list(self._subscribers):
|
|
76
|
+
await _maybe_await(handler(_copy_event(event)))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class LxmfInbound:
|
|
81
|
+
"""Normalized LXMF inbound payload for adapter processing."""
|
|
82
|
+
|
|
83
|
+
message_id: Optional[str]
|
|
84
|
+
source_id: Optional[str]
|
|
85
|
+
topic_id: Optional[str]
|
|
86
|
+
text: Optional[str]
|
|
87
|
+
fields: dict
|
|
88
|
+
commands: list[dict]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class MessageDeduper:
|
|
92
|
+
"""TTL-based duplicate filter for LXMF message IDs."""
|
|
93
|
+
|
|
94
|
+
def __init__(self, *, ttl_seconds: float, clock: Callable[[], float]) -> None:
|
|
95
|
+
self._ttl_seconds = ttl_seconds
|
|
96
|
+
self._clock = clock
|
|
97
|
+
self._entries: dict[str, float] = {}
|
|
98
|
+
self._order: list[tuple[float, str]] = []
|
|
99
|
+
|
|
100
|
+
def is_duplicate(self, message_id: str) -> bool:
|
|
101
|
+
"""Return True when message_id has been seen within the TTL window."""
|
|
102
|
+
|
|
103
|
+
now = self._clock()
|
|
104
|
+
self._prune(now)
|
|
105
|
+
if message_id in self._entries:
|
|
106
|
+
return True
|
|
107
|
+
self._entries[message_id] = now
|
|
108
|
+
self._order.append((now, message_id))
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
def _prune(self, now: float) -> None:
|
|
112
|
+
cutoff = now - self._ttl_seconds
|
|
113
|
+
while self._order and self._order[0][0] < cutoff:
|
|
114
|
+
_, message_id = self._order.pop(0)
|
|
115
|
+
self._entries.pop(message_id, None)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class ReticulumInternalAdapter:
|
|
119
|
+
"""Adapter translating LXMF inputs to internal API commands."""
|
|
120
|
+
|
|
121
|
+
def __init__(
|
|
122
|
+
self,
|
|
123
|
+
*,
|
|
124
|
+
send_message: Callable[[str, Optional[str]], None],
|
|
125
|
+
clock: Callable[[], float] = time.time,
|
|
126
|
+
) -> None:
|
|
127
|
+
self.event_bus = InlineEventBus()
|
|
128
|
+
self.core = InternalApiCore(self.event_bus)
|
|
129
|
+
self._send_message = send_message
|
|
130
|
+
self._clock = clock
|
|
131
|
+
self._seen_nodes: set[str] = set()
|
|
132
|
+
self._deduper = MessageDeduper(ttl_seconds=_DEDUP_TTL_SECONDS, clock=clock)
|
|
133
|
+
self._pending_text: dict[str, tuple[str, str]] = {}
|
|
134
|
+
self.event_bus.subscribe(self._handle_event)
|
|
135
|
+
|
|
136
|
+
def handle_inbound(self, inbound: LxmfInbound) -> None:
|
|
137
|
+
"""Handle normalized LXMF inputs."""
|
|
138
|
+
|
|
139
|
+
message_id = (inbound.message_id or "").lower()
|
|
140
|
+
if message_id and self._deduper.is_duplicate(message_id):
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
node_id = inbound.source_id
|
|
144
|
+
if node_id:
|
|
145
|
+
if node_id not in self._seen_nodes:
|
|
146
|
+
self._seen_nodes.add(node_id)
|
|
147
|
+
self._register_node(node_id)
|
|
148
|
+
self.core.touch_node(node_id)
|
|
149
|
+
|
|
150
|
+
commands = inbound.commands or []
|
|
151
|
+
for command in commands:
|
|
152
|
+
name = _command_name(command)
|
|
153
|
+
if not name:
|
|
154
|
+
continue
|
|
155
|
+
normalized = name.lower()
|
|
156
|
+
if normalized == "join":
|
|
157
|
+
if node_id and node_id not in self._seen_nodes:
|
|
158
|
+
self._seen_nodes.add(node_id)
|
|
159
|
+
self._register_node(node_id)
|
|
160
|
+
continue
|
|
161
|
+
if normalized == "subscribetopic":
|
|
162
|
+
topic_id = _extract_topic_id(command)
|
|
163
|
+
if node_id and topic_id:
|
|
164
|
+
self._subscribe_topic(node_id, topic_id)
|
|
165
|
+
continue
|
|
166
|
+
if normalized == "createtopic":
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
if inbound.topic_id and inbound.text:
|
|
170
|
+
self._publish_text(node_id, inbound.topic_id, inbound.text)
|
|
171
|
+
|
|
172
|
+
telemetry_payloads = _extract_telemetry_payloads(inbound.fields)
|
|
173
|
+
if inbound.topic_id and telemetry_payloads:
|
|
174
|
+
for payload in telemetry_payloads:
|
|
175
|
+
self._publish_telemetry(node_id, inbound.topic_id, payload)
|
|
176
|
+
|
|
177
|
+
def _register_node(self, node_id: str) -> None:
|
|
178
|
+
command = _build_command(
|
|
179
|
+
CommandType.REGISTER_NODE,
|
|
180
|
+
{
|
|
181
|
+
"node_id": node_id,
|
|
182
|
+
"node_type": NodeType.RETICULUM,
|
|
183
|
+
"metadata": None,
|
|
184
|
+
},
|
|
185
|
+
issuer_id=node_id,
|
|
186
|
+
)
|
|
187
|
+
_run_command(self.core, command)
|
|
188
|
+
|
|
189
|
+
def _subscribe_topic(self, node_id: str, topic_id: str) -> None:
|
|
190
|
+
command = _build_command(
|
|
191
|
+
CommandType.SUBSCRIBE_TOPIC,
|
|
192
|
+
{"subscriber_id": node_id, "topic_path": topic_id},
|
|
193
|
+
issuer_id=node_id,
|
|
194
|
+
)
|
|
195
|
+
_run_command(self.core, command)
|
|
196
|
+
|
|
197
|
+
def _publish_text(self, node_id: Optional[str], topic_id: str, text: str) -> None:
|
|
198
|
+
if not node_id:
|
|
199
|
+
return
|
|
200
|
+
command = _build_command(
|
|
201
|
+
CommandType.PUBLISH_MESSAGE,
|
|
202
|
+
{
|
|
203
|
+
"topic_path": topic_id,
|
|
204
|
+
"message_type": MessageType.TEXT,
|
|
205
|
+
"content": {
|
|
206
|
+
"message_type": MessageType.TEXT,
|
|
207
|
+
"text": text,
|
|
208
|
+
"encoding": "utf-8",
|
|
209
|
+
},
|
|
210
|
+
"qos": Qos.BEST_EFFORT,
|
|
211
|
+
},
|
|
212
|
+
issuer_id=node_id,
|
|
213
|
+
)
|
|
214
|
+
message_id = command.command_id.hex
|
|
215
|
+
self._pending_text[message_id] = (topic_id, text)
|
|
216
|
+
result = _run_command(self.core, command)
|
|
217
|
+
if result.status != CommandStatus.ACCEPTED:
|
|
218
|
+
self._pending_text.pop(message_id, None)
|
|
219
|
+
|
|
220
|
+
def _publish_telemetry(
|
|
221
|
+
self, node_id: Optional[str], topic_id: str, payload: dict
|
|
222
|
+
) -> None:
|
|
223
|
+
if not node_id:
|
|
224
|
+
return
|
|
225
|
+
telemetry_type = payload.get("telemetry_type")
|
|
226
|
+
command = _build_command(
|
|
227
|
+
CommandType.PUBLISH_MESSAGE,
|
|
228
|
+
{
|
|
229
|
+
"topic_path": topic_id,
|
|
230
|
+
"message_type": MessageType.TELEMETRY,
|
|
231
|
+
"content": {
|
|
232
|
+
"message_type": MessageType.TELEMETRY,
|
|
233
|
+
"telemetry_type": telemetry_type if isinstance(telemetry_type, str) else None,
|
|
234
|
+
"data": payload,
|
|
235
|
+
},
|
|
236
|
+
"qos": Qos.BEST_EFFORT,
|
|
237
|
+
},
|
|
238
|
+
issuer_id=node_id,
|
|
239
|
+
)
|
|
240
|
+
_run_command(self.core, command)
|
|
241
|
+
|
|
242
|
+
async def _handle_event(self, event: EventEnvelope) -> None:
|
|
243
|
+
"""Fan out message events to LXMF recipients."""
|
|
244
|
+
|
|
245
|
+
if event.event_type != EventType.MESSAGE_PUBLISHED:
|
|
246
|
+
return
|
|
247
|
+
payload = event.payload
|
|
248
|
+
message_id = getattr(payload, "message_id", "")
|
|
249
|
+
pending = self._pending_text.pop(message_id, None)
|
|
250
|
+
if not pending:
|
|
251
|
+
return
|
|
252
|
+
topic_id, text = pending
|
|
253
|
+
message_text = f"[topic:{topic_id}]\n{text}"
|
|
254
|
+
for subscriber_id in self.core.get_subscriber_ids(topic_id):
|
|
255
|
+
self._send_message(message_text, subscriber_id)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _run_command(core: InternalApiCore, command: CommandEnvelope):
|
|
259
|
+
return asyncio.run(core.handle_command(command))
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _build_command(
|
|
263
|
+
command_type: CommandType, payload: dict, *, issuer_id: str
|
|
264
|
+
) -> CommandEnvelope:
|
|
265
|
+
command = CommandEnvelope.model_validate(
|
|
266
|
+
{
|
|
267
|
+
"api_version": select_api_version(),
|
|
268
|
+
"command_id": uuid4(),
|
|
269
|
+
"command_type": command_type,
|
|
270
|
+
"issued_at": datetime.now(timezone.utc),
|
|
271
|
+
"issuer": {"type": IssuerType.RETICULUM, "id": issuer_id},
|
|
272
|
+
"payload": payload,
|
|
273
|
+
}
|
|
274
|
+
)
|
|
275
|
+
_LOGGER.debug(
|
|
276
|
+
"Reticulum adapter command built",
|
|
277
|
+
extra={
|
|
278
|
+
"command_id": str(command.command_id),
|
|
279
|
+
"command_type": command.command_type.value,
|
|
280
|
+
"correlation_id": str(command.command_id),
|
|
281
|
+
},
|
|
282
|
+
)
|
|
283
|
+
return command
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _copy_event(event: EventEnvelope) -> EventEnvelope:
|
|
287
|
+
copy_method = getattr(event, "model_copy", None)
|
|
288
|
+
if callable(copy_method):
|
|
289
|
+
return copy_method(deep=True)
|
|
290
|
+
return copy.deepcopy(event)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _command_name(command: dict) -> Optional[str]:
|
|
294
|
+
value = command.get("Command") or command.get("command")
|
|
295
|
+
if isinstance(value, str):
|
|
296
|
+
return value.strip()
|
|
297
|
+
plugin_value = command.get(PLUGIN_COMMAND) or command.get(str(PLUGIN_COMMAND))
|
|
298
|
+
if isinstance(plugin_value, str):
|
|
299
|
+
return plugin_value.strip()
|
|
300
|
+
return None
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _extract_topic_id(command: dict) -> Optional[str]:
|
|
304
|
+
for key in ("TopicID", "topic_id", "topic", "Topic"):
|
|
305
|
+
value = command.get(key)
|
|
306
|
+
if value:
|
|
307
|
+
return str(value)
|
|
308
|
+
return None
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _extract_telemetry_payloads(fields: dict) -> list[dict]:
|
|
312
|
+
payloads: list[dict] = []
|
|
313
|
+
if not isinstance(fields, dict):
|
|
314
|
+
return payloads
|
|
315
|
+
if LXMF.FIELD_TELEMETRY in fields:
|
|
316
|
+
raw = fields.get(LXMF.FIELD_TELEMETRY)
|
|
317
|
+
payload = _decode_telemetry_payload(raw)
|
|
318
|
+
if isinstance(payload, dict):
|
|
319
|
+
payloads.append(payload)
|
|
320
|
+
if LXMF.FIELD_TELEMETRY_STREAM in fields:
|
|
321
|
+
raw_stream = fields.get(LXMF.FIELD_TELEMETRY_STREAM)
|
|
322
|
+
entries = _decode_telemetry_stream(raw_stream)
|
|
323
|
+
for payload in entries:
|
|
324
|
+
if isinstance(payload, dict):
|
|
325
|
+
payloads.append(payload)
|
|
326
|
+
return payloads
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _decode_telemetry_payload(raw) -> Optional[dict]:
|
|
330
|
+
if isinstance(raw, (bytes, bytearray)):
|
|
331
|
+
try:
|
|
332
|
+
decoded = unpackb(raw, strict_map_key=False)
|
|
333
|
+
except Exception:
|
|
334
|
+
return None
|
|
335
|
+
return decoded if isinstance(decoded, dict) else None
|
|
336
|
+
if isinstance(raw, dict):
|
|
337
|
+
return raw
|
|
338
|
+
return None
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _decode_telemetry_stream(raw) -> list[dict]:
|
|
342
|
+
if isinstance(raw, (bytes, bytearray)):
|
|
343
|
+
try:
|
|
344
|
+
raw = unpackb(raw, strict_map_key=False)
|
|
345
|
+
except Exception:
|
|
346
|
+
return []
|
|
347
|
+
if not isinstance(raw, (list, tuple)):
|
|
348
|
+
return []
|
|
349
|
+
payloads: list[dict] = []
|
|
350
|
+
for entry in raw:
|
|
351
|
+
if not isinstance(entry, (list, tuple)) or len(entry) < 3:
|
|
352
|
+
continue
|
|
353
|
+
payload = entry[2]
|
|
354
|
+
if isinstance(payload, (bytes, bytearray)):
|
|
355
|
+
payload = _decode_telemetry_payload(payload)
|
|
356
|
+
if isinstance(payload, dict):
|
|
357
|
+
payloads.append(payload)
|
|
358
|
+
return payloads
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
import time
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from queue import Empty, Full, Queue
|
|
5
|
+
|
|
6
|
+
import LXMF
|
|
7
|
+
import RNS
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class OutboundPayload:
|
|
12
|
+
"""
|
|
13
|
+
Message payload scheduled for outbound delivery.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
connection (RNS.Destination): Destination to deliver the message to.
|
|
17
|
+
message_text (str): Plaintext message body to deliver.
|
|
18
|
+
destination_hash (bytes | None): Raw destination hash for diagnostics.
|
|
19
|
+
destination_hex (str | None): Hex-encoded destination hash for logging.
|
|
20
|
+
fields (dict | None): Optional LXMF fields to include with the message.
|
|
21
|
+
attempts (int): Number of delivery attempts performed.
|
|
22
|
+
next_attempt_at (float): Monotonic timestamp before the next attempt.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
connection: RNS.Destination
|
|
26
|
+
message_text: str
|
|
27
|
+
destination_hash: bytes | None
|
|
28
|
+
destination_hex: str | None
|
|
29
|
+
fields: dict | None = None
|
|
30
|
+
attempts: int = 0
|
|
31
|
+
next_attempt_at: float = field(default_factory=time.monotonic)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class OutboundMessageQueue:
|
|
35
|
+
"""
|
|
36
|
+
Threaded dispatcher that delivers LXMF messages without blocking callers.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
lxm_router: LXMF.LXMRouter,
|
|
42
|
+
sender: RNS.Destination,
|
|
43
|
+
*,
|
|
44
|
+
queue_size: int = 64,
|
|
45
|
+
worker_count: int = 2,
|
|
46
|
+
send_timeout: float = 5.0,
|
|
47
|
+
backoff_seconds: float = 0.5,
|
|
48
|
+
max_attempts: int = 3,
|
|
49
|
+
):
|
|
50
|
+
"""
|
|
51
|
+
Initialize a bounded outbound queue.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
lxm_router (LXMF.LXMRouter): Router responsible for delivery.
|
|
55
|
+
sender (RNS.Destination): Sender identity to attach to messages.
|
|
56
|
+
queue_size (int): Maximum queued payloads before applying backpressure.
|
|
57
|
+
worker_count (int): Parallel worker threads processing the queue.
|
|
58
|
+
send_timeout (float): Seconds to wait for a send before timing out.
|
|
59
|
+
backoff_seconds (float): Base delay between retry attempts.
|
|
60
|
+
max_attempts (int): Maximum attempts before dropping a message.
|
|
61
|
+
"""
|
|
62
|
+
self._lxm_router = lxm_router
|
|
63
|
+
self._sender = sender
|
|
64
|
+
self._queue: Queue[OutboundPayload] = Queue(maxsize=max(queue_size, 1))
|
|
65
|
+
self._worker_count = max(worker_count, 1)
|
|
66
|
+
self._send_timeout = max(send_timeout, 0.1)
|
|
67
|
+
self._backoff_seconds = max(backoff_seconds, 0.01)
|
|
68
|
+
self._max_attempts = max(max_attempts, 1)
|
|
69
|
+
self._stop_event = threading.Event()
|
|
70
|
+
self._workers: list[threading.Thread] = []
|
|
71
|
+
self._inflight = 0
|
|
72
|
+
self._inflight_lock = threading.Lock()
|
|
73
|
+
|
|
74
|
+
def start(self) -> None:
|
|
75
|
+
"""Start background worker threads if they are not already running."""
|
|
76
|
+
|
|
77
|
+
if self._workers:
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
self._stop_event.clear()
|
|
81
|
+
for index in range(self._worker_count):
|
|
82
|
+
worker = threading.Thread(
|
|
83
|
+
target=self._worker_loop,
|
|
84
|
+
name=f"lxmf-outbound-{index}",
|
|
85
|
+
daemon=True,
|
|
86
|
+
)
|
|
87
|
+
worker.start()
|
|
88
|
+
self._workers.append(worker)
|
|
89
|
+
|
|
90
|
+
def stop(self) -> None:
|
|
91
|
+
"""Signal workers to stop and wait for them to exit."""
|
|
92
|
+
|
|
93
|
+
if not self._workers:
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
self._stop_event.set()
|
|
97
|
+
for worker in self._workers:
|
|
98
|
+
worker.join(timeout=0.5)
|
|
99
|
+
self._workers.clear()
|
|
100
|
+
|
|
101
|
+
def queue_message(
|
|
102
|
+
self,
|
|
103
|
+
connection: RNS.Destination,
|
|
104
|
+
message_text: str,
|
|
105
|
+
destination_hash: bytes | None,
|
|
106
|
+
destination_hex: str | None,
|
|
107
|
+
fields: dict | None = None,
|
|
108
|
+
) -> bool:
|
|
109
|
+
"""
|
|
110
|
+
Enqueue a message for delivery.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
connection (RNS.Destination): Destination to deliver the message to.
|
|
114
|
+
message_text (str): Plaintext message body to deliver.
|
|
115
|
+
destination_hash (bytes | None): Raw destination hash for diagnostics.
|
|
116
|
+
destination_hex (str | None): Hex-encoded destination hash for logging.
|
|
117
|
+
fields (dict | None): Optional LXMF message fields.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
bool: ``True`` when the message was queued successfully.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
payload = OutboundPayload(
|
|
124
|
+
connection=connection,
|
|
125
|
+
message_text=message_text,
|
|
126
|
+
destination_hash=destination_hash,
|
|
127
|
+
destination_hex=destination_hex,
|
|
128
|
+
fields=fields,
|
|
129
|
+
)
|
|
130
|
+
return self._enqueue_payload(payload)
|
|
131
|
+
|
|
132
|
+
def wait_for_flush(self, timeout: float = 1.0) -> bool:
|
|
133
|
+
"""
|
|
134
|
+
Wait until the queue and inflight sends complete.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
timeout (float): Seconds to wait before giving up.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
bool: ``True`` when the queue drained before the timeout elapsed.
|
|
141
|
+
"""
|
|
142
|
+
deadline = time.monotonic() + timeout
|
|
143
|
+
while time.monotonic() < deadline:
|
|
144
|
+
with self._inflight_lock:
|
|
145
|
+
inflight = self._inflight
|
|
146
|
+
if self._queue.empty() and inflight == 0:
|
|
147
|
+
return True
|
|
148
|
+
time.sleep(0.01)
|
|
149
|
+
return False
|
|
150
|
+
|
|
151
|
+
def _enqueue_payload(self, payload: OutboundPayload) -> bool:
|
|
152
|
+
try:
|
|
153
|
+
self._queue.put_nowait(payload)
|
|
154
|
+
return True
|
|
155
|
+
except Full:
|
|
156
|
+
dropped = self._drop_oldest()
|
|
157
|
+
if dropped is not None:
|
|
158
|
+
RNS.log(
|
|
159
|
+
(
|
|
160
|
+
"Outbound queue is full; dropped oldest payload destined for"
|
|
161
|
+
f" {dropped.destination_hex or 'unknown destination'}"
|
|
162
|
+
),
|
|
163
|
+
getattr(RNS, "LOG_WARNING", 2),
|
|
164
|
+
)
|
|
165
|
+
try:
|
|
166
|
+
self._queue.put_nowait(payload)
|
|
167
|
+
return True
|
|
168
|
+
except Full:
|
|
169
|
+
RNS.log(
|
|
170
|
+
(
|
|
171
|
+
"Outbound queue is saturated; dropping payload destined for"
|
|
172
|
+
f" {payload.destination_hex or 'unknown destination'}"
|
|
173
|
+
),
|
|
174
|
+
getattr(RNS, "LOG_WARNING", 2),
|
|
175
|
+
)
|
|
176
|
+
return False
|
|
177
|
+
|
|
178
|
+
def _drop_oldest(self) -> OutboundPayload | None:
|
|
179
|
+
try:
|
|
180
|
+
oldest = self._queue.get_nowait()
|
|
181
|
+
self._queue.task_done()
|
|
182
|
+
return oldest
|
|
183
|
+
except Empty:
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
def _worker_loop(self) -> None:
|
|
187
|
+
while not self._stop_event.is_set() or not self._queue.empty():
|
|
188
|
+
try:
|
|
189
|
+
payload = self._queue.get(timeout=0.1)
|
|
190
|
+
except Empty:
|
|
191
|
+
continue
|
|
192
|
+
|
|
193
|
+
now = time.monotonic()
|
|
194
|
+
if payload.next_attempt_at > now:
|
|
195
|
+
delay = min(payload.next_attempt_at - now, 0.1)
|
|
196
|
+
time.sleep(delay)
|
|
197
|
+
self._enqueue_payload(payload)
|
|
198
|
+
self._queue.task_done()
|
|
199
|
+
continue
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
self._process_payload(payload)
|
|
203
|
+
finally:
|
|
204
|
+
self._queue.task_done()
|
|
205
|
+
|
|
206
|
+
def _process_payload(self, payload: OutboundPayload) -> None:
|
|
207
|
+
with self._inflight_lock:
|
|
208
|
+
self._inflight += 1
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
success = self._send_with_timeout(payload)
|
|
212
|
+
if not success:
|
|
213
|
+
self._handle_failure(payload)
|
|
214
|
+
finally:
|
|
215
|
+
with self._inflight_lock:
|
|
216
|
+
self._inflight -= 1
|
|
217
|
+
|
|
218
|
+
def _send_with_timeout(self, payload: OutboundPayload) -> bool:
|
|
219
|
+
error: list[Exception | None] = [None]
|
|
220
|
+
|
|
221
|
+
def _send() -> None:
|
|
222
|
+
try:
|
|
223
|
+
message = LXMF.LXMessage(
|
|
224
|
+
payload.connection,
|
|
225
|
+
self._sender,
|
|
226
|
+
payload.message_text,
|
|
227
|
+
fields=payload.fields or {},
|
|
228
|
+
desired_method=LXMF.LXMessage.DIRECT,
|
|
229
|
+
)
|
|
230
|
+
if payload.destination_hash:
|
|
231
|
+
message.destination_hash = payload.destination_hash
|
|
232
|
+
self._lxm_router.handle_outbound(message)
|
|
233
|
+
except Exception as exc: # pragma: no cover - defensive logging
|
|
234
|
+
error[0] = exc
|
|
235
|
+
|
|
236
|
+
sender = threading.Thread(target=_send, name="lxmf-send", daemon=True)
|
|
237
|
+
sender.start()
|
|
238
|
+
sender.join(timeout=self._send_timeout)
|
|
239
|
+
if sender.is_alive():
|
|
240
|
+
RNS.log(
|
|
241
|
+
(
|
|
242
|
+
"Timed out delivering outbound message to"
|
|
243
|
+
f" {payload.destination_hex or 'unknown destination'}"
|
|
244
|
+
),
|
|
245
|
+
getattr(RNS, "LOG_WARNING", 2),
|
|
246
|
+
)
|
|
247
|
+
return False
|
|
248
|
+
|
|
249
|
+
if error[0] is not None:
|
|
250
|
+
RNS.log(
|
|
251
|
+
(
|
|
252
|
+
"Failed to deliver outbound message to"
|
|
253
|
+
f" {payload.destination_hex or 'unknown destination'}: {error[0]}"
|
|
254
|
+
),
|
|
255
|
+
getattr(RNS, "LOG_WARNING", 2),
|
|
256
|
+
)
|
|
257
|
+
return False
|
|
258
|
+
return True
|
|
259
|
+
|
|
260
|
+
def _handle_failure(self, payload: OutboundPayload) -> None:
|
|
261
|
+
payload.attempts += 1
|
|
262
|
+
if payload.attempts >= self._max_attempts:
|
|
263
|
+
RNS.log(
|
|
264
|
+
(
|
|
265
|
+
"Dropping outbound message to"
|
|
266
|
+
f" {payload.destination_hex or 'unknown destination'} after"
|
|
267
|
+
f" {payload.attempts} attempts"
|
|
268
|
+
),
|
|
269
|
+
getattr(RNS, "LOG_WARNING", 2),
|
|
270
|
+
)
|
|
271
|
+
self._propagate_failed_message(payload)
|
|
272
|
+
return
|
|
273
|
+
|
|
274
|
+
payload.next_attempt_at = (
|
|
275
|
+
time.monotonic() + self._backoff_seconds * payload.attempts
|
|
276
|
+
)
|
|
277
|
+
# Reason: requeue with backoff so slower destinations do not halt others.
|
|
278
|
+
self._enqueue_payload(payload)
|
|
279
|
+
|
|
280
|
+
def _propagate_failed_message(self, payload: OutboundPayload) -> None:
|
|
281
|
+
if not getattr(self._lxm_router, "propagation_node", False):
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
try:
|
|
285
|
+
message = LXMF.LXMessage(
|
|
286
|
+
payload.connection,
|
|
287
|
+
self._sender,
|
|
288
|
+
payload.message_text,
|
|
289
|
+
fields=payload.fields or {},
|
|
290
|
+
desired_method=LXMF.LXMessage.DIRECT,
|
|
291
|
+
)
|
|
292
|
+
if payload.destination_hash:
|
|
293
|
+
message.destination_hash = payload.destination_hash
|
|
294
|
+
message.pack()
|
|
295
|
+
if not message.packed:
|
|
296
|
+
return
|
|
297
|
+
self._lxm_router.lxmf_propagation(message.packed)
|
|
298
|
+
RNS.log(
|
|
299
|
+
(
|
|
300
|
+
"Stored failed outbound message for propagation to"
|
|
301
|
+
f" {payload.destination_hex or 'unknown destination'}"
|
|
302
|
+
),
|
|
303
|
+
getattr(RNS, "LOG_INFO", 4),
|
|
304
|
+
)
|
|
305
|
+
except Exception as exc: # pragma: no cover - defensive logging
|
|
306
|
+
RNS.log(
|
|
307
|
+
(
|
|
308
|
+
"Failed to store outbound message for propagation to"
|
|
309
|
+
f" {payload.destination_hex or 'unknown destination'}: {exc}"
|
|
310
|
+
),
|
|
311
|
+
getattr(RNS, "LOG_WARNING", 2),
|
|
312
|
+
)
|