ReticulumTelemetryHub 0.1.0__py3-none-any.whl → 0.143.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. reticulum_telemetry_hub/api/__init__.py +23 -0
  2. reticulum_telemetry_hub/api/models.py +323 -0
  3. reticulum_telemetry_hub/api/service.py +836 -0
  4. reticulum_telemetry_hub/api/storage.py +528 -0
  5. reticulum_telemetry_hub/api/storage_base.py +156 -0
  6. reticulum_telemetry_hub/api/storage_models.py +118 -0
  7. reticulum_telemetry_hub/atak_cot/__init__.py +49 -0
  8. reticulum_telemetry_hub/atak_cot/base.py +277 -0
  9. reticulum_telemetry_hub/atak_cot/chat.py +506 -0
  10. reticulum_telemetry_hub/atak_cot/detail.py +235 -0
  11. reticulum_telemetry_hub/atak_cot/event.py +181 -0
  12. reticulum_telemetry_hub/atak_cot/pytak_client.py +569 -0
  13. reticulum_telemetry_hub/atak_cot/tak_connector.py +848 -0
  14. reticulum_telemetry_hub/config/__init__.py +25 -0
  15. reticulum_telemetry_hub/config/constants.py +7 -0
  16. reticulum_telemetry_hub/config/manager.py +515 -0
  17. reticulum_telemetry_hub/config/models.py +215 -0
  18. reticulum_telemetry_hub/embedded_lxmd/__init__.py +5 -0
  19. reticulum_telemetry_hub/embedded_lxmd/embedded.py +418 -0
  20. reticulum_telemetry_hub/internal_api/__init__.py +21 -0
  21. reticulum_telemetry_hub/internal_api/bus.py +344 -0
  22. reticulum_telemetry_hub/internal_api/core.py +690 -0
  23. reticulum_telemetry_hub/internal_api/v1/__init__.py +74 -0
  24. reticulum_telemetry_hub/internal_api/v1/enums.py +109 -0
  25. reticulum_telemetry_hub/internal_api/v1/manifest.json +8 -0
  26. reticulum_telemetry_hub/internal_api/v1/schemas.py +478 -0
  27. reticulum_telemetry_hub/internal_api/versioning.py +63 -0
  28. reticulum_telemetry_hub/lxmf_daemon/Handlers.py +122 -0
  29. reticulum_telemetry_hub/lxmf_daemon/LXMF.py +252 -0
  30. reticulum_telemetry_hub/lxmf_daemon/LXMPeer.py +898 -0
  31. reticulum_telemetry_hub/lxmf_daemon/LXMRouter.py +4227 -0
  32. reticulum_telemetry_hub/lxmf_daemon/LXMessage.py +1006 -0
  33. reticulum_telemetry_hub/lxmf_daemon/LXStamper.py +490 -0
  34. reticulum_telemetry_hub/lxmf_daemon/__init__.py +10 -0
  35. reticulum_telemetry_hub/lxmf_daemon/_version.py +1 -0
  36. reticulum_telemetry_hub/lxmf_daemon/lxmd.py +1655 -0
  37. reticulum_telemetry_hub/lxmf_telemetry/model/fields/field_telemetry_stream.py +6 -0
  38. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/__init__.py +3 -0
  39. {lxmf_telemetry → reticulum_telemetry_hub/lxmf_telemetry}/model/persistance/appearance.py +19 -19
  40. {lxmf_telemetry → reticulum_telemetry_hub/lxmf_telemetry}/model/persistance/peer.py +17 -13
  41. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/__init__.py +65 -0
  42. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/acceleration.py +68 -0
  43. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/ambient_light.py +37 -0
  44. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/angular_velocity.py +68 -0
  45. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/battery.py +68 -0
  46. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/connection_map.py +258 -0
  47. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/generic.py +841 -0
  48. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/gravity.py +68 -0
  49. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/humidity.py +37 -0
  50. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/information.py +42 -0
  51. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/location.py +110 -0
  52. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/lxmf_propagation.py +429 -0
  53. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/magnetic_field.py +68 -0
  54. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/physical_link.py +53 -0
  55. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/pressure.py +37 -0
  56. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/proximity.py +37 -0
  57. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/received.py +75 -0
  58. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/rns_transport.py +209 -0
  59. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/sensor.py +65 -0
  60. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/sensor_enum.py +27 -0
  61. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/sensor_mapping.py +58 -0
  62. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/temperature.py +37 -0
  63. {lxmf_telemetry → reticulum_telemetry_hub/lxmf_telemetry}/model/persistance/sensors/time.py +36 -32
  64. {lxmf_telemetry → reticulum_telemetry_hub/lxmf_telemetry}/model/persistance/telemeter.py +26 -23
  65. reticulum_telemetry_hub/lxmf_telemetry/sampler.py +229 -0
  66. reticulum_telemetry_hub/lxmf_telemetry/telemeter_manager.py +409 -0
  67. reticulum_telemetry_hub/lxmf_telemetry/telemetry_controller.py +804 -0
  68. reticulum_telemetry_hub/northbound/__init__.py +5 -0
  69. reticulum_telemetry_hub/northbound/app.py +195 -0
  70. reticulum_telemetry_hub/northbound/auth.py +119 -0
  71. reticulum_telemetry_hub/northbound/gateway.py +310 -0
  72. reticulum_telemetry_hub/northbound/internal_adapter.py +302 -0
  73. reticulum_telemetry_hub/northbound/models.py +213 -0
  74. reticulum_telemetry_hub/northbound/routes_chat.py +123 -0
  75. reticulum_telemetry_hub/northbound/routes_files.py +119 -0
  76. reticulum_telemetry_hub/northbound/routes_rest.py +345 -0
  77. reticulum_telemetry_hub/northbound/routes_subscribers.py +150 -0
  78. reticulum_telemetry_hub/northbound/routes_topics.py +178 -0
  79. reticulum_telemetry_hub/northbound/routes_ws.py +107 -0
  80. reticulum_telemetry_hub/northbound/serializers.py +72 -0
  81. reticulum_telemetry_hub/northbound/services.py +373 -0
  82. reticulum_telemetry_hub/northbound/websocket.py +855 -0
  83. reticulum_telemetry_hub/reticulum_server/__main__.py +2237 -0
  84. reticulum_telemetry_hub/reticulum_server/command_manager.py +1268 -0
  85. reticulum_telemetry_hub/reticulum_server/command_text.py +399 -0
  86. reticulum_telemetry_hub/reticulum_server/constants.py +1 -0
  87. reticulum_telemetry_hub/reticulum_server/event_log.py +357 -0
  88. reticulum_telemetry_hub/reticulum_server/internal_adapter.py +358 -0
  89. reticulum_telemetry_hub/reticulum_server/outbound_queue.py +312 -0
  90. reticulum_telemetry_hub/reticulum_server/services.py +422 -0
  91. reticulumtelemetryhub-0.143.0.dist-info/METADATA +181 -0
  92. reticulumtelemetryhub-0.143.0.dist-info/RECORD +97 -0
  93. {reticulumtelemetryhub-0.1.0.dist-info → reticulumtelemetryhub-0.143.0.dist-info}/WHEEL +1 -1
  94. reticulumtelemetryhub-0.143.0.dist-info/licenses/LICENSE +277 -0
  95. lxmf_telemetry/model/fields/field_telemetry_stream.py +0 -7
  96. lxmf_telemetry/model/persistance/__init__.py +0 -3
  97. lxmf_telemetry/model/persistance/sensors/location.py +0 -69
  98. lxmf_telemetry/model/persistance/sensors/magnetic_field.py +0 -36
  99. lxmf_telemetry/model/persistance/sensors/sensor.py +0 -44
  100. lxmf_telemetry/model/persistance/sensors/sensor_enum.py +0 -24
  101. lxmf_telemetry/model/persistance/sensors/sensor_mapping.py +0 -9
  102. lxmf_telemetry/telemetry_controller.py +0 -124
  103. reticulum_server/main.py +0 -182
  104. reticulumtelemetryhub-0.1.0.dist-info/METADATA +0 -15
  105. reticulumtelemetryhub-0.1.0.dist-info/RECORD +0 -19
  106. {lxmf_telemetry → reticulum_telemetry_hub}/__init__.py +0 -0
  107. {lxmf_telemetry/model/persistance/sensors → reticulum_telemetry_hub/lxmf_telemetry}/__init__.py +0 -0
  108. {reticulum_server → reticulum_telemetry_hub/reticulum_server}/__init__.py +0 -0
@@ -0,0 +1,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,7 @@
1
+ """Common configuration defaults for the telemetry hub runtime."""
2
+
3
+ DEFAULT_STORAGE_PATH = "RTH_Store"
4
+ DEFAULT_ANNOUNCE_INTERVAL = 60
5
+ DEFAULT_HUB_TELEMETRY_INTERVAL = 600
6
+ DEFAULT_SERVICE_TELEMETRY_INTERVAL = 900
7
+ DEFAULT_LOG_LEVEL_NAME = "debug"
@@ -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]