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