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,528 @@
1
+ """Database storage helpers for the Reticulum Telemetry Hub API."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ from pathlib import Path
6
+ from typing import List
7
+ from typing import Optional
8
+ import uuid
9
+
10
+ from sqlalchemy import create_engine
11
+ from sqlalchemy import func as sa_func
12
+ from sqlalchemy.engine import Engine
13
+ from sqlalchemy.exc import OperationalError
14
+ from sqlalchemy.orm import sessionmaker
15
+ from sqlalchemy.pool import QueuePool
16
+
17
+ from .models import ChatMessage
18
+ from .models import Client
19
+ from .models import FileAttachment
20
+ from .models import Subscriber
21
+ from .models import Topic
22
+ from .storage_base import HubStorageBase
23
+ from .storage_models import Base
24
+ from .storage_models import ChatMessageRecord
25
+ from .storage_models import ClientRecord
26
+ from .storage_models import FileRecord
27
+ from .storage_models import IdentityAnnounceRecord
28
+ from .storage_models import IdentityStateRecord
29
+ from .storage_models import SubscriberRecord
30
+ from .storage_models import TopicRecord
31
+ from .storage_models import _utcnow
32
+
33
+
34
+ class HubStorage(HubStorageBase):
35
+ """SQLAlchemy-backed persistence layer for the RTH API."""
36
+ _POOL_SIZE = 25
37
+ _POOL_OVERFLOW = 50
38
+ _CONNECT_TIMEOUT_SECONDS = 30
39
+ _session_retries = 3
40
+ _session_backoff = 0.1
41
+
42
+ def __init__(self, db_path: Path):
43
+ """Create a storage instance backed by SQLite.
44
+
45
+ Args:
46
+ db_path (Path): Path to the SQLite database file.
47
+ """
48
+ db_path = Path(db_path)
49
+ db_path.parent.mkdir(parents=True, exist_ok=True)
50
+ self._engine = self._create_engine(db_path)
51
+ self._enable_wal_mode()
52
+ Base.metadata.create_all(self._engine)
53
+ self._ensure_file_topic_column()
54
+ self._session_factory = sessionmaker( # pylint: disable=invalid-name
55
+ bind=self._engine, expire_on_commit=False
56
+ )
57
+
58
+ @property
59
+ def _Session(self): # pylint: disable=invalid-name
60
+ """Return a session factory for backward compatibility in tests."""
61
+ return self._session_factory
62
+
63
+ def create_topic(self, topic: Topic) -> Topic:
64
+ """Insert or update a topic record.
65
+
66
+ Args:
67
+ topic (Topic): Topic to persist.
68
+
69
+ Returns:
70
+ Topic: Stored topic with an ID assigned.
71
+ """
72
+ with self._session_scope() as session:
73
+ record = TopicRecord(
74
+ id=topic.topic_id or uuid.uuid4().hex,
75
+ name=topic.topic_name,
76
+ path=topic.topic_path,
77
+ description=topic.topic_description,
78
+ )
79
+ session.merge(record)
80
+ session.commit()
81
+ return Topic(
82
+ topic_id=record.id,
83
+ topic_name=record.name,
84
+ topic_path=record.path,
85
+ topic_description=record.description or "",
86
+ )
87
+
88
+ def list_topics(self) -> List[Topic]:
89
+ """Return all topics ordered by insertion."""
90
+ with self._session_scope() as session:
91
+ records = (
92
+ session.query(TopicRecord)
93
+ .order_by(TopicRecord.created_at, TopicRecord.id)
94
+ .all()
95
+ )
96
+ return [
97
+ Topic(
98
+ topic_id=r.id,
99
+ topic_name=r.name,
100
+ topic_path=r.path,
101
+ topic_description=r.description or "",
102
+ )
103
+ for r in records
104
+ ]
105
+
106
+ def get_topic(self, topic_id: str) -> Optional[Topic]:
107
+ """Fetch a topic by identifier.
108
+
109
+ Args:
110
+ topic_id (str): Unique topic identifier.
111
+
112
+ Returns:
113
+ Optional[Topic]: Matching topic or ``None`` if missing.
114
+ """
115
+ with self._session_scope() as session:
116
+ record = session.get(TopicRecord, topic_id)
117
+ if not record:
118
+ return None
119
+ return Topic(
120
+ topic_id=record.id,
121
+ topic_name=record.name,
122
+ topic_path=record.path,
123
+ topic_description=record.description or "",
124
+ )
125
+
126
+ def delete_topic(self, topic_id: str) -> Optional[Topic]:
127
+ """Delete a topic record.
128
+
129
+ Args:
130
+ topic_id (str): Identifier of the topic to remove.
131
+
132
+ Returns:
133
+ Optional[Topic]: Removed topic or ``None`` when absent.
134
+ """
135
+ with self._session_scope() as session:
136
+ record = session.get(TopicRecord, topic_id)
137
+ if not record:
138
+ return None
139
+ session.delete(record)
140
+ session.commit()
141
+ return Topic(
142
+ topic_id=record.id,
143
+ topic_name=record.name,
144
+ topic_path=record.path,
145
+ topic_description=record.description or "",
146
+ )
147
+
148
+ def update_topic(
149
+ self,
150
+ topic_id: str,
151
+ *,
152
+ topic_name: Optional[str] = None,
153
+ topic_path: Optional[str] = None,
154
+ topic_description: Optional[str] = None,
155
+ ) -> Optional[Topic]:
156
+ """Update a topic with provided fields.
157
+
158
+ Args:
159
+ topic_id (str): Identifier of the topic to update.
160
+ topic_name (Optional[str]): New name when provided.
161
+ topic_path (Optional[str]): New path when provided.
162
+ topic_description (Optional[str]): New description when provided.
163
+
164
+ Returns:
165
+ Optional[Topic]: Updated topic or ``None`` when not found.
166
+ """
167
+ with self._session_scope() as session:
168
+ record = session.get(TopicRecord, topic_id)
169
+ if not record:
170
+ return None
171
+ if topic_name is not None:
172
+ record.name = topic_name
173
+ if topic_path is not None:
174
+ record.path = topic_path
175
+ if topic_description is not None:
176
+ record.description = topic_description
177
+ session.commit()
178
+ return Topic(
179
+ topic_id=record.id,
180
+ topic_name=record.name,
181
+ topic_path=record.path,
182
+ topic_description=record.description or "",
183
+ )
184
+
185
+ def create_subscriber(self, subscriber: Subscriber) -> Subscriber:
186
+ """Insert or update a subscriber record.
187
+
188
+ Args:
189
+ subscriber (Subscriber): Subscriber data to persist.
190
+
191
+ Returns:
192
+ Subscriber: Stored subscriber with ID assigned.
193
+ """
194
+ with self._session_scope() as session:
195
+ record = SubscriberRecord(
196
+ id=subscriber.subscriber_id or uuid.uuid4().hex,
197
+ destination=subscriber.destination,
198
+ topic_id=subscriber.topic_id,
199
+ reject_tests=subscriber.reject_tests,
200
+ metadata_json=subscriber.metadata or {},
201
+ )
202
+ session.merge(record)
203
+ session.commit()
204
+ return self._subscriber_from_record(record)
205
+
206
+ def list_subscribers(self) -> List[Subscriber]:
207
+ """Return all subscribers."""
208
+ with self._session_scope() as session:
209
+ records = session.query(SubscriberRecord).all()
210
+ return [self._subscriber_from_record(r) for r in records]
211
+
212
+ def get_subscriber(self, subscriber_id: str) -> Optional[Subscriber]:
213
+ """Fetch a subscriber by ID.
214
+
215
+ Args:
216
+ subscriber_id (str): Unique subscriber identifier.
217
+
218
+ Returns:
219
+ Optional[Subscriber]: Matching subscriber or ``None``.
220
+ """
221
+ with self._session_scope() as session:
222
+ record = session.get(SubscriberRecord, subscriber_id)
223
+ return self._subscriber_from_record(record) if record else None
224
+
225
+ def delete_subscriber(self, subscriber_id: str) -> Optional[Subscriber]:
226
+ """Delete a subscriber.
227
+
228
+ Args:
229
+ subscriber_id (str): Identifier of the subscriber to remove.
230
+
231
+ Returns:
232
+ Optional[Subscriber]: Removed subscriber or ``None`` if missing.
233
+ """
234
+ with self._session_scope() as session:
235
+ record = session.get(SubscriberRecord, subscriber_id)
236
+ if not record:
237
+ return None
238
+ session.delete(record)
239
+ session.commit()
240
+ return self._subscriber_from_record(record)
241
+
242
+ def update_subscriber(self, subscriber: Subscriber) -> Subscriber:
243
+ """Update a subscriber by merging fields."""
244
+ return self.create_subscriber(subscriber)
245
+
246
+ def upsert_client(self, identity: str) -> Client:
247
+ """Insert or update a client record.
248
+
249
+ Args:
250
+ identity (str): Client identity hash.
251
+
252
+ Returns:
253
+ Client: Stored or updated client instance.
254
+ """
255
+ with self._session_scope() as session:
256
+ record = session.get(ClientRecord, identity)
257
+ if record:
258
+ record.last_seen = _utcnow()
259
+ else:
260
+ record = ClientRecord(identity=identity, last_seen=_utcnow())
261
+ session.add(record)
262
+ session.commit()
263
+ return self._client_from_record(record)
264
+
265
+ def remove_client(self, identity: str) -> bool:
266
+ """Remove a client from storage.
267
+
268
+ Args:
269
+ identity (str): Identity hash to delete.
270
+
271
+ Returns:
272
+ bool: ``True`` when deletion occurred, ``False`` otherwise.
273
+ """
274
+ with self._session_scope() as session:
275
+ record = session.get(ClientRecord, identity)
276
+ if not record:
277
+ return False
278
+ session.delete(record)
279
+ session.commit()
280
+ return True
281
+
282
+ def list_clients(self) -> List[Client]:
283
+ """Return all known clients."""
284
+ with self._session_scope() as session:
285
+ records = session.query(ClientRecord).all()
286
+ announce_map = self._identity_announce_map(session)
287
+ return [
288
+ self._client_from_record(
289
+ record, announce_map.get(record.identity.lower())
290
+ )
291
+ for record in records
292
+ ]
293
+
294
+ def get_client(self, identity: str) -> Client | None:
295
+ """Return a client by identity when it exists.
296
+
297
+ Args:
298
+ identity (str): Unique identity hash for the client.
299
+
300
+ Returns:
301
+ Client | None: Stored client or ``None`` when unknown.
302
+ """
303
+ with self._session_scope() as session:
304
+ record = session.get(ClientRecord, identity)
305
+ if not record:
306
+ return None
307
+ announce = session.get(IdentityAnnounceRecord, identity.lower())
308
+ return self._client_from_record(record, announce)
309
+
310
+ def create_file_record(self, attachment: FileAttachment) -> FileAttachment:
311
+ """Persist metadata about a stored file or image."""
312
+ with self._session_scope() as session:
313
+ record = FileRecord(
314
+ name=attachment.name,
315
+ path=attachment.path,
316
+ media_type=attachment.media_type,
317
+ category=attachment.category,
318
+ size=attachment.size,
319
+ topic_id=attachment.topic_id,
320
+ created_at=attachment.created_at,
321
+ updated_at=attachment.updated_at,
322
+ )
323
+ session.add(record)
324
+ session.commit()
325
+ session.refresh(record)
326
+ return self._file_from_record(record)
327
+
328
+ def list_file_records(self, category: str | None = None) -> List[FileAttachment]:
329
+ """Return all stored file records, optionally filtered by category."""
330
+ with self._session_scope() as session:
331
+ query = session.query(FileRecord)
332
+ if category:
333
+ query = query.filter(FileRecord.category == category)
334
+ records = query.all()
335
+ return [self._file_from_record(record) for record in records]
336
+
337
+ def get_file_record(self, record_id: int) -> FileAttachment | None:
338
+ """Return a stored file by its database identifier."""
339
+ with self._session_scope() as session:
340
+ record = session.get(FileRecord, record_id)
341
+ return self._file_from_record(record) if record else None
342
+
343
+ def upsert_identity_state(
344
+ self,
345
+ identity: str,
346
+ *,
347
+ is_banned: bool | None = None,
348
+ is_blackholed: bool | None = None,
349
+ ) -> IdentityStateRecord:
350
+ """Insert or update the moderation state for an identity."""
351
+
352
+ with self._session_scope() as session:
353
+ record = session.get(IdentityStateRecord, identity)
354
+ if record is None:
355
+ record = IdentityStateRecord(identity=identity)
356
+ session.add(record)
357
+ if is_banned is not None:
358
+ record.is_banned = bool(is_banned)
359
+ if is_blackholed is not None:
360
+ record.is_blackholed = bool(is_blackholed)
361
+ record.updated_at = _utcnow()
362
+ session.commit()
363
+ return record
364
+
365
+ def upsert_identity_announce(
366
+ self,
367
+ identity: str,
368
+ *,
369
+ display_name: str | None = None,
370
+ source_interface: str | None = None,
371
+ ) -> IdentityAnnounceRecord:
372
+ """Insert or update Reticulum announce metadata."""
373
+
374
+ identity = identity.lower()
375
+ now = _utcnow()
376
+ with self._session_scope() as session:
377
+ record = session.get(IdentityAnnounceRecord, identity)
378
+ if record is None:
379
+ record = IdentityAnnounceRecord(
380
+ destination_hash=identity,
381
+ display_name=display_name,
382
+ first_seen=now,
383
+ last_seen=now,
384
+ source_interface=source_interface,
385
+ )
386
+ session.add(record)
387
+ else:
388
+ record.last_seen = now
389
+ if display_name and (
390
+ record.display_name is None or record.display_name != display_name
391
+ ):
392
+ record.display_name = display_name
393
+ if source_interface and (
394
+ record.source_interface is None
395
+ or record.source_interface != source_interface
396
+ ):
397
+ record.source_interface = source_interface
398
+ session.commit()
399
+ return record
400
+
401
+ def get_identity_announce(self, identity: str) -> IdentityAnnounceRecord | None:
402
+ """Return announce metadata for an identity when present."""
403
+
404
+ with self._session_scope() as session:
405
+ return session.get(IdentityAnnounceRecord, identity.lower())
406
+
407
+ def list_identity_announces(self) -> List[IdentityAnnounceRecord]:
408
+ """Return all announce metadata records."""
409
+
410
+ with self._session_scope() as session:
411
+ return session.query(IdentityAnnounceRecord).all()
412
+
413
+ def get_identity_state(self, identity: str) -> IdentityStateRecord | None:
414
+ """Return the moderation state for an identity when present."""
415
+
416
+ with self._session_scope() as session:
417
+ return session.get(IdentityStateRecord, identity)
418
+
419
+ def list_identity_states(self) -> List[IdentityStateRecord]:
420
+ """Return all identity moderation state records."""
421
+
422
+ with self._session_scope() as session:
423
+ return session.query(IdentityStateRecord).all()
424
+
425
+ def create_chat_message(self, message: ChatMessage) -> ChatMessage:
426
+ """Insert or update a chat message record."""
427
+
428
+ with self._session_scope() as session:
429
+ record = ChatMessageRecord(
430
+ id=message.message_id or uuid.uuid4().hex,
431
+ direction=message.direction,
432
+ scope=message.scope,
433
+ state=message.state,
434
+ content=message.content,
435
+ source=message.source,
436
+ destination=message.destination,
437
+ topic_id=message.topic_id,
438
+ attachments_json=[attachment.to_dict() for attachment in message.attachments],
439
+ created_at=message.created_at,
440
+ updated_at=message.updated_at,
441
+ )
442
+ session.merge(record)
443
+ session.commit()
444
+ return self._chat_from_record(record)
445
+
446
+ def list_chat_messages(
447
+ self,
448
+ *,
449
+ limit: int = 200,
450
+ direction: str | None = None,
451
+ topic_id: str | None = None,
452
+ destination: str | None = None,
453
+ source: str | None = None,
454
+ ) -> List[ChatMessage]:
455
+ """Return chat messages with optional filters."""
456
+
457
+ with self._session_scope() as session:
458
+ query = session.query(ChatMessageRecord)
459
+ if direction:
460
+ query = query.filter(ChatMessageRecord.direction == direction)
461
+ if topic_id:
462
+ query = query.filter(ChatMessageRecord.topic_id == topic_id)
463
+ if destination:
464
+ query = query.filter(ChatMessageRecord.destination == destination)
465
+ if source:
466
+ query = query.filter(ChatMessageRecord.source == source)
467
+ records = (
468
+ query.order_by(ChatMessageRecord.created_at.desc())
469
+ .limit(max(limit, 1))
470
+ .all()
471
+ )
472
+ return [self._chat_from_record(record) for record in records]
473
+
474
+ def update_chat_message_state(self, message_id: str, state: str) -> ChatMessage | None:
475
+ """Update a chat message delivery state."""
476
+
477
+ with self._session_scope() as session:
478
+ record = session.get(ChatMessageRecord, message_id)
479
+ if not record:
480
+ return None
481
+ record.state = state
482
+ record.updated_at = _utcnow()
483
+ session.commit()
484
+ return self._chat_from_record(record)
485
+
486
+ def chat_message_stats(self) -> dict[str, int]:
487
+ """Return basic chat message counters."""
488
+
489
+ with self._session_scope() as session:
490
+ rows = (
491
+ session.query(
492
+ ChatMessageRecord.state, sa_func.count(ChatMessageRecord.id)
493
+ )
494
+ .group_by(ChatMessageRecord.state)
495
+ .all()
496
+ )
497
+ return {state: count for state, count in rows}
498
+
499
+ def _create_engine(self, db_path: Path) -> Engine:
500
+ """Build a SQLite engine configured for concurrency.
501
+
502
+ Args:
503
+ db_path (Path): Database path for the engine.
504
+
505
+ Returns:
506
+ Engine: Configured SQLAlchemy engine.
507
+ """
508
+ return create_engine(
509
+ f"sqlite:///{db_path}",
510
+ connect_args={
511
+ "check_same_thread": False,
512
+ "timeout": self._CONNECT_TIMEOUT_SECONDS,
513
+ },
514
+ poolclass=QueuePool,
515
+ pool_size=self._POOL_SIZE,
516
+ max_overflow=self._POOL_OVERFLOW,
517
+ pool_pre_ping=True,
518
+ )
519
+
520
+ def _enable_wal_mode(self) -> None:
521
+ """Enable write-ahead logging on the SQLite connection."""
522
+ try:
523
+ with self._engine.connect().execution_options(
524
+ isolation_level="AUTOCOMMIT"
525
+ ) as conn:
526
+ conn.exec_driver_sql("PRAGMA journal_mode=WAL;")
527
+ except OperationalError as exc:
528
+ logging.warning("Failed to enable WAL mode: %s", exc)
@@ -0,0 +1,156 @@
1
+ """Shared storage helpers for the Reticulum Community Hub API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextlib import contextmanager
6
+ import logging
7
+ import time
8
+ from typing import Any
9
+
10
+ from sqlalchemy import text
11
+ from sqlalchemy.exc import OperationalError
12
+
13
+ from .models import ChatAttachment
14
+ from .models import ChatMessage
15
+ from .models import Client
16
+ from .models import FileAttachment
17
+ from .models import Subscriber
18
+ from .storage_models import ChatMessageRecord
19
+ from .storage_models import ClientRecord
20
+ from .storage_models import FileRecord
21
+ from .storage_models import IdentityAnnounceRecord
22
+ from .storage_models import SubscriberRecord
23
+
24
+
25
+ class HubStorageBase:
26
+ """Mixin with shared storage helper methods."""
27
+
28
+ _engine: Any
29
+ _session_factory: Any
30
+ _session_retries: int
31
+ _session_backoff: float
32
+
33
+ def _ensure_file_topic_column(self) -> None:
34
+ """Ensure the file_records table has the topic_id column."""
35
+
36
+ try:
37
+ with self._engine.connect() as conn: # type: ignore[attr-defined]
38
+ result = conn.execute(text("PRAGMA table_info(file_records);"))
39
+ column_names = [row[1] for row in result.fetchall()]
40
+ if "topic_id" not in column_names:
41
+ conn.execute(
42
+ text("ALTER TABLE file_records ADD COLUMN topic_id VARCHAR;")
43
+ )
44
+ except OperationalError as exc:
45
+ logging.warning("Failed to ensure file_records.topic_id column: %s", exc)
46
+
47
+ @contextmanager
48
+ def _session_scope(self):
49
+ """Yield a database session with automatic cleanup."""
50
+
51
+ session = self._acquire_session_with_retry() # type: ignore[attr-defined]
52
+ try:
53
+ yield session
54
+ finally:
55
+ session.close()
56
+
57
+ def _acquire_session_with_retry(self):
58
+ """Return a SQLite session, retrying on lock contention."""
59
+ last_exc: OperationalError | None = None
60
+ for attempt in range(1, self._session_retries + 1): # type: ignore[attr-defined]
61
+ session = None
62
+ try:
63
+ session = self._session_factory() # type: ignore[attr-defined]
64
+ session.execute(text("SELECT 1"))
65
+ return session
66
+ except OperationalError as exc:
67
+ last_exc = exc
68
+ lock_detail = str(exc).strip() or "database is locked"
69
+ if session is not None:
70
+ session.close()
71
+ logging.warning(
72
+ "SQLite session acquisition failed (attempt %d/%d): %s",
73
+ attempt,
74
+ self._session_retries, # type: ignore[attr-defined]
75
+ lock_detail,
76
+ )
77
+ time.sleep(self._session_backoff * attempt) # type: ignore[attr-defined]
78
+ logging.error(
79
+ "Unable to obtain SQLite session after %d attempts",
80
+ self._session_retries, # type: ignore[attr-defined]
81
+ )
82
+ if last_exc:
83
+ raise last_exc
84
+ raise RuntimeError("Failed to create SQLite session")
85
+
86
+ @staticmethod
87
+ def _subscriber_from_record(record: SubscriberRecord) -> Subscriber:
88
+ """Convert a SubscriberRecord into a domain model."""
89
+ return Subscriber(
90
+ subscriber_id=record.id,
91
+ destination=record.destination,
92
+ topic_id=record.topic_id,
93
+ reject_tests=record.reject_tests,
94
+ metadata=record.metadata_json or {},
95
+ )
96
+
97
+ @staticmethod
98
+ def _client_from_record(
99
+ record: ClientRecord,
100
+ announce: IdentityAnnounceRecord | None = None,
101
+ ) -> Client:
102
+ """Convert a ClientRecord into a domain model."""
103
+ metadata = dict(record.metadata_json or {})
104
+ display_name = None
105
+ if announce is not None and announce.display_name:
106
+ display_name = announce.display_name
107
+ metadata.setdefault("display_name", display_name)
108
+ elif isinstance(metadata.get("display_name"), str):
109
+ display_name = metadata.get("display_name")
110
+ client = Client(identity=record.identity, metadata=metadata, display_name=display_name)
111
+ client.last_seen = record.last_seen
112
+ return client
113
+
114
+ @staticmethod
115
+ def _file_from_record(record: FileRecord) -> FileAttachment:
116
+ """Convert a FileRecord into a domain model."""
117
+ return FileAttachment(
118
+ file_id=record.id,
119
+ name=record.name,
120
+ path=record.path,
121
+ media_type=record.media_type,
122
+ category=record.category,
123
+ size=record.size,
124
+ topic_id=record.topic_id,
125
+ created_at=record.created_at,
126
+ updated_at=record.updated_at,
127
+ )
128
+
129
+ @staticmethod
130
+ def _chat_from_record(record: ChatMessageRecord) -> ChatMessage:
131
+ """Convert a ChatMessageRecord into a domain model."""
132
+ attachments = [
133
+ ChatAttachment.from_dict(item)
134
+ for item in (record.attachments_json or [])
135
+ if isinstance(item, dict)
136
+ ]
137
+ return ChatMessage(
138
+ message_id=record.id,
139
+ direction=record.direction,
140
+ scope=record.scope,
141
+ state=record.state,
142
+ content=record.content,
143
+ source=record.source,
144
+ destination=record.destination,
145
+ topic_id=record.topic_id,
146
+ attachments=attachments,
147
+ created_at=record.created_at,
148
+ updated_at=record.updated_at,
149
+ )
150
+
151
+ @staticmethod
152
+ def _identity_announce_map(session) -> dict[str, IdentityAnnounceRecord]:
153
+ """Return a lookup table for announce metadata."""
154
+
155
+ records = session.query(IdentityAnnounceRecord).all()
156
+ return {record.destination_hash: record for record in records}