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,836 @@
1
+ """Reticulum Telemetry Hub API service operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from datetime import datetime
7
+ from datetime import timedelta
8
+ from datetime import timezone
9
+ from pathlib import Path
10
+ from typing import List
11
+ from typing import Optional
12
+
13
+ from reticulum_telemetry_hub.config import HubConfigurationManager
14
+
15
+ from .models import ChatAttachment
16
+ from .models import ChatMessage
17
+ from .models import Client
18
+ from .models import FileAttachment
19
+ from .models import IdentityStatus
20
+ from .models import ReticulumInfo
21
+ from .models import Subscriber
22
+ from .models import Topic
23
+ from .storage import HubStorage
24
+
25
+
26
+ class ReticulumTelemetryHubAPI: # pylint: disable=too-many-public-methods
27
+ """Persistence-backed implementation of the ReticulumTelemetryHub API."""
28
+
29
+ def __init__(
30
+ self,
31
+ config_manager: Optional[HubConfigurationManager] = None,
32
+ storage: Optional[HubStorage] = None,
33
+ ) -> None:
34
+ """Initialize the API service with configuration and storage providers.
35
+
36
+ Args:
37
+ config_manager (Optional[HubConfigurationManager]): Manager
38
+ supplying hub configuration. When omitted, a default manager
39
+ loads the hub configuration and database path.
40
+ storage (Optional[HubStorage]): Persistence provider for clients,
41
+ topics, and subscribers. Defaults to storage built with the
42
+ configuration's database path.
43
+
44
+ """
45
+ self._config_manager = config_manager or HubConfigurationManager()
46
+ hub_db_path = self._config_manager.config.hub_database_path
47
+ self._storage = storage or HubStorage(hub_db_path)
48
+ self._file_category = "file"
49
+ self._image_category = "image"
50
+
51
+ # ------------------------------------------------------------------ #
52
+ # RTH operations
53
+ # ------------------------------------------------------------------ #
54
+ def join(self, identity: str) -> bool:
55
+ """Register a client with the Reticulum Telemetry Hub.
56
+
57
+ Args:
58
+ identity (str): Unique Reticulum identity string.
59
+
60
+ Returns:
61
+ bool: ``True`` when the identity is recorded or updated.
62
+
63
+ Raises:
64
+ ValueError: If ``identity`` is empty.
65
+
66
+ Examples:
67
+ >>> api.join("ABCDE")
68
+ True
69
+ """
70
+ if not identity:
71
+ raise ValueError("identity is required")
72
+ self._storage.upsert_client(identity)
73
+ return True
74
+
75
+ def leave(self, identity: str) -> bool:
76
+ """Remove a client from the hub.
77
+
78
+ Args:
79
+ identity (str): Identity previously joined to the hub.
80
+
81
+ Returns:
82
+ bool: ``True`` if the client existed and was removed; ``False``
83
+ otherwise.
84
+
85
+ Raises:
86
+ ValueError: If ``identity`` is empty.
87
+ """
88
+ if not identity:
89
+ raise ValueError("identity is required")
90
+ return self._storage.remove_client(identity)
91
+
92
+ # ------------------------------------------------------------------ #
93
+ # Client operations
94
+ # ------------------------------------------------------------------ #
95
+ def list_clients(self) -> List[Client]:
96
+ """Return all clients that have joined the hub.
97
+
98
+ Returns:
99
+ List[Client]: All persisted client records in insertion order.
100
+ """
101
+ return self._storage.list_clients()
102
+
103
+ def has_client(self, identity: str) -> bool:
104
+ """Return ``True`` when the client is registered with the hub.
105
+
106
+ Args:
107
+ identity (str): Identity to look up.
108
+
109
+ Returns:
110
+ bool: ``True`` if the identity exists in the client registry.
111
+ """
112
+ if not identity:
113
+ return False
114
+ return self._storage.get_client(identity) is not None
115
+
116
+ def record_identity_announce(
117
+ self,
118
+ identity: str,
119
+ *,
120
+ display_name: str | None = None,
121
+ source_interface: str | None = None,
122
+ ) -> None:
123
+ """Persist announce metadata for a Reticulum identity.
124
+
125
+ Args:
126
+ identity (str): Destination hash in hex form.
127
+ display_name (str | None): Optional display name from announce data.
128
+ source_interface (str | None): Optional source interface label.
129
+ """
130
+
131
+ if not identity:
132
+ raise ValueError("identity is required")
133
+ identity = identity.lower()
134
+ self._storage.upsert_identity_announce(
135
+ identity,
136
+ display_name=display_name,
137
+ source_interface=source_interface,
138
+ )
139
+
140
+ def resolve_identity_display_name(self, identity: str) -> str | None:
141
+ """Return the stored display name for an identity when available."""
142
+
143
+ if not identity:
144
+ return None
145
+ record = self._storage.get_identity_announce(identity.lower())
146
+ if record is None:
147
+ return None
148
+ return record.display_name
149
+
150
+ # ------------------------------------------------------------------ #
151
+ # File operations
152
+ # ------------------------------------------------------------------ #
153
+ def store_file(
154
+ self,
155
+ file_path: str | Path,
156
+ *,
157
+ name: Optional[str] = None,
158
+ media_type: str | None = None,
159
+ topic_id: Optional[str] = None,
160
+ ) -> FileAttachment:
161
+ """Persist metadata for a file stored on disk.
162
+
163
+ Args:
164
+ file_path (str | Path): Location of the file to record.
165
+ name (Optional[str]): Human readable name for the file. Defaults
166
+ to the filename.
167
+ media_type (Optional[str]): MIME type if known.
168
+
169
+ Returns:
170
+ FileAttachment: Stored file metadata with an ID.
171
+
172
+ Raises:
173
+ ValueError: If the file path is invalid or cannot be read.
174
+ """
175
+
176
+ return self._store_attachment(
177
+ file_path=file_path,
178
+ name=name,
179
+ media_type=media_type,
180
+ topic_id=topic_id,
181
+ category=self._file_category,
182
+ base_path=self._config_manager.config.file_storage_path,
183
+ )
184
+
185
+ def store_image(
186
+ self,
187
+ image_path: str | Path,
188
+ *,
189
+ name: Optional[str] = None,
190
+ media_type: str | None = None,
191
+ topic_id: Optional[str] = None,
192
+ ) -> FileAttachment:
193
+ """Persist metadata for an image stored on disk."""
194
+
195
+ return self._store_attachment(
196
+ file_path=image_path,
197
+ name=name,
198
+ media_type=media_type,
199
+ topic_id=topic_id,
200
+ category=self._image_category,
201
+ base_path=self._config_manager.config.image_storage_path,
202
+ )
203
+
204
+ def list_files(self) -> List[FileAttachment]:
205
+ """Return stored file records."""
206
+
207
+ return self._storage.list_file_records(category=self._file_category)
208
+
209
+ def list_images(self) -> List[FileAttachment]:
210
+ """Return stored image records."""
211
+
212
+ return self._storage.list_file_records(category=self._image_category)
213
+
214
+ def retrieve_file(self, record_id: int) -> FileAttachment:
215
+ """Fetch stored file metadata by ID."""
216
+
217
+ return self._retrieve_attachment(record_id, expected_category=self._file_category)
218
+
219
+ def retrieve_image(self, record_id: int) -> FileAttachment:
220
+ """Fetch stored image metadata by ID."""
221
+
222
+ return self._retrieve_attachment(record_id, expected_category=self._image_category)
223
+
224
+ def store_uploaded_attachment(
225
+ self,
226
+ *,
227
+ content: bytes,
228
+ filename: str,
229
+ media_type: Optional[str],
230
+ category: str,
231
+ topic_id: Optional[str] = None,
232
+ ) -> FileAttachment:
233
+ """Persist uploaded attachment bytes to disk and record metadata."""
234
+
235
+ safe_name = Path(filename).name
236
+ if not safe_name:
237
+ raise ValueError("filename is required")
238
+ if category == self._image_category:
239
+ base_path = self._config_manager.config.image_storage_path
240
+ elif category == self._file_category:
241
+ base_path = self._config_manager.config.file_storage_path
242
+ else:
243
+ raise ValueError("unsupported category")
244
+ base_path.mkdir(parents=True, exist_ok=True)
245
+ suffix = Path(safe_name).suffix
246
+ stored_name = f"{uuid.uuid4().hex}{suffix}"
247
+ target_path = base_path / stored_name
248
+ target_path.write_bytes(content)
249
+ return self._store_attachment(
250
+ file_path=target_path,
251
+ name=safe_name,
252
+ media_type=media_type,
253
+ topic_id=topic_id,
254
+ category=category,
255
+ base_path=base_path,
256
+ )
257
+
258
+ @staticmethod
259
+ def chat_attachment_from_file(attachment: FileAttachment) -> ChatAttachment:
260
+ """Convert a FileAttachment into a ChatAttachment reference."""
261
+
262
+ return ChatAttachment(
263
+ file_id=attachment.file_id or 0,
264
+ category=attachment.category,
265
+ name=attachment.name,
266
+ size=attachment.size,
267
+ media_type=attachment.media_type,
268
+ )
269
+
270
+ def record_chat_message(self, message: ChatMessage) -> ChatMessage:
271
+ """Persist a chat message and return the stored record."""
272
+
273
+ message.message_id = message.message_id or uuid.uuid4().hex
274
+ return self._storage.create_chat_message(message)
275
+
276
+ def list_chat_messages(
277
+ self,
278
+ *,
279
+ limit: int = 200,
280
+ direction: Optional[str] = None,
281
+ topic_id: Optional[str] = None,
282
+ destination: Optional[str] = None,
283
+ source: Optional[str] = None,
284
+ ) -> List[ChatMessage]:
285
+ """Return persisted chat messages."""
286
+
287
+ return self._storage.list_chat_messages(
288
+ limit=limit,
289
+ direction=direction,
290
+ topic_id=topic_id,
291
+ destination=destination,
292
+ source=source,
293
+ )
294
+
295
+ def update_chat_message_state(self, message_id: str, state: str) -> ChatMessage | None:
296
+ """Update a chat message delivery state."""
297
+
298
+ return self._storage.update_chat_message_state(message_id, state)
299
+
300
+ def chat_message_stats(self) -> dict[str, int]:
301
+ """Return aggregated chat message counters."""
302
+
303
+ return self._storage.chat_message_stats()
304
+
305
+ # ------------------------------------------------------------------ #
306
+ # Topic operations
307
+ # ------------------------------------------------------------------ #
308
+ def create_topic(self, topic: Topic) -> Topic:
309
+ """Create a topic in the hub database.
310
+
311
+ Args:
312
+ topic (Topic): Topic definition to store. ``topic_id`` is
313
+ auto-generated when not provided.
314
+
315
+ Returns:
316
+ Topic: Persisted topic record with a guaranteed ``topic_id``.
317
+
318
+ Raises:
319
+ ValueError: If ``topic.topic_name`` or ``topic.topic_path`` is
320
+ missing.
321
+
322
+ Notes:
323
+ A hex UUID is generated for ``topic_id`` when it is absent to
324
+ ensure unique topic identifiers across requests.
325
+ """
326
+ if not topic.topic_name or not topic.topic_path:
327
+ raise ValueError("TopicName and TopicPath are required")
328
+ topic.topic_id = topic.topic_id or uuid.uuid4().hex
329
+ return self._storage.create_topic(topic)
330
+
331
+ def list_topics(self) -> List[Topic]:
332
+ """List all topics known to the hub.
333
+
334
+ Returns:
335
+ List[Topic]: Current topic catalog from storage.
336
+ """
337
+ return self._storage.list_topics()
338
+
339
+ def retrieve_topic(self, topic_id: str) -> Topic:
340
+ """Fetch a topic by its identifier.
341
+
342
+ Args:
343
+ topic_id (str): Identifier of the topic to retrieve.
344
+
345
+ Returns:
346
+ Topic: The matching topic.
347
+
348
+ Raises:
349
+ KeyError: If the topic does not exist.
350
+ """
351
+ topic = self._storage.get_topic(topic_id)
352
+ if not topic:
353
+ raise KeyError(f"Topic '{topic_id}' not found")
354
+ return topic
355
+
356
+ def delete_topic(self, topic_id: str) -> Topic:
357
+ """Delete a topic by its identifier.
358
+
359
+ Args:
360
+ topic_id (str): Identifier of the topic to delete.
361
+
362
+ Returns:
363
+ Topic: The removed topic record.
364
+
365
+ Raises:
366
+ KeyError: If the topic does not exist.
367
+ """
368
+ topic = self._storage.delete_topic(topic_id)
369
+ if not topic:
370
+ raise KeyError(f"Topic '{topic_id}' not found")
371
+ return topic
372
+
373
+ def patch_topic(self, topic_id: str, **updates) -> Topic:
374
+ """Update selected fields of a topic.
375
+
376
+ Args:
377
+ topic_id (str): Identifier of the topic to update.
378
+ **updates: Optional fields to modify, accepting either snake_case
379
+ or title-cased keys (``topic_name``/``TopicName``,
380
+ ``topic_path``/``TopicPath``, ``topic_description``/
381
+ ``TopicDescription``).
382
+
383
+ Returns:
384
+ Topic: Updated topic. If no update fields are provided, the
385
+ existing topic is returned unchanged.
386
+
387
+ Raises:
388
+ KeyError: If the topic does not exist.
389
+
390
+ Notes:
391
+ ``topic_description`` defaults to an empty string when explicitly
392
+ set to ``None`` or an empty value.
393
+ """
394
+ topic = self.retrieve_topic(topic_id)
395
+ update_fields = {}
396
+ if "topic_name" in updates or "TopicName" in updates:
397
+ topic.topic_name = updates.get("topic_name") or updates.get("TopicName")
398
+ update_fields["topic_name"] = topic.topic_name
399
+ if "topic_path" in updates or "TopicPath" in updates:
400
+ topic.topic_path = updates.get("topic_path") or updates.get("TopicPath")
401
+ update_fields["topic_path"] = topic.topic_path
402
+ if "topic_description" in updates or "TopicDescription" in updates:
403
+ description = updates.get(
404
+ "topic_description", updates.get("TopicDescription")
405
+ )
406
+ topic.topic_description = description or ""
407
+ update_fields["topic_description"] = topic.topic_description
408
+ if not update_fields:
409
+ return topic
410
+ updated_topic = self._storage.update_topic(topic.topic_id, **update_fields)
411
+ if not updated_topic:
412
+ raise KeyError(f"Topic '{topic_id}' not found")
413
+ return updated_topic
414
+
415
+ def subscribe_topic(
416
+ self,
417
+ topic_id: str,
418
+ destination: str,
419
+ reject_tests: Optional[int] = None,
420
+ metadata: Optional[dict] = None,
421
+ ) -> Subscriber:
422
+ """Subscribe a destination to a topic.
423
+
424
+ Args:
425
+ topic_id (str): Identifier of the topic to subscribe to.
426
+ destination (str): Destination identity or address.
427
+ reject_tests (Optional[int]): Value indicating whether to reject
428
+ test messages; stored as provided.
429
+ metadata (Optional[dict]): Subscriber metadata. Defaults to an
430
+ empty dict when not provided.
431
+
432
+ Returns:
433
+ Subscriber: Persisted subscriber with a generated ``subscriber_id``
434
+ and the topic's resolved ``topic_id``.
435
+
436
+ Raises:
437
+ KeyError: If the referenced topic does not exist.
438
+ ValueError: If ``destination`` is empty.
439
+
440
+ Examples:
441
+ >>> api.subscribe_topic(topic_id, "dest")
442
+ Subscriber(..., subscriber_id="<uuid>", metadata={})
443
+ """
444
+ topic = self.retrieve_topic(topic_id)
445
+ subscriber = Subscriber(
446
+ destination=destination,
447
+ topic_id=topic.topic_id,
448
+ reject_tests=reject_tests,
449
+ metadata=metadata or {},
450
+ )
451
+ return self.create_subscriber(subscriber)
452
+
453
+ # ------------------------------------------------------------------ #
454
+ # Subscriber operations
455
+ # ------------------------------------------------------------------ #
456
+ def create_subscriber(self, subscriber: Subscriber) -> Subscriber:
457
+ """Create a subscriber record.
458
+
459
+ Args:
460
+ subscriber (Subscriber): Subscriber definition.
461
+ ``subscriber_id`` is auto-generated when missing. ``topic_id``
462
+ defaults to an empty string when not provided.
463
+
464
+ Returns:
465
+ Subscriber: Persisted subscriber with ensured identifiers.
466
+
467
+ Raises:
468
+ ValueError: If ``subscriber.destination`` is empty.
469
+
470
+ Notes:
471
+ ``subscriber.metadata`` is stored as-is; callers should supply an
472
+ empty dict when no metadata is required to avoid ``None`` values.
473
+ """
474
+ if not subscriber.destination:
475
+ raise ValueError("Subscriber destination is required")
476
+ subscriber.topic_id = subscriber.topic_id or ""
477
+ subscriber.subscriber_id = subscriber.subscriber_id or uuid.uuid4().hex
478
+ return self._storage.create_subscriber(subscriber)
479
+
480
+ def list_subscribers(self) -> List[Subscriber]:
481
+ """List all subscribers.
482
+
483
+ Returns:
484
+ List[Subscriber]: Subscribers currently stored in the hub.
485
+ """
486
+ return self._storage.list_subscribers()
487
+
488
+ def list_subscribers_for_topic(self, topic_id: str) -> List[Subscriber]:
489
+ """Return subscribers for a specific topic.
490
+
491
+ Args:
492
+ topic_id (str): Topic identifier to filter by.
493
+
494
+ Returns:
495
+ List[Subscriber]: Subscribers attached to the topic.
496
+
497
+ Raises:
498
+ KeyError: If the topic does not exist.
499
+ """
500
+ self.retrieve_topic(topic_id)
501
+ return [
502
+ subscriber
503
+ for subscriber in self._storage.list_subscribers()
504
+ if subscriber.topic_id == topic_id
505
+ ]
506
+
507
+ def list_topics_for_destination(self, destination: str) -> List[Topic]:
508
+ """Return topics a destination is subscribed to.
509
+
510
+ Args:
511
+ destination (str): Destination identity hash to query.
512
+
513
+ Returns:
514
+ List[Topic]: Topics matching the destination's subscriptions.
515
+ """
516
+ topic_ids = {
517
+ subscriber.topic_id
518
+ for subscriber in self._storage.list_subscribers()
519
+ if subscriber.destination == destination and subscriber.topic_id
520
+ }
521
+ return [topic for topic in self.list_topics() if topic.topic_id in topic_ids]
522
+
523
+ def retrieve_subscriber(self, subscriber_id: str) -> Subscriber:
524
+ """Fetch a subscriber by identifier.
525
+
526
+ Args:
527
+ subscriber_id (str): Identifier of the subscriber to retrieve.
528
+
529
+ Returns:
530
+ Subscriber: The matching subscriber.
531
+
532
+ Raises:
533
+ KeyError: If the subscriber does not exist.
534
+ """
535
+ subscriber = self._storage.get_subscriber(subscriber_id)
536
+ if not subscriber:
537
+ raise KeyError(f"Subscriber '{subscriber_id}' not found")
538
+ return subscriber
539
+
540
+ def delete_subscriber(self, subscriber_id: str) -> Subscriber:
541
+ """Delete a subscriber by identifier.
542
+
543
+ Args:
544
+ subscriber_id (str): Identifier of the subscriber to delete.
545
+
546
+ Returns:
547
+ Subscriber: The removed subscriber record.
548
+
549
+ Raises:
550
+ KeyError: If the subscriber does not exist.
551
+ """
552
+ subscriber = self._storage.delete_subscriber(subscriber_id)
553
+ if not subscriber:
554
+ raise KeyError(f"Subscriber '{subscriber_id}' not found")
555
+ return subscriber
556
+
557
+ def patch_subscriber(self, subscriber_id: str, **updates) -> Subscriber:
558
+ """Update selected subscriber fields.
559
+
560
+ Args:
561
+ subscriber_id (str): Identifier of the subscriber to update.
562
+ **updates: Optional fields to modify, accepting either snake_case
563
+ or title-cased keys (``destination``/``Destination``,
564
+ ``topic_id``/``TopicID``, ``reject_tests``/``RejectTests``,
565
+ ``metadata``/``Metadata``).
566
+
567
+ Returns:
568
+ Subscriber: Updated subscriber record.
569
+
570
+ Raises:
571
+ KeyError: If the subscriber does not exist.
572
+
573
+ Notes:
574
+ The metadata dictionary is replaced only when provided; otherwise,
575
+ existing metadata remains unchanged. Topic existence is not
576
+ validated during updates.
577
+ """
578
+ subscriber = self.retrieve_subscriber(subscriber_id)
579
+ if "destination" in updates or "Destination" in updates:
580
+ subscriber.destination = updates.get("destination") or updates.get(
581
+ "Destination"
582
+ )
583
+ if "topic_id" in updates or "TopicID" in updates:
584
+ subscriber.topic_id = updates.get("topic_id") or updates.get("TopicID")
585
+ if "reject_tests" in updates:
586
+ subscriber.reject_tests = updates["reject_tests"]
587
+ elif "RejectTests" in updates:
588
+ subscriber.reject_tests = updates["RejectTests"]
589
+ metadata_key = None
590
+ if "metadata" in updates:
591
+ metadata_key = "metadata"
592
+ elif "Metadata" in updates:
593
+ metadata_key = "Metadata"
594
+
595
+ if metadata_key is not None:
596
+ subscriber.metadata = updates[metadata_key]
597
+ return self._storage.update_subscriber(subscriber)
598
+
599
+ def add_subscriber(self, subscriber: Subscriber) -> Subscriber:
600
+ """Alias for :meth:`create_subscriber`.
601
+
602
+ Args:
603
+ subscriber (Subscriber): Subscriber definition to persist.
604
+
605
+ Returns:
606
+ Subscriber: Persisted subscriber record.
607
+ """
608
+ return self.create_subscriber(subscriber)
609
+
610
+ # ------------------------------------------------------------------ #
611
+ # Reticulum info
612
+ # ------------------------------------------------------------------ #
613
+ def get_app_info(self) -> ReticulumInfo:
614
+ """Return the current Reticulum configuration snapshot.
615
+
616
+ Returns:
617
+ ReticulumInfo: Configuration values sourced from the configuration
618
+ manager, including the app name, version, and description.
619
+ """
620
+ info_dict = self._config_manager.reticulum_info_snapshot()
621
+ return ReticulumInfo(**info_dict)
622
+
623
+ def get_config_text(self) -> str:
624
+ """Return the raw hub configuration file content."""
625
+
626
+ return self._config_manager.get_config_text()
627
+
628
+ def validate_config_text(self, config_text: str) -> dict:
629
+ """Validate the provided configuration payload."""
630
+
631
+ return self._config_manager.validate_config_text(config_text)
632
+
633
+ def apply_config_text(self, config_text: str) -> dict:
634
+ """Persist a new configuration payload and reload."""
635
+
636
+ result = self._config_manager.apply_config_text(config_text)
637
+ self._config_manager.reload()
638
+ return result
639
+
640
+ def rollback_config_text(self, backup_path: str | None = None) -> dict:
641
+ """Rollback configuration from the latest backup."""
642
+
643
+ result = self._config_manager.rollback_config_text(backup_path=backup_path)
644
+ self._config_manager.reload()
645
+ return result
646
+
647
+ def reload_config(self) -> ReticulumInfo:
648
+ """Reload the configuration from disk."""
649
+
650
+ config = self._config_manager.reload()
651
+ return ReticulumInfo(**config.to_reticulum_info_dict())
652
+
653
+ def list_identity_statuses(self) -> List[IdentityStatus]:
654
+ """Return identity statuses merged with client data."""
655
+
656
+ clients = {client.identity: client for client in self._storage.list_clients()}
657
+ states = {
658
+ state.identity: state for state in self._storage.list_identity_states()
659
+ }
660
+ announces = {
661
+ record.destination_hash: record
662
+ for record in self._storage.list_identity_announces()
663
+ }
664
+ identities = sorted(
665
+ set(clients.keys()) | set(states.keys()) | set(announces.keys())
666
+ )
667
+ statuses: List[IdentityStatus] = []
668
+ cutoff = datetime.now(timezone.utc) - timedelta(minutes=60)
669
+ for identity in identities:
670
+ client = clients.get(identity)
671
+ state = states.get(identity)
672
+ announce = announces.get(identity.lower())
673
+ display_name = announce.display_name if announce else None
674
+ metadata = dict(client.metadata if client else {})
675
+ if display_name and "display_name" not in metadata:
676
+ metadata["display_name"] = display_name
677
+ is_banned = bool(state.is_banned) if state else False
678
+ is_blackholed = bool(state.is_blackholed) if state else False
679
+ announce_last_seen = None
680
+ if announce and announce.last_seen:
681
+ announce_last_seen = announce.last_seen
682
+ if announce_last_seen.tzinfo is None:
683
+ announce_last_seen = announce_last_seen.replace(tzinfo=timezone.utc)
684
+ last_seen = announce_last_seen or (client.last_seen if client else None)
685
+ status = "inactive"
686
+ if announce_last_seen and announce_last_seen >= cutoff:
687
+ status = "active"
688
+ if is_blackholed:
689
+ status = "blackholed"
690
+ elif is_banned:
691
+ status = "banned"
692
+ statuses.append(
693
+ IdentityStatus(
694
+ identity=identity,
695
+ status=status,
696
+ last_seen=last_seen,
697
+ display_name=display_name,
698
+ metadata=metadata,
699
+ is_banned=is_banned,
700
+ is_blackholed=is_blackholed,
701
+ )
702
+ )
703
+ return statuses
704
+
705
+ def ban_identity(self, identity: str) -> IdentityStatus:
706
+ """Mark an identity as banned."""
707
+
708
+ if not identity:
709
+ raise ValueError("identity is required")
710
+ state = self._storage.upsert_identity_state(identity, is_banned=True)
711
+ client = self._storage.get_client(identity)
712
+ announce = self._storage.get_identity_announce(identity.lower())
713
+ display_name = announce.display_name if announce else None
714
+ metadata = dict(client.metadata if client else {})
715
+ if display_name and "display_name" not in metadata:
716
+ metadata["display_name"] = display_name
717
+ last_seen = announce.last_seen if announce else None
718
+ if last_seen and last_seen.tzinfo is None:
719
+ last_seen = last_seen.replace(tzinfo=timezone.utc)
720
+ return IdentityStatus(
721
+ identity=identity,
722
+ status="banned",
723
+ last_seen=last_seen or (client.last_seen if client else None),
724
+ display_name=display_name,
725
+ metadata=metadata,
726
+ is_banned=state.is_banned,
727
+ is_blackholed=state.is_blackholed,
728
+ )
729
+
730
+ def unban_identity(self, identity: str) -> IdentityStatus:
731
+ """Clear ban/blackhole flags for an identity."""
732
+
733
+ if not identity:
734
+ raise ValueError("identity is required")
735
+ state = self._storage.upsert_identity_state(
736
+ identity, is_banned=False, is_blackholed=False
737
+ )
738
+ client = self._storage.get_client(identity)
739
+ announce = self._storage.get_identity_announce(identity.lower())
740
+ display_name = announce.display_name if announce else None
741
+ metadata = dict(client.metadata if client else {})
742
+ if display_name and "display_name" not in metadata:
743
+ metadata["display_name"] = display_name
744
+ last_seen = announce.last_seen if announce else None
745
+ if last_seen and last_seen.tzinfo is None:
746
+ last_seen = last_seen.replace(tzinfo=timezone.utc)
747
+ status = "inactive"
748
+ if last_seen and last_seen >= datetime.now(timezone.utc) - timedelta(minutes=60):
749
+ status = "active"
750
+ return IdentityStatus(
751
+ identity=identity,
752
+ status=status,
753
+ last_seen=last_seen or (client.last_seen if client else None),
754
+ display_name=display_name,
755
+ metadata=metadata,
756
+ is_banned=state.is_banned,
757
+ is_blackholed=state.is_blackholed,
758
+ )
759
+
760
+ def blackhole_identity(self, identity: str) -> IdentityStatus:
761
+ """Mark an identity as blackholed."""
762
+
763
+ if not identity:
764
+ raise ValueError("identity is required")
765
+ state = self._storage.upsert_identity_state(identity, is_blackholed=True)
766
+ client = self._storage.get_client(identity)
767
+ announce = self._storage.get_identity_announce(identity.lower())
768
+ display_name = announce.display_name if announce else None
769
+ metadata = dict(client.metadata if client else {})
770
+ if display_name and "display_name" not in metadata:
771
+ metadata["display_name"] = display_name
772
+ last_seen = announce.last_seen if announce else None
773
+ if last_seen and last_seen.tzinfo is None:
774
+ last_seen = last_seen.replace(tzinfo=timezone.utc)
775
+ return IdentityStatus(
776
+ identity=identity,
777
+ status="blackholed",
778
+ last_seen=last_seen or (client.last_seen if client else None),
779
+ display_name=display_name,
780
+ metadata=metadata,
781
+ is_banned=state.is_banned,
782
+ is_blackholed=state.is_blackholed,
783
+ )
784
+
785
+ def _store_attachment( # pylint: disable=too-many-arguments
786
+ self,
787
+ *,
788
+ file_path: str | Path,
789
+ name: Optional[str],
790
+ media_type: str | None,
791
+ topic_id: Optional[str],
792
+ category: str,
793
+ base_path: Path,
794
+ ) -> FileAttachment:
795
+ """Validate inputs and persist file metadata."""
796
+
797
+ if category not in {self._file_category, self._image_category}:
798
+ raise ValueError("unsupported category")
799
+ if not file_path:
800
+ raise ValueError("file_path is required")
801
+ path_obj = Path(file_path)
802
+ if not path_obj.is_file():
803
+ raise ValueError(f"File '{file_path}' does not exist")
804
+ resolved_name = name or path_obj.name
805
+ if not resolved_name:
806
+ raise ValueError("name is required")
807
+ base_path.mkdir(parents=True, exist_ok=True)
808
+ resolved_base_path = base_path.resolve()
809
+ resolved_path = path_obj.resolve()
810
+ try:
811
+ resolved_path.relative_to(resolved_base_path)
812
+ except ValueError as exc:
813
+ raise ValueError(
814
+ f"File '{file_path}' must be stored within '{resolved_base_path}'"
815
+ ) from exc
816
+ stat_result = resolved_path.stat()
817
+ timestamp = datetime.now(timezone.utc)
818
+ attachment = FileAttachment(
819
+ name=resolved_name,
820
+ path=str(resolved_path),
821
+ category=category,
822
+ size=stat_result.st_size,
823
+ media_type=media_type,
824
+ topic_id=topic_id,
825
+ created_at=timestamp,
826
+ updated_at=timestamp,
827
+ )
828
+ return self._storage.create_file_record(attachment)
829
+
830
+ def _retrieve_attachment(self, record_id: int, *, expected_category: str) -> FileAttachment:
831
+ """Return an attachment by ID, ensuring it matches the category."""
832
+
833
+ record = self._storage.get_file_record(record_id)
834
+ if not record or record.category != expected_category:
835
+ raise KeyError(f"File '{record_id}' not found")
836
+ return record