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