ReticulumTelemetryHub 0.1.0__py3-none-any.whl → 0.143.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. reticulum_telemetry_hub/api/__init__.py +23 -0
  2. reticulum_telemetry_hub/api/models.py +323 -0
  3. reticulum_telemetry_hub/api/service.py +836 -0
  4. reticulum_telemetry_hub/api/storage.py +528 -0
  5. reticulum_telemetry_hub/api/storage_base.py +156 -0
  6. reticulum_telemetry_hub/api/storage_models.py +118 -0
  7. reticulum_telemetry_hub/atak_cot/__init__.py +49 -0
  8. reticulum_telemetry_hub/atak_cot/base.py +277 -0
  9. reticulum_telemetry_hub/atak_cot/chat.py +506 -0
  10. reticulum_telemetry_hub/atak_cot/detail.py +235 -0
  11. reticulum_telemetry_hub/atak_cot/event.py +181 -0
  12. reticulum_telemetry_hub/atak_cot/pytak_client.py +569 -0
  13. reticulum_telemetry_hub/atak_cot/tak_connector.py +848 -0
  14. reticulum_telemetry_hub/config/__init__.py +25 -0
  15. reticulum_telemetry_hub/config/constants.py +7 -0
  16. reticulum_telemetry_hub/config/manager.py +515 -0
  17. reticulum_telemetry_hub/config/models.py +215 -0
  18. reticulum_telemetry_hub/embedded_lxmd/__init__.py +5 -0
  19. reticulum_telemetry_hub/embedded_lxmd/embedded.py +418 -0
  20. reticulum_telemetry_hub/internal_api/__init__.py +21 -0
  21. reticulum_telemetry_hub/internal_api/bus.py +344 -0
  22. reticulum_telemetry_hub/internal_api/core.py +690 -0
  23. reticulum_telemetry_hub/internal_api/v1/__init__.py +74 -0
  24. reticulum_telemetry_hub/internal_api/v1/enums.py +109 -0
  25. reticulum_telemetry_hub/internal_api/v1/manifest.json +8 -0
  26. reticulum_telemetry_hub/internal_api/v1/schemas.py +478 -0
  27. reticulum_telemetry_hub/internal_api/versioning.py +63 -0
  28. reticulum_telemetry_hub/lxmf_daemon/Handlers.py +122 -0
  29. reticulum_telemetry_hub/lxmf_daemon/LXMF.py +252 -0
  30. reticulum_telemetry_hub/lxmf_daemon/LXMPeer.py +898 -0
  31. reticulum_telemetry_hub/lxmf_daemon/LXMRouter.py +4227 -0
  32. reticulum_telemetry_hub/lxmf_daemon/LXMessage.py +1006 -0
  33. reticulum_telemetry_hub/lxmf_daemon/LXStamper.py +490 -0
  34. reticulum_telemetry_hub/lxmf_daemon/__init__.py +10 -0
  35. reticulum_telemetry_hub/lxmf_daemon/_version.py +1 -0
  36. reticulum_telemetry_hub/lxmf_daemon/lxmd.py +1655 -0
  37. reticulum_telemetry_hub/lxmf_telemetry/model/fields/field_telemetry_stream.py +6 -0
  38. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/__init__.py +3 -0
  39. {lxmf_telemetry → reticulum_telemetry_hub/lxmf_telemetry}/model/persistance/appearance.py +19 -19
  40. {lxmf_telemetry → reticulum_telemetry_hub/lxmf_telemetry}/model/persistance/peer.py +17 -13
  41. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/__init__.py +65 -0
  42. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/acceleration.py +68 -0
  43. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/ambient_light.py +37 -0
  44. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/angular_velocity.py +68 -0
  45. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/battery.py +68 -0
  46. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/connection_map.py +258 -0
  47. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/generic.py +841 -0
  48. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/gravity.py +68 -0
  49. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/humidity.py +37 -0
  50. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/information.py +42 -0
  51. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/location.py +110 -0
  52. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/lxmf_propagation.py +429 -0
  53. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/magnetic_field.py +68 -0
  54. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/physical_link.py +53 -0
  55. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/pressure.py +37 -0
  56. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/proximity.py +37 -0
  57. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/received.py +75 -0
  58. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/rns_transport.py +209 -0
  59. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/sensor.py +65 -0
  60. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/sensor_enum.py +27 -0
  61. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/sensor_mapping.py +58 -0
  62. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/temperature.py +37 -0
  63. {lxmf_telemetry → reticulum_telemetry_hub/lxmf_telemetry}/model/persistance/sensors/time.py +36 -32
  64. {lxmf_telemetry → reticulum_telemetry_hub/lxmf_telemetry}/model/persistance/telemeter.py +26 -23
  65. reticulum_telemetry_hub/lxmf_telemetry/sampler.py +229 -0
  66. reticulum_telemetry_hub/lxmf_telemetry/telemeter_manager.py +409 -0
  67. reticulum_telemetry_hub/lxmf_telemetry/telemetry_controller.py +804 -0
  68. reticulum_telemetry_hub/northbound/__init__.py +5 -0
  69. reticulum_telemetry_hub/northbound/app.py +195 -0
  70. reticulum_telemetry_hub/northbound/auth.py +119 -0
  71. reticulum_telemetry_hub/northbound/gateway.py +310 -0
  72. reticulum_telemetry_hub/northbound/internal_adapter.py +302 -0
  73. reticulum_telemetry_hub/northbound/models.py +213 -0
  74. reticulum_telemetry_hub/northbound/routes_chat.py +123 -0
  75. reticulum_telemetry_hub/northbound/routes_files.py +119 -0
  76. reticulum_telemetry_hub/northbound/routes_rest.py +345 -0
  77. reticulum_telemetry_hub/northbound/routes_subscribers.py +150 -0
  78. reticulum_telemetry_hub/northbound/routes_topics.py +178 -0
  79. reticulum_telemetry_hub/northbound/routes_ws.py +107 -0
  80. reticulum_telemetry_hub/northbound/serializers.py +72 -0
  81. reticulum_telemetry_hub/northbound/services.py +373 -0
  82. reticulum_telemetry_hub/northbound/websocket.py +855 -0
  83. reticulum_telemetry_hub/reticulum_server/__main__.py +2237 -0
  84. reticulum_telemetry_hub/reticulum_server/command_manager.py +1268 -0
  85. reticulum_telemetry_hub/reticulum_server/command_text.py +399 -0
  86. reticulum_telemetry_hub/reticulum_server/constants.py +1 -0
  87. reticulum_telemetry_hub/reticulum_server/event_log.py +357 -0
  88. reticulum_telemetry_hub/reticulum_server/internal_adapter.py +358 -0
  89. reticulum_telemetry_hub/reticulum_server/outbound_queue.py +312 -0
  90. reticulum_telemetry_hub/reticulum_server/services.py +422 -0
  91. reticulumtelemetryhub-0.143.0.dist-info/METADATA +181 -0
  92. reticulumtelemetryhub-0.143.0.dist-info/RECORD +97 -0
  93. {reticulumtelemetryhub-0.1.0.dist-info → reticulumtelemetryhub-0.143.0.dist-info}/WHEEL +1 -1
  94. reticulumtelemetryhub-0.143.0.dist-info/licenses/LICENSE +277 -0
  95. lxmf_telemetry/model/fields/field_telemetry_stream.py +0 -7
  96. lxmf_telemetry/model/persistance/__init__.py +0 -3
  97. lxmf_telemetry/model/persistance/sensors/location.py +0 -69
  98. lxmf_telemetry/model/persistance/sensors/magnetic_field.py +0 -36
  99. lxmf_telemetry/model/persistance/sensors/sensor.py +0 -44
  100. lxmf_telemetry/model/persistance/sensors/sensor_enum.py +0 -24
  101. lxmf_telemetry/model/persistance/sensors/sensor_mapping.py +0 -9
  102. lxmf_telemetry/telemetry_controller.py +0 -124
  103. reticulum_server/main.py +0 -182
  104. reticulumtelemetryhub-0.1.0.dist-info/METADATA +0 -15
  105. reticulumtelemetryhub-0.1.0.dist-info/RECORD +0 -19
  106. {lxmf_telemetry → reticulum_telemetry_hub}/__init__.py +0 -0
  107. {lxmf_telemetry/model/persistance/sensors → reticulum_telemetry_hub/lxmf_telemetry}/__init__.py +0 -0
  108. {reticulum_server → reticulum_telemetry_hub/reticulum_server}/__init__.py +0 -0
@@ -0,0 +1,1268 @@
1
+ # Command management for Reticulum Telemetry Hub
2
+ from __future__ import annotations
3
+
4
+ from functools import partial
5
+ from typing import Any, Dict, List, Optional
6
+ import json
7
+ from pathlib import Path
8
+ import re
9
+ import time
10
+ import RNS
11
+ import LXMF
12
+
13
+ from reticulum_telemetry_hub.api.models import Client, FileAttachment, Subscriber, Topic
14
+ from reticulum_telemetry_hub.api.service import ReticulumTelemetryHubAPI
15
+ from reticulum_telemetry_hub.config.manager import HubConfigurationManager
16
+ from reticulum_telemetry_hub.reticulum_server.event_log import EventLog
17
+
18
+ from .constants import PLUGIN_COMMAND
19
+ from .command_text import (
20
+ build_help_text,
21
+ build_examples_text,
22
+ format_attachment_list,
23
+ format_subscriber_list,
24
+ format_topic_list,
25
+ topic_subscribe_hint,
26
+ )
27
+ from ..lxmf_telemetry.telemetry_controller import TelemetryController
28
+
29
+
30
+ class CommandManager:
31
+ """Manage RTH command execution."""
32
+
33
+ # Command names based on the API specification
34
+ CMD_HELP = "Help"
35
+ CMD_EXAMPLES = "Examples"
36
+ CMD_JOIN = "join"
37
+ CMD_LEAVE = "leave"
38
+ CMD_LIST_CLIENTS = "ListClients"
39
+ CMD_RETRIEVE_TOPIC = "RetrieveTopic"
40
+ CMD_CREATE_TOPIC = "CreateTopic"
41
+ CMD_DELETE_TOPIC = "DeleteTopic"
42
+ CMD_LIST_TOPIC = "ListTopic"
43
+ CMD_PATCH_TOPIC = "PatchTopic"
44
+ CMD_SUBSCRIBE_TOPIC = "SubscribeTopic"
45
+ CMD_RETRIEVE_SUBSCRIBER = "RetrieveSubscriber"
46
+ CMD_ADD_SUBSCRIBER = "AddSubscriber"
47
+ CMD_CREATE_SUBSCRIBER = "CreateSubscriber"
48
+ CMD_DELETE_SUBSCRIBER = "DeleteSubscriber"
49
+ CMD_LIST_SUBSCRIBER = "ListSubscriber"
50
+ CMD_PATCH_SUBSCRIBER = "PatchSubscriber"
51
+ CMD_REMOVE_SUBSCRIBER = "RemoveSubscriber"
52
+ CMD_GET_APP_INFO = "getAppInfo"
53
+ CMD_LIST_FILES = "ListFiles"
54
+ CMD_LIST_IMAGES = "ListImages"
55
+ CMD_RETRIEVE_FILE = "RetrieveFile"
56
+ CMD_RETRIEVE_IMAGE = "RetrieveImage"
57
+ CMD_ASSOCIATE_TOPIC_ID = "AssociateTopicID"
58
+ CMD_STATUS = "GetStatus"
59
+ CMD_LIST_EVENTS = "ListEvents"
60
+ CMD_BAN_IDENTITY = "BanIdentity"
61
+ CMD_UNBAN_IDENTITY = "UnbanIdentity"
62
+ CMD_BLACKHOLE_IDENTITY = "BlackholeIdentity"
63
+ CMD_LIST_IDENTITIES = "ListIdentities"
64
+ CMD_GET_CONFIG = "GetConfig"
65
+ CMD_VALIDATE_CONFIG = "ValidateConfig"
66
+ CMD_APPLY_CONFIG = "ApplyConfig"
67
+ CMD_ROLLBACK_CONFIG = "RollbackConfig"
68
+ CMD_FLUSH_TELEMETRY = "FlushTelemetry"
69
+ CMD_RELOAD_CONFIG = "ReloadConfig"
70
+ CMD_DUMP_ROUTING = "DumpRouting"
71
+ POSITIONAL_FIELDS: Dict[str, List[str]] = {
72
+ CMD_CREATE_TOPIC: ["TopicName", "TopicPath"],
73
+ CMD_RETRIEVE_TOPIC: ["TopicID"],
74
+ CMD_DELETE_TOPIC: ["TopicID"],
75
+ CMD_PATCH_TOPIC: ["TopicID", "TopicName", "TopicPath", "TopicDescription"],
76
+ CMD_SUBSCRIBE_TOPIC: ["TopicID", "RejectTests"],
77
+ CMD_CREATE_SUBSCRIBER: ["Destination", "TopicID"],
78
+ CMD_ADD_SUBSCRIBER: ["Destination", "TopicID"],
79
+ CMD_RETRIEVE_SUBSCRIBER: ["SubscriberID"],
80
+ CMD_DELETE_SUBSCRIBER: ["SubscriberID"],
81
+ CMD_REMOVE_SUBSCRIBER: ["SubscriberID"],
82
+ CMD_PATCH_SUBSCRIBER: ["SubscriberID"],
83
+ CMD_RETRIEVE_FILE: ["FileID"],
84
+ CMD_RETRIEVE_IMAGE: ["FileID"],
85
+ CMD_ASSOCIATE_TOPIC_ID: ["TopicID"],
86
+ }
87
+
88
+ def __init__(
89
+ self,
90
+ connections: dict,
91
+ tel_controller: TelemetryController,
92
+ my_lxmf_dest: RNS.Destination,
93
+ api: ReticulumTelemetryHubAPI,
94
+ *,
95
+ config_manager: HubConfigurationManager | None = None,
96
+ event_log: EventLog | None = None,
97
+ ):
98
+ self.connections = connections
99
+ self.tel_controller = tel_controller
100
+ self.my_lxmf_dest = my_lxmf_dest
101
+ self.api = api
102
+ self.config_manager = config_manager
103
+ self.event_log = event_log
104
+ self.pending_field_requests: Dict[str, Dict[str, Dict[str, Any]]] = {}
105
+ self._command_aliases_cache: Dict[str, str] = {}
106
+ self._start_time = time.time()
107
+
108
+ def _all_command_names(self) -> List[str]:
109
+ """Return the list of supported command names."""
110
+
111
+ return [
112
+ self.CMD_HELP,
113
+ self.CMD_EXAMPLES,
114
+ self.CMD_JOIN,
115
+ self.CMD_LEAVE,
116
+ self.CMD_LIST_CLIENTS,
117
+ self.CMD_RETRIEVE_TOPIC,
118
+ self.CMD_CREATE_TOPIC,
119
+ self.CMD_DELETE_TOPIC,
120
+ self.CMD_LIST_TOPIC,
121
+ self.CMD_PATCH_TOPIC,
122
+ self.CMD_SUBSCRIBE_TOPIC,
123
+ self.CMD_RETRIEVE_SUBSCRIBER,
124
+ self.CMD_ADD_SUBSCRIBER,
125
+ self.CMD_CREATE_SUBSCRIBER,
126
+ self.CMD_DELETE_SUBSCRIBER,
127
+ self.CMD_REMOVE_SUBSCRIBER,
128
+ self.CMD_LIST_SUBSCRIBER,
129
+ self.CMD_PATCH_SUBSCRIBER,
130
+ self.CMD_GET_APP_INFO,
131
+ self.CMD_LIST_FILES,
132
+ self.CMD_LIST_IMAGES,
133
+ self.CMD_RETRIEVE_FILE,
134
+ self.CMD_RETRIEVE_IMAGE,
135
+ self.CMD_ASSOCIATE_TOPIC_ID,
136
+ self.CMD_STATUS,
137
+ self.CMD_LIST_EVENTS,
138
+ self.CMD_BAN_IDENTITY,
139
+ self.CMD_UNBAN_IDENTITY,
140
+ self.CMD_BLACKHOLE_IDENTITY,
141
+ self.CMD_LIST_IDENTITIES,
142
+ self.CMD_GET_CONFIG,
143
+ self.CMD_VALIDATE_CONFIG,
144
+ self.CMD_APPLY_CONFIG,
145
+ self.CMD_ROLLBACK_CONFIG,
146
+ self.CMD_FLUSH_TELEMETRY,
147
+ self.CMD_RELOAD_CONFIG,
148
+ self.CMD_DUMP_ROUTING,
149
+ ]
150
+
151
+ def _command_alias_map(self) -> Dict[str, str]:
152
+ """Return a mapping of lowercase aliases to canonical commands."""
153
+
154
+ if self._command_aliases_cache:
155
+ return self._command_aliases_cache
156
+ for command_name in self._all_command_names():
157
+ aliases = {
158
+ command_name.lower(),
159
+ self._lower_camel(command_name).lower(),
160
+ }
161
+ for alias in aliases:
162
+ self._command_aliases_cache.setdefault(alias, command_name)
163
+ self._command_aliases_cache.setdefault(
164
+ "retrievesubscriber", self.CMD_RETRIEVE_SUBSCRIBER
165
+ )
166
+ self._command_aliases_cache.setdefault(
167
+ "retreivesubscriber", self.CMD_RETRIEVE_SUBSCRIBER
168
+ )
169
+ return self._command_aliases_cache
170
+
171
+ @staticmethod
172
+ def _lower_camel(command_name: str) -> str:
173
+ """Return the command name with a lowercase prefix."""
174
+
175
+ if not command_name:
176
+ return command_name
177
+ return command_name[0].lower() + command_name[1:]
178
+
179
+ def _normalize_command_name(self, name: Optional[str]) -> Optional[str]:
180
+ """Normalize command names across casing variants."""
181
+
182
+ if name is None:
183
+ return None
184
+ normalized = name.strip()
185
+ if not normalized:
186
+ return None
187
+ alias_map = self._command_alias_map()
188
+ return alias_map.get(normalized.lower(), normalized)
189
+
190
+ # ------------------------------------------------------------------
191
+ # public API
192
+ # ------------------------------------------------------------------
193
+ def handle_commands(
194
+ self, commands: List[dict], message: LXMF.LXMessage
195
+ ) -> List[LXMF.LXMessage]:
196
+ """Process a list of commands and return generated responses."""
197
+
198
+ responses: List[LXMF.LXMessage] = []
199
+ for raw_command in commands:
200
+ normalized, error_response = self._normalize_command(raw_command, message)
201
+ if error_response is not None:
202
+ responses.append(error_response)
203
+ continue
204
+ if normalized is None:
205
+ continue
206
+ try:
207
+ msg = self.handle_command(normalized, message)
208
+ except Exception as exc: # pragma: no cover - defensive log
209
+ command_name = normalized.get(PLUGIN_COMMAND) or normalized.get(
210
+ "Command"
211
+ )
212
+ RNS.log(
213
+ f"Command '{command_name}' failed: {exc}",
214
+ getattr(RNS, "LOG_WARNING", 2),
215
+ )
216
+ msg = self._reply(
217
+ message, f"Command failed: {command_name or 'unknown'}"
218
+ )
219
+ if msg:
220
+ if isinstance(msg, list):
221
+ responses.extend(msg)
222
+ else:
223
+ responses.append(msg)
224
+ return responses
225
+
226
+ def _normalize_command(
227
+ self, raw_command: Any, message: LXMF.LXMessage
228
+ ) -> tuple[Optional[dict], Optional[LXMF.LXMessage]]:
229
+ """Normalize incoming command payloads, including JSON-wrapped strings.
230
+
231
+ Args:
232
+ raw_command (Any): The incoming payload from LXMF.
233
+ message (LXMF.LXMessage): Source LXMF message for contextual replies.
234
+
235
+ Returns:
236
+ tuple[Optional[dict], Optional[LXMF.LXMessage]]: Normalized payload and
237
+ optional error reply when parsing fails.
238
+ """
239
+
240
+ if isinstance(raw_command, str):
241
+ raw_command, error_response = self._parse_json_object(raw_command, message)
242
+ if error_response is not None:
243
+ return None, error_response
244
+
245
+ if isinstance(raw_command, (list, tuple)):
246
+ raw_command = {index: value for index, value in enumerate(raw_command)}
247
+
248
+ if isinstance(raw_command, dict):
249
+ normalized, error_response = self._unwrap_sideband_payload(
250
+ raw_command, message
251
+ )
252
+ if error_response is not None:
253
+ return None, error_response
254
+ normalized = self._apply_positional_payload(normalized)
255
+ return normalized, None
256
+
257
+ return None, self._reply(
258
+ message, f"Unsupported command payload type: {type(raw_command).__name__}"
259
+ )
260
+
261
+ def _parse_json_object(
262
+ self, payload: str, message: LXMF.LXMessage
263
+ ) -> tuple[Optional[dict], Optional[LXMF.LXMessage]]:
264
+ """Parse a JSON string and ensure it represents an object.
265
+
266
+ Args:
267
+ payload (str): Raw JSON string containing command data.
268
+ message (LXMF.LXMessage): Source LXMF message for error replies.
269
+
270
+ Returns:
271
+ tuple[Optional[dict], Optional[LXMF.LXMessage]]: Parsed JSON
272
+ object or an error response when parsing fails.
273
+ """
274
+
275
+ try:
276
+ parsed = json.loads(payload)
277
+ except json.JSONDecodeError:
278
+ error = self._reply(
279
+ message, f"Command payload is not valid JSON: {payload!r}"
280
+ )
281
+ return None, error
282
+ if not isinstance(parsed, dict):
283
+ return None, self._reply(message, "Parsed command must be a JSON object")
284
+ return parsed, None
285
+
286
+ def _unwrap_sideband_payload(
287
+ self, payload: dict, message: LXMF.LXMessage
288
+ ) -> tuple[dict, Optional[LXMF.LXMessage]]:
289
+ """Remove Sideband numeric-key wrappers and parse nested JSON content.
290
+
291
+ Args:
292
+ payload (dict): Incoming command payload.
293
+ message (LXMF.LXMessage): Source LXMF message for error replies.
294
+
295
+ Returns:
296
+ tuple[dict, Optional[LXMF.LXMessage]]: Normalized command payload and
297
+ an optional error response when nested parsing fails.
298
+ """
299
+
300
+ if len(payload) == 1:
301
+ key = next(iter(payload))
302
+ if isinstance(key, (int, str)) and str(key).isdigit():
303
+ inner_payload = payload[key]
304
+ if isinstance(inner_payload, dict):
305
+ return inner_payload, None
306
+ if isinstance(inner_payload, str) and inner_payload.lstrip().startswith(
307
+ "{"
308
+ ):
309
+ parsed, error_response = self._parse_json_object(
310
+ inner_payload, message
311
+ )
312
+ if error_response is not None:
313
+ return payload, error_response
314
+ if parsed is not None:
315
+ return parsed, None
316
+ return payload, None
317
+
318
+ def _apply_positional_payload(self, payload: dict) -> dict:
319
+ """Expand numeric-key payloads into named command dictionaries.
320
+
321
+ Sideband can emit command payloads as ``{0: "CreateTopic", 1: "Weather"}``
322
+ instead of JSON objects. This helper maps known positional arguments into
323
+ the expected named fields so downstream handlers receive structured data.
324
+
325
+ Args:
326
+ payload (dict): Raw command payload.
327
+
328
+ Returns:
329
+ dict: Normalized payload including "Command" and PLUGIN_COMMAND keys
330
+ when conversion succeeds; otherwise the original payload.
331
+ """
332
+
333
+ if PLUGIN_COMMAND in payload or "Command" in payload:
334
+ has_named_fields = any(not self._is_numeric_key(key) for key in payload)
335
+ if has_named_fields:
336
+ return payload
337
+
338
+ numeric_keys = {key for key in payload if self._is_numeric_key(key)}
339
+ if not numeric_keys:
340
+ return payload
341
+
342
+ command_name_raw = payload.get(0) if 0 in payload else payload.get("0")
343
+ if not isinstance(command_name_raw, str):
344
+ return payload
345
+
346
+ command_name = self._normalize_command_name(command_name_raw) or command_name_raw
347
+ positional_fields = self._positional_fields_for_command(command_name)
348
+ if not positional_fields:
349
+ return payload
350
+
351
+ normalized: dict = {PLUGIN_COMMAND: command_name, "Command": command_name}
352
+ for index, field_name in enumerate(positional_fields, start=1):
353
+ value = self._numeric_lookup(payload, index)
354
+ if value is not None:
355
+ normalized[field_name] = value
356
+
357
+ for key, value in payload.items():
358
+ if self._is_numeric_key(key):
359
+ continue
360
+ normalized[key] = value
361
+ return normalized
362
+
363
+ def _positional_fields_for_command(self, command_name: str) -> List[str]:
364
+ """Return positional field hints for known commands.
365
+
366
+ Args:
367
+ command_name (str): Name of the incoming command.
368
+
369
+ Returns:
370
+ List[str]: Ordered field names expected for positional payloads.
371
+ """
372
+
373
+ return self.POSITIONAL_FIELDS.get(command_name, [])
374
+
375
+ @staticmethod
376
+ def _numeric_lookup(payload: dict, index: int) -> Any:
377
+ """Fetch a value from digit-only keys in either int or str form.
378
+
379
+ Args:
380
+ payload (dict): Payload to search.
381
+ index (int): Numeric index to look up.
382
+
383
+ Returns:
384
+ Any: The value bound to the numeric key when present.
385
+ """
386
+
387
+ if index in payload:
388
+ return payload.get(index)
389
+ index_key = str(index)
390
+ if index_key in payload:
391
+ return payload.get(index_key)
392
+ for key in payload:
393
+ if not CommandManager._is_numeric_key(key):
394
+ continue
395
+ try:
396
+ if int(str(key)) == index:
397
+ return payload.get(key)
398
+ except ValueError:
399
+ continue
400
+ return None
401
+
402
+ @staticmethod
403
+ def _has_numeric_key(payload: dict, index: int) -> bool:
404
+ """Return True when the payload includes a matching numeric key.
405
+
406
+ Args:
407
+ payload (dict): Payload to search.
408
+ index (int): Numeric index to look up.
409
+
410
+ Returns:
411
+ bool: True when the key exists in any numeric string form.
412
+ """
413
+
414
+ for key in payload:
415
+ if not CommandManager._is_numeric_key(key):
416
+ continue
417
+ try:
418
+ if int(str(key)) == index:
419
+ return True
420
+ except ValueError:
421
+ continue
422
+ return False
423
+
424
+ @staticmethod
425
+ def _is_numeric_key(key: Any) -> bool:
426
+ """Return True when the key is a digit-like identifier.
427
+
428
+ Args:
429
+ key (Any): Key to evaluate.
430
+
431
+ Returns:
432
+ bool: True when the key contains only digits.
433
+ """
434
+
435
+ try:
436
+ return str(key).isdigit()
437
+ except Exception:
438
+ return False
439
+
440
+ # ------------------------------------------------------------------
441
+ # individual command processing
442
+ # ------------------------------------------------------------------
443
+ def handle_command(
444
+ self, command: dict, message: LXMF.LXMessage
445
+ ) -> Optional[LXMF.LXMessage]:
446
+ command = self._merge_pending_fields(command, message)
447
+ name = command.get(PLUGIN_COMMAND) or command.get("Command")
448
+ name = self._normalize_command_name(name)
449
+ telemetry_request_present = self._has_numeric_key(
450
+ command, TelemetryController.TELEMETRY_REQUEST
451
+ )
452
+ is_telemetry_command = (
453
+ isinstance(name, str) and name.strip().lower() == "telemetryrequest"
454
+ )
455
+ if name:
456
+ command[PLUGIN_COMMAND] = name
457
+ command["Command"] = name
458
+ if name is not None:
459
+ dispatch_map = {
460
+ self.CMD_HELP: lambda: self._handle_help(message),
461
+ self.CMD_EXAMPLES: lambda: self._handle_examples(message),
462
+ self.CMD_JOIN: lambda: self._handle_join(message),
463
+ self.CMD_LEAVE: lambda: self._handle_leave(message),
464
+ self.CMD_LIST_CLIENTS: lambda: self._handle_list_clients(message),
465
+ self.CMD_GET_APP_INFO: lambda: self._handle_get_app_info(message),
466
+ self.CMD_LIST_TOPIC: lambda: self._handle_list_topics(message),
467
+ self.CMD_LIST_FILES: lambda: self._handle_list_files(message),
468
+ self.CMD_LIST_IMAGES: lambda: self._handle_list_images(message),
469
+ self.CMD_CREATE_TOPIC: lambda: self._handle_create_topic(
470
+ command, message
471
+ ),
472
+ self.CMD_RETRIEVE_TOPIC: lambda: self._handle_retrieve_topic(
473
+ command, message
474
+ ),
475
+ self.CMD_DELETE_TOPIC: lambda: self._handle_delete_topic(
476
+ command, message
477
+ ),
478
+ self.CMD_PATCH_TOPIC: lambda: self._handle_patch_topic(
479
+ command, message
480
+ ),
481
+ self.CMD_SUBSCRIBE_TOPIC: lambda: self._handle_subscribe_topic(
482
+ command, message
483
+ ),
484
+ self.CMD_LIST_SUBSCRIBER: lambda: self._handle_list_subscribers(
485
+ message
486
+ ),
487
+ self.CMD_RETRIEVE_FILE: lambda: self._handle_retrieve_file(
488
+ command, message
489
+ ),
490
+ self.CMD_RETRIEVE_IMAGE: lambda: self._handle_retrieve_image(
491
+ command, message
492
+ ),
493
+ self.CMD_ASSOCIATE_TOPIC_ID: lambda: self._handle_associate_topic_id(
494
+ command, message
495
+ ),
496
+ self.CMD_CREATE_SUBSCRIBER: lambda: self._handle_create_subscriber(
497
+ command, message
498
+ ),
499
+ self.CMD_ADD_SUBSCRIBER: lambda: self._handle_create_subscriber(
500
+ command, message
501
+ ),
502
+ self.CMD_RETRIEVE_SUBSCRIBER: partial(
503
+ self._handle_retrieve_subscriber, command, message
504
+ ),
505
+ self.CMD_DELETE_SUBSCRIBER: lambda: self._handle_delete_subscriber(
506
+ command, message
507
+ ),
508
+ self.CMD_REMOVE_SUBSCRIBER: lambda: self._handle_delete_subscriber(
509
+ command, message
510
+ ),
511
+ self.CMD_PATCH_SUBSCRIBER: lambda: self._handle_patch_subscriber(
512
+ command, message
513
+ ),
514
+ self.CMD_STATUS: lambda: self._handle_status(message),
515
+ self.CMD_LIST_EVENTS: lambda: self._handle_list_events(message),
516
+ self.CMD_BAN_IDENTITY: lambda: self._handle_ban_identity(
517
+ command, message
518
+ ),
519
+ self.CMD_UNBAN_IDENTITY: lambda: self._handle_unban_identity(
520
+ command, message
521
+ ),
522
+ self.CMD_BLACKHOLE_IDENTITY: lambda: self._handle_blackhole_identity(
523
+ command, message
524
+ ),
525
+ self.CMD_LIST_IDENTITIES: lambda: self._handle_list_identities(message),
526
+ self.CMD_GET_CONFIG: lambda: self._handle_get_config(message),
527
+ self.CMD_VALIDATE_CONFIG: lambda: self._handle_validate_config(
528
+ command, message
529
+ ),
530
+ self.CMD_APPLY_CONFIG: lambda: self._handle_apply_config(
531
+ command, message
532
+ ),
533
+ self.CMD_ROLLBACK_CONFIG: lambda: self._handle_rollback_config(
534
+ command, message
535
+ ),
536
+ self.CMD_FLUSH_TELEMETRY: lambda: self._handle_flush_telemetry(message),
537
+ self.CMD_RELOAD_CONFIG: lambda: self._handle_reload_config(message),
538
+ self.CMD_DUMP_ROUTING: lambda: self._handle_dump_routing(message),
539
+ }
540
+ handler = dispatch_map.get(name)
541
+ if handler is not None:
542
+ return handler()
543
+ if telemetry_request_present and is_telemetry_command:
544
+ return self.tel_controller.handle_command(
545
+ command, message, self.my_lxmf_dest
546
+ )
547
+ return self._handle_unknown_command(name, message)
548
+ # Delegate to telemetry controller for telemetry related commands
549
+ return self.tel_controller.handle_command(command, message, self.my_lxmf_dest)
550
+
551
+ # ------------------------------------------------------------------
552
+ # command implementations
553
+ # ------------------------------------------------------------------
554
+ def _create_dest(self, identity: RNS.Identity) -> RNS.Destination:
555
+ return RNS.Destination(
556
+ identity,
557
+ RNS.Destination.OUT,
558
+ RNS.Destination.SINGLE,
559
+ "lxmf",
560
+ "delivery",
561
+ )
562
+
563
+ def _handle_join(self, message: LXMF.LXMessage) -> LXMF.LXMessage:
564
+ dest = self._create_dest(message.source.identity)
565
+ self.connections[dest.identity.hash] = dest
566
+ identity_hex = self._identity_hex(dest.identity)
567
+ self.api.join(identity_hex)
568
+ RNS.log(f"Connection added: {message.source}")
569
+ display_name, label = self._resolve_identity_label(identity_hex)
570
+ self._record_event(
571
+ "client_join",
572
+ f"Client joined: {label}",
573
+ metadata={"identity": identity_hex, "display_name": display_name},
574
+ )
575
+ return LXMF.LXMessage(
576
+ dest,
577
+ self.my_lxmf_dest,
578
+ "Connection established",
579
+ desired_method=LXMF.LXMessage.DIRECT,
580
+ )
581
+
582
+ def _handle_leave(self, message: LXMF.LXMessage) -> LXMF.LXMessage:
583
+ dest = self._create_dest(message.source.identity)
584
+ self.connections.pop(dest.identity.hash, None)
585
+ identity_hex = self._identity_hex(dest.identity)
586
+ self.api.leave(identity_hex)
587
+ RNS.log(f"Connection removed: {message.source}")
588
+ display_name, label = self._resolve_identity_label(identity_hex)
589
+ self._record_event(
590
+ "client_leave",
591
+ f"Client left: {label}",
592
+ metadata={"identity": identity_hex, "display_name": display_name},
593
+ )
594
+ return LXMF.LXMessage(
595
+ dest,
596
+ self.my_lxmf_dest,
597
+ "Connection removed",
598
+ desired_method=LXMF.LXMessage.DIRECT,
599
+ )
600
+
601
+ def _handle_list_clients(self, message: LXMF.LXMessage) -> LXMF.LXMessage:
602
+ clients = self.api.list_clients()
603
+ client_hashes = [self._format_client_entry(client) for client in clients]
604
+ return self._reply(message, ",".join(client_hashes) or "")
605
+
606
+ def _handle_get_app_info(self, message: LXMF.LXMessage) -> LXMF.LXMessage:
607
+ app_info = self.api.get_app_info()
608
+ payload = {
609
+ "name": getattr(app_info, "app_name", ""),
610
+ "version": getattr(app_info, "app_version", ""),
611
+ "description": getattr(app_info, "app_description", ""),
612
+ }
613
+ return self._reply(message, json.dumps(payload, sort_keys=True))
614
+
615
+ def _handle_list_topics(self, message: LXMF.LXMessage) -> LXMF.LXMessage:
616
+ topics = self.api.list_topics()
617
+ content_lines = format_topic_list(topics)
618
+ content_lines.append(topic_subscribe_hint(self.CMD_SUBSCRIBE_TOPIC))
619
+ return self._reply(message, "\n".join(content_lines))
620
+
621
+ def _handle_list_files(self, message: LXMF.LXMessage) -> LXMF.LXMessage:
622
+ files = self.api.list_files()
623
+ lines = format_attachment_list(files, empty_text="No files stored yet.")
624
+ return self._reply(message, "\n".join(lines))
625
+
626
+ def _handle_list_images(self, message: LXMF.LXMessage) -> LXMF.LXMessage:
627
+ images = self.api.list_images()
628
+ lines = format_attachment_list(images, empty_text="No images stored yet.")
629
+ return self._reply(message, "\n".join(lines))
630
+
631
+ def _handle_associate_topic_id(
632
+ self, command: dict, message: LXMF.LXMessage
633
+ ) -> LXMF.LXMessage:
634
+ topic_id = self._extract_topic_id(command)
635
+ if not topic_id:
636
+ return self._prompt_for_fields(
637
+ self.CMD_ASSOCIATE_TOPIC_ID, ["TopicID"], message, command
638
+ )
639
+ payload = json.dumps({"TopicID": topic_id}, sort_keys=True)
640
+ return self._reply(message, f"Attachment TopicID set: {payload}")
641
+
642
+ def _handle_create_topic(
643
+ self, command: dict, message: LXMF.LXMessage
644
+ ) -> LXMF.LXMessage:
645
+ missing = self._missing_fields(command, ["TopicName", "TopicPath"])
646
+ if missing:
647
+ return self._prompt_for_fields(
648
+ self.CMD_CREATE_TOPIC, missing, message, command
649
+ )
650
+ topic = Topic.from_dict(command)
651
+ created = self.api.create_topic(topic)
652
+ payload = json.dumps(created.to_dict(), sort_keys=True)
653
+ self._record_event("topic_created", f"Topic created: {created.topic_id}")
654
+ return self._reply(message, f"Topic created: {payload}")
655
+
656
+ def _handle_retrieve_topic(
657
+ self, command: dict, message: LXMF.LXMessage
658
+ ) -> LXMF.LXMessage:
659
+ topic_id = self._extract_topic_id(command)
660
+ if not topic_id:
661
+ return self._prompt_for_fields(
662
+ self.CMD_RETRIEVE_TOPIC, ["TopicID"], message, command
663
+ )
664
+ try:
665
+ topic = self.api.retrieve_topic(topic_id)
666
+ except KeyError as exc:
667
+ return self._reply(message, str(exc))
668
+ payload = json.dumps(topic.to_dict(), sort_keys=True)
669
+ return self._reply(message, payload)
670
+
671
+ def _handle_delete_topic(
672
+ self, command: dict, message: LXMF.LXMessage
673
+ ) -> LXMF.LXMessage:
674
+ topic_id = self._extract_topic_id(command)
675
+ if not topic_id:
676
+ return self._prompt_for_fields(
677
+ self.CMD_DELETE_TOPIC, ["TopicID"], message, command
678
+ )
679
+ try:
680
+ topic = self.api.delete_topic(topic_id)
681
+ except KeyError as exc:
682
+ return self._reply(message, str(exc))
683
+ payload = json.dumps(topic.to_dict(), sort_keys=True)
684
+ self._record_event("topic_deleted", f"Topic deleted: {topic.topic_id}")
685
+ return self._reply(message, f"Topic deleted: {payload}")
686
+
687
+ def _handle_patch_topic(
688
+ self, command: dict, message: LXMF.LXMessage
689
+ ) -> LXMF.LXMessage:
690
+ topic_id = self._extract_topic_id(command)
691
+ if not topic_id:
692
+ return self._prompt_for_fields(
693
+ self.CMD_PATCH_TOPIC, ["TopicID"], message, command
694
+ )
695
+ updates = {k: v for k, v in command.items() if k != PLUGIN_COMMAND}
696
+ try:
697
+ topic = self.api.patch_topic(topic_id, **updates)
698
+ except KeyError as exc:
699
+ return self._reply(message, str(exc))
700
+ payload = json.dumps(topic.to_dict(), sort_keys=True)
701
+ self._record_event("topic_updated", f"Topic updated: {topic.topic_id}")
702
+ return self._reply(message, f"Topic updated: {payload}")
703
+
704
+ def _handle_subscribe_topic(
705
+ self, command: dict, message: LXMF.LXMessage
706
+ ) -> LXMF.LXMessage:
707
+ topic_id = self._extract_topic_id(command)
708
+ if not topic_id:
709
+ return self._prompt_for_fields(
710
+ self.CMD_SUBSCRIBE_TOPIC, ["TopicID"], message, command
711
+ )
712
+ destination = self._identity_hex(message.source.identity)
713
+ reject_tests = None
714
+ if "RejectTests" in command:
715
+ reject_tests = command["RejectTests"]
716
+ elif "reject_tests" in command:
717
+ reject_tests = command["reject_tests"]
718
+ metadata = command.get("Metadata") or command.get("metadata") or {}
719
+ try:
720
+ subscriber = self.api.subscribe_topic(
721
+ topic_id,
722
+ destination=destination,
723
+ reject_tests=reject_tests,
724
+ metadata=metadata,
725
+ )
726
+ except KeyError as exc:
727
+ return self._reply(message, str(exc))
728
+ payload = json.dumps(subscriber.to_dict(), sort_keys=True)
729
+ self._record_event(
730
+ "topic_subscribed",
731
+ f"Destination subscribed to {topic_id}",
732
+ )
733
+ return self._reply(message, f"Subscribed: {payload}")
734
+
735
+ def _handle_retrieve_file(
736
+ self, command: dict, message: LXMF.LXMessage
737
+ ) -> LXMF.LXMessage:
738
+ file_id_value = self._extract_file_id(command)
739
+ file_id = self._coerce_int_id(file_id_value)
740
+ if file_id is None:
741
+ if file_id_value is None:
742
+ return self._prompt_for_fields(
743
+ self.CMD_RETRIEVE_FILE, ["FileID"], message, command
744
+ )
745
+ return self._reply(message, "FileID must be an integer")
746
+ try:
747
+ attachment = self.api.retrieve_file(file_id)
748
+ except KeyError as exc:
749
+ return self._reply(message, str(exc))
750
+ try:
751
+ fields = self._build_attachment_fields(attachment)
752
+ except FileNotFoundError:
753
+ return self._reply(
754
+ message, f"File '{file_id}' not found on disk; remove and re-upload."
755
+ )
756
+ payload = json.dumps(attachment.to_dict(), sort_keys=True)
757
+ return self._reply(message, f"File retrieved: {payload}", fields=fields)
758
+
759
+ def _handle_retrieve_image(
760
+ self, command: dict, message: LXMF.LXMessage
761
+ ) -> LXMF.LXMessage:
762
+ image_id_value = self._extract_file_id(command)
763
+ image_id = self._coerce_int_id(image_id_value)
764
+ if image_id is None:
765
+ if image_id_value is None:
766
+ return self._prompt_for_fields(
767
+ self.CMD_RETRIEVE_IMAGE, ["FileID"], message, command
768
+ )
769
+ return self._reply(message, "FileID must be an integer")
770
+ try:
771
+ attachment = self.api.retrieve_image(image_id)
772
+ except KeyError as exc:
773
+ return self._reply(message, str(exc))
774
+ try:
775
+ fields = self._build_attachment_fields(attachment)
776
+ except FileNotFoundError:
777
+ return self._reply(
778
+ message, f"Image '{image_id}' not found on disk; remove and re-upload."
779
+ )
780
+ payload = json.dumps(attachment.to_dict(), sort_keys=True)
781
+ return self._reply(message, f"Image retrieved: {payload}", fields=fields)
782
+
783
+ def _handle_list_subscribers(self, message: LXMF.LXMessage) -> LXMF.LXMessage:
784
+ subscribers = self.api.list_subscribers()
785
+ lines = format_subscriber_list(subscribers)
786
+ return self._reply(message, "\n".join(lines))
787
+
788
+ def _handle_help(self, message: LXMF.LXMessage) -> LXMF.LXMessage:
789
+ return self._reply(message, build_help_text(self))
790
+
791
+ def _handle_examples(self, message: LXMF.LXMessage) -> LXMF.LXMessage:
792
+ return self._reply(message, build_examples_text(self))
793
+
794
+ def _handle_unknown_command(
795
+ self, name: str, message: LXMF.LXMessage
796
+ ) -> LXMF.LXMessage:
797
+ sender = self._identity_hex(message.source.identity)
798
+ RNS.log(f"Unknown command '{name}' from {sender}", getattr(RNS, "LOG_ERROR", 1))
799
+ help_text = build_help_text(self)
800
+ payload = f"Unknown command\n\n{help_text}"
801
+ return self._reply(message, payload)
802
+
803
+ def _prompt_for_fields(
804
+ self,
805
+ command_name: str,
806
+ missing_fields: List[str],
807
+ message: LXMF.LXMessage,
808
+ command: dict,
809
+ ) -> LXMF.LXMessage:
810
+ """Store pending requests and prompt the sender for missing fields."""
811
+
812
+ sender_key = self._sender_key(message)
813
+ self._register_pending_request(
814
+ sender_key, command_name, missing_fields, command
815
+ )
816
+ example_payload = self._build_prompt_example(
817
+ command_name, missing_fields, command
818
+ )
819
+ lines = [
820
+ f"{command_name} is missing required fields: {', '.join(missing_fields)}.",
821
+ "Reply with the missing fields in JSON format to continue.",
822
+ f"Example: {example_payload}",
823
+ ]
824
+ return self._reply(message, "\n".join(lines))
825
+
826
+ def _register_pending_request(
827
+ self,
828
+ sender_key: str,
829
+ command_name: str,
830
+ missing_fields: List[str],
831
+ command: dict,
832
+ ) -> None:
833
+ """Persist partial command data while waiting for required fields."""
834
+
835
+ stored_command = dict(command)
836
+ requests_for_sender = self.pending_field_requests.setdefault(sender_key, {})
837
+ requests_for_sender[command_name] = {
838
+ "command": stored_command,
839
+ "missing": list(missing_fields),
840
+ }
841
+
842
+ def _merge_pending_fields(self, command: dict, message: LXMF.LXMessage) -> dict:
843
+ """Combine new command fragments with any pending prompt state."""
844
+
845
+ sender_key = self._sender_key(message)
846
+ pending_commands = self.pending_field_requests.get(sender_key)
847
+ if not pending_commands:
848
+ return command
849
+ command_name = command.get(PLUGIN_COMMAND) or command.get("Command")
850
+ if command_name is None:
851
+ return command
852
+ pending_entry = pending_commands.get(command_name)
853
+ if pending_entry is None:
854
+ return command
855
+ merged_command = dict(pending_entry.get("command", {}))
856
+ merged_command.update(command)
857
+ merged_command.setdefault(PLUGIN_COMMAND, command_name)
858
+ merged_command.setdefault("Command", command_name)
859
+ remaining_missing = self._missing_fields(
860
+ merged_command, pending_entry.get("missing", [])
861
+ )
862
+ if remaining_missing:
863
+ pending_entry["missing"] = remaining_missing
864
+ pending_entry["command"] = merged_command
865
+ else:
866
+ del pending_commands[command_name]
867
+ if not pending_commands:
868
+ self.pending_field_requests.pop(sender_key, None)
869
+ return merged_command
870
+
871
+ @staticmethod
872
+ def _field_value(command: dict, field: str) -> Any:
873
+ """Return a field value supporting common casing variants."""
874
+
875
+ alternate_keys = {
876
+ field,
877
+ field.lower(),
878
+ field.replace("ID", "id"),
879
+ field.replace("ID", "_id"),
880
+ field.replace("Name", "name"),
881
+ field.replace("Name", "_name"),
882
+ field.replace("Path", "path"),
883
+ field.replace("Path", "_path"),
884
+ }
885
+ snake_key = re.sub(r"(?<!^)(?=[A-Z])", "_", field).lower()
886
+ alternate_keys.add(snake_key)
887
+ alternate_keys.add(snake_key.replace("_i_d", "_id"))
888
+ lower_camel = field[:1].lower() + field[1:]
889
+ alternate_keys.add(lower_camel)
890
+ alternate_keys.add(field.replace("ID", "Id"))
891
+ alternate_keys.add(lower_camel.replace("ID", "Id"))
892
+ for key in alternate_keys:
893
+ if key in command:
894
+ return command.get(key)
895
+ return command.get(field)
896
+
897
+ def _missing_fields(self, command: dict, required_fields: List[str]) -> List[str]:
898
+ """Identify which required fields are still empty."""
899
+
900
+ missing: List[str] = []
901
+ for field in required_fields:
902
+ value = self._field_value(command, field)
903
+ if value is None or value == "":
904
+ missing.append(field)
905
+ return missing
906
+
907
+ def _build_prompt_example(
908
+ self, command_name: str, missing_fields: List[str], command: dict
909
+ ) -> str:
910
+ """Construct a JSON example showing the missing fields."""
911
+
912
+ template: Dict[str, Any] = {"Command": command_name}
913
+ for key, value in command.items():
914
+ if key in {PLUGIN_COMMAND, "Command"}:
915
+ continue
916
+ template[key] = value
917
+ for field in missing_fields:
918
+ if self._field_value(template, field) in {None, ""}:
919
+ template[field] = f"<{field}>"
920
+ return json.dumps(template, sort_keys=True)
921
+
922
+ def _sender_key(self, message: LXMF.LXMessage) -> str:
923
+ """Return the hex identity key representing the message sender."""
924
+
925
+ return self._identity_hex(message.source.identity)
926
+
927
+ def _handle_create_subscriber(
928
+ self, command: dict, message: LXMF.LXMessage
929
+ ) -> LXMF.LXMessage:
930
+ destination = self._field_value(command, "Destination")
931
+ if not destination:
932
+ command = dict(command)
933
+ command["Destination"] = self._sender_key(message)
934
+ missing = self._missing_fields(command, ["Destination"])
935
+ if missing:
936
+ return self._prompt_for_fields(
937
+ self.CMD_CREATE_SUBSCRIBER, missing, message, command
938
+ )
939
+ subscriber = Subscriber.from_dict(command)
940
+ try:
941
+ created = self.api.create_subscriber(subscriber)
942
+ except ValueError as exc:
943
+ return self._reply(message, f"Subscriber creation failed: {exc}")
944
+ payload = json.dumps(created.to_dict(), sort_keys=True)
945
+ self._record_event(
946
+ "subscriber_created",
947
+ f"Subscriber created: {created.subscriber_id}",
948
+ )
949
+ return self._reply(message, f"Subscriber created: {payload}")
950
+
951
+ def _handle_retrieve_subscriber(
952
+ self, command: dict, message: LXMF.LXMessage
953
+ ) -> LXMF.LXMessage:
954
+ subscriber_id = self._extract_subscriber_id(command)
955
+ if not subscriber_id:
956
+ return self._prompt_for_fields(
957
+ self.CMD_RETRIEVE_SUBSCRIBER, ["SubscriberID"], message, command
958
+ )
959
+ try:
960
+ subscriber = self.api.retrieve_subscriber(subscriber_id)
961
+ except KeyError as exc:
962
+ return self._reply(message, str(exc))
963
+ payload = json.dumps(subscriber.to_dict(), sort_keys=True)
964
+ return self._reply(message, payload)
965
+
966
+ def _handle_delete_subscriber(
967
+ self, command: dict, message: LXMF.LXMessage
968
+ ) -> LXMF.LXMessage:
969
+ subscriber_id = self._extract_subscriber_id(command)
970
+ if not subscriber_id:
971
+ return self._prompt_for_fields(
972
+ self.CMD_DELETE_SUBSCRIBER, ["SubscriberID"], message, command
973
+ )
974
+ try:
975
+ subscriber = self.api.delete_subscriber(subscriber_id)
976
+ except KeyError as exc:
977
+ return self._reply(message, str(exc))
978
+ payload = json.dumps(subscriber.to_dict(), sort_keys=True)
979
+ self._record_event(
980
+ "subscriber_deleted",
981
+ f"Subscriber deleted: {subscriber.subscriber_id}",
982
+ )
983
+ return self._reply(message, f"Subscriber deleted: {payload}")
984
+
985
+ def _handle_patch_subscriber(
986
+ self, command: dict, message: LXMF.LXMessage
987
+ ) -> LXMF.LXMessage:
988
+ subscriber_id = self._extract_subscriber_id(command)
989
+ if not subscriber_id:
990
+ return self._prompt_for_fields(
991
+ self.CMD_PATCH_SUBSCRIBER, ["SubscriberID"], message, command
992
+ )
993
+ updates = {k: v for k, v in command.items() if k != PLUGIN_COMMAND}
994
+ try:
995
+ subscriber = self.api.patch_subscriber(subscriber_id, **updates)
996
+ except KeyError as exc:
997
+ return self._reply(message, str(exc))
998
+ payload = json.dumps(subscriber.to_dict(), sort_keys=True)
999
+ self._record_event(
1000
+ "subscriber_updated",
1001
+ f"Subscriber updated: {subscriber.subscriber_id}",
1002
+ )
1003
+ return self._reply(message, f"Subscriber updated: {payload}")
1004
+
1005
+ def _handle_status(self, message: LXMF.LXMessage) -> LXMF.LXMessage:
1006
+ """Return the dashboard status snapshot."""
1007
+
1008
+ uptime_seconds = int(time.time() - self._start_time)
1009
+ status = {
1010
+ "uptime_seconds": uptime_seconds,
1011
+ "clients": len(self.connections),
1012
+ "topics": len(self.api.list_topics()),
1013
+ "subscribers": len(self.api.list_subscribers()),
1014
+ "files": len(self.api.list_files()),
1015
+ "images": len(self.api.list_images()),
1016
+ "telemetry": self.tel_controller.telemetry_stats(),
1017
+ }
1018
+ payload = json.dumps(status, sort_keys=True)
1019
+ return self._reply(message, payload)
1020
+
1021
+ def _handle_list_events(self, message: LXMF.LXMessage) -> LXMF.LXMessage:
1022
+ """Return recent event entries for the dashboard."""
1023
+
1024
+ events = []
1025
+ if self.event_log is not None:
1026
+ events = self.event_log.list_events(limit=50)
1027
+ payload = json.dumps(events, sort_keys=True)
1028
+ return self._reply(message, payload)
1029
+
1030
+ def _handle_ban_identity(
1031
+ self, command: dict, message: LXMF.LXMessage
1032
+ ) -> LXMF.LXMessage:
1033
+ """Mark an identity as banned."""
1034
+
1035
+ identity = self._extract_identity(command)
1036
+ if not identity:
1037
+ return self._prompt_for_fields(
1038
+ self.CMD_BAN_IDENTITY, ["Identity"], message, command
1039
+ )
1040
+ status = self.api.ban_identity(identity)
1041
+ payload = json.dumps(status.to_dict(), sort_keys=True)
1042
+ self._record_event("identity_banned", f"Identity banned: {identity}")
1043
+ return self._reply(message, payload)
1044
+
1045
+ def _handle_unban_identity(
1046
+ self, command: dict, message: LXMF.LXMessage
1047
+ ) -> LXMF.LXMessage:
1048
+ """Remove a ban/blackhole from an identity."""
1049
+
1050
+ identity = self._extract_identity(command)
1051
+ if not identity:
1052
+ return self._prompt_for_fields(
1053
+ self.CMD_UNBAN_IDENTITY, ["Identity"], message, command
1054
+ )
1055
+ status = self.api.unban_identity(identity)
1056
+ payload = json.dumps(status.to_dict(), sort_keys=True)
1057
+ self._record_event("identity_unbanned", f"Identity unbanned: {identity}")
1058
+ return self._reply(message, payload)
1059
+
1060
+ def _handle_blackhole_identity(
1061
+ self, command: dict, message: LXMF.LXMessage
1062
+ ) -> LXMF.LXMessage:
1063
+ """Mark an identity as blackholed."""
1064
+
1065
+ identity = self._extract_identity(command)
1066
+ if not identity:
1067
+ return self._prompt_for_fields(
1068
+ self.CMD_BLACKHOLE_IDENTITY, ["Identity"], message, command
1069
+ )
1070
+ status = self.api.blackhole_identity(identity)
1071
+ payload = json.dumps(status.to_dict(), sort_keys=True)
1072
+ self._record_event("identity_blackholed", f"Identity blackholed: {identity}")
1073
+ return self._reply(message, payload)
1074
+
1075
+ def _handle_list_identities(self, message: LXMF.LXMessage) -> LXMF.LXMessage:
1076
+ """Return identity status entries for admin tools."""
1077
+
1078
+ identities = self.api.list_identity_statuses()
1079
+ payload = json.dumps([entry.to_dict() for entry in identities], sort_keys=True)
1080
+ return self._reply(message, payload)
1081
+
1082
+ def _handle_get_config(self, message: LXMF.LXMessage) -> LXMF.LXMessage:
1083
+ """Return the current config.ini content."""
1084
+
1085
+ config_text = self.api.get_config_text()
1086
+ return self._reply(message, config_text)
1087
+
1088
+ def _handle_validate_config(
1089
+ self, command: dict, message: LXMF.LXMessage
1090
+ ) -> LXMF.LXMessage:
1091
+ """Validate config content without applying changes."""
1092
+
1093
+ config_text = command.get("ConfigText") or command.get("config_text")
1094
+ if not config_text:
1095
+ return self._prompt_for_fields(
1096
+ self.CMD_VALIDATE_CONFIG, ["ConfigText"], message, command
1097
+ )
1098
+ result = self.api.validate_config_text(str(config_text))
1099
+ payload = json.dumps(result, sort_keys=True)
1100
+ return self._reply(message, payload)
1101
+
1102
+ def _handle_apply_config(
1103
+ self, command: dict, message: LXMF.LXMessage
1104
+ ) -> LXMF.LXMessage:
1105
+ """Apply a new config.ini payload."""
1106
+
1107
+ config_text = command.get("ConfigText") or command.get("config_text")
1108
+ if not config_text:
1109
+ return self._prompt_for_fields(
1110
+ self.CMD_APPLY_CONFIG, ["ConfigText"], message, command
1111
+ )
1112
+ try:
1113
+ result = self.api.apply_config_text(str(config_text))
1114
+ except ValueError as exc:
1115
+ return self._reply(message, f"Config apply failed: {exc}")
1116
+ payload = json.dumps(result, sort_keys=True)
1117
+ self._record_event("config_applied", "Configuration updated")
1118
+ return self._reply(message, payload)
1119
+
1120
+ def _handle_rollback_config(
1121
+ self, command: dict, message: LXMF.LXMessage
1122
+ ) -> LXMF.LXMessage:
1123
+ """Rollback configuration using the latest backup."""
1124
+
1125
+ backup_path = command.get("BackupPath") or command.get("backup_path")
1126
+ backup_value = str(backup_path) if backup_path else None
1127
+ result = self.api.rollback_config_text(backup_path=backup_value)
1128
+ payload = json.dumps(result, sort_keys=True)
1129
+ self._record_event("config_rollback", "Configuration rollback applied")
1130
+ return self._reply(message, payload)
1131
+
1132
+ def _handle_flush_telemetry(self, message: LXMF.LXMessage) -> LXMF.LXMessage:
1133
+ """Clear stored telemetry records."""
1134
+
1135
+ deleted = self.tel_controller.clear_telemetry()
1136
+ payload = json.dumps({"deleted": deleted}, sort_keys=True)
1137
+ self._record_event("telemetry_flushed", f"Telemetry flushed ({deleted} rows)")
1138
+ return self._reply(message, payload)
1139
+
1140
+ def _handle_reload_config(self, message: LXMF.LXMessage) -> LXMF.LXMessage:
1141
+ """Reload configuration from disk."""
1142
+
1143
+ config = self.api.reload_config()
1144
+ payload = json.dumps(config.to_dict(), sort_keys=True)
1145
+ self._record_event("config_reloaded", "Configuration reloaded")
1146
+ return self._reply(message, payload)
1147
+
1148
+ def _handle_dump_routing(self, message: LXMF.LXMessage) -> LXMF.LXMessage:
1149
+ """Return a summary of connected destinations."""
1150
+
1151
+ destinations = [
1152
+ self._identity_hex(dest.identity) for dest in self.connections.values()
1153
+ ]
1154
+ payload = json.dumps({"destinations": destinations}, sort_keys=True)
1155
+ return self._reply(message, payload)
1156
+
1157
+ @staticmethod
1158
+ def _identity_hex(identity: RNS.Identity) -> str:
1159
+ hash_bytes = getattr(identity, "hash", b"") or b""
1160
+ return hash_bytes.hex()
1161
+
1162
+ def _resolve_identity_label(self, identity: str) -> tuple[str | None, str]:
1163
+ display_name = None
1164
+ if hasattr(self.api, "resolve_identity_display_name"):
1165
+ try:
1166
+ display_name = self.api.resolve_identity_display_name(identity)
1167
+ except Exception: # pragma: no cover - defensive
1168
+ display_name = None
1169
+ if display_name:
1170
+ return display_name, f"{display_name} ({identity})"
1171
+ return None, identity
1172
+
1173
+ @staticmethod
1174
+ def _format_client_entry(client: Client) -> str:
1175
+ metadata = client.metadata or {}
1176
+ metadata_str = json.dumps(metadata, sort_keys=True)
1177
+ try:
1178
+ identity_bytes = bytes.fromhex(client.identity)
1179
+ identity_value = RNS.prettyhexrep(identity_bytes)
1180
+ except (ValueError, TypeError):
1181
+ identity_value = client.identity
1182
+ return f"{identity_value}|{metadata_str}"
1183
+
1184
+ def _reply(
1185
+ self, message: LXMF.LXMessage, content: str, *, fields: Optional[dict] = None
1186
+ ) -> LXMF.LXMessage:
1187
+ dest = self._create_dest(message.source.identity)
1188
+ return LXMF.LXMessage(
1189
+ dest,
1190
+ self.my_lxmf_dest,
1191
+ content,
1192
+ fields=fields,
1193
+ desired_method=LXMF.LXMessage.DIRECT,
1194
+ )
1195
+
1196
+ @staticmethod
1197
+ def _extract_topic_id(command: dict) -> Optional[str]:
1198
+ return (
1199
+ command.get("TopicID")
1200
+ or command.get("topic_id")
1201
+ or command.get("id")
1202
+ or command.get("ID")
1203
+ )
1204
+
1205
+ @staticmethod
1206
+ def _extract_subscriber_id(command: dict) -> Optional[str]:
1207
+ return (
1208
+ command.get("SubscriberID")
1209
+ or command.get("subscriber_id")
1210
+ or command.get("id")
1211
+ or command.get("ID")
1212
+ )
1213
+
1214
+ @staticmethod
1215
+ def _extract_file_id(command: dict) -> Optional[Any]:
1216
+ for field in ("FileID", "ImageID", "ID"):
1217
+ value = CommandManager._field_value(command, field)
1218
+ if value is not None:
1219
+ return value
1220
+ return None
1221
+
1222
+ @staticmethod
1223
+ def _extract_identity(command: dict) -> Optional[str]:
1224
+ """Return identity hash from a command payload."""
1225
+
1226
+ return (
1227
+ command.get("Identity")
1228
+ or command.get("identity")
1229
+ or command.get("Destination")
1230
+ or command.get("destination")
1231
+ )
1232
+
1233
+ @staticmethod
1234
+ def _coerce_int_id(value: Any) -> Optional[int]:
1235
+ try:
1236
+ return int(value)
1237
+ except (TypeError, ValueError):
1238
+ return None
1239
+
1240
+ def _attachment_payload(self, attachment: FileAttachment) -> list:
1241
+ """Build a list payload compatible with Sideband/MeshChat clients."""
1242
+
1243
+ file_path = Path(attachment.path)
1244
+ data = file_path.read_bytes()
1245
+ if attachment.media_type:
1246
+ return [attachment.name, data, attachment.media_type]
1247
+ return [attachment.name, data]
1248
+
1249
+ def _build_attachment_fields(self, attachment: FileAttachment) -> dict:
1250
+ """Return LXMF fields carrying attachment content."""
1251
+
1252
+ payload = self._attachment_payload(attachment)
1253
+ category = (attachment.category or "").lower()
1254
+ if category == "image":
1255
+ return {
1256
+ LXMF.FIELD_IMAGE: payload,
1257
+ LXMF.FIELD_FILE_ATTACHMENTS: [payload],
1258
+ }
1259
+ return {LXMF.FIELD_FILE_ATTACHMENTS: [payload]}
1260
+
1261
+ def _record_event(
1262
+ self, event_type: str, message: str, *, metadata: Optional[dict] = None
1263
+ ) -> None:
1264
+ """Emit an event log entry when a log sink is configured."""
1265
+
1266
+ if self.event_log is None:
1267
+ return
1268
+ self.event_log.add_event(event_type, message, metadata=metadata)