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,25 @@
|
|
|
1
|
+
"""Configuration helpers for Reticulum Telemetry Hub.
|
|
2
|
+
|
|
3
|
+
These classes provide an object-based view of the configuration files the hub
|
|
4
|
+
relies upon (Reticulum, LXMF router, and hub storage paths). Using objects in
|
|
5
|
+
code makes it easier to reason about config state, avoids ad-hoc string
|
|
6
|
+
parsing, and supports introspection APIs.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .models import (
|
|
10
|
+
HubAppConfig,
|
|
11
|
+
HubRuntimeConfig,
|
|
12
|
+
LXMFRouterConfig,
|
|
13
|
+
RNSInterfaceConfig,
|
|
14
|
+
ReticulumConfig,
|
|
15
|
+
)
|
|
16
|
+
from .manager import HubConfigurationManager
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"RNSInterfaceConfig",
|
|
20
|
+
"ReticulumConfig",
|
|
21
|
+
"LXMFRouterConfig",
|
|
22
|
+
"HubAppConfig",
|
|
23
|
+
"HubRuntimeConfig",
|
|
24
|
+
"HubConfigurationManager",
|
|
25
|
+
]
|
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
"""Helpers for reading and merging hub configuration files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from configparser import ConfigParser
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from typing import Mapping, Optional
|
|
10
|
+
|
|
11
|
+
from dotenv import load_dotenv as load_env
|
|
12
|
+
|
|
13
|
+
from reticulum_telemetry_hub.config.constants import DEFAULT_STORAGE_PATH
|
|
14
|
+
from .models import (
|
|
15
|
+
HubAppConfig,
|
|
16
|
+
HubRuntimeConfig,
|
|
17
|
+
LXMFRouterConfig,
|
|
18
|
+
RNSInterfaceConfig,
|
|
19
|
+
ReticulumConfig,
|
|
20
|
+
TakConnectionConfig,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _expand_user_path(value: Path | str) -> Path:
|
|
25
|
+
"""Expand user paths honoring HOME overrides on Windows."""
|
|
26
|
+
value_str = str(value)
|
|
27
|
+
if value_str.startswith("~"):
|
|
28
|
+
home = os.environ.get("HOME")
|
|
29
|
+
if home:
|
|
30
|
+
tail = value_str[1:]
|
|
31
|
+
if tail.startswith(("/", "\\")):
|
|
32
|
+
tail = tail[1:]
|
|
33
|
+
return Path(home) / tail
|
|
34
|
+
return Path(value_str).expanduser()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class HubConfigurationManager: # pylint: disable=too-many-instance-attributes
|
|
38
|
+
"""Load hub related configuration files and expose them as Python objects."""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
storage_path: Optional[Path] = None,
|
|
43
|
+
config_path: Optional[Path] = None,
|
|
44
|
+
reticulum_config_path: Optional[Path] = None,
|
|
45
|
+
lxmf_router_config_path: Optional[Path] = None,
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Load configuration files and prepare helpers.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
storage_path (Optional[Path]): Root path for hub storage.
|
|
51
|
+
reticulum_config_path (Optional[Path]): Override path to the
|
|
52
|
+
Reticulum configuration file.
|
|
53
|
+
lxmf_router_config_path (Optional[Path]): Override path to the
|
|
54
|
+
LXMF router configuration file.
|
|
55
|
+
"""
|
|
56
|
+
load_env()
|
|
57
|
+
self.storage_path = _expand_user_path(storage_path or DEFAULT_STORAGE_PATH)
|
|
58
|
+
self.config_path = _expand_user_path(
|
|
59
|
+
config_path or self.storage_path / "config.ini"
|
|
60
|
+
)
|
|
61
|
+
self._config_parser = self._load_config_parser(self.config_path)
|
|
62
|
+
self.runtime_config = self._load_runtime_config()
|
|
63
|
+
|
|
64
|
+
reticulum_path_override = self.runtime_config.reticulum_config_path
|
|
65
|
+
lxmf_path_override = self.runtime_config.lxmf_router_config_path
|
|
66
|
+
|
|
67
|
+
self.reticulum_config_path = _expand_user_path(
|
|
68
|
+
reticulum_config_path
|
|
69
|
+
or reticulum_path_override
|
|
70
|
+
or Path.home() / ".reticulum" / "config"
|
|
71
|
+
)
|
|
72
|
+
self.lxmf_router_config_path = _expand_user_path(
|
|
73
|
+
lxmf_router_config_path
|
|
74
|
+
or lxmf_path_override
|
|
75
|
+
or Path.home() / ".lxmd" / "config"
|
|
76
|
+
)
|
|
77
|
+
self._tak_config = self._load_tak_config()
|
|
78
|
+
self._config = self._load()
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def config(self) -> HubAppConfig:
|
|
82
|
+
"""Return the aggregated hub configuration.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
HubAppConfig: Current configuration snapshot.
|
|
86
|
+
"""
|
|
87
|
+
return self._config
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def tak_config(self) -> TakConnectionConfig:
|
|
91
|
+
"""Return the TAK connector configuration.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
TakConnectionConfig: Current TAK connection settings.
|
|
95
|
+
"""
|
|
96
|
+
return self._tak_config
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def config_parser(self) -> ConfigParser:
|
|
100
|
+
"""Expose the raw ``ConfigParser`` loaded from disk."""
|
|
101
|
+
|
|
102
|
+
return self._config_parser
|
|
103
|
+
|
|
104
|
+
def reload(self) -> HubAppConfig:
|
|
105
|
+
"""Reload configuration files from disk and environment.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
HubAppConfig: Freshly parsed application configuration.
|
|
109
|
+
"""
|
|
110
|
+
self._config_parser = self._load_config_parser(self.config_path)
|
|
111
|
+
self.runtime_config = self._load_runtime_config()
|
|
112
|
+
self._tak_config = self._load_tak_config()
|
|
113
|
+
self._config = self._load()
|
|
114
|
+
return self._config
|
|
115
|
+
|
|
116
|
+
def reticulum_info_snapshot(self) -> dict:
|
|
117
|
+
"""Return a summary of Reticulum runtime configuration."""
|
|
118
|
+
return self._config.to_reticulum_info_dict()
|
|
119
|
+
|
|
120
|
+
def get_config_text(self) -> str:
|
|
121
|
+
"""Return the raw config.ini content when present."""
|
|
122
|
+
|
|
123
|
+
if not self.config_path.exists():
|
|
124
|
+
return ""
|
|
125
|
+
return self.config_path.read_text(encoding="utf-8")
|
|
126
|
+
|
|
127
|
+
def validate_config_text(self, config_text: str) -> dict:
|
|
128
|
+
"""Validate a config.ini payload without applying it.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
config_text (str): Raw ini contents to validate.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
dict: Validation result with ``valid`` and ``errors`` keys.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
parser = ConfigParser()
|
|
138
|
+
errors: list[str] = []
|
|
139
|
+
try:
|
|
140
|
+
parser.read_string(config_text)
|
|
141
|
+
except Exception as exc: # pragma: no cover - defensive parsing
|
|
142
|
+
errors.append(str(exc))
|
|
143
|
+
return {"valid": not errors, "errors": errors}
|
|
144
|
+
|
|
145
|
+
def apply_config_text(self, config_text: str) -> dict:
|
|
146
|
+
"""Persist the provided config.ini content and keep a backup.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
config_text (str): Raw ini content to persist.
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
dict: Details about the persisted backup and target path.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
validation = self.validate_config_text(config_text)
|
|
156
|
+
if not validation.get("valid"):
|
|
157
|
+
errors = validation.get("errors") or []
|
|
158
|
+
details = "; ".join(str(error) for error in errors if error)
|
|
159
|
+
message = "Invalid configuration payload"
|
|
160
|
+
if details:
|
|
161
|
+
message = f"{message}: {details}"
|
|
162
|
+
raise ValueError(message)
|
|
163
|
+
backup_path = self._backup_config()
|
|
164
|
+
self.config_path.write_text(config_text, encoding="utf-8")
|
|
165
|
+
return {
|
|
166
|
+
"applied": True,
|
|
167
|
+
"config_path": str(self.config_path),
|
|
168
|
+
"backup_path": str(backup_path) if backup_path else None,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
def rollback_config_text(self, backup_path: str | None = None) -> dict:
|
|
172
|
+
"""Restore the config.ini file from a backup.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
backup_path (str | None): Optional backup path override.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
dict: Details about the restored backup.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
target_backup = Path(backup_path) if backup_path else self._latest_backup()
|
|
182
|
+
if target_backup is None or not target_backup.exists():
|
|
183
|
+
return {"rolled_back": False, "error": "No backup available"}
|
|
184
|
+
content = target_backup.read_text(encoding="utf-8")
|
|
185
|
+
self.config_path.write_text(content, encoding="utf-8")
|
|
186
|
+
return {"rolled_back": True, "backup_path": str(target_backup)}
|
|
187
|
+
|
|
188
|
+
# ------------------------------------------------------------------ #
|
|
189
|
+
# private helpers
|
|
190
|
+
# ------------------------------------------------------------------ #
|
|
191
|
+
def _load_config_parser(self, path: Path) -> ConfigParser:
|
|
192
|
+
"""Return a parser populated from ``config.ini`` when present."""
|
|
193
|
+
|
|
194
|
+
parser = ConfigParser()
|
|
195
|
+
if path.exists():
|
|
196
|
+
parser.read(path)
|
|
197
|
+
return parser
|
|
198
|
+
|
|
199
|
+
def _load_runtime_config(self) -> HubRuntimeConfig: # pylint: disable=too-many-locals
|
|
200
|
+
"""Construct the runtime configuration from ``config.ini``."""
|
|
201
|
+
|
|
202
|
+
defaults = HubRuntimeConfig()
|
|
203
|
+
self._ensure_directory(self.storage_path)
|
|
204
|
+
hub_section = self._get_section("hub")
|
|
205
|
+
services_value = hub_section.get("services", "")
|
|
206
|
+
services = tuple(
|
|
207
|
+
part.strip() for part in services_value.split(",") if part.strip()
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
reticulum_path = hub_section.get("reticulum_config_path")
|
|
211
|
+
lxmf_path = hub_section.get("lxmf_router_config_path")
|
|
212
|
+
telemetry_filename = hub_section.get(
|
|
213
|
+
"telemetry_filename", defaults.telemetry_filename
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
gps_section = self._get_section("gpsd")
|
|
217
|
+
gps_host = gps_section.get("host", defaults.gpsd_host)
|
|
218
|
+
gps_port = self._coerce_int(gps_section.get("port"), defaults.gpsd_port)
|
|
219
|
+
|
|
220
|
+
file_section = self._get_section("files")
|
|
221
|
+
image_section = self._get_section("images")
|
|
222
|
+
|
|
223
|
+
files_path_value = file_section.get("path") or file_section.get("directory")
|
|
224
|
+
images_path_value = image_section.get("path") or image_section.get("directory")
|
|
225
|
+
|
|
226
|
+
file_storage_path = _expand_user_path(
|
|
227
|
+
files_path_value or (self.storage_path / "files")
|
|
228
|
+
)
|
|
229
|
+
image_storage_path = _expand_user_path(
|
|
230
|
+
images_path_value or (self.storage_path / "images")
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
file_storage_path = self._ensure_directory(file_storage_path)
|
|
234
|
+
image_storage_path = self._ensure_directory(image_storage_path)
|
|
235
|
+
|
|
236
|
+
return HubRuntimeConfig(
|
|
237
|
+
display_name=hub_section.get("display_name", defaults.display_name),
|
|
238
|
+
announce_interval=self._coerce_int(
|
|
239
|
+
hub_section.get("announce_interval"), defaults.announce_interval
|
|
240
|
+
),
|
|
241
|
+
hub_telemetry_interval=self._coerce_int(
|
|
242
|
+
hub_section.get("hub_telemetry_interval"),
|
|
243
|
+
defaults.hub_telemetry_interval,
|
|
244
|
+
),
|
|
245
|
+
service_telemetry_interval=self._coerce_int(
|
|
246
|
+
hub_section.get("service_telemetry_interval"),
|
|
247
|
+
defaults.service_telemetry_interval,
|
|
248
|
+
),
|
|
249
|
+
log_level=hub_section.get("log_level", defaults.log_level).lower(),
|
|
250
|
+
embedded_lxmd=self._get_bool(
|
|
251
|
+
hub_section, "embedded_lxmd", defaults.embedded_lxmd
|
|
252
|
+
),
|
|
253
|
+
default_services=services,
|
|
254
|
+
gpsd_host=gps_host,
|
|
255
|
+
gpsd_port=gps_port,
|
|
256
|
+
reticulum_config_path=(
|
|
257
|
+
_expand_user_path(reticulum_path) if reticulum_path else None
|
|
258
|
+
),
|
|
259
|
+
lxmf_router_config_path=(
|
|
260
|
+
_expand_user_path(lxmf_path) if lxmf_path else None
|
|
261
|
+
),
|
|
262
|
+
telemetry_filename=telemetry_filename,
|
|
263
|
+
file_storage_path=file_storage_path,
|
|
264
|
+
image_storage_path=image_storage_path,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
def _get_section(self, name: str) -> Mapping[str, str]:
|
|
268
|
+
"""Return a config section if it exists."""
|
|
269
|
+
|
|
270
|
+
if self._config_parser.has_section(name):
|
|
271
|
+
return self._config_parser[name]
|
|
272
|
+
return {}
|
|
273
|
+
|
|
274
|
+
def _load(self) -> HubAppConfig:
|
|
275
|
+
"""Assemble the high level hub configuration object."""
|
|
276
|
+
reticulum = self._load_reticulum_config(self.reticulum_config_path)
|
|
277
|
+
lxmf = self._load_lxmf_config(self.lxmf_router_config_path)
|
|
278
|
+
app_name, app_version, app_description = self._load_app_metadata()
|
|
279
|
+
storage_path = self.storage_path
|
|
280
|
+
database_path = storage_path / "reticulum.db"
|
|
281
|
+
hub_db_path = storage_path / "rth_api.sqlite"
|
|
282
|
+
return HubAppConfig(
|
|
283
|
+
storage_path=storage_path,
|
|
284
|
+
database_path=database_path,
|
|
285
|
+
hub_database_path=hub_db_path,
|
|
286
|
+
file_storage_path=self.runtime_config.file_storage_path
|
|
287
|
+
or storage_path
|
|
288
|
+
/ "files",
|
|
289
|
+
image_storage_path=self.runtime_config.image_storage_path
|
|
290
|
+
or storage_path
|
|
291
|
+
/ "images",
|
|
292
|
+
runtime=self.runtime_config,
|
|
293
|
+
reticulum=reticulum,
|
|
294
|
+
lxmf_router=lxmf,
|
|
295
|
+
app_name=app_name,
|
|
296
|
+
app_version=app_version,
|
|
297
|
+
app_description=app_description,
|
|
298
|
+
tak_connection=self._tak_config,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
def _load_reticulum_config(self, path: Path) -> ReticulumConfig:
|
|
302
|
+
"""Parse the Reticulum configuration file."""
|
|
303
|
+
parser = ConfigParser()
|
|
304
|
+
if path.exists():
|
|
305
|
+
parser.read(path)
|
|
306
|
+
|
|
307
|
+
# Use values from config.ini when present; fall back to external files.
|
|
308
|
+
file_ret_section = (
|
|
309
|
+
dict(parser["reticulum"]) if parser.has_section("reticulum") else {}
|
|
310
|
+
)
|
|
311
|
+
cfg_ret_section = dict(self._get_section("reticulum"))
|
|
312
|
+
ret_section = {**file_ret_section, **cfg_ret_section}
|
|
313
|
+
|
|
314
|
+
file_iface_section = dict(self._find_interface_section(parser))
|
|
315
|
+
cfg_iface_section = {}
|
|
316
|
+
for name in ("interfaces", "interface", "tcp_interface"):
|
|
317
|
+
if self._config_parser.has_section(name):
|
|
318
|
+
cfg_iface_section = dict(self._config_parser[name])
|
|
319
|
+
break
|
|
320
|
+
interface_section = {**file_iface_section, **cfg_iface_section}
|
|
321
|
+
|
|
322
|
+
enable_transport = self._get_bool(ret_section, "enable_transport", True)
|
|
323
|
+
share_instance = self._get_bool(ret_section, "share_instance", True)
|
|
324
|
+
|
|
325
|
+
listen_port = self._coerce_int(interface_section.get("listen_port"), 4242)
|
|
326
|
+
interface = RNSInterfaceConfig(
|
|
327
|
+
listen_ip=interface_section.get("listen_ip", "0.0.0.0"),
|
|
328
|
+
listen_port=listen_port,
|
|
329
|
+
interface_enabled=self._get_bool(
|
|
330
|
+
interface_section, "interface_enabled", True
|
|
331
|
+
),
|
|
332
|
+
interface_type=interface_section.get("type", "TCPServerInterface"),
|
|
333
|
+
)
|
|
334
|
+
return ReticulumConfig(
|
|
335
|
+
path=path,
|
|
336
|
+
enable_transport=enable_transport,
|
|
337
|
+
share_instance=share_instance,
|
|
338
|
+
tcp_interface=interface,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
def _load_lxmf_config(self, path: Path) -> LXMFRouterConfig:
|
|
342
|
+
"""Parse the LXMF router configuration file."""
|
|
343
|
+
parser = ConfigParser()
|
|
344
|
+
if path.exists():
|
|
345
|
+
parser.read(path)
|
|
346
|
+
|
|
347
|
+
file_prop_section = (
|
|
348
|
+
dict(parser["propagation"]) if parser.has_section("propagation") else {}
|
|
349
|
+
)
|
|
350
|
+
cfg_prop_section = dict(self._get_section("propagation"))
|
|
351
|
+
propagation_section = {**file_prop_section, **cfg_prop_section}
|
|
352
|
+
|
|
353
|
+
file_lxmf_section = dict(parser["lxmf"]) if parser.has_section("lxmf") else {}
|
|
354
|
+
cfg_lxmf_section = dict(self._get_section("lxmf"))
|
|
355
|
+
lxmf_section = {**file_lxmf_section, **cfg_lxmf_section}
|
|
356
|
+
|
|
357
|
+
enable_node_value = propagation_section.get("enable_node")
|
|
358
|
+
if enable_node_value is None:
|
|
359
|
+
enable_node_value = propagation_section.get("propagation_node")
|
|
360
|
+
if enable_node_value is None:
|
|
361
|
+
enable_node_value = lxmf_section.get("enable_node")
|
|
362
|
+
if enable_node_value is None:
|
|
363
|
+
enable_node_value = lxmf_section.get("propagation_node")
|
|
364
|
+
enable_node = self._get_bool(
|
|
365
|
+
{"enable_node": enable_node_value}, "enable_node", True
|
|
366
|
+
)
|
|
367
|
+
announce_interval = self._coerce_int(
|
|
368
|
+
propagation_section.get("announce_interval"), 10
|
|
369
|
+
)
|
|
370
|
+
display_name = lxmf_section.get("display_name", "RTH_router")
|
|
371
|
+
return LXMFRouterConfig(
|
|
372
|
+
path=path,
|
|
373
|
+
enable_node=enable_node,
|
|
374
|
+
announce_interval_minutes=announce_interval,
|
|
375
|
+
display_name=display_name,
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
@staticmethod
|
|
379
|
+
def _coerce_int(value: str | None, default: int) -> int:
|
|
380
|
+
"""Return an integer from a string value or fallback."""
|
|
381
|
+
|
|
382
|
+
if value is None:
|
|
383
|
+
return default
|
|
384
|
+
try:
|
|
385
|
+
return int(value)
|
|
386
|
+
except ValueError:
|
|
387
|
+
return default
|
|
388
|
+
|
|
389
|
+
@staticmethod
|
|
390
|
+
def _coerce_float(value: str | None, default: float) -> float:
|
|
391
|
+
"""Return a float from a string value or fallback."""
|
|
392
|
+
|
|
393
|
+
if value is None:
|
|
394
|
+
return default
|
|
395
|
+
try:
|
|
396
|
+
return float(value)
|
|
397
|
+
except ValueError:
|
|
398
|
+
return default
|
|
399
|
+
|
|
400
|
+
@staticmethod
|
|
401
|
+
def _get_bool(section, key: str, default: bool) -> bool:
|
|
402
|
+
"""Interpret boolean-like strings from a config section."""
|
|
403
|
+
value = section.get(key)
|
|
404
|
+
if value is None:
|
|
405
|
+
return default
|
|
406
|
+
return str(value).strip().lower() in {"1", "true", "yes", "on"}
|
|
407
|
+
|
|
408
|
+
@staticmethod
|
|
409
|
+
def _find_interface_section(parser: ConfigParser) -> dict:
|
|
410
|
+
"""Find the first TCP interface section in a configuration parser."""
|
|
411
|
+
candidate_sections = [
|
|
412
|
+
name
|
|
413
|
+
for name in parser.sections()
|
|
414
|
+
if name.lower().startswith("interfaces") or "tcp" in name.lower()
|
|
415
|
+
]
|
|
416
|
+
if candidate_sections:
|
|
417
|
+
return parser[candidate_sections[0]]
|
|
418
|
+
return {}
|
|
419
|
+
|
|
420
|
+
@staticmethod
|
|
421
|
+
def _ensure_directory(path: Path) -> Path:
|
|
422
|
+
"""
|
|
423
|
+
Guarantee that a directory exists.
|
|
424
|
+
|
|
425
|
+
Args:
|
|
426
|
+
path (Path): Directory to create when missing.
|
|
427
|
+
|
|
428
|
+
Returns:
|
|
429
|
+
Path: The original path for chaining.
|
|
430
|
+
"""
|
|
431
|
+
|
|
432
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
433
|
+
return path
|
|
434
|
+
|
|
435
|
+
def _load_app_metadata(self) -> tuple[str, str | None, str]:
|
|
436
|
+
"""Return human-readable application metadata from ``config.ini``.
|
|
437
|
+
|
|
438
|
+
Returns:
|
|
439
|
+
tuple[str, str | None, str]: Name, version, and description for the
|
|
440
|
+
application, preferring the ``[app]`` section when present.
|
|
441
|
+
"""
|
|
442
|
+
|
|
443
|
+
section = self._get_section("app")
|
|
444
|
+
default_name = "ReticulumTelemetryHub"
|
|
445
|
+
default_version = HubAppConfig._safe_get_version(default_name) # pylint: disable=protected-access
|
|
446
|
+
name = section.get("name") or section.get("app_name") or default_name
|
|
447
|
+
version = (
|
|
448
|
+
section.get("version")
|
|
449
|
+
or section.get("app_version")
|
|
450
|
+
or section.get("build")
|
|
451
|
+
or default_version
|
|
452
|
+
)
|
|
453
|
+
description = (
|
|
454
|
+
section.get("description")
|
|
455
|
+
or section.get("app_description")
|
|
456
|
+
or section.get("summary")
|
|
457
|
+
or ""
|
|
458
|
+
)
|
|
459
|
+
return name, version, description
|
|
460
|
+
|
|
461
|
+
def _load_tak_config(self) -> TakConnectionConfig:
|
|
462
|
+
"""Construct the TAK configuration using ``config.ini`` values."""
|
|
463
|
+
|
|
464
|
+
defaults = TakConnectionConfig()
|
|
465
|
+
# Prefer the new uppercase [TAK] section; fall back to legacy [tak].
|
|
466
|
+
section = self._get_section("TAK") or self._get_section("tak")
|
|
467
|
+
|
|
468
|
+
interval = self._coerce_float(
|
|
469
|
+
section.get("poll_interval_seconds")
|
|
470
|
+
or section.get("interval_seconds")
|
|
471
|
+
or section.get("interval"),
|
|
472
|
+
defaults.poll_interval_seconds,
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
keepalive_interval = self._coerce_float(
|
|
476
|
+
section.get("keepalive_interval_seconds")
|
|
477
|
+
or section.get("keepalive_interval")
|
|
478
|
+
or section.get("keepalive"),
|
|
479
|
+
defaults.keepalive_interval_seconds,
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
tak_proto = self._coerce_int(section.get("tak_proto"), defaults.tak_proto)
|
|
483
|
+
fts_compat = self._coerce_int(section.get("fts_compat"), defaults.fts_compat)
|
|
484
|
+
|
|
485
|
+
return TakConnectionConfig(
|
|
486
|
+
cot_url=section.get("cot_url", defaults.cot_url),
|
|
487
|
+
callsign=section.get("callsign", defaults.callsign),
|
|
488
|
+
poll_interval_seconds=interval,
|
|
489
|
+
keepalive_interval_seconds=keepalive_interval,
|
|
490
|
+
tls_client_cert=section.get("tls_client_cert"),
|
|
491
|
+
tls_client_key=section.get("tls_client_key"),
|
|
492
|
+
tls_ca=section.get("tls_ca"),
|
|
493
|
+
tls_insecure=self._get_bool(section, "tls_insecure", defaults.tls_insecure),
|
|
494
|
+
tak_proto=tak_proto,
|
|
495
|
+
fts_compat=fts_compat,
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
def _backup_config(self) -> Path | None:
|
|
499
|
+
"""Create a timestamped backup of config.ini when it exists."""
|
|
500
|
+
|
|
501
|
+
if not self.config_path.exists():
|
|
502
|
+
return None
|
|
503
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
|
|
504
|
+
backup_path = self.config_path.with_suffix(f".ini.bak.{timestamp}")
|
|
505
|
+
content = self.config_path.read_text(encoding="utf-8")
|
|
506
|
+
backup_path.write_text(content, encoding="utf-8")
|
|
507
|
+
return backup_path
|
|
508
|
+
|
|
509
|
+
def _latest_backup(self) -> Path | None:
|
|
510
|
+
"""Return the most recent config.ini backup file."""
|
|
511
|
+
|
|
512
|
+
backups = sorted(self.config_path.parent.glob("config.ini.bak.*"))
|
|
513
|
+
if not backups:
|
|
514
|
+
return None
|
|
515
|
+
return backups[-1]
|