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,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
+ )