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,804 @@
|
|
|
1
|
+
from contextlib import contextmanager
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import string
|
|
6
|
+
import time
|
|
7
|
+
from typing import Callable, Optional
|
|
8
|
+
|
|
9
|
+
from reticulum_telemetry_hub.api.service import ReticulumTelemetryHubAPI
|
|
10
|
+
from reticulum_telemetry_hub.reticulum_server.event_log import EventLog
|
|
11
|
+
|
|
12
|
+
import LXMF
|
|
13
|
+
import RNS
|
|
14
|
+
from msgpack import packb, unpackb
|
|
15
|
+
from reticulum_telemetry_hub.lxmf_telemetry.model.persistance import Base
|
|
16
|
+
from reticulum_telemetry_hub.lxmf_telemetry.model.persistance.telemeter import (
|
|
17
|
+
Telemeter,
|
|
18
|
+
)
|
|
19
|
+
from reticulum_telemetry_hub.lxmf_telemetry.model.persistance.sensors.lxmf_propagation import (
|
|
20
|
+
LXMFPropagation,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
from reticulum_telemetry_hub.lxmf_telemetry.model.persistance.sensors.sensor_mapping import (
|
|
24
|
+
sid_mapping,
|
|
25
|
+
)
|
|
26
|
+
from reticulum_telemetry_hub.lxmf_telemetry.model.persistance.sensors.sensor_enum import (
|
|
27
|
+
SID_ACCELERATION,
|
|
28
|
+
SID_AMBIENT_LIGHT,
|
|
29
|
+
SID_ANGULAR_VELOCITY,
|
|
30
|
+
SID_BATTERY,
|
|
31
|
+
SID_CONNECTION_MAP,
|
|
32
|
+
SID_CUSTOM,
|
|
33
|
+
SID_FUEL,
|
|
34
|
+
SID_GRAVITY,
|
|
35
|
+
SID_HUMIDITY,
|
|
36
|
+
SID_INFORMATION,
|
|
37
|
+
SID_LOCATION,
|
|
38
|
+
SID_LXMF_PROPAGATION,
|
|
39
|
+
SID_MAGNETIC_FIELD,
|
|
40
|
+
SID_NVM,
|
|
41
|
+
SID_PHYSICAL_LINK,
|
|
42
|
+
SID_POWER_CONSUMPTION,
|
|
43
|
+
SID_POWER_PRODUCTION,
|
|
44
|
+
SID_PRESSURE,
|
|
45
|
+
SID_PROCESSOR,
|
|
46
|
+
SID_PROXIMITY,
|
|
47
|
+
SID_RAM,
|
|
48
|
+
SID_RECEIVED,
|
|
49
|
+
SID_RNS_TRANSPORT,
|
|
50
|
+
SID_TANK,
|
|
51
|
+
SID_TEMPERATURE,
|
|
52
|
+
SID_TIME,
|
|
53
|
+
)
|
|
54
|
+
from sqlalchemy import create_engine
|
|
55
|
+
from sqlalchemy import func as sa_func
|
|
56
|
+
from sqlalchemy import text
|
|
57
|
+
from sqlalchemy.engine import Engine
|
|
58
|
+
from sqlalchemy.exc import OperationalError
|
|
59
|
+
from sqlalchemy.orm import Session, joinedload, sessionmaker
|
|
60
|
+
from sqlalchemy.pool import QueuePool
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class TelemetryController:
|
|
64
|
+
"""This class is responsible for managing the telemetry data."""
|
|
65
|
+
|
|
66
|
+
TELEMETRY_REQUEST = 1
|
|
67
|
+
SID_HUMAN_NAMES = {
|
|
68
|
+
SID_TIME: "time",
|
|
69
|
+
SID_LOCATION: "location",
|
|
70
|
+
SID_PRESSURE: "pressure",
|
|
71
|
+
SID_BATTERY: "battery",
|
|
72
|
+
SID_PHYSICAL_LINK: "physical_link",
|
|
73
|
+
SID_ACCELERATION: "acceleration",
|
|
74
|
+
SID_TEMPERATURE: "temperature",
|
|
75
|
+
SID_HUMIDITY: "humidity",
|
|
76
|
+
SID_MAGNETIC_FIELD: "magnetic_field",
|
|
77
|
+
SID_AMBIENT_LIGHT: "ambient_light",
|
|
78
|
+
SID_GRAVITY: "gravity",
|
|
79
|
+
SID_ANGULAR_VELOCITY: "angular_velocity",
|
|
80
|
+
SID_PROXIMITY: "proximity",
|
|
81
|
+
SID_INFORMATION: "information",
|
|
82
|
+
SID_RECEIVED: "received",
|
|
83
|
+
SID_POWER_CONSUMPTION: "power_consumption",
|
|
84
|
+
SID_POWER_PRODUCTION: "power_production",
|
|
85
|
+
SID_PROCESSOR: "processor",
|
|
86
|
+
SID_RAM: "ram",
|
|
87
|
+
SID_NVM: "nvm",
|
|
88
|
+
SID_TANK: "tank",
|
|
89
|
+
SID_FUEL: "fuel",
|
|
90
|
+
SID_LXMF_PROPAGATION: "lxmf_propagation",
|
|
91
|
+
SID_RNS_TRANSPORT: "rns_transport",
|
|
92
|
+
SID_CONNECTION_MAP: "connection_map",
|
|
93
|
+
SID_CUSTOM: "custom",
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
_POOL_SIZE = 30
|
|
97
|
+
_POOL_OVERFLOW = 60
|
|
98
|
+
_CONNECT_TIMEOUT_SECONDS = 30
|
|
99
|
+
_SESSION_RETRIES = 3
|
|
100
|
+
_SESSION_BACKOFF = 0.1
|
|
101
|
+
|
|
102
|
+
def __init__(
|
|
103
|
+
self,
|
|
104
|
+
*,
|
|
105
|
+
engine: Engine | None = None,
|
|
106
|
+
db_path: str | Path | None = None,
|
|
107
|
+
api: ReticulumTelemetryHubAPI | None = None,
|
|
108
|
+
event_log: EventLog | None = None,
|
|
109
|
+
) -> None:
|
|
110
|
+
if engine is not None and db_path is not None:
|
|
111
|
+
raise ValueError("Provide either 'engine' or 'db_path', not both")
|
|
112
|
+
|
|
113
|
+
if engine is None:
|
|
114
|
+
db_location = Path(db_path) if db_path is not None else Path("telemetry.db")
|
|
115
|
+
engine = self._create_engine(db_location)
|
|
116
|
+
|
|
117
|
+
self._engine = engine
|
|
118
|
+
self._enable_wal_mode()
|
|
119
|
+
Base.metadata.create_all(self._engine)
|
|
120
|
+
self._session_cls = sessionmaker(bind=self._engine, expire_on_commit=False)
|
|
121
|
+
self._telemetry_listener: (
|
|
122
|
+
Callable[[dict, str | bytes | None, Optional[datetime]], None] | None
|
|
123
|
+
) = None
|
|
124
|
+
self._api = api
|
|
125
|
+
self._event_log = event_log
|
|
126
|
+
self._ingest_count = 0
|
|
127
|
+
self._last_ingest_at: datetime | None = None
|
|
128
|
+
|
|
129
|
+
def set_api(self, api: ReticulumTelemetryHubAPI | None) -> None:
|
|
130
|
+
"""Attach an API service for topic-aware telemetry filtering."""
|
|
131
|
+
|
|
132
|
+
self._api = api
|
|
133
|
+
|
|
134
|
+
def set_event_log(self, event_log: EventLog | None) -> None:
|
|
135
|
+
"""Attach an event log for telemetry activity updates."""
|
|
136
|
+
|
|
137
|
+
self._event_log = event_log
|
|
138
|
+
|
|
139
|
+
def _create_engine(self, db_location: Path) -> Engine:
|
|
140
|
+
return create_engine(
|
|
141
|
+
f"sqlite:///{db_location}",
|
|
142
|
+
connect_args={
|
|
143
|
+
"check_same_thread": False,
|
|
144
|
+
"timeout": self._CONNECT_TIMEOUT_SECONDS,
|
|
145
|
+
},
|
|
146
|
+
poolclass=QueuePool,
|
|
147
|
+
pool_size=self._POOL_SIZE,
|
|
148
|
+
max_overflow=self._POOL_OVERFLOW,
|
|
149
|
+
pool_pre_ping=True,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def _enable_wal_mode(self) -> None:
|
|
153
|
+
if self._engine.url.get_backend_name() != "sqlite":
|
|
154
|
+
return
|
|
155
|
+
try:
|
|
156
|
+
with self._engine.connect().execution_options(
|
|
157
|
+
isolation_level="AUTOCOMMIT"
|
|
158
|
+
) as conn:
|
|
159
|
+
conn.exec_driver_sql("PRAGMA journal_mode=WAL;")
|
|
160
|
+
except OperationalError as exc:
|
|
161
|
+
RNS.log(f"Failed enabling WAL mode: {exc}", RNS.LOG_WARNING)
|
|
162
|
+
|
|
163
|
+
@contextmanager
|
|
164
|
+
def _session_scope(self):
|
|
165
|
+
"""Yield a telemetry DB session that always closes."""
|
|
166
|
+
|
|
167
|
+
session = self._acquire_session_with_retry()
|
|
168
|
+
try:
|
|
169
|
+
yield session
|
|
170
|
+
finally:
|
|
171
|
+
session.close()
|
|
172
|
+
|
|
173
|
+
def _acquire_session_with_retry(self):
|
|
174
|
+
"""Return a database session, retrying on transient OperationalError."""
|
|
175
|
+
|
|
176
|
+
last_exc: OperationalError | None = None
|
|
177
|
+
for attempt in range(1, self._SESSION_RETRIES + 1):
|
|
178
|
+
session = None
|
|
179
|
+
try:
|
|
180
|
+
session = self._session_cls()
|
|
181
|
+
session.execute(text("SELECT 1"))
|
|
182
|
+
return session
|
|
183
|
+
except OperationalError as exc:
|
|
184
|
+
last_exc = exc
|
|
185
|
+
if session is not None:
|
|
186
|
+
session.close()
|
|
187
|
+
RNS.log(
|
|
188
|
+
(
|
|
189
|
+
"SQLite session acquisition failed "
|
|
190
|
+
f"(attempt {attempt}/{self._SESSION_RETRIES}): {exc}"
|
|
191
|
+
),
|
|
192
|
+
RNS.LOG_WARNING,
|
|
193
|
+
)
|
|
194
|
+
time.sleep(self._SESSION_BACKOFF * attempt)
|
|
195
|
+
RNS.log(
|
|
196
|
+
"Unable to obtain telemetry database session after retries",
|
|
197
|
+
RNS.LOG_ERROR,
|
|
198
|
+
)
|
|
199
|
+
if last_exc:
|
|
200
|
+
raise last_exc
|
|
201
|
+
raise RuntimeError("Failed to acquire telemetry session")
|
|
202
|
+
|
|
203
|
+
def _load_telemetry(
|
|
204
|
+
self,
|
|
205
|
+
session: Session,
|
|
206
|
+
start_time: Optional[datetime] = None,
|
|
207
|
+
end_time: Optional[datetime] = None,
|
|
208
|
+
) -> list[Telemeter]:
|
|
209
|
+
query = session.query(Telemeter)
|
|
210
|
+
if start_time:
|
|
211
|
+
query = query.filter(Telemeter.time >= start_time)
|
|
212
|
+
if end_time:
|
|
213
|
+
query = query.filter(Telemeter.time <= end_time)
|
|
214
|
+
query = query.order_by(Telemeter.time.desc())
|
|
215
|
+
tels = query.options(
|
|
216
|
+
joinedload(Telemeter.sensors),
|
|
217
|
+
joinedload(Telemeter.sensors.of_type(LXMFPropagation)).joinedload(
|
|
218
|
+
LXMFPropagation.peers
|
|
219
|
+
),
|
|
220
|
+
).all()
|
|
221
|
+
return tels
|
|
222
|
+
|
|
223
|
+
def get_telemetry(
|
|
224
|
+
self, start_time: Optional[datetime] = None, end_time: Optional[datetime] = None
|
|
225
|
+
) -> list[Telemeter]:
|
|
226
|
+
"""Get the telemetry data."""
|
|
227
|
+
with self._session_scope() as ses:
|
|
228
|
+
return self._load_telemetry(ses, start_time, end_time)
|
|
229
|
+
|
|
230
|
+
def list_telemetry_entries(
|
|
231
|
+
self, *, since: int, topic_id: str | None = None
|
|
232
|
+
) -> list[dict[str, object]]:
|
|
233
|
+
"""Return telemetry entries as JSON-friendly dictionaries.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
since (int): Unix timestamp (seconds) for the earliest telemetry
|
|
237
|
+
records to include.
|
|
238
|
+
topic_id (str | None): Optional topic identifier for filtering.
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
list[dict[str, object]]: Telemetry entries formatted for the
|
|
242
|
+
northbound API.
|
|
243
|
+
|
|
244
|
+
Raises:
|
|
245
|
+
KeyError: If ``topic_id`` is provided but does not exist.
|
|
246
|
+
ValueError: If topic filtering is requested without an API service.
|
|
247
|
+
"""
|
|
248
|
+
|
|
249
|
+
timebase_dt = datetime.fromtimestamp(int(since))
|
|
250
|
+
with self._session_scope() as ses:
|
|
251
|
+
telemeters = self._load_telemetry(ses, start_time=timebase_dt)
|
|
252
|
+
telemeters = self._latest_by_peer(telemeters)
|
|
253
|
+
if topic_id:
|
|
254
|
+
if self._api is None:
|
|
255
|
+
raise ValueError("Topic filtering requires an API service")
|
|
256
|
+
subscribers = self._api.list_subscribers_for_topic(topic_id)
|
|
257
|
+
allowed = {sub.destination for sub in subscribers}
|
|
258
|
+
telemeters = [
|
|
259
|
+
telemeter
|
|
260
|
+
for telemeter in telemeters
|
|
261
|
+
if telemeter.peer_dest in allowed
|
|
262
|
+
]
|
|
263
|
+
|
|
264
|
+
entries: list[dict[str, object]] = []
|
|
265
|
+
for telemeter in telemeters:
|
|
266
|
+
timestamp = int(telemeter.time.timestamp()) if telemeter.time else 0
|
|
267
|
+
payload = self._serialize_telemeter(telemeter)
|
|
268
|
+
readable_payload = self._humanize_telemetry(payload)
|
|
269
|
+
display_name = None
|
|
270
|
+
if self._api is not None and hasattr(
|
|
271
|
+
self._api, "resolve_identity_display_name"
|
|
272
|
+
):
|
|
273
|
+
try:
|
|
274
|
+
display_name = self._api.resolve_identity_display_name(
|
|
275
|
+
telemeter.peer_dest
|
|
276
|
+
)
|
|
277
|
+
except Exception: # pragma: no cover - defensive
|
|
278
|
+
display_name = None
|
|
279
|
+
entries.append(
|
|
280
|
+
{
|
|
281
|
+
"peer_destination": telemeter.peer_dest,
|
|
282
|
+
"timestamp": timestamp,
|
|
283
|
+
"telemetry": self._json_safe(readable_payload),
|
|
284
|
+
"display_name": display_name,
|
|
285
|
+
"identity_label": display_name,
|
|
286
|
+
}
|
|
287
|
+
)
|
|
288
|
+
return entries
|
|
289
|
+
|
|
290
|
+
def register_listener(
|
|
291
|
+
self,
|
|
292
|
+
listener: Callable[[dict, str | bytes | None, Optional[datetime]], None],
|
|
293
|
+
) -> None:
|
|
294
|
+
"""Register a callback invoked when telemetry is ingested."""
|
|
295
|
+
|
|
296
|
+
self._telemetry_listener = listener
|
|
297
|
+
|
|
298
|
+
def save_telemetry(
|
|
299
|
+
self,
|
|
300
|
+
telemetry_data: dict | bytes,
|
|
301
|
+
peer_dest,
|
|
302
|
+
timestamp: Optional[datetime] = None,
|
|
303
|
+
) -> None:
|
|
304
|
+
"""Save the telemetry data."""
|
|
305
|
+
tel = self._deserialize_telemeter(telemetry_data, peer_dest)
|
|
306
|
+
|
|
307
|
+
payload = telemetry_data
|
|
308
|
+
if isinstance(payload, (bytes, bytearray)):
|
|
309
|
+
try:
|
|
310
|
+
payload = unpackb(payload, strict_map_key=False)
|
|
311
|
+
except Exception: # pragma: no cover - defensive decoding
|
|
312
|
+
payload = None
|
|
313
|
+
|
|
314
|
+
has_sensor_timestamp = False
|
|
315
|
+
if isinstance(payload, dict):
|
|
316
|
+
time_value = payload.get(SID_TIME)
|
|
317
|
+
has_sensor_timestamp = isinstance(time_value, (int, float))
|
|
318
|
+
|
|
319
|
+
if not has_sensor_timestamp and timestamp is not None:
|
|
320
|
+
tel.time = timestamp
|
|
321
|
+
with self._session_scope() as ses:
|
|
322
|
+
ses.add(tel)
|
|
323
|
+
ses.commit()
|
|
324
|
+
self._record_ingest(tel)
|
|
325
|
+
|
|
326
|
+
def clear_telemetry(self) -> int:
|
|
327
|
+
"""Remove all telemetry entries from storage.
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
int: Number of rows removed from the telemetry table.
|
|
331
|
+
"""
|
|
332
|
+
|
|
333
|
+
with self._session_scope() as ses:
|
|
334
|
+
deleted = ses.query(Telemeter).delete()
|
|
335
|
+
ses.commit()
|
|
336
|
+
return int(deleted or 0)
|
|
337
|
+
|
|
338
|
+
def telemetry_stats(self) -> dict:
|
|
339
|
+
"""Return basic telemetry ingestion statistics."""
|
|
340
|
+
|
|
341
|
+
total = self._ingest_count
|
|
342
|
+
last_ingest_at = self._last_ingest_at
|
|
343
|
+
try:
|
|
344
|
+
with self._session_scope() as ses:
|
|
345
|
+
total = int(ses.query(sa_func.count(Telemeter.id)).scalar() or 0)
|
|
346
|
+
last_ingest_at = ses.query(sa_func.max(Telemeter.time)).scalar()
|
|
347
|
+
if isinstance(last_ingest_at, str):
|
|
348
|
+
last_ingest_at = datetime.fromisoformat(last_ingest_at)
|
|
349
|
+
except Exception: # pragma: no cover - defensive fallback
|
|
350
|
+
pass
|
|
351
|
+
|
|
352
|
+
last_ingest = last_ingest_at.isoformat() if last_ingest_at else None
|
|
353
|
+
return {
|
|
354
|
+
"ingest_count": total,
|
|
355
|
+
"last_ingest_at": last_ingest,
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
def ingest_local_payload(
|
|
359
|
+
self,
|
|
360
|
+
payload: dict,
|
|
361
|
+
*,
|
|
362
|
+
peer_dest: str,
|
|
363
|
+
) -> bytes | None:
|
|
364
|
+
"""Persist ``payload`` and return a msgpack encoded snapshot.
|
|
365
|
+
|
|
366
|
+
The telemetry sampler uses this helper to ensure locally collected
|
|
367
|
+
sensor data flows through the same persistence pipeline as incoming
|
|
368
|
+
LXMF telemetry before broadcasting it to connected peers.
|
|
369
|
+
"""
|
|
370
|
+
|
|
371
|
+
if not payload:
|
|
372
|
+
return None
|
|
373
|
+
|
|
374
|
+
self.save_telemetry(payload, peer_dest)
|
|
375
|
+
return packb(payload, use_bin_type=True)
|
|
376
|
+
|
|
377
|
+
def handle_message(self, message: LXMF.LXMessage) -> bool:
|
|
378
|
+
"""Handle the incoming message."""
|
|
379
|
+
handled = False
|
|
380
|
+
if LXMF.FIELD_TELEMETRY in message.fields:
|
|
381
|
+
tel_data: dict = unpackb(
|
|
382
|
+
message.fields[LXMF.FIELD_TELEMETRY], strict_map_key=False
|
|
383
|
+
)
|
|
384
|
+
readable = self._humanize_telemetry(tel_data)
|
|
385
|
+
timestamp = self._extract_timestamp(readable)
|
|
386
|
+
peer_dest = RNS.hexrep(message.source_hash, False)
|
|
387
|
+
display_name, label = self._resolve_peer_label(peer_dest)
|
|
388
|
+
RNS.log(f"Telemetry received from {label}")
|
|
389
|
+
RNS.log(f"Telemetry decoded: {readable}")
|
|
390
|
+
self.save_telemetry(tel_data, peer_dest)
|
|
391
|
+
self._notify_listener(readable, message.source_hash, timestamp)
|
|
392
|
+
self._record_event(
|
|
393
|
+
"telemetry_received",
|
|
394
|
+
f"Telemetry received from {label}",
|
|
395
|
+
metadata={"identity": peer_dest, "display_name": display_name},
|
|
396
|
+
)
|
|
397
|
+
handled = True
|
|
398
|
+
if LXMF.FIELD_TELEMETRY_STREAM in message.fields:
|
|
399
|
+
tels_data = message.fields[LXMF.FIELD_TELEMETRY_STREAM]
|
|
400
|
+
if isinstance(tels_data, (bytes, bytearray)):
|
|
401
|
+
# Sideband sends telemetry streams as raw lists; decode msgpack
|
|
402
|
+
# if a sender pre-encodes the field.
|
|
403
|
+
tels_data = unpackb(tels_data, strict_map_key=False)
|
|
404
|
+
for tel_data in tels_data:
|
|
405
|
+
if not isinstance(tel_data, (list, tuple)) or len(tel_data) < 3:
|
|
406
|
+
RNS.log(
|
|
407
|
+
"Telemetry stream entries must include peer hash, timestamp, and payload; skipping"
|
|
408
|
+
)
|
|
409
|
+
continue
|
|
410
|
+
|
|
411
|
+
peer_hash, raw_timestamp, payload = tel_data[:3]
|
|
412
|
+
if not isinstance(peer_hash, (bytes, bytearray)):
|
|
413
|
+
RNS.log("Telemetry stream entry missing peer hash bytes; skipping")
|
|
414
|
+
continue
|
|
415
|
+
|
|
416
|
+
peer_dest = RNS.hexrep(peer_hash, False)
|
|
417
|
+
|
|
418
|
+
timestamp = None
|
|
419
|
+
if isinstance(raw_timestamp, (int, float)):
|
|
420
|
+
timestamp = datetime.fromtimestamp(raw_timestamp)
|
|
421
|
+
elif raw_timestamp is not None:
|
|
422
|
+
RNS.log(
|
|
423
|
+
"Telemetry stream timestamp must be numeric or null; skipping entry"
|
|
424
|
+
)
|
|
425
|
+
continue
|
|
426
|
+
|
|
427
|
+
if not payload:
|
|
428
|
+
RNS.log("Telemetry payload missing; skipping entry")
|
|
429
|
+
continue
|
|
430
|
+
|
|
431
|
+
readable = self._humanize_telemetry(payload)
|
|
432
|
+
display_name, label = self._resolve_peer_label(peer_dest)
|
|
433
|
+
RNS.log(f"Telemetry stream from {label} at {timestamp}: {readable}")
|
|
434
|
+
self.save_telemetry(payload, peer_dest, timestamp)
|
|
435
|
+
stream_timestamp = timestamp or self._extract_timestamp(readable)
|
|
436
|
+
self._notify_listener(readable, peer_hash, stream_timestamp)
|
|
437
|
+
self._record_event(
|
|
438
|
+
"telemetry_stream",
|
|
439
|
+
f"Telemetry stream entry from {label}",
|
|
440
|
+
metadata={"identity": peer_dest, "display_name": display_name},
|
|
441
|
+
)
|
|
442
|
+
handled = True
|
|
443
|
+
|
|
444
|
+
return handled
|
|
445
|
+
|
|
446
|
+
def handle_command(
|
|
447
|
+
self, command: dict, message: LXMF.LXMessage, my_lxm_dest
|
|
448
|
+
) -> Optional[LXMF.LXMessage]:
|
|
449
|
+
"""Handle the incoming command."""
|
|
450
|
+
request_key = self._numeric_command_key(
|
|
451
|
+
command, TelemetryController.TELEMETRY_REQUEST
|
|
452
|
+
)
|
|
453
|
+
if request_key is not None:
|
|
454
|
+
request_value = command[request_key]
|
|
455
|
+
topic_id = self._extract_topic_id(command)
|
|
456
|
+
|
|
457
|
+
# Sideband (and compatible clients) send telemetry requests either as a
|
|
458
|
+
# standalone timestamp or as ``[timestamp, collector_flag]``. The
|
|
459
|
+
# hub currently ignores the optional collector flag, but we still
|
|
460
|
+
# need to unpack the timestamp so ``datetime.fromtimestamp`` doesn't
|
|
461
|
+
# receive a list and raise ``TypeError``.
|
|
462
|
+
if isinstance(request_value, (list, tuple)):
|
|
463
|
+
if not request_value:
|
|
464
|
+
return None
|
|
465
|
+
timebase_raw = request_value[0]
|
|
466
|
+
else:
|
|
467
|
+
timebase_raw = request_value
|
|
468
|
+
|
|
469
|
+
if not isinstance(timebase_raw, (int, float)):
|
|
470
|
+
raise TypeError(
|
|
471
|
+
"Telemetry request timestamp must be numeric; "
|
|
472
|
+
f"received {type(timebase_raw)!r}"
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
timebase = int(timebase_raw)
|
|
476
|
+
human_readable_entries: list[dict[str, object]] = []
|
|
477
|
+
with self._session_scope() as ses:
|
|
478
|
+
timebase_dt = datetime.fromtimestamp(timebase)
|
|
479
|
+
teles = self._load_telemetry(ses, start_time=timebase_dt)
|
|
480
|
+
# Return one snapshot per peer using the most recent entry.
|
|
481
|
+
teles = self._latest_by_peer(teles)
|
|
482
|
+
teles = self._filter_by_topic(teles, topic_id, message)
|
|
483
|
+
if teles is None:
|
|
484
|
+
return self._reply(
|
|
485
|
+
message,
|
|
486
|
+
my_lxm_dest,
|
|
487
|
+
"Telemetry request denied: sender is not subscribed to the topic.",
|
|
488
|
+
)
|
|
489
|
+
packed_tels = []
|
|
490
|
+
dest = RNS.Destination(
|
|
491
|
+
message.source.identity,
|
|
492
|
+
RNS.Destination.OUT,
|
|
493
|
+
RNS.Destination.SINGLE,
|
|
494
|
+
"lxmf",
|
|
495
|
+
"delivery",
|
|
496
|
+
)
|
|
497
|
+
for tel in teles:
|
|
498
|
+
peer_hash = self._peer_hash_bytes(tel)
|
|
499
|
+
if peer_hash is None:
|
|
500
|
+
continue
|
|
501
|
+
tel_data = self._serialize_telemeter(tel)
|
|
502
|
+
human_readable_entries.append(
|
|
503
|
+
{
|
|
504
|
+
"peer_destination": tel.peer_dest,
|
|
505
|
+
"timestamp": round(tel.time.timestamp()),
|
|
506
|
+
"telemetry": self._humanize_telemetry(tel_data),
|
|
507
|
+
}
|
|
508
|
+
)
|
|
509
|
+
packed_tels.append(
|
|
510
|
+
[
|
|
511
|
+
peer_hash,
|
|
512
|
+
round(tel.time.timestamp()),
|
|
513
|
+
packb(tel_data, use_bin_type=True),
|
|
514
|
+
None,
|
|
515
|
+
]
|
|
516
|
+
)
|
|
517
|
+
message = LXMF.LXMessage(
|
|
518
|
+
dest,
|
|
519
|
+
my_lxm_dest,
|
|
520
|
+
desired_method=LXMF.LXMessage.DIRECT,
|
|
521
|
+
)
|
|
522
|
+
# Sideband expects telemetry streams as plain lists; avoid
|
|
523
|
+
# double-encoding the field so clients can iterate entries directly.
|
|
524
|
+
message.fields[LXMF.FIELD_TELEMETRY_STREAM] = packed_tels
|
|
525
|
+
readable_json = json.dumps(
|
|
526
|
+
self._json_safe(human_readable_entries), default=str
|
|
527
|
+
)
|
|
528
|
+
print(f"Sending telemetry of {len(human_readable_entries)} clients")
|
|
529
|
+
print("Telemetry response in human readeble format: " f"{readable_json}")
|
|
530
|
+
self._record_event(
|
|
531
|
+
"telemetry_request",
|
|
532
|
+
f"Telemetry request served ({len(human_readable_entries)} entries)",
|
|
533
|
+
metadata={"topic_id": topic_id} if topic_id else None,
|
|
534
|
+
)
|
|
535
|
+
return message
|
|
536
|
+
else:
|
|
537
|
+
return None
|
|
538
|
+
|
|
539
|
+
def _serialize_telemeter(self, telemeter: Telemeter) -> dict:
|
|
540
|
+
"""Serialize the telemeter data."""
|
|
541
|
+
telemeter_data = {}
|
|
542
|
+
for sensor in telemeter.sensors:
|
|
543
|
+
sensor_data = sensor.pack()
|
|
544
|
+
telemeter_data[sensor.sid] = sensor_data
|
|
545
|
+
|
|
546
|
+
# Ensure the timestamp sensor is always present so downstream
|
|
547
|
+
# consumers (e.g. Sideband) can reconstitute the Telemeter.
|
|
548
|
+
timestamp = int(telemeter.time.timestamp())
|
|
549
|
+
time_payload = telemeter_data.get(SID_TIME)
|
|
550
|
+
if not isinstance(time_payload, (int, float)):
|
|
551
|
+
telemeter_data[SID_TIME] = timestamp
|
|
552
|
+
else:
|
|
553
|
+
telemeter_data[SID_TIME] = int(time_payload)
|
|
554
|
+
|
|
555
|
+
return telemeter_data
|
|
556
|
+
|
|
557
|
+
def _deserialize_telemeter(self, tel_data, peer_dest: str = "") -> Telemeter:
|
|
558
|
+
"""Deserialize the telemeter data.
|
|
559
|
+
|
|
560
|
+
The method accepts either already unpacked telemetry dictionaries or
|
|
561
|
+
raw msgpack-encoded bytes. The optional ``peer_dest`` parameter is
|
|
562
|
+
primarily used when storing data received from the network.
|
|
563
|
+
"""
|
|
564
|
+
if isinstance(tel_data, (bytes, bytearray)):
|
|
565
|
+
tel_data = unpackb(tel_data, strict_map_key=False)
|
|
566
|
+
|
|
567
|
+
tel = Telemeter(peer_dest)
|
|
568
|
+
# Iterate in the order defined by ``sid_mapping`` so tests relying on
|
|
569
|
+
# specific sensor ordering remain stable.
|
|
570
|
+
for sid in sid_mapping:
|
|
571
|
+
if sid in tel_data:
|
|
572
|
+
if tel_data[sid] is None:
|
|
573
|
+
RNS.log(f"Sensor data for {sid} is None")
|
|
574
|
+
continue
|
|
575
|
+
sensor = sid_mapping[sid]()
|
|
576
|
+
sensor.unpack(tel_data[sid])
|
|
577
|
+
tel.sensors.append(sensor)
|
|
578
|
+
time_value = tel_data.get(SID_TIME)
|
|
579
|
+
if isinstance(time_value, (int, float)):
|
|
580
|
+
tel.time = datetime.fromtimestamp(int(time_value))
|
|
581
|
+
return tel
|
|
582
|
+
|
|
583
|
+
def _humanize_telemetry(self, tel_data: dict) -> dict:
|
|
584
|
+
"""Return a friendly dict mapping sensor names to decoded readings."""
|
|
585
|
+
if isinstance(tel_data, (bytes, bytearray)):
|
|
586
|
+
tel_data = unpackb(tel_data, strict_map_key=False)
|
|
587
|
+
|
|
588
|
+
readable: dict[str, object] = {}
|
|
589
|
+
for sid, payload in tel_data.items():
|
|
590
|
+
name = self.SID_HUMAN_NAMES.get(sid, f"sid_{sid}")
|
|
591
|
+
sensor_cls = sid_mapping.get(sid)
|
|
592
|
+
if sensor_cls is None:
|
|
593
|
+
readable[name] = payload
|
|
594
|
+
continue
|
|
595
|
+
sensor = sensor_cls()
|
|
596
|
+
try:
|
|
597
|
+
decoded = sensor.unpack(payload)
|
|
598
|
+
except Exception as exc: # pragma: no cover - defensive logging
|
|
599
|
+
RNS.log(f"Failed decoding telemetry sensor {name}: {exc}")
|
|
600
|
+
decoded = payload
|
|
601
|
+
readable[name] = decoded
|
|
602
|
+
return readable
|
|
603
|
+
|
|
604
|
+
def _latest_by_peer(self, telemeters: list[Telemeter]) -> list[Telemeter]:
|
|
605
|
+
"""Return the most recent telemetry entry per peer."""
|
|
606
|
+
latest: dict[str, Telemeter] = {}
|
|
607
|
+
for tel in telemeters:
|
|
608
|
+
# The list is already ordered newest->oldest, so first wins.
|
|
609
|
+
if tel.peer_dest not in latest:
|
|
610
|
+
latest[tel.peer_dest] = tel
|
|
611
|
+
return list(latest.values())
|
|
612
|
+
|
|
613
|
+
def _notify_listener(
|
|
614
|
+
self,
|
|
615
|
+
telemetry: dict,
|
|
616
|
+
peer_hash: str | bytes | None,
|
|
617
|
+
timestamp: Optional[datetime],
|
|
618
|
+
) -> None:
|
|
619
|
+
"""Invoke the registered telemetry listener when present."""
|
|
620
|
+
|
|
621
|
+
if self._telemetry_listener is None:
|
|
622
|
+
return
|
|
623
|
+
try:
|
|
624
|
+
self._telemetry_listener(telemetry, peer_hash, timestamp)
|
|
625
|
+
except Exception as exc: # pragma: no cover - defensive logging
|
|
626
|
+
RNS.log(f"Telemetry listener raised an exception: {exc}", RNS.LOG_WARNING)
|
|
627
|
+
|
|
628
|
+
def _record_event(
|
|
629
|
+
self,
|
|
630
|
+
event_type: str,
|
|
631
|
+
message: str,
|
|
632
|
+
*,
|
|
633
|
+
metadata: Optional[dict] = None,
|
|
634
|
+
) -> None:
|
|
635
|
+
"""Emit a telemetry event to the shared event log."""
|
|
636
|
+
|
|
637
|
+
if self._event_log is None:
|
|
638
|
+
return
|
|
639
|
+
self._event_log.add_event(event_type, message, metadata=metadata)
|
|
640
|
+
|
|
641
|
+
def _resolve_peer_label(self, peer_dest: str) -> tuple[str | None, str]:
|
|
642
|
+
"""Return display name and label for a peer destination."""
|
|
643
|
+
|
|
644
|
+
display_name = None
|
|
645
|
+
if self._api is not None and hasattr(self._api, "resolve_identity_display_name"):
|
|
646
|
+
try:
|
|
647
|
+
display_name = self._api.resolve_identity_display_name(peer_dest)
|
|
648
|
+
except Exception: # pragma: no cover - defensive
|
|
649
|
+
display_name = None
|
|
650
|
+
if display_name:
|
|
651
|
+
return display_name, f"{display_name} ({peer_dest})"
|
|
652
|
+
return None, peer_dest
|
|
653
|
+
|
|
654
|
+
def _record_ingest(self, telemeter: Telemeter) -> None:
|
|
655
|
+
"""Update telemetry ingestion statistics."""
|
|
656
|
+
|
|
657
|
+
self._ingest_count += 1
|
|
658
|
+
if telemeter.time:
|
|
659
|
+
self._last_ingest_at = telemeter.time
|
|
660
|
+
|
|
661
|
+
@staticmethod
|
|
662
|
+
def _reply(
|
|
663
|
+
message: LXMF.LXMessage, my_lxm_dest, content: str
|
|
664
|
+
) -> LXMF.LXMessage:
|
|
665
|
+
"""Return an LXMF reply message to the sender."""
|
|
666
|
+
|
|
667
|
+
dest = RNS.Destination(
|
|
668
|
+
message.source.identity,
|
|
669
|
+
RNS.Destination.OUT,
|
|
670
|
+
RNS.Destination.SINGLE,
|
|
671
|
+
"lxmf",
|
|
672
|
+
"delivery",
|
|
673
|
+
)
|
|
674
|
+
return LXMF.LXMessage(
|
|
675
|
+
dest,
|
|
676
|
+
my_lxm_dest,
|
|
677
|
+
content,
|
|
678
|
+
desired_method=LXMF.LXMessage.DIRECT,
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
@staticmethod
|
|
682
|
+
def _extract_topic_id(command: dict) -> Optional[str]:
|
|
683
|
+
"""Return a topic id from a telemetry command payload."""
|
|
684
|
+
|
|
685
|
+
return (
|
|
686
|
+
command.get("TopicID")
|
|
687
|
+
or command.get("topic_id")
|
|
688
|
+
or command.get("topicId")
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
@staticmethod
|
|
692
|
+
def _numeric_command_key(command: dict, index: int) -> int | str | None:
|
|
693
|
+
"""Return the numeric command key matching the provided index.
|
|
694
|
+
|
|
695
|
+
Args:
|
|
696
|
+
command (dict): Incoming command payload.
|
|
697
|
+
index (int): Numeric index to locate.
|
|
698
|
+
|
|
699
|
+
Returns:
|
|
700
|
+
int | str | None: The matching key when present.
|
|
701
|
+
"""
|
|
702
|
+
|
|
703
|
+
for key in command:
|
|
704
|
+
try:
|
|
705
|
+
if str(key).isdigit() and int(str(key)) == index:
|
|
706
|
+
return key
|
|
707
|
+
except ValueError:
|
|
708
|
+
continue
|
|
709
|
+
return None
|
|
710
|
+
|
|
711
|
+
def _filter_by_topic(
|
|
712
|
+
self,
|
|
713
|
+
telemeters: list[Telemeter],
|
|
714
|
+
topic_id: str | None,
|
|
715
|
+
message: LXMF.LXMessage,
|
|
716
|
+
) -> list[Telemeter] | None:
|
|
717
|
+
"""Filter telemetry entries to those subscribed to a topic."""
|
|
718
|
+
|
|
719
|
+
if topic_id is None:
|
|
720
|
+
return telemeters
|
|
721
|
+
if self._api is None:
|
|
722
|
+
return telemeters
|
|
723
|
+
try:
|
|
724
|
+
subscribers = self._api.list_subscribers_for_topic(topic_id)
|
|
725
|
+
except KeyError:
|
|
726
|
+
return []
|
|
727
|
+
destination = self._identity_hex(message.source.identity)
|
|
728
|
+
allowed = {sub.destination for sub in subscribers}
|
|
729
|
+
if destination not in allowed:
|
|
730
|
+
return None
|
|
731
|
+
return [tel for tel in telemeters if tel.peer_dest in allowed]
|
|
732
|
+
|
|
733
|
+
@staticmethod
|
|
734
|
+
def _identity_hex(identity: RNS.Identity) -> str:
|
|
735
|
+
"""Return the identity hash as a lowercase hex string."""
|
|
736
|
+
|
|
737
|
+
hash_bytes = getattr(identity, "hash", b"") or b""
|
|
738
|
+
return hash_bytes.hex()
|
|
739
|
+
|
|
740
|
+
def _extract_timestamp(self, telemetry: dict) -> Optional[datetime]:
|
|
741
|
+
"""Return a datetime parsed from a telemetry payload when available."""
|
|
742
|
+
|
|
743
|
+
time_payload = telemetry.get("time")
|
|
744
|
+
if isinstance(time_payload, dict):
|
|
745
|
+
raw_timestamp = time_payload.get("timestamp")
|
|
746
|
+
if isinstance(raw_timestamp, (int, float)):
|
|
747
|
+
return datetime.fromtimestamp(int(raw_timestamp))
|
|
748
|
+
iso_value = time_payload.get("iso")
|
|
749
|
+
if isinstance(iso_value, str):
|
|
750
|
+
try:
|
|
751
|
+
return datetime.fromisoformat(iso_value)
|
|
752
|
+
except ValueError:
|
|
753
|
+
return None
|
|
754
|
+
if isinstance(time_payload, (int, float)):
|
|
755
|
+
return datetime.fromtimestamp(int(time_payload))
|
|
756
|
+
return None
|
|
757
|
+
|
|
758
|
+
def _peer_hash_bytes(self, telemeter: Telemeter) -> Optional[bytes]:
|
|
759
|
+
"""Return the peer hash for ``telemeter`` as bytes or ``None`` on failure."""
|
|
760
|
+
|
|
761
|
+
peer_dest = (telemeter.peer_dest or "").strip()
|
|
762
|
+
if not peer_dest:
|
|
763
|
+
RNS.log("Telemetry entry missing peer destination; skipping")
|
|
764
|
+
return None
|
|
765
|
+
|
|
766
|
+
normalized = "".join(ch for ch in peer_dest if ch in string.hexdigits)
|
|
767
|
+
if not normalized:
|
|
768
|
+
RNS.log(
|
|
769
|
+
f"Telemetry entry peer destination missing hex characters: {peer_dest!r}"
|
|
770
|
+
)
|
|
771
|
+
return None
|
|
772
|
+
if len(normalized) % 2 != 0:
|
|
773
|
+
RNS.log(
|
|
774
|
+
f"Telemetry entry peer destination has odd length after normalization: {peer_dest!r}"
|
|
775
|
+
)
|
|
776
|
+
return None
|
|
777
|
+
|
|
778
|
+
try:
|
|
779
|
+
return bytes.fromhex(normalized)
|
|
780
|
+
except ValueError as exc:
|
|
781
|
+
RNS.log(
|
|
782
|
+
f"Skipping telemetry entry with invalid peer destination {peer_dest!r}: {exc}"
|
|
783
|
+
)
|
|
784
|
+
return None
|
|
785
|
+
|
|
786
|
+
def _json_safe(self, value):
|
|
787
|
+
"""Return ``value`` converted into a JSON-safe structure."""
|
|
788
|
+
|
|
789
|
+
if isinstance(value, dict):
|
|
790
|
+
return {self._json_key(k): self._json_safe(v) for k, v in value.items()}
|
|
791
|
+
if isinstance(value, (list, tuple, set)):
|
|
792
|
+
return [self._json_safe(v) for v in value]
|
|
793
|
+
if isinstance(value, (bytes, bytearray)):
|
|
794
|
+
return value.hex()
|
|
795
|
+
return value
|
|
796
|
+
|
|
797
|
+
def _json_key(self, key):
|
|
798
|
+
"""Return a JSON-safe dict key representation."""
|
|
799
|
+
|
|
800
|
+
if isinstance(key, (str, int, float, bool)) or key is None:
|
|
801
|
+
return key
|
|
802
|
+
if isinstance(key, (bytes, bytearray)):
|
|
803
|
+
return key.hex()
|
|
804
|
+
return str(key)
|