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,2237 @@
1
+ """
2
+ Reticulum Telemetry Hub (RTH)
3
+ ================================
4
+
5
+ This module provides the CLI entry point that launches the Reticulum Telemetry
6
+ Hub process. The hub brings together several components:
7
+
8
+ * ``TelemetryController`` persists telemetry streams and handles inbound command
9
+ requests arriving over LXMF.
10
+ * ``CommandManager`` implements the Reticulum plugin command vocabulary
11
+ (join/leave/telemetry etc.) and publishes the appropriate LXMF responses.
12
+ * ``AnnounceHandler`` subscribes to Reticulum announcements so the hub can keep
13
+ a lightweight directory of peers.
14
+ * ``ReticulumTelemetryHub`` wires the Reticulum stack, LXMF router and local
15
+ identity together, runs headlessly, and relays messages between connected
16
+ peers.
17
+
18
+ Running the script directly allows operators to:
19
+
20
+ * Generate or load a persistent Reticulum identity stored under ``STORAGE_PATH``.
21
+ * Announce the LXMF delivery destination on a fixed interval (headless only).
22
+ * Inspect/log inbound messages and fan them out to connected peers.
23
+
24
+ Use ``python -m reticulum_telemetry_hub.reticulum_server`` to start the hub.
25
+ Command line arguments let you override the storage path, choose a display name,
26
+ or run in headless mode for unattended deployments.
27
+ """
28
+
29
+ import argparse
30
+ import asyncio
31
+ import base64
32
+ import binascii
33
+ import json
34
+ import mimetypes
35
+ import re
36
+ import string
37
+ import time
38
+ import threading
39
+ import uuid
40
+ from datetime import datetime, timezone
41
+ from pathlib import Path
42
+ from typing import Any
43
+ from typing import Callable
44
+ from typing import cast
45
+
46
+ import LXMF
47
+ import RNS
48
+
49
+ from reticulum_telemetry_hub.api.models import ChatMessage
50
+ from reticulum_telemetry_hub.api.models import FileAttachment
51
+ from reticulum_telemetry_hub.api.service import ReticulumTelemetryHubAPI
52
+ from reticulum_telemetry_hub.config.manager import HubConfigurationManager
53
+ from reticulum_telemetry_hub.config.manager import _expand_user_path
54
+ from reticulum_telemetry_hub.embedded_lxmd import EmbeddedLxmd
55
+ from reticulum_telemetry_hub.lxmf_daemon.LXMF import display_name_from_app_data
56
+ from reticulum_telemetry_hub.atak_cot.tak_connector import TakConnector
57
+ from reticulum_telemetry_hub.lxmf_telemetry.telemetry_controller import (
58
+ TelemetryController,
59
+ )
60
+ from reticulum_telemetry_hub.lxmf_telemetry.sampler import TelemetrySampler
61
+ from reticulum_telemetry_hub.lxmf_telemetry.telemeter_manager import TelemeterManager
62
+ from reticulum_telemetry_hub.reticulum_server.services import (
63
+ SERVICE_FACTORIES,
64
+ HubService,
65
+ )
66
+ from reticulum_telemetry_hub.reticulum_server.constants import PLUGIN_COMMAND
67
+ from reticulum_telemetry_hub.reticulum_server.outbound_queue import (
68
+ OutboundMessageQueue,
69
+ )
70
+ from reticulum_telemetry_hub.reticulum_server.event_log import EventLog
71
+ from reticulum_telemetry_hub.reticulum_server.event_log import resolve_event_log_path
72
+ from reticulum_telemetry_hub.reticulum_server.internal_adapter import LxmfInbound
73
+ from reticulum_telemetry_hub.reticulum_server.internal_adapter import ReticulumInternalAdapter
74
+ from .command_manager import CommandManager
75
+ from reticulum_telemetry_hub.config.constants import (
76
+ DEFAULT_ANNOUNCE_INTERVAL,
77
+ DEFAULT_HUB_TELEMETRY_INTERVAL,
78
+ DEFAULT_LOG_LEVEL_NAME,
79
+ DEFAULT_SERVICE_TELEMETRY_INTERVAL,
80
+ DEFAULT_STORAGE_PATH,
81
+ )
82
+
83
+
84
+ def _utcnow() -> datetime:
85
+ return datetime.now(timezone.utc).replace(tzinfo=None)
86
+
87
+
88
+ # Constants
89
+ STORAGE_PATH = DEFAULT_STORAGE_PATH # Path to store temporary files
90
+ APP_NAME = LXMF.APP_NAME + ".delivery" # Application name for LXMF
91
+ DEFAULT_LOG_LEVEL = getattr(RNS, "LOG_DEBUG", getattr(RNS, "LOG_INFO", 3))
92
+ LOG_LEVELS = {
93
+ "error": getattr(RNS, "LOG_ERROR", 1),
94
+ "warning": getattr(RNS, "LOG_WARNING", 2),
95
+ "info": getattr(RNS, "LOG_INFO", 3),
96
+ "debug": getattr(RNS, "LOG_DEBUG", DEFAULT_LOG_LEVEL),
97
+ }
98
+ TOPIC_REGISTRY_TTL_SECONDS = 5
99
+ ESCAPED_COMMAND_PREFIX = "\\\\\\"
100
+ DEFAULT_OUTBOUND_QUEUE_SIZE = 64
101
+ DEFAULT_OUTBOUND_WORKERS = 2
102
+ DEFAULT_OUTBOUND_SEND_TIMEOUT = 5.0
103
+ DEFAULT_OUTBOUND_BACKOFF = 0.5
104
+ DEFAULT_OUTBOUND_MAX_ATTEMPTS = 3
105
+
106
+
107
+ def _resolve_interval(value: int | None, fallback: int) -> int:
108
+ """Return the positive interval derived from CLI/config values."""
109
+
110
+ if value is not None:
111
+ return max(0, int(value))
112
+
113
+ return max(0, int(fallback))
114
+
115
+
116
+ def _dispatch_coroutine(coroutine) -> None:
117
+ """Execute ``coroutine`` on the active event loop or create one if needed.
118
+
119
+ Args:
120
+ coroutine: Awaitable object to schedule or run synchronously.
121
+ """
122
+
123
+ try:
124
+ loop = asyncio.get_running_loop()
125
+ except RuntimeError:
126
+ asyncio.run(coroutine)
127
+ return
128
+
129
+ loop.create_task(coroutine)
130
+
131
+
132
+ class AnnounceHandler:
133
+ """Track simple metadata about peers announcing on the Reticulum bus."""
134
+
135
+ def __init__(
136
+ self,
137
+ identities: dict[str, str],
138
+ api: ReticulumTelemetryHubAPI | None = None,
139
+ ):
140
+ self.aspect_filter = APP_NAME
141
+ self.identities = identities
142
+ self._api = api
143
+
144
+ def received_announce(self, destination_hash, announced_identity, app_data):
145
+ # RNS.log("\t+--- LXMF Announcement -----------------------------------------")
146
+ # RNS.log(f"\t| Source hash : {RNS.prettyhexrep(destination_hash)}")
147
+ # RNS.log(f"\t| Announced identity : {announced_identity}")
148
+ # RNS.log(f"\t| App data : {app_data}")
149
+ # RNS.log("\t+---------------------------------------------------------------")
150
+ label = self._decode_app_data(app_data)
151
+ hash_keys = []
152
+ destination_key = self._normalize_hash(destination_hash)
153
+ if destination_key:
154
+ hash_keys.append(destination_key)
155
+ identity_key = self._normalize_hash(announced_identity)
156
+ if identity_key and identity_key not in hash_keys:
157
+ hash_keys.append(identity_key)
158
+ if label:
159
+ for key in hash_keys:
160
+ self.identities[key] = label
161
+ for key in hash_keys:
162
+ self._persist_announce_async(key, label)
163
+
164
+ @staticmethod
165
+ def _normalize_hash(value) -> str | None:
166
+ if value is None:
167
+ return None
168
+ if isinstance(value, (bytes, bytearray, memoryview)):
169
+ return bytes(value).hex().lower()
170
+ hash_value = getattr(value, "hash", None)
171
+ if isinstance(hash_value, (bytes, bytearray, memoryview)):
172
+ return bytes(hash_value).hex().lower()
173
+ if isinstance(value, str):
174
+ candidate = value.strip().lower()
175
+ if candidate and all(ch in string.hexdigits for ch in candidate):
176
+ return candidate
177
+ return None
178
+
179
+ @staticmethod
180
+ def _decode_app_data(app_data) -> str | None:
181
+ if app_data is None:
182
+ return None
183
+
184
+ if isinstance(app_data, (bytes, bytearray)):
185
+ try:
186
+ display_name = display_name_from_app_data(bytes(app_data))
187
+ except Exception:
188
+ display_name = None
189
+
190
+ if display_name:
191
+ display_name = display_name.strip()
192
+ return display_name or None
193
+
194
+ return None
195
+
196
+ def _persist_announce_async(
197
+ self, destination_hash: str, display_name: str | None
198
+ ) -> None:
199
+ api = self._api
200
+ if api is None:
201
+ return
202
+
203
+ def _persist() -> None:
204
+ try:
205
+ api.record_identity_announce(
206
+ destination_hash,
207
+ display_name=display_name,
208
+ )
209
+ except Exception as exc: # pragma: no cover - defensive log
210
+ RNS.log(
211
+ f"Failed to persist announce metadata for {destination_hash}: {exc}",
212
+ getattr(RNS, "LOG_WARNING", 2),
213
+ )
214
+
215
+ thread = threading.Thread(target=_persist, daemon=True)
216
+ thread.start()
217
+
218
+
219
+ class ReticulumTelemetryHub:
220
+ """Runtime container that glues Reticulum, LXMF and telemetry services.
221
+
222
+ The hub owns the Reticulum stack, LXMF router, telemetry persistence layer
223
+ and connection bookkeeping. It runs headlessly and periodically announces
224
+ its delivery identity.
225
+ """
226
+
227
+ lxm_router: LXMF.LXMRouter
228
+ connections: dict[bytes, RNS.Destination]
229
+ identities: dict[str, str]
230
+ my_lxmf_dest: RNS.Destination | None
231
+ ret: RNS.Reticulum
232
+ storage_path: Path
233
+ identity_path: Path
234
+ tel_controller: TelemetryController
235
+ config_manager: HubConfigurationManager | None
236
+ embedded_lxmd: EmbeddedLxmd | None
237
+ _shared_lxm_router: LXMF.LXMRouter | None = None
238
+ telemetry_sampler: TelemetrySampler | None
239
+ telemeter_manager: TelemeterManager | None
240
+ tak_connector: TakConnector | None
241
+ _active_services: dict[str, HubService]
242
+
243
+ TELEMETRY_PLACEHOLDERS = {"telemetry data", "telemetry update"}
244
+
245
+ @staticmethod
246
+ def _get_router_callable(
247
+ router: LXMF.LXMRouter, attribute: str
248
+ ) -> Callable[..., Any]:
249
+ """
250
+ Return a callable attribute from the LXMF router.
251
+
252
+ Args:
253
+ router (LXMF.LXMRouter): Router exposing LXMF hooks.
254
+ attribute (str): Name of the required callable attribute.
255
+
256
+ Returns:
257
+ Callable[..., Any]: Router hook matching ``attribute``.
258
+
259
+ Raises:
260
+ AttributeError: When the attribute is missing or not callable.
261
+ """
262
+
263
+ hook = getattr(router, attribute, None)
264
+ if not callable(hook):
265
+ msg = f"LXMF router is missing required callable '{attribute}'"
266
+ raise AttributeError(msg)
267
+ return cast(Callable[..., Any], hook)
268
+
269
+ def _invoke_router_hook(self, attribute: str, *args: Any, **kwargs: Any) -> Any:
270
+ """
271
+ Invoke a callable hook on the LXMF router.
272
+
273
+ Args:
274
+ attribute (str): Name of the callable attribute to invoke.
275
+ *args: Positional arguments forwarded to the callable.
276
+ **kwargs: Keyword arguments forwarded to the callable.
277
+
278
+ Returns:
279
+ Any: Response from the invoked callable.
280
+ """
281
+
282
+ router_callable = self._get_router_callable(self.lxm_router, attribute)
283
+ return router_callable(*args, **kwargs)
284
+
285
+ def __init__(
286
+ self,
287
+ display_name: str,
288
+ storage_path: Path,
289
+ identity_path: Path,
290
+ *,
291
+ embedded: bool = False,
292
+ announce_interval: int = DEFAULT_ANNOUNCE_INTERVAL,
293
+ loglevel: int = DEFAULT_LOG_LEVEL,
294
+ hub_telemetry_interval: float | None = DEFAULT_HUB_TELEMETRY_INTERVAL,
295
+ service_telemetry_interval: float | None = DEFAULT_SERVICE_TELEMETRY_INTERVAL,
296
+ config_manager: HubConfigurationManager | None = None,
297
+ config_path: Path | None = None,
298
+ outbound_queue_size: int = DEFAULT_OUTBOUND_QUEUE_SIZE,
299
+ outbound_workers: int = DEFAULT_OUTBOUND_WORKERS,
300
+ outbound_send_timeout: float = DEFAULT_OUTBOUND_SEND_TIMEOUT,
301
+ outbound_backoff: float = DEFAULT_OUTBOUND_BACKOFF,
302
+ outbound_max_attempts: int = DEFAULT_OUTBOUND_MAX_ATTEMPTS,
303
+ ):
304
+ """Initialize the telemetry hub runtime container.
305
+
306
+ Args:
307
+ display_name (str): Label announced with the LXMF destination.
308
+ storage_path (Path): Directory containing hub storage files.
309
+ identity_path (Path): Path to the persisted LXMF identity.
310
+ embedded (bool): Whether to run the LXMF router threads in-process.
311
+ announce_interval (int): Seconds between LXMF announces.
312
+ loglevel (int): RNS log level to emit.
313
+ hub_telemetry_interval (float | None): Interval for local telemetry sampling.
314
+ service_telemetry_interval (float | None): Interval for remote service sampling.
315
+ config_manager (HubConfigurationManager | None): Optional preloaded configuration manager.
316
+ config_path (Path | None): Path to ``config.ini`` when creating a manager internally.
317
+ outbound_queue_size (int): Maximum queued outbound LXMF payloads before applying backpressure.
318
+ outbound_workers (int): Number of outbound worker threads to spin up.
319
+ outbound_send_timeout (float): Seconds to wait before timing out a send attempt.
320
+ outbound_backoff (float): Base number of seconds to wait between retry attempts.
321
+ outbound_max_attempts (int): Number of attempts before an outbound message is dropped.
322
+ """
323
+ # Normalize paths early so downstream helpers can rely on Path objects.
324
+ self.storage_path = Path(storage_path)
325
+ self.identity_path = Path(identity_path)
326
+ self.storage_path.mkdir(parents=True, exist_ok=True)
327
+ self.identity_path.parent.mkdir(parents=True, exist_ok=True)
328
+ self.announce_interval = announce_interval
329
+ self.hub_telemetry_interval = hub_telemetry_interval
330
+ self.service_telemetry_interval = service_telemetry_interval
331
+ self.loglevel = loglevel
332
+ self.outbound_queue_size = outbound_queue_size
333
+ self.outbound_workers = outbound_workers
334
+ self.outbound_send_timeout = outbound_send_timeout
335
+ self.outbound_backoff = outbound_backoff
336
+ self.outbound_max_attempts = outbound_max_attempts
337
+
338
+ # Reuse an existing Reticulum instance when running in-process tests
339
+ # to avoid triggering the single-instance guard in the RNS library.
340
+ existing_reticulum = RNS.Reticulum.get_instance()
341
+ if existing_reticulum is not None:
342
+ self.ret = existing_reticulum
343
+ RNS.loglevel = self.loglevel
344
+ else:
345
+ self.ret = RNS.Reticulum(loglevel=self.loglevel)
346
+ RNS.loglevel = self.loglevel
347
+
348
+ telemetry_db_path = self.storage_path / "telemetry.db"
349
+ event_log_path = resolve_event_log_path(self.storage_path)
350
+ self.event_log = EventLog(event_path=event_log_path)
351
+ self.tel_controller = TelemetryController(
352
+ db_path=telemetry_db_path,
353
+ event_log=self.event_log,
354
+ )
355
+ self._message_listeners: list[Callable[[dict[str, object]], None]] = []
356
+ self.config_manager: HubConfigurationManager | None = config_manager
357
+ self.embedded_lxmd: EmbeddedLxmd | None = None
358
+ self.telemetry_sampler: TelemetrySampler | None = None
359
+ self.telemeter_manager: TelemeterManager | None = None
360
+ self._shutdown = False
361
+ self.connections: dict[bytes, RNS.Destination] = {}
362
+ self._daemon_started = False
363
+ self._active_services = {}
364
+ self._outbound_queue: OutboundMessageQueue | None = None
365
+
366
+ identity = self.load_or_generate_identity(self.identity_path)
367
+
368
+ if ReticulumTelemetryHub._shared_lxm_router is None:
369
+ ReticulumTelemetryHub._shared_lxm_router = LXMF.LXMRouter(
370
+ storagepath=str(self.storage_path)
371
+ )
372
+ shared_router = ReticulumTelemetryHub._shared_lxm_router
373
+ if shared_router is None:
374
+ msg = "Shared LXMF router failed to initialize"
375
+ raise RuntimeError(msg)
376
+
377
+ self.lxm_router = cast(LXMF.LXMRouter, shared_router)
378
+
379
+ self.my_lxmf_dest = self._invoke_router_hook(
380
+ "register_delivery_identity", identity, display_name=display_name
381
+ )
382
+
383
+ self.identities: dict[str, str] = {}
384
+
385
+ self._invoke_router_hook("set_message_storage_limit", megabytes=5)
386
+ self._invoke_router_hook("register_delivery_callback", self.delivery_callback)
387
+
388
+ if self.config_manager is None:
389
+ self.config_manager = HubConfigurationManager(
390
+ storage_path=self.storage_path, config_path=config_path
391
+ )
392
+
393
+ self.embedded_lxmd = None
394
+ if embedded:
395
+ self.embedded_lxmd = EmbeddedLxmd(
396
+ router=self.lxm_router,
397
+ destination=self.my_lxmf_dest,
398
+ config_manager=self.config_manager,
399
+ telemetry_controller=self.tel_controller,
400
+ )
401
+ self.embedded_lxmd.start()
402
+
403
+ self.api = ReticulumTelemetryHubAPI(config_manager=self.config_manager)
404
+ self._backfill_identity_announces()
405
+ self._load_persisted_clients()
406
+ RNS.Transport.register_announce_handler(
407
+ AnnounceHandler(self.identities, api=self.api)
408
+ )
409
+ self.tel_controller.set_api(self.api)
410
+ self.telemeter_manager = TelemeterManager(config_manager=self.config_manager)
411
+ tak_config_manager = self.config_manager
412
+ self.tak_connector = TakConnector(
413
+ config=tak_config_manager.tak_config if tak_config_manager else None,
414
+ telemeter_manager=self.telemeter_manager,
415
+ telemetry_controller=self.tel_controller,
416
+ identity_lookup=self._lookup_identity_label,
417
+ )
418
+ self.tel_controller.register_listener(self._handle_telemetry_for_tak)
419
+ self.telemetry_sampler = TelemetrySampler(
420
+ self.tel_controller,
421
+ self.lxm_router,
422
+ self.my_lxmf_dest,
423
+ connections=self.connections,
424
+ hub_interval=hub_telemetry_interval,
425
+ service_interval=service_telemetry_interval,
426
+ telemeter_manager=self.telemeter_manager,
427
+ )
428
+
429
+ self.command_manager = CommandManager(
430
+ self.connections,
431
+ self.tel_controller,
432
+ self.my_lxmf_dest,
433
+ self.api,
434
+ config_manager=self.config_manager,
435
+ event_log=self.event_log,
436
+ )
437
+ self.internal_adapter = ReticulumInternalAdapter(send_message=self.send_message)
438
+ self.topic_subscribers: dict[str, set[str]] = {}
439
+ self._topic_registry_last_refresh: float = 0.0
440
+ self._refresh_topic_registry()
441
+
442
+ def command_handler(self, commands: list, message: LXMF.LXMessage) -> list[LXMF.LXMessage]:
443
+ """Handles commands received from the client and returns responses.
444
+
445
+ Args:
446
+ commands (list): List of commands received from the client
447
+ message (LXMF.LXMessage): LXMF message object
448
+
449
+ Returns:
450
+ list[LXMF.LXMessage]: Responses generated for the commands.
451
+ """
452
+ responses = self.command_manager.handle_commands(commands, message)
453
+ if self._commands_affect_subscribers(commands):
454
+ self._refresh_topic_registry()
455
+ return responses
456
+
457
+ def register_message_listener(
458
+ self, listener: Callable[[dict[str, object]], None]
459
+ ) -> Callable[[], None]:
460
+ """Register a callback invoked for inbound LXMF messages."""
461
+
462
+ self._message_listeners.append(listener)
463
+
464
+ def _remove_listener() -> None:
465
+ """Remove a previously registered message listener."""
466
+
467
+ if listener in self._message_listeners:
468
+ self._message_listeners.remove(listener)
469
+
470
+ return _remove_listener
471
+
472
+ def _notify_message_listeners(self, entry: dict[str, object]) -> None:
473
+ """Dispatch an inbound message entry to registered listeners."""
474
+
475
+ listeners = list(getattr(self, "_message_listeners", []))
476
+ for listener in listeners:
477
+ try:
478
+ listener(entry)
479
+ except Exception as exc: # pragma: no cover - defensive logging
480
+ RNS.log(
481
+ f"Message listener raised an exception: {exc}",
482
+ getattr(RNS, "LOG_WARNING", 2),
483
+ )
484
+
485
+ def _record_message_event(
486
+ self,
487
+ *,
488
+ content: str,
489
+ source_label: str,
490
+ source_hash: str | None,
491
+ topic_id: str | None,
492
+ timestamp: datetime,
493
+ direction: str,
494
+ state: str,
495
+ destination: str | None,
496
+ attachments: list[FileAttachment],
497
+ message_id: str | None = None,
498
+ ) -> None:
499
+ """Emit a message event for northbound consumers."""
500
+
501
+ scope = "topic" if topic_id else "dm"
502
+ if direction == "outbound" and not destination and not topic_id:
503
+ scope = "broadcast"
504
+ api = getattr(self, "api", None)
505
+ has_chat_support = api is not None and all(
506
+ hasattr(api, name) for name in ("record_chat_message", "chat_attachment_from_file")
507
+ )
508
+ attachment_payloads = []
509
+ if has_chat_support:
510
+ attachment_payloads = [
511
+ api.chat_attachment_from_file(item).to_dict()
512
+ for item in attachments
513
+ ]
514
+ chat_message = ChatMessage(
515
+ message_id=message_id,
516
+ direction=direction,
517
+ scope=scope,
518
+ state=state,
519
+ content=content,
520
+ source=source_hash or source_label,
521
+ destination=destination,
522
+ topic_id=topic_id,
523
+ attachments=[
524
+ api.chat_attachment_from_file(item) for item in attachments
525
+ ],
526
+ created_at=timestamp,
527
+ updated_at=timestamp,
528
+ )
529
+ stored = api.record_chat_message(chat_message)
530
+ entry = stored.to_dict()
531
+ entry["SourceHash"] = source_hash or ""
532
+ entry["SourceLabel"] = source_label
533
+ entry["Timestamp"] = timestamp.isoformat()
534
+ entry["Attachments"] = attachment_payloads
535
+ self._notify_message_listeners(entry)
536
+ else:
537
+ entry = {
538
+ "MessageID": message_id,
539
+ "Direction": direction,
540
+ "Scope": scope,
541
+ "State": state,
542
+ "Content": content,
543
+ "Source": source_hash or source_label,
544
+ "Destination": destination,
545
+ "TopicID": topic_id,
546
+ "Attachments": attachment_payloads,
547
+ "CreatedAt": timestamp.isoformat(),
548
+ "UpdatedAt": timestamp.isoformat(),
549
+ "SourceHash": source_hash or "",
550
+ "SourceLabel": source_label,
551
+ "Timestamp": timestamp.isoformat(),
552
+ }
553
+ self._notify_message_listeners(entry)
554
+ event_log = getattr(self, "event_log", None)
555
+ if event_log is not None:
556
+ event_log.add_event(
557
+ "message_received" if direction == "inbound" else "message_sent",
558
+ (
559
+ f"Message received from {source_label}"
560
+ if direction == "inbound"
561
+ else "Message sent from hub"
562
+ ),
563
+ metadata=entry,
564
+ )
565
+
566
+ def _parse_escape_prefixed_commands(
567
+ self, message: LXMF.LXMessage
568
+ ) -> tuple[list[dict] | None, bool, str | None]:
569
+ """Parse a command list from an escape-prefixed message body.
570
+
571
+ The `Commands` LXMF field may be unavailable in some clients, so the
572
+ hub accepts a leading ``\\\\\\`` prefix in the message content and
573
+ treats the remainder as a command payload.
574
+
575
+ Args:
576
+ message (LXMF.LXMessage): LXMF message object.
577
+
578
+ Returns:
579
+ tuple[list[dict] | None, bool, str | None]: Normalized command list,
580
+ an empty list when the payload is malformed, or ``None`` when no
581
+ escape prefix is present, paired with a boolean indicating whether
582
+ the escape prefix was detected and an optional error message.
583
+ """
584
+
585
+ if LXMF.FIELD_COMMANDS in message.fields:
586
+ return None, False, None
587
+
588
+ if message.content is None or message.content == b"":
589
+ return None, False, None
590
+
591
+ try:
592
+ content_text = message.content_as_string()
593
+ except Exception as exc:
594
+ RNS.log(
595
+ f"Unable to decode message content for escape-prefixed commands: {exc}",
596
+ RNS.LOG_WARNING,
597
+ )
598
+ return [], False, "Unable to decode message content."
599
+
600
+ if not content_text.startswith(ESCAPED_COMMAND_PREFIX):
601
+ return None, False, None
602
+
603
+ # Reason: the prefix signals that the body should be treated as a command
604
+ # payload even when the `Commands` field is unavailable.
605
+ body = content_text[len(ESCAPED_COMMAND_PREFIX) :].strip()
606
+ if not body:
607
+ RNS.log(
608
+ "Ignored escape-prefixed command payload with no body.",
609
+ RNS.LOG_WARNING,
610
+ )
611
+ return [], True, "Command payload is empty."
612
+
613
+ if body.startswith("\\[") or body.startswith("\\{"):
614
+ body = body[1:]
615
+
616
+ parsed_payload = None
617
+ if body.startswith("{") or body.startswith("["):
618
+ try:
619
+ parsed_payload = json.loads(body)
620
+ except json.JSONDecodeError as exc:
621
+ RNS.log(
622
+ f"Failed to parse escape-prefixed JSON payload: {exc}",
623
+ RNS.LOG_WARNING,
624
+ )
625
+ return [], True, "Command payload is not valid JSON."
626
+
627
+ if parsed_payload is None:
628
+ return [{"Command": body}], True, None
629
+
630
+ if isinstance(parsed_payload, dict):
631
+ return [parsed_payload], True, None
632
+
633
+ if isinstance(parsed_payload, list):
634
+ if not parsed_payload:
635
+ RNS.log(
636
+ "Ignored escape-prefixed command list with no entries.",
637
+ RNS.LOG_WARNING,
638
+ )
639
+ return [], True, "Command payload list is empty."
640
+
641
+ if not all(isinstance(item, dict) for item in parsed_payload):
642
+ RNS.log(
643
+ "Escape-prefixed JSON must be an object or list of objects.",
644
+ RNS.LOG_WARNING,
645
+ )
646
+ return [], True, "Command payload must be a JSON object or list of objects."
647
+
648
+ return parsed_payload, True, None
649
+
650
+ RNS.log(
651
+ "Escape-prefixed payload must decode to a JSON object or list of objects.",
652
+ RNS.LOG_WARNING,
653
+ )
654
+ return [], True, "Command payload must be a JSON object or list of objects."
655
+
656
+ def delivery_callback(self, message: LXMF.LXMessage):
657
+ """Callback function to handle incoming messages.
658
+
659
+ Args:
660
+ message (LXMF.LXMessage): LXMF message object
661
+ """
662
+ try:
663
+ # Format the timestamp of the message
664
+ time_string = time.strftime(
665
+ "%Y-%m-%d %H:%M:%S", time.localtime(message.timestamp)
666
+ )
667
+ signature_string = "Signature is invalid, reason undetermined"
668
+
669
+ # Determine the signature validation status
670
+ if message.signature_validated:
671
+ signature_string = "Validated"
672
+ elif message.unverified_reason == LXMF.LXMessage.SIGNATURE_INVALID:
673
+ signature_string = "Invalid signature"
674
+ return
675
+ elif message.unverified_reason == LXMF.LXMessage.SOURCE_UNKNOWN:
676
+ signature_string = "Cannot verify, source is unknown"
677
+ return
678
+
679
+ # Log the delivery details
680
+ self.log_delivery_details(message, time_string, signature_string)
681
+
682
+ command_payload_present = False
683
+ adapter_commands: list[dict] = []
684
+ sender_joined = False
685
+ attachment_replies: list[LXMF.LXMessage] = []
686
+ stored_attachments: list[FileAttachment] = []
687
+ # Handle the commands
688
+ command_replies: list[LXMF.LXMessage] = []
689
+ if message.signature_validated:
690
+ commands: list[dict] | None = None
691
+ escape_error: str | None = None
692
+ if LXMF.FIELD_COMMANDS in message.fields:
693
+ command_payload_present = True
694
+ commands = message.fields[LXMF.FIELD_COMMANDS]
695
+ else:
696
+ escape_commands, escape_detected, escape_error = (
697
+ self._parse_escape_prefixed_commands(message)
698
+ )
699
+ if escape_detected:
700
+ command_payload_present = True
701
+ if escape_commands:
702
+ commands = escape_commands
703
+
704
+ topic_id = self._extract_attachment_topic_id(commands)
705
+ (
706
+ attachment_replies,
707
+ stored_attachments,
708
+ ) = self._persist_attachments_from_fields(message, topic_id=topic_id)
709
+ if escape_error:
710
+ error_reply = self._reply_message(
711
+ message, f"Command error: {escape_error}"
712
+ )
713
+ if error_reply is not None:
714
+ attachment_replies.append(error_reply)
715
+
716
+ if commands:
717
+ command_replies = self.command_handler(commands, message) or []
718
+ adapter_commands = list(commands)
719
+
720
+ responses = attachment_replies + command_replies
721
+ text_only_replies: list[LXMF.LXMessage] = []
722
+ for response in command_replies:
723
+ response_fields = getattr(response, "fields", None) or {}
724
+ if isinstance(response_fields, dict) and any(
725
+ key in response_fields
726
+ for key in (LXMF.FIELD_FILE_ATTACHMENTS, LXMF.FIELD_IMAGE)
727
+ ):
728
+ text_only = self._reply_message(
729
+ message, response.content_as_string(), fields={}
730
+ )
731
+ if text_only is not None:
732
+ text_only_replies.append(text_only)
733
+
734
+ responses.extend(text_only_replies)
735
+ for response in responses:
736
+ try:
737
+ self.lxm_router.handle_outbound(response)
738
+ except Exception as exc: # pragma: no cover - defensive log
739
+ has_attachment = False
740
+ response_fields = getattr(response, "fields", None) or {}
741
+ if isinstance(response_fields, dict):
742
+ has_attachment = any(
743
+ key in response_fields
744
+ for key in (LXMF.FIELD_FILE_ATTACHMENTS, LXMF.FIELD_IMAGE)
745
+ )
746
+ RNS.log(
747
+ f"Failed to send response: {exc}",
748
+ getattr(RNS, "LOG_WARNING", 2),
749
+ )
750
+ if has_attachment:
751
+ fallback = self._reply_message(
752
+ message,
753
+ "Failed to send attachment response; the file may be too large.",
754
+ )
755
+ if fallback is None:
756
+ continue
757
+ try:
758
+ self.lxm_router.handle_outbound(fallback)
759
+ except Exception as retry_exc: # pragma: no cover - defensive log
760
+ RNS.log(
761
+ f"Failed to send fallback response: {retry_exc}",
762
+ getattr(RNS, "LOG_WARNING", 2),
763
+ )
764
+ if responses:
765
+ command_payload_present = True
766
+
767
+ sender_joined = self._sender_is_joined(message)
768
+ telemetry_handled = self.tel_controller.handle_message(message)
769
+ if telemetry_handled:
770
+ RNS.log("Telemetry data saved")
771
+
772
+ if not sender_joined:
773
+ self._reply_with_app_info(message)
774
+
775
+ adapter = getattr(self, "internal_adapter", None)
776
+ if adapter is not None and message.signature_validated:
777
+ try:
778
+ inbound = LxmfInbound(
779
+ message_id=self._message_id_hex(message),
780
+ source_id=self._message_source_hex(message),
781
+ topic_id=self._extract_target_topic(message.fields),
782
+ text=self._message_text(message),
783
+ fields=message.fields or {},
784
+ commands=adapter_commands,
785
+ )
786
+ adapter.handle_inbound(inbound)
787
+ except Exception as exc: # pragma: no cover - defensive logging
788
+ RNS.log(
789
+ f"Internal adapter failed to process inbound message: {exc}",
790
+ getattr(RNS, "LOG_WARNING", 2),
791
+ )
792
+
793
+ # Skip if the message content is empty and no attachments were stored.
794
+ if (message.content is None or message.content == b"") and not stored_attachments:
795
+ return
796
+
797
+ if self._is_telemetry_only(message, telemetry_handled):
798
+ return
799
+
800
+ if command_payload_present:
801
+ return
802
+
803
+ source = message.get_source()
804
+ source_hash = getattr(source, "hash", None) or message.source_hash
805
+ source_label = self._lookup_identity_label(source_hash)
806
+ topic_id = self._extract_target_topic(message.fields)
807
+ content_text = self._message_text(message)
808
+ try:
809
+ message_time = datetime.fromtimestamp(
810
+ getattr(message, "timestamp", time.time()),
811
+ tz=timezone.utc,
812
+ ).replace(tzinfo=None)
813
+ except Exception:
814
+ message_time = _utcnow()
815
+
816
+ self._record_message_event(
817
+ content=content_text,
818
+ source_label=source_label,
819
+ source_hash=self._message_source_hex(message),
820
+ topic_id=topic_id,
821
+ timestamp=message_time,
822
+ direction="inbound",
823
+ state="delivered",
824
+ destination=None,
825
+ attachments=stored_attachments,
826
+ message_id=self._message_id_hex(message),
827
+ )
828
+
829
+ tak_connector = getattr(self, "tak_connector", None)
830
+ if tak_connector is not None and content_text:
831
+ try:
832
+ asyncio.run(
833
+ tak_connector.send_chat_event(
834
+ content=content_text,
835
+ sender_label=source_label,
836
+ topic_id=topic_id,
837
+ source_hash=source_hash,
838
+ timestamp=message_time,
839
+ )
840
+ )
841
+ except Exception as exc: # pragma: no cover - defensive log
842
+ RNS.log(
843
+ f"Failed to send CoT chat event: {exc}",
844
+ getattr(RNS, "LOG_WARNING", 2),
845
+ )
846
+
847
+ # Broadcast the message to all connected clients
848
+ msg = f"{source_label} > {content_text}"
849
+ source_hex = self._message_source_hex(message)
850
+ exclude = {source_hex} if source_hex else None
851
+ self.send_message(msg, topic=topic_id, exclude=exclude)
852
+ except Exception as e:
853
+ RNS.log(f"Error: {e}")
854
+
855
+ def send_message(
856
+ self,
857
+ message: str,
858
+ *,
859
+ topic: str | None = None,
860
+ destination: str | None = None,
861
+ exclude: set[str] | None = None,
862
+ fields: dict | None = None,
863
+ ) -> bool:
864
+ """Sends a message to connected clients.
865
+
866
+ Args:
867
+ message (str): Text to broadcast.
868
+ topic (str | None): Topic filter limiting recipients.
869
+ destination (str | None): Optional destination hash for a targeted send.
870
+ exclude (set[str] | None): Optional set of lowercase destination
871
+ hashes that should not receive the broadcast.
872
+ fields (dict | None): Optional LXMF message fields.
873
+ """
874
+
875
+ queue = self._ensure_outbound_queue()
876
+ if queue is None:
877
+ RNS.log(
878
+ "Outbound queue unavailable; dropping message broadcast request.",
879
+ getattr(RNS, "LOG_WARNING", 2),
880
+ )
881
+ return False
882
+
883
+ available = (
884
+ list(self.connections.values())
885
+ if hasattr(self.connections, "values")
886
+ else list(self.connections)
887
+ )
888
+ excluded = {value.lower() for value in exclude if value} if exclude else set()
889
+ normalized_destination = destination.lower() if destination else None
890
+ if topic:
891
+ subscriber_hex = self._subscribers_for_topic(topic)
892
+ available = [
893
+ connection
894
+ for connection in available
895
+ if self._connection_hex(connection) in subscriber_hex
896
+ ]
897
+ enqueued_any = False
898
+ for connection in available:
899
+ connection_hex = self._connection_hex(connection)
900
+ if normalized_destination and connection_hex != normalized_destination:
901
+ continue
902
+ if excluded and connection_hex and connection_hex in excluded:
903
+ continue
904
+ identity = getattr(connection, "identity", None)
905
+ destination_hash = getattr(identity, "hash", None)
906
+ enqueued = queue.queue_message(
907
+ connection,
908
+ message,
909
+ (
910
+ destination_hash
911
+ if isinstance(destination_hash, (bytes, bytearray))
912
+ else None
913
+ ),
914
+ connection_hex,
915
+ fields,
916
+ )
917
+ if enqueued:
918
+ enqueued_any = True
919
+ if not enqueued:
920
+ RNS.log(
921
+ (
922
+ "Failed to enqueue outbound LXMF message for"
923
+ f" {connection_hex or 'unknown destination'}"
924
+ ),
925
+ getattr(RNS, "LOG_WARNING", 2),
926
+ )
927
+ return enqueued_any
928
+
929
+ def dispatch_northbound_message(
930
+ self,
931
+ message: str,
932
+ topic_id: str | None = None,
933
+ destination: str | None = None,
934
+ fields: dict | None = None,
935
+ ) -> ChatMessage | None:
936
+ """Dispatch a message originating from the northbound interface."""
937
+
938
+ api = getattr(self, "api", None)
939
+ attachments: list[FileAttachment] = []
940
+ scope = "broadcast"
941
+ if destination:
942
+ scope = "dm"
943
+ elif topic_id:
944
+ scope = "topic"
945
+ if isinstance(fields, dict):
946
+ raw_attachments = fields.get("attachments")
947
+ if isinstance(raw_attachments, list):
948
+ attachments = [item for item in raw_attachments if isinstance(item, FileAttachment)]
949
+ override_scope = fields.get("scope")
950
+ if isinstance(override_scope, str) and override_scope.strip():
951
+ scope = override_scope.strip()
952
+ queued = None
953
+ now = _utcnow()
954
+ if api is not None:
955
+ queued = api.record_chat_message(
956
+ ChatMessage(
957
+ direction="outbound",
958
+ scope=scope,
959
+ state="queued",
960
+ content=message,
961
+ source=None,
962
+ destination=destination,
963
+ topic_id=topic_id,
964
+ attachments=[api.chat_attachment_from_file(item) for item in attachments],
965
+ created_at=now,
966
+ updated_at=now,
967
+ )
968
+ )
969
+ self._notify_message_listeners(queued.to_dict())
970
+ if getattr(self, "event_log", None) is not None:
971
+ self.event_log.add_event(
972
+ "message_queued",
973
+ "Message queued for delivery",
974
+ metadata=queued.to_dict(),
975
+ )
976
+ lxmf_fields = None
977
+ if attachments:
978
+ try:
979
+ lxmf_fields = self._build_lxmf_attachment_fields(attachments)
980
+ except Exception as exc: # pragma: no cover - defensive log
981
+ RNS.log(
982
+ f"Failed to build attachment fields: {exc}",
983
+ getattr(RNS, "LOG_WARNING", 2),
984
+ )
985
+ sent = self.send_message(
986
+ message,
987
+ topic=topic_id,
988
+ destination=destination,
989
+ fields=lxmf_fields,
990
+ )
991
+ if api is not None and queued is not None:
992
+ updated = api.update_chat_message_state(
993
+ queued.message_id or "", "sent" if sent else "failed"
994
+ )
995
+ if updated is not None:
996
+ self._notify_message_listeners(updated.to_dict())
997
+ if getattr(self, "event_log", None) is not None:
998
+ self.event_log.add_event(
999
+ "message_sent" if sent else "message_failed",
1000
+ "Message sent" if sent else "Message failed",
1001
+ metadata=updated.to_dict(),
1002
+ )
1003
+ return updated
1004
+ return queued
1005
+ return None
1006
+
1007
+ def _ensure_outbound_queue(self) -> OutboundMessageQueue | None:
1008
+ """
1009
+ Initialize and start the outbound worker queue.
1010
+
1011
+ Returns:
1012
+ OutboundMessageQueue | None: Active outbound queue instance when available.
1013
+ """
1014
+
1015
+ if self.my_lxmf_dest is None:
1016
+ return None
1017
+
1018
+ if not hasattr(self, "_outbound_queue"):
1019
+ self._outbound_queue = None
1020
+
1021
+ if self._outbound_queue is None:
1022
+ self._outbound_queue = OutboundMessageQueue(
1023
+ self.lxm_router,
1024
+ self.my_lxmf_dest,
1025
+ queue_size=getattr(
1026
+ self, "outbound_queue_size", DEFAULT_OUTBOUND_QUEUE_SIZE
1027
+ )
1028
+ or DEFAULT_OUTBOUND_QUEUE_SIZE,
1029
+ worker_count=getattr(self, "outbound_workers", DEFAULT_OUTBOUND_WORKERS)
1030
+ or DEFAULT_OUTBOUND_WORKERS,
1031
+ send_timeout=getattr(
1032
+ self, "outbound_send_timeout", DEFAULT_OUTBOUND_SEND_TIMEOUT
1033
+ )
1034
+ or DEFAULT_OUTBOUND_SEND_TIMEOUT,
1035
+ backoff_seconds=getattr(
1036
+ self, "outbound_backoff", DEFAULT_OUTBOUND_BACKOFF
1037
+ )
1038
+ or DEFAULT_OUTBOUND_BACKOFF,
1039
+ max_attempts=getattr(
1040
+ self, "outbound_max_attempts", DEFAULT_OUTBOUND_MAX_ATTEMPTS
1041
+ )
1042
+ or DEFAULT_OUTBOUND_MAX_ATTEMPTS,
1043
+ )
1044
+ self._outbound_queue.start()
1045
+ return self._outbound_queue
1046
+
1047
+ def wait_for_outbound_flush(self, timeout: float = 1.0) -> bool:
1048
+ """
1049
+ Wait until outbound messages clear the queue.
1050
+
1051
+ Args:
1052
+ timeout (float): Seconds to wait before giving up.
1053
+
1054
+ Returns:
1055
+ bool: ``True`` when the queue drained before the timeout elapsed.
1056
+ """
1057
+
1058
+ queue = getattr(self, "_outbound_queue", None)
1059
+ if queue is None:
1060
+ return True
1061
+ return queue.wait_for_flush(timeout=timeout)
1062
+
1063
+ @property
1064
+ def outbound_queue(self) -> OutboundMessageQueue | None:
1065
+ """Return the active outbound queue instance for diagnostics/testing."""
1066
+
1067
+ return self._outbound_queue
1068
+
1069
+ def log_delivery_details(self, message, time_string, signature_string):
1070
+ RNS.log("\t+--- LXMF Delivery ---------------------------------------------")
1071
+ RNS.log(f"\t| Source hash : {RNS.prettyhexrep(message.source_hash)}")
1072
+ RNS.log(f"\t| Source instance : {message.get_source()}")
1073
+ RNS.log(
1074
+ f"\t| Destination hash : {RNS.prettyhexrep(message.destination_hash)}"
1075
+ )
1076
+ # RNS.log(f"\t| Destination identity : {message.source_identity}")
1077
+ RNS.log(f"\t| Destination instance : {message.get_destination()}")
1078
+ RNS.log(f"\t| Transport Encryption : {message.transport_encryption}")
1079
+ RNS.log(f"\t| Timestamp : {time_string}")
1080
+ RNS.log(f"\t| Title : {message.title_as_string()}")
1081
+ RNS.log(f"\t| Content : {message.content_as_string()}")
1082
+ RNS.log(f"\t| Fields : {message.fields}")
1083
+ RNS.log(f"\t| Message signature : {signature_string}")
1084
+ RNS.log("\t+---------------------------------------------------------------")
1085
+
1086
+ def _lookup_identity_label(self, source_hash) -> str:
1087
+ if isinstance(source_hash, (bytes, bytearray)):
1088
+ hash_key = source_hash.hex().lower()
1089
+ pretty = RNS.prettyhexrep(source_hash)
1090
+ elif source_hash:
1091
+ hash_key = str(source_hash).lower()
1092
+ pretty = hash_key
1093
+ else:
1094
+ return "unknown"
1095
+ label = self.identities.get(hash_key)
1096
+ if not label:
1097
+ api = getattr(self, "api", None)
1098
+ if api is not None and hasattr(api, "resolve_identity_display_name"):
1099
+ try:
1100
+ label = api.resolve_identity_display_name(hash_key)
1101
+ except Exception as exc: # pragma: no cover - defensive log
1102
+ RNS.log(
1103
+ f"Failed to resolve announce display name for {hash_key}: {exc}",
1104
+ getattr(RNS, "LOG_WARNING", 2),
1105
+ )
1106
+ if label:
1107
+ self.identities[hash_key] = label
1108
+ return label or pretty
1109
+
1110
+ def _backfill_identity_announces(self) -> None:
1111
+ api = getattr(self, "api", None)
1112
+ storage = getattr(api, "_storage", None)
1113
+ if storage is None:
1114
+ return
1115
+ try:
1116
+ records = storage.list_identity_announces()
1117
+ except Exception as exc: # pragma: no cover - defensive log
1118
+ RNS.log(
1119
+ f"Failed to load announce records for backfill: {exc}",
1120
+ getattr(RNS, "LOG_WARNING", 2),
1121
+ )
1122
+ return
1123
+
1124
+ if not records:
1125
+ return
1126
+
1127
+ existing = {record.destination_hash.lower() for record in records}
1128
+ created = 0
1129
+ for record in records:
1130
+ if not record.display_name:
1131
+ continue
1132
+ try:
1133
+ destination_bytes = bytes.fromhex(record.destination_hash)
1134
+ except ValueError:
1135
+ continue
1136
+ identity = RNS.Identity.recall(destination_bytes)
1137
+ if identity is None:
1138
+ continue
1139
+ identity_hash = identity.hash.hex().lower()
1140
+ if identity_hash in existing:
1141
+ continue
1142
+ try:
1143
+ api.record_identity_announce(
1144
+ identity_hash,
1145
+ display_name=record.display_name,
1146
+ source_interface=record.source_interface,
1147
+ )
1148
+ except Exception as exc: # pragma: no cover - defensive log
1149
+ RNS.log(
1150
+ (
1151
+ "Failed to backfill announce metadata for "
1152
+ f"{identity_hash}: {exc}"
1153
+ ),
1154
+ getattr(RNS, "LOG_WARNING", 2),
1155
+ )
1156
+ continue
1157
+ existing.add(identity_hash)
1158
+ created += 1
1159
+
1160
+ if created:
1161
+ RNS.log(
1162
+ f"Backfilled {created} identity announce records for display names.",
1163
+ getattr(RNS, "LOG_INFO", 3),
1164
+ )
1165
+
1166
+ def _load_persisted_clients(self) -> None:
1167
+ api = getattr(self, "api", None)
1168
+ if api is None:
1169
+ return
1170
+ try:
1171
+ clients = api.list_clients()
1172
+ except Exception as exc: # pragma: no cover - defensive log
1173
+ RNS.log(
1174
+ f"Failed to load persisted clients: {exc}",
1175
+ getattr(RNS, "LOG_WARNING", 2),
1176
+ )
1177
+ return
1178
+
1179
+ loaded = 0
1180
+ for client in clients:
1181
+ identity = getattr(client, "identity", None)
1182
+ if not identity:
1183
+ continue
1184
+ try:
1185
+ identity_hash = bytes.fromhex(identity)
1186
+ except ValueError:
1187
+ continue
1188
+ if identity_hash in self.connections:
1189
+ continue
1190
+ try:
1191
+ recalled = RNS.Identity.recall(identity_hash, from_identity_hash=True)
1192
+ except Exception:
1193
+ recalled = None
1194
+ if recalled is None:
1195
+ continue
1196
+ try:
1197
+ dest = RNS.Destination(
1198
+ recalled,
1199
+ RNS.Destination.OUT,
1200
+ RNS.Destination.SINGLE,
1201
+ "lxmf",
1202
+ "delivery",
1203
+ )
1204
+ except Exception:
1205
+ continue
1206
+ self.connections[dest.identity.hash] = dest
1207
+ loaded += 1
1208
+
1209
+ if loaded:
1210
+ RNS.log(
1211
+ f"Loaded {loaded} persisted clients into the connection cache.",
1212
+ getattr(RNS, "LOG_INFO", 3),
1213
+ )
1214
+
1215
+ def _handle_telemetry_for_tak(
1216
+ self,
1217
+ telemetry: dict,
1218
+ peer_hash: str | bytes | None,
1219
+ timestamp: datetime | None,
1220
+ ) -> None:
1221
+ """Convert telemetry payloads into CoT events for TAK consumers."""
1222
+
1223
+ tak_connector = getattr(self, "tak_connector", None)
1224
+ if tak_connector is None:
1225
+ return
1226
+ try:
1227
+ _dispatch_coroutine(
1228
+ tak_connector.send_telemetry_event(
1229
+ telemetry,
1230
+ peer_hash=peer_hash,
1231
+ timestamp=timestamp,
1232
+ )
1233
+ )
1234
+ except Exception as exc: # pragma: no cover - defensive logging
1235
+ RNS.log(
1236
+ f"Failed to send telemetry CoT event: {exc}",
1237
+ getattr(RNS, "LOG_WARNING", 2),
1238
+ )
1239
+
1240
+ def _extract_target_topic(self, fields) -> str | None:
1241
+ if not isinstance(fields, dict):
1242
+ return None
1243
+ for key in ("TopicID", "topic_id", "topic", "Topic"):
1244
+ topic_id = fields.get(key)
1245
+ if topic_id:
1246
+ return str(topic_id)
1247
+ commands = fields.get(LXMF.FIELD_COMMANDS)
1248
+ if isinstance(commands, list):
1249
+ for command in commands:
1250
+ if not isinstance(command, dict):
1251
+ continue
1252
+ for key in ("TopicID", "topic_id", "topic", "Topic"):
1253
+ topic_id = command.get(key)
1254
+ if topic_id:
1255
+ return str(topic_id)
1256
+ return None
1257
+
1258
+ def _refresh_topic_registry(self) -> None:
1259
+ self._topic_registry_last_refresh = time.monotonic()
1260
+ if not self.api:
1261
+ return
1262
+ try:
1263
+ subscribers = self.api.list_subscribers()
1264
+ except Exception as exc: # pragma: no cover - defensive logging
1265
+ RNS.log(
1266
+ f"Failed to refresh topic registry: {exc}",
1267
+ getattr(RNS, "LOG_WARNING", 2),
1268
+ )
1269
+ self.topic_subscribers = {}
1270
+ return
1271
+ registry: dict[str, set[str]] = {}
1272
+ for subscriber in subscribers:
1273
+ topic_id = getattr(subscriber, "topic_id", None)
1274
+ destination = getattr(subscriber, "destination", "")
1275
+ if not topic_id or not destination:
1276
+ continue
1277
+ registry.setdefault(topic_id, set()).add(destination.lower())
1278
+ self.topic_subscribers = registry
1279
+ self._topic_registry_last_refresh = time.monotonic()
1280
+
1281
+ def _subscribers_for_topic(self, topic_id: str) -> set[str]:
1282
+ if not topic_id:
1283
+ return set()
1284
+ if not hasattr(self, "_topic_registry_last_refresh"):
1285
+ self._topic_registry_last_refresh = time.monotonic()
1286
+ now = time.monotonic()
1287
+ last_refresh = getattr(self, "_topic_registry_last_refresh", 0.0)
1288
+ is_stale = (now - last_refresh) >= TOPIC_REGISTRY_TTL_SECONDS
1289
+ if is_stale or topic_id not in self.topic_subscribers:
1290
+ if self.api:
1291
+ self._refresh_topic_registry()
1292
+ else:
1293
+ self._topic_registry_last_refresh = now
1294
+ return self.topic_subscribers.get(topic_id, set())
1295
+
1296
+ def _commands_affect_subscribers(self, commands: list[dict] | None) -> bool:
1297
+ """Return True when commands modify subscriber mappings."""
1298
+
1299
+ if not commands:
1300
+ return False
1301
+
1302
+ subscriber_commands = {
1303
+ CommandManager.CMD_SUBSCRIBE_TOPIC,
1304
+ CommandManager.CMD_CREATE_SUBSCRIBER,
1305
+ CommandManager.CMD_ADD_SUBSCRIBER,
1306
+ CommandManager.CMD_DELETE_SUBSCRIBER,
1307
+ CommandManager.CMD_REMOVE_SUBSCRIBER,
1308
+ CommandManager.CMD_PATCH_SUBSCRIBER,
1309
+ }
1310
+
1311
+ for command in commands:
1312
+ if not isinstance(command, dict):
1313
+ continue
1314
+ name = command.get(PLUGIN_COMMAND) or command.get("Command")
1315
+ if name in subscriber_commands:
1316
+ return True
1317
+
1318
+ return False
1319
+
1320
+ @staticmethod
1321
+ def _connection_hex(connection: RNS.Destination) -> str | None:
1322
+ identity = getattr(connection, "identity", None)
1323
+ hash_bytes = getattr(identity, "hash", None)
1324
+ if isinstance(hash_bytes, (bytes, bytearray)) and hash_bytes:
1325
+ return hash_bytes.hex().lower()
1326
+ return None
1327
+
1328
+ def _message_source_hex(self, message: LXMF.LXMessage) -> str | None:
1329
+ source = message.get_source()
1330
+ if source is not None:
1331
+ identity = getattr(source, "identity", None)
1332
+ hash_bytes = getattr(identity, "hash", None)
1333
+ if isinstance(hash_bytes, (bytes, bytearray)) and hash_bytes:
1334
+ return hash_bytes.hex().lower()
1335
+ source_hash = getattr(message, "source_hash", None)
1336
+ if isinstance(source_hash, (bytes, bytearray)) and source_hash:
1337
+ return source_hash.hex().lower()
1338
+ return None
1339
+
1340
+ @staticmethod
1341
+ def _message_id_hex(message: LXMF.LXMessage) -> str | None:
1342
+ message_id = getattr(message, "message_id", None) or getattr(message, "hash", None)
1343
+ if isinstance(message_id, (bytes, bytearray)) and message_id:
1344
+ return message_id.hex().lower()
1345
+ if isinstance(message_id, str) and message_id:
1346
+ return message_id.lower()
1347
+ return None
1348
+
1349
+ def _sender_is_joined(self, message: LXMF.LXMessage) -> bool:
1350
+ """Return True when the message sender has previously joined.
1351
+
1352
+ Args:
1353
+ message (LXMF.LXMessage): Incoming LXMF message.
1354
+
1355
+ Returns:
1356
+ bool: ``True`` if the sender exists in the connection cache or the
1357
+ persisted client registry.
1358
+ """
1359
+
1360
+ connections = getattr(self, "connections", {}) or {}
1361
+ source = None
1362
+ try:
1363
+ source = message.get_source()
1364
+ except Exception:
1365
+ source = None
1366
+ identity = getattr(source, "identity", None)
1367
+ hash_bytes = getattr(identity, "hash", None)
1368
+ if isinstance(hash_bytes, (bytes, bytearray)) and hash_bytes:
1369
+ if hash_bytes in connections:
1370
+ return True
1371
+
1372
+ sender_hex = self._message_source_hex(message)
1373
+ if not sender_hex:
1374
+ return False
1375
+ api = getattr(self, "api", None)
1376
+ if api is None:
1377
+ return False
1378
+ try:
1379
+ if hasattr(api, "has_client"):
1380
+ return bool(api.has_client(sender_hex))
1381
+ if hasattr(api, "list_clients"):
1382
+ lower_hex = sender_hex.lower()
1383
+ return any(
1384
+ getattr(client, "identity", "").lower() == lower_hex
1385
+ for client in api.list_clients()
1386
+ )
1387
+ except Exception as exc: # pragma: no cover - defensive log
1388
+ RNS.log(
1389
+ f"Failed to determine join status for {sender_hex}: {exc}",
1390
+ getattr(RNS, "LOG_WARNING", 2),
1391
+ )
1392
+ return False
1393
+
1394
+ def _reply_with_app_info(self, message: LXMF.LXMessage) -> None:
1395
+ """Send an application info reply to the given message source.
1396
+
1397
+ Args:
1398
+ message (LXMF.LXMessage): Message requiring an informational reply.
1399
+ """
1400
+
1401
+ command_manager = getattr(self, "command_manager", None)
1402
+ router = getattr(self, "lxm_router", None)
1403
+ if command_manager is None or router is None:
1404
+ return
1405
+ handler = getattr(command_manager, "_handle_get_app_info", None)
1406
+ if handler is None:
1407
+ return
1408
+ try:
1409
+ response = handler(message)
1410
+ except Exception as exc: # pragma: no cover - defensive log
1411
+ RNS.log(
1412
+ f"Unable to build app info reply: {exc}",
1413
+ getattr(RNS, "LOG_WARNING", 2),
1414
+ )
1415
+ return
1416
+ try:
1417
+ router.handle_outbound(response)
1418
+ except Exception as exc: # pragma: no cover - defensive log
1419
+ RNS.log(
1420
+ f"Unable to send app info reply: {exc}",
1421
+ getattr(RNS, "LOG_WARNING", 2),
1422
+ )
1423
+
1424
+ def _persist_attachments_from_fields(
1425
+ self, message: LXMF.LXMessage, *, topic_id: str | None = None
1426
+ ) -> tuple[list[LXMF.LXMessage], list[FileAttachment]]:
1427
+ """
1428
+ Persist file and image attachments from LXMF fields.
1429
+
1430
+ Args:
1431
+ message (LXMF.LXMessage): Incoming LXMF message that may include
1432
+ ``FIELD_FILE_ATTACHMENTS`` or ``FIELD_IMAGE`` entries.
1433
+
1434
+ Returns:
1435
+ tuple[list[LXMF.LXMessage], list[FileAttachment]]: Replies acknowledging
1436
+ stored attachments and the stored attachment records.
1437
+ """
1438
+
1439
+ if not message.fields:
1440
+ return [], []
1441
+ stored_files, file_errors = self._store_attachment_payloads(
1442
+ message.fields.get(LXMF.FIELD_FILE_ATTACHMENTS),
1443
+ category="file",
1444
+ default_prefix="file",
1445
+ topic_id=topic_id,
1446
+ )
1447
+ stored_images, image_errors = self._store_attachment_payloads(
1448
+ message.fields.get(LXMF.FIELD_IMAGE),
1449
+ category="image",
1450
+ default_prefix="image",
1451
+ topic_id=topic_id,
1452
+ )
1453
+ stored_attachments = stored_files + stored_images
1454
+ attachment_errors = file_errors + image_errors
1455
+ acknowledgements: list[LXMF.LXMessage] = []
1456
+ if stored_files:
1457
+ reply = self._build_attachment_reply(
1458
+ message, stored_files, heading="Stored files:"
1459
+ )
1460
+ if reply:
1461
+ acknowledgements.append(reply)
1462
+ if stored_images:
1463
+ reply = self._build_attachment_reply(
1464
+ message, stored_images, heading="Stored images:"
1465
+ )
1466
+ if reply:
1467
+ acknowledgements.append(reply)
1468
+ if attachment_errors:
1469
+ reply = self._build_attachment_error_reply(
1470
+ message, attachment_errors, heading="Attachment errors:"
1471
+ )
1472
+ if reply:
1473
+ acknowledgements.append(reply)
1474
+ return acknowledgements, stored_attachments
1475
+
1476
+ def _store_attachment_payloads(
1477
+ self, payload, *, category: str, default_prefix: str, topic_id: str | None = None
1478
+ ) -> tuple[list[FileAttachment], list[str]]:
1479
+ """
1480
+ Normalize and store incoming attachments.
1481
+
1482
+ Args:
1483
+ payload: Raw LXMF field payload (bytes, dict, or list).
1484
+ category (str): Attachment category ("file" or "image").
1485
+ default_prefix (str): Filename prefix when no name is supplied.
1486
+
1487
+ Returns:
1488
+ tuple[list, list[str]]: Stored attachment records from the API and
1489
+ any errors encountered while parsing.
1490
+ """
1491
+
1492
+ if payload in (None, {}, []):
1493
+ return [], []
1494
+ api = getattr(self, "api", None)
1495
+ base_path = self._attachment_base_path(category)
1496
+ if api is None or base_path is None:
1497
+ return [], []
1498
+ entries = self._normalize_attachment_payloads(
1499
+ payload, category=category, default_prefix=default_prefix
1500
+ )
1501
+ stored: list[FileAttachment] = []
1502
+ errors: list[str] = []
1503
+ for entry in entries:
1504
+ if entry.get("error"):
1505
+ errors.append(entry["error"])
1506
+ continue
1507
+ stored_entry = self._write_and_record_attachment(
1508
+ data=entry["data"],
1509
+ name=entry["name"],
1510
+ media_type=entry.get("media_type"),
1511
+ category=category,
1512
+ base_path=base_path,
1513
+ topic_id=topic_id,
1514
+ )
1515
+ if stored_entry is not None:
1516
+ stored.append(stored_entry)
1517
+ return stored, errors
1518
+
1519
+ def _attachment_payload(self, attachment: FileAttachment) -> list:
1520
+ """Return an LXMF-compatible attachment payload list."""
1521
+
1522
+ file_path = Path(attachment.path)
1523
+ data = file_path.read_bytes()
1524
+ if attachment.media_type:
1525
+ return [attachment.name, data, attachment.media_type]
1526
+ return [attachment.name, data]
1527
+
1528
+ def _build_lxmf_attachment_fields(
1529
+ self, attachments: list[FileAttachment]
1530
+ ) -> dict | None:
1531
+ """Build LXMF fields for outbound attachments."""
1532
+
1533
+ if not attachments:
1534
+ return None
1535
+ file_payloads: list[list] = []
1536
+ image_payloads: list[list] = []
1537
+ for attachment in attachments:
1538
+ payload = self._attachment_payload(attachment)
1539
+ category = (attachment.category or "").lower()
1540
+ if category == "image":
1541
+ image_payloads.append(payload)
1542
+ file_payloads.append(payload)
1543
+ else:
1544
+ file_payloads.append(payload)
1545
+ fields: dict = {}
1546
+ if file_payloads:
1547
+ fields[LXMF.FIELD_FILE_ATTACHMENTS] = file_payloads
1548
+ if image_payloads:
1549
+ fields[LXMF.FIELD_IMAGE] = image_payloads
1550
+ return fields
1551
+
1552
+ def _normalize_attachment_payloads(
1553
+ self, payload, *, category: str, default_prefix: str
1554
+ ) -> list[dict]:
1555
+ """
1556
+ Convert the raw LXMF payload into attachment dictionaries.
1557
+
1558
+ Args:
1559
+ payload: Raw LXMF field value.
1560
+ category (str): Attachment category ("file" or "image").
1561
+ default_prefix (str): Prefix for generated filenames.
1562
+
1563
+ Returns:
1564
+ list[dict]: Normalized payload entries.
1565
+ """
1566
+
1567
+ entries = payload
1568
+ if not isinstance(payload, (list, tuple)):
1569
+ entries = [payload]
1570
+ normalized: list[dict] = []
1571
+ for index, entry in enumerate(entries):
1572
+ parsed = self._parse_attachment_entry(
1573
+ entry, category=category, default_prefix=default_prefix, index=index
1574
+ )
1575
+ if parsed is not None:
1576
+ normalized.append(parsed)
1577
+ return normalized
1578
+
1579
+ def _parse_attachment_entry(
1580
+ self, entry, *, category: str, default_prefix: str, index: int
1581
+ ) -> dict | None:
1582
+ """
1583
+ Extract attachment data, name, and media type from an entry.
1584
+
1585
+ Args:
1586
+ entry: Raw attachment value (dict, bytes, or string).
1587
+ category (str): Attachment category ("file" or "image").
1588
+ default_prefix (str): Prefix for generated filenames.
1589
+ index (int): Entry index for uniqueness.
1590
+
1591
+ Returns:
1592
+ dict | None: Parsed attachment info when data is available.
1593
+ """
1594
+
1595
+ data = None
1596
+ media_type = None
1597
+ name = None
1598
+ if isinstance(entry, dict):
1599
+ data = self._first_present_value(
1600
+ entry, ["data", "bytes", "content", "blob"]
1601
+ )
1602
+ media_type = self._first_present_value(
1603
+ entry, ["media_type", "mime", "mime_type", "type"]
1604
+ )
1605
+ name = self._first_present_value(
1606
+ entry, ["name", "filename", "file_name", "title"]
1607
+ )
1608
+ elif isinstance(entry, (bytes, bytearray, memoryview)):
1609
+ data = bytes(entry)
1610
+ elif isinstance(entry, str):
1611
+ data = entry
1612
+ elif isinstance(entry, (list, tuple)):
1613
+ if len(entry) >= 2:
1614
+ name = entry[0] if isinstance(entry[0], str) else name
1615
+ data = entry[1]
1616
+ if len(entry) >= 3 and isinstance(entry[2], str):
1617
+ media_type = entry[2]
1618
+ elif entry:
1619
+ data = entry[0]
1620
+
1621
+ if data is None:
1622
+ reason = "Missing attachment data"
1623
+ attachment_name = name or f"{category}-{index + 1}"
1624
+ RNS.log(
1625
+ f"Ignoring attachment without data (category={category}).",
1626
+ getattr(RNS, "LOG_WARNING", 2),
1627
+ )
1628
+ return {"error": f"{reason}: {attachment_name}"}
1629
+
1630
+ if isinstance(media_type, str):
1631
+ media_type = media_type.strip() or None
1632
+ data = self._coerce_attachment_data(data, media_type=media_type)
1633
+ if data is None:
1634
+ reason = "Unsupported attachment data format"
1635
+ attachment_name = name or f"{category}-{index + 1}"
1636
+ RNS.log(
1637
+ f"Ignoring attachment with unsupported data format (category={category}).",
1638
+ getattr(RNS, "LOG_WARNING", 2),
1639
+ )
1640
+ return {"error": f"{reason}: {attachment_name}"}
1641
+ if not data:
1642
+ reason = "Empty attachment data"
1643
+ attachment_name = name or f"{category}-{index + 1}"
1644
+ RNS.log(
1645
+ f"Ignoring empty attachment payload (category={category}).",
1646
+ getattr(RNS, "LOG_WARNING", 2),
1647
+ )
1648
+ return {"error": f"{reason}: {attachment_name}"}
1649
+ if not media_type and category == "image":
1650
+ media_type = self._infer_image_media_type(data)
1651
+ safe_name = self._sanitize_attachment_name(
1652
+ name or self._default_attachment_name(default_prefix, index, media_type)
1653
+ )
1654
+ if media_type and not Path(safe_name).suffix:
1655
+ extension = self._guess_media_type_extension(media_type)
1656
+ if extension:
1657
+ safe_name = f"{safe_name}{extension}"
1658
+ media_type = media_type or self._guess_media_type(safe_name, category)
1659
+ return {"data": data, "name": safe_name, "media_type": media_type}
1660
+
1661
+ @staticmethod
1662
+ def _sanitize_attachment_name(name: str) -> str:
1663
+ """Return a filename-safe attachment name."""
1664
+
1665
+ candidate = Path(name).name or "attachment"
1666
+ return candidate
1667
+
1668
+ def _default_attachment_name(
1669
+ self, prefix: str, index: int, media_type: str | None
1670
+ ) -> str:
1671
+ """Return a unique attachment name using the prefix and media type."""
1672
+
1673
+ suffix = ""
1674
+ guessed = self._guess_media_type_extension(media_type)
1675
+ if guessed:
1676
+ suffix = guessed
1677
+ unique_id = uuid.uuid4().hex[:8]
1678
+ return f"{prefix}-{int(time.time())}-{index}-{unique_id}{suffix}"
1679
+
1680
+ @staticmethod
1681
+ def _guess_media_type(name: str, category: str) -> str | None:
1682
+ """Guess the media type from the name or category."""
1683
+
1684
+ guessed, _ = mimetypes.guess_type(name)
1685
+ if guessed:
1686
+ return guessed
1687
+ if category == "image":
1688
+ return "image/octet-stream"
1689
+ return "application/octet-stream"
1690
+
1691
+ @staticmethod
1692
+ def _infer_image_media_type(data: bytes) -> str | None:
1693
+ """Infer an image media type from raw bytes.
1694
+
1695
+ Args:
1696
+ data (bytes): Raw image bytes.
1697
+
1698
+ Returns:
1699
+ str | None: MIME type when recognized, otherwise ``None``.
1700
+ """
1701
+
1702
+ if data.startswith(b"\x89PNG\r\n\x1a\n"):
1703
+ return "image/png"
1704
+ if data.startswith(b"\xff\xd8\xff"):
1705
+ return "image/jpeg"
1706
+ if data.startswith((b"GIF87a", b"GIF89a")):
1707
+ return "image/gif"
1708
+ if data.startswith(b"BM"):
1709
+ return "image/bmp"
1710
+ if data.startswith(b"RIFF") and data[8:12] == b"WEBP":
1711
+ return "image/webp"
1712
+ return None
1713
+
1714
+ @staticmethod
1715
+ def _guess_media_type_extension(media_type: str | None) -> str:
1716
+ """Guess a file extension from the supplied media type."""
1717
+
1718
+ if not media_type:
1719
+ return ""
1720
+ guessed = mimetypes.guess_extension(media_type) or ""
1721
+ return guessed
1722
+
1723
+ @staticmethod
1724
+ def _first_present_value(entry: dict, keys: list[str]):
1725
+ """Return the first key value present in a dictionary.
1726
+
1727
+ Args:
1728
+ entry (dict): Attachment metadata map.
1729
+ keys (list[str]): Keys to check in order.
1730
+
1731
+ Returns:
1732
+ Any: The first matching value or ``None`` when absent.
1733
+ """
1734
+
1735
+ lower_lookup = {}
1736
+ for key in entry:
1737
+ if isinstance(key, str):
1738
+ lower_lookup.setdefault(key.lower(), key)
1739
+ for key in keys:
1740
+ if key in entry:
1741
+ return entry.get(key)
1742
+ lookup_key = lower_lookup.get(key.lower())
1743
+ if lookup_key is not None:
1744
+ return entry.get(lookup_key)
1745
+ return None
1746
+
1747
+ @staticmethod
1748
+ def _decode_base64_payload(payload: str) -> bytes | None:
1749
+ """Decode base64 content safely.
1750
+
1751
+ Args:
1752
+ payload (str): Base64-encoded string.
1753
+
1754
+ Returns:
1755
+ bytes | None: Decoded bytes or ``None`` if decoding fails.
1756
+ """
1757
+
1758
+ compact = "".join(payload.split())
1759
+ try:
1760
+ return base64.b64decode(compact, validate=True)
1761
+ except (binascii.Error, ValueError):
1762
+ return None
1763
+
1764
+ @staticmethod
1765
+ def _should_decode_base64(payload: str) -> bool:
1766
+ """Heuristically determine whether a string looks base64 encoded."""
1767
+
1768
+ compact = "".join(payload.split())
1769
+ if compact.startswith("data:") and "base64," in compact:
1770
+ return True
1771
+ if any(marker in compact for marker in ("=", "+", "/")):
1772
+ return True
1773
+ if len(compact) >= 12 and len(compact) % 4 == 0:
1774
+ return bool(re.fullmatch(r"[A-Za-z0-9+/=]+", compact))
1775
+ return False
1776
+
1777
+ def _coerce_attachment_data(
1778
+ self, data, *, media_type: str | None
1779
+ ) -> bytes | None:
1780
+ """Normalize attachment data into bytes.
1781
+
1782
+ Args:
1783
+ data (Any): Raw attachment data.
1784
+ media_type (str | None): Attachment media type.
1785
+
1786
+ Returns:
1787
+ bytes | None: Normalized bytes or ``None`` when unsupported.
1788
+ """
1789
+
1790
+ if isinstance(data, (bytes, bytearray, memoryview)):
1791
+ return bytes(data)
1792
+
1793
+ if isinstance(data, (list, tuple)):
1794
+ if all(isinstance(item, int) for item in data):
1795
+ try:
1796
+ return bytes(data)
1797
+ except ValueError:
1798
+ return None
1799
+
1800
+ if isinstance(data, str):
1801
+ payload = data.strip()
1802
+ if not payload:
1803
+ return b""
1804
+ if payload.startswith("data:") and "base64," in payload:
1805
+ encoded = payload.split("base64,", 1)[1]
1806
+ decoded = self._decode_base64_payload(encoded)
1807
+ if decoded is not None:
1808
+ return decoded
1809
+ # Reason: attachments may arrive as base64 when sent from JSON-only clients.
1810
+ if self._should_decode_base64(payload):
1811
+ decoded = self._decode_base64_payload(payload)
1812
+ if decoded is not None:
1813
+ return decoded
1814
+ return payload.encode("utf-8")
1815
+
1816
+ return None
1817
+
1818
+ def _write_and_record_attachment(
1819
+ self,
1820
+ *,
1821
+ data: bytes,
1822
+ name: str,
1823
+ media_type: str | None,
1824
+ category: str,
1825
+ base_path: Path,
1826
+ topic_id: str | None,
1827
+ ):
1828
+ """
1829
+ Write an attachment to disk and record it via the API.
1830
+
1831
+ Args:
1832
+ data (bytes): Raw attachment data.
1833
+ name (str): Attachment filename.
1834
+ media_type (str | None): Optional MIME type.
1835
+ category (str): Attachment category ("file" or "image").
1836
+ base_path (Path): Directory to write the attachment.
1837
+
1838
+ Returns:
1839
+ FileAttachment | None: Stored record or None on failure.
1840
+ """
1841
+
1842
+ api = getattr(self, "api", None)
1843
+ if api is None:
1844
+ return None
1845
+ try:
1846
+ target_path = self._unique_path(base_path, name)
1847
+ target_path.parent.mkdir(parents=True, exist_ok=True)
1848
+ target_path.write_bytes(data)
1849
+ if category == "image":
1850
+ return api.store_image(
1851
+ target_path,
1852
+ name=target_path.name,
1853
+ media_type=media_type,
1854
+ topic_id=topic_id,
1855
+ )
1856
+ return api.store_file(
1857
+ target_path,
1858
+ name=target_path.name,
1859
+ media_type=media_type,
1860
+ topic_id=topic_id,
1861
+ )
1862
+ except Exception as exc: # pragma: no cover - defensive log
1863
+ RNS.log(
1864
+ f"Failed to persist {category} attachment '{name}': {exc}",
1865
+ getattr(RNS, "LOG_WARNING", 2),
1866
+ )
1867
+ return None
1868
+
1869
+ def _extract_attachment_topic_id(self, commands: list[dict] | None) -> str | None:
1870
+ """Return the TopicID from an AssociateTopicID command if provided."""
1871
+
1872
+ if not commands:
1873
+ return None
1874
+ command_manager = getattr(self, "command_manager", None)
1875
+ normalizer = (
1876
+ getattr(command_manager, "_normalize_command_name", None)
1877
+ if command_manager is not None
1878
+ else None
1879
+ )
1880
+ for command in commands:
1881
+ if not isinstance(command, dict):
1882
+ continue
1883
+ name = command.get(PLUGIN_COMMAND) or command.get("Command")
1884
+ if not name:
1885
+ continue
1886
+ normalized = normalizer(name) if callable(normalizer) else name
1887
+ if normalized == CommandManager.CMD_ASSOCIATE_TOPIC_ID:
1888
+ topic_id = CommandManager._extract_topic_id(command)
1889
+ if topic_id:
1890
+ return str(topic_id)
1891
+ return None
1892
+
1893
+ @staticmethod
1894
+ def _unique_path(base_path: Path, name: str) -> Path:
1895
+ """Return a unique, non-existing path for the attachment."""
1896
+
1897
+ candidate = base_path / name
1898
+ if not candidate.exists():
1899
+ return candidate
1900
+ index = 1
1901
+ stem = candidate.stem
1902
+ suffix = candidate.suffix
1903
+ while True:
1904
+ next_candidate = candidate.with_name(f"{stem}_{index}{suffix}")
1905
+ if not next_candidate.exists():
1906
+ return next_candidate
1907
+ index += 1
1908
+
1909
+ def _attachment_base_path(self, category: str) -> Path | None:
1910
+ """Return the configured base path for the given category."""
1911
+
1912
+ api = getattr(self, "api", None)
1913
+ if api is None:
1914
+ return None
1915
+ config_manager = getattr(api, "_config_manager", None)
1916
+ if config_manager is None:
1917
+ return None
1918
+ config = getattr(config_manager, "config", None)
1919
+ if config is None:
1920
+ return None
1921
+ if category == "image":
1922
+ return config.image_storage_path
1923
+ return config.file_storage_path
1924
+
1925
+ def _build_attachment_reply(
1926
+ self, message: LXMF.LXMessage, attachments, *, heading: str
1927
+ ) -> LXMF.LXMessage | None:
1928
+ """Create an acknowledgement LXMF message for stored attachments."""
1929
+
1930
+ lines = [heading]
1931
+ for index, attachment in enumerate(attachments, start=1):
1932
+ attachment_id = getattr(attachment, "file_id", None)
1933
+ name = getattr(attachment, "name", "<file>")
1934
+ id_text = attachment_id if attachment_id is not None else "<pending>"
1935
+ lines.append(f"{index}. {name} (ID: {id_text})")
1936
+ return self._reply_message(message, "\n".join(lines))
1937
+
1938
+ def _build_attachment_error_reply(
1939
+ self, message: LXMF.LXMessage, errors: list[str], *, heading: str
1940
+ ) -> LXMF.LXMessage | None:
1941
+ """Create an acknowledgement LXMF message for attachment errors."""
1942
+
1943
+ lines = [heading]
1944
+ for index, error in enumerate(errors, start=1):
1945
+ lines.append(f"{index}. {error}")
1946
+ return self._reply_message(message, "\n".join(lines))
1947
+
1948
+ def _reply_message(
1949
+ self, message: LXMF.LXMessage, content: str, fields: dict | None = None
1950
+ ) -> LXMF.LXMessage | None:
1951
+ """Construct a reply LXMF message to the sender."""
1952
+
1953
+ if self.my_lxmf_dest is None:
1954
+ return None
1955
+ destination = None
1956
+ try:
1957
+ command_manager = getattr(self, "command_manager", None)
1958
+ if command_manager is not None and hasattr(command_manager, "_create_dest"):
1959
+ destination = (
1960
+ command_manager._create_dest( # pylint: disable=protected-access
1961
+ message.source.identity
1962
+ )
1963
+ )
1964
+ except Exception:
1965
+ destination = None
1966
+ if destination is None:
1967
+ try:
1968
+ destination = RNS.Destination(
1969
+ message.source.identity,
1970
+ RNS.Destination.OUT,
1971
+ RNS.Destination.SINGLE,
1972
+ "lxmf",
1973
+ "delivery",
1974
+ )
1975
+ except Exception as exc: # pragma: no cover - defensive log
1976
+ RNS.log(
1977
+ f"Unable to build reply destination: {exc}",
1978
+ getattr(RNS, "LOG_WARNING", 2),
1979
+ )
1980
+ return None
1981
+ return LXMF.LXMessage(
1982
+ destination,
1983
+ self.my_lxmf_dest,
1984
+ content,
1985
+ fields=fields or {},
1986
+ desired_method=LXMF.LXMessage.DIRECT,
1987
+ )
1988
+
1989
+ def _is_telemetry_only(
1990
+ self, message: LXMF.LXMessage, telemetry_handled: bool
1991
+ ) -> bool:
1992
+ if not telemetry_handled:
1993
+ return False
1994
+ fields = message.fields or {}
1995
+ telemetry_keys = {LXMF.FIELD_TELEMETRY, LXMF.FIELD_TELEMETRY_STREAM}
1996
+ if not any(key in fields for key in telemetry_keys):
1997
+ return False
1998
+ for key, value in fields.items():
1999
+ if key in telemetry_keys:
2000
+ continue
2001
+ if value not in (None, "", b"", {}, [], ()): # pragma: no cover - guard
2002
+ return False
2003
+ content_text = self._message_text(message)
2004
+ if not content_text:
2005
+ return True
2006
+ return content_text.lower() in self.TELEMETRY_PLACEHOLDERS
2007
+
2008
+ @staticmethod
2009
+ def _message_text(message: LXMF.LXMessage) -> str:
2010
+ content = getattr(message, "content", None)
2011
+ if not content:
2012
+ return ""
2013
+ try:
2014
+ return message.content_as_string().strip()
2015
+ except Exception: # pragma: no cover - defensive
2016
+ return ""
2017
+
2018
+ def load_or_generate_identity(self, identity_path: Path):
2019
+ identity_path = Path(identity_path)
2020
+ if identity_path.exists():
2021
+ try:
2022
+ RNS.log("Loading existing identity")
2023
+ return RNS.Identity.from_file(str(identity_path))
2024
+ except Exception:
2025
+ RNS.log("Failed to load existing identity, generating new")
2026
+ else:
2027
+ RNS.log("Generating new identity")
2028
+
2029
+ identity = RNS.Identity() # Create a new identity
2030
+ identity_path.parent.mkdir(parents=True, exist_ok=True)
2031
+ identity.to_file(str(identity_path)) # Save the new identity to file
2032
+ return identity
2033
+
2034
+ def run(
2035
+ self,
2036
+ *,
2037
+ daemon_mode: bool = False,
2038
+ services: list[str] | tuple[str, ...] | None = None,
2039
+ ):
2040
+ RNS.log(
2041
+ f"Starting headless hub; announcing every {self.announce_interval}s",
2042
+ getattr(RNS, "LOG_INFO", 3),
2043
+ )
2044
+ if daemon_mode:
2045
+ self.start_daemon_workers(services=services)
2046
+ while not self._shutdown:
2047
+ self.my_lxmf_dest.announce()
2048
+ RNS.log("LXMF identity announced", getattr(RNS, "LOG_DEBUG", self.loglevel))
2049
+ time.sleep(self.announce_interval)
2050
+
2051
+ def start_daemon_workers(
2052
+ self, *, services: list[str] | tuple[str, ...] | None = None
2053
+ ) -> None:
2054
+ """Start background telemetry collectors and optional services."""
2055
+
2056
+ if self._daemon_started:
2057
+ return
2058
+
2059
+ self._ensure_outbound_queue()
2060
+
2061
+ if self.telemetry_sampler is not None:
2062
+ self.telemetry_sampler.start()
2063
+
2064
+ requested = list(services or [])
2065
+ for name in requested:
2066
+ service = self._create_service(name)
2067
+ if service is None:
2068
+ continue
2069
+ started = service.start()
2070
+ if started:
2071
+ self._active_services[name] = service
2072
+
2073
+ self._daemon_started = True
2074
+
2075
+ def stop_daemon_workers(self) -> None:
2076
+ if self._daemon_started:
2077
+ for key, service in list(self._active_services.items()):
2078
+ try:
2079
+ service.stop()
2080
+ finally:
2081
+ # Ensure the registry is cleared even if ``stop`` raises.
2082
+ self._active_services.pop(key, None)
2083
+
2084
+ if self.telemetry_sampler is not None:
2085
+ self.telemetry_sampler.stop()
2086
+
2087
+ self._daemon_started = False
2088
+
2089
+ if self._outbound_queue is not None:
2090
+ self.wait_for_outbound_flush(timeout=1.0)
2091
+ # Reason: ensure outbound thread exits cleanly between daemon runs.
2092
+ self._outbound_queue.stop()
2093
+
2094
+ def _create_service(self, name: str) -> HubService | None:
2095
+ factory = SERVICE_FACTORIES.get(name)
2096
+ if factory is None:
2097
+ RNS.log(
2098
+ f"Unknown daemon service '{name}'; available services: {sorted(SERVICE_FACTORIES)}",
2099
+ RNS.LOG_WARNING,
2100
+ )
2101
+ return None
2102
+ try:
2103
+ return factory(self)
2104
+ except Exception as exc: # pragma: no cover - defensive
2105
+ RNS.log(
2106
+ f"Failed to initialize daemon service '{name}': {exc}",
2107
+ RNS.LOG_ERROR,
2108
+ )
2109
+ return None
2110
+
2111
+ def shutdown(self):
2112
+ if self._shutdown:
2113
+ return
2114
+ self._shutdown = True
2115
+ self.stop_daemon_workers()
2116
+ if self.embedded_lxmd is not None:
2117
+ self.embedded_lxmd.stop()
2118
+ self.embedded_lxmd = None
2119
+ self.telemetry_sampler = None
2120
+
2121
+
2122
+ if __name__ == "__main__":
2123
+ ap = argparse.ArgumentParser()
2124
+ ap.add_argument(
2125
+ "-c",
2126
+ "--config",
2127
+ dest="config_path",
2128
+ help="Path to a unified config.ini file",
2129
+ default=None,
2130
+ )
2131
+ ap.add_argument("-s", "--storage_dir", help="Storage directory path", default=None)
2132
+ ap.add_argument("--display_name", help="Display name for the server", default=None)
2133
+ ap.add_argument(
2134
+ "--announce-interval",
2135
+ type=int,
2136
+ default=None,
2137
+ help="Seconds between announcement broadcasts",
2138
+ )
2139
+ ap.add_argument(
2140
+ "--hub-telemetry-interval",
2141
+ type=int,
2142
+ default=None,
2143
+ help="Seconds between local telemetry snapshots.",
2144
+ )
2145
+ ap.add_argument(
2146
+ "--service-telemetry-interval",
2147
+ type=int,
2148
+ default=None,
2149
+ help="Seconds between remote telemetry collector polls.",
2150
+ )
2151
+ ap.add_argument(
2152
+ "--log-level",
2153
+ choices=list(LOG_LEVELS.keys()),
2154
+ default=None,
2155
+ help="Log level to emit RNS traffic to stdout",
2156
+ )
2157
+ ap.add_argument(
2158
+ "--embedded",
2159
+ "--embedded-lxmd",
2160
+ dest="embedded",
2161
+ action=argparse.BooleanOptionalAction,
2162
+ default=None,
2163
+ help="Run the LXMF router/propagation threads in-process.",
2164
+ )
2165
+ ap.add_argument(
2166
+ "--daemon",
2167
+ dest="daemon",
2168
+ action="store_true",
2169
+ help="Start local telemetry collectors and optional services.",
2170
+ )
2171
+ ap.add_argument(
2172
+ "--service",
2173
+ dest="services",
2174
+ action="append",
2175
+ default=[],
2176
+ metavar="NAME",
2177
+ help=(
2178
+ "Enable an optional daemon service (e.g., gpsd). Repeat the flag for"
2179
+ " multiple services."
2180
+ ),
2181
+ )
2182
+
2183
+ args = ap.parse_args()
2184
+
2185
+ storage_path = _expand_user_path(args.storage_dir or STORAGE_PATH)
2186
+ identity_path = storage_path / "identity"
2187
+ config_path = (
2188
+ _expand_user_path(args.config_path)
2189
+ if args.config_path
2190
+ else storage_path / "config.ini"
2191
+ )
2192
+
2193
+ config_manager = HubConfigurationManager(
2194
+ storage_path=storage_path, config_path=config_path
2195
+ )
2196
+ app_config = config_manager.config
2197
+ runtime_config = app_config.runtime
2198
+
2199
+ display_name = args.display_name or runtime_config.display_name
2200
+ announce_interval = args.announce_interval or runtime_config.announce_interval
2201
+ hub_interval = _resolve_interval(
2202
+ args.hub_telemetry_interval,
2203
+ runtime_config.hub_telemetry_interval or DEFAULT_HUB_TELEMETRY_INTERVAL,
2204
+ )
2205
+ service_interval = _resolve_interval(
2206
+ args.service_telemetry_interval,
2207
+ runtime_config.service_telemetry_interval or DEFAULT_SERVICE_TELEMETRY_INTERVAL,
2208
+ )
2209
+
2210
+ log_level_name = (
2211
+ args.log_level or runtime_config.log_level or DEFAULT_LOG_LEVEL_NAME
2212
+ ).lower()
2213
+ loglevel = LOG_LEVELS.get(log_level_name, DEFAULT_LOG_LEVEL)
2214
+
2215
+ embedded = runtime_config.embedded_lxmd if args.embedded is None else args.embedded
2216
+ requested_services = list(runtime_config.default_services)
2217
+ requested_services.extend(args.services or [])
2218
+ services = list(dict.fromkeys(requested_services))
2219
+
2220
+ reticulum_server = ReticulumTelemetryHub(
2221
+ display_name,
2222
+ storage_path,
2223
+ identity_path,
2224
+ embedded=embedded,
2225
+ announce_interval=announce_interval,
2226
+ loglevel=loglevel,
2227
+ hub_telemetry_interval=hub_interval,
2228
+ service_telemetry_interval=service_interval,
2229
+ config_manager=config_manager,
2230
+ )
2231
+
2232
+ try:
2233
+ reticulum_server.run(daemon_mode=args.daemon, services=services)
2234
+ except KeyboardInterrupt:
2235
+ RNS.log("Received interrupt, shutting down", RNS.LOG_INFO)
2236
+ finally:
2237
+ reticulum_server.shutdown()