mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-10-31 04:01:59 +01:00
Merge branch 'pr-397' into dev
This commit is contained in:
commit
24a45ba641
20 files changed
+1367
-396
No files matched your search
+20
-16
@@ -7,6 +7,7 @@ from typing import Any, Dict, Optional, overload
|
||||
import yaml
|
||||
|
||||
from repeater.exceptions import ConfigurationError
|
||||
from repeater.modem_config import normalize_modem_config
|
||||
from repeater.policy_engine import default_policy_engine_config
|
||||
|
||||
logger = logging.getLogger("Config")
|
||||
@@ -105,7 +106,7 @@ class NullRadio:
|
||||
class BaselineCrcCounterRadio:
|
||||
"""Radio proxy that exposes CRC errors relative to repeater startup.
|
||||
|
||||
pyMC modem transports report the modem firmware's cumulative CRC counter.
|
||||
openHop Modem transports report the modem firmware's cumulative CRC counter.
|
||||
The SX1262 wrapper's counter starts at process startup, which lets the engine
|
||||
persist deltas without knowing the radio backend. Mirror that wrapper flow
|
||||
here by normalizing the modem's raw counter at the transport boundary.
|
||||
@@ -214,6 +215,7 @@ def load_config(config_path: Optional[str] = None) -> Dict[str, Any]:
|
||||
try:
|
||||
with open(config_path) as f:
|
||||
config = yaml.safe_load(f) or {}
|
||||
config = normalize_modem_config(config)
|
||||
logger.info(f"Loaded config from {config_path}")
|
||||
except Exception as e:
|
||||
raise ConfigurationError(f"Failed to load configuration from {config_path}: {e}") from e
|
||||
@@ -341,6 +343,7 @@ def save_config(config_data: Dict[str, Any], config_path: Optional[str] = None)
|
||||
)
|
||||
|
||||
try:
|
||||
config_data = normalize_modem_config(config_data)
|
||||
# Create backup of existing config
|
||||
config_file = Path(config_path)
|
||||
if config_file.exists():
|
||||
@@ -449,6 +452,7 @@ def _load_or_create_identity_key(path: Optional[str] = None) -> bytes:
|
||||
|
||||
|
||||
def get_radio_for_board(board_config: dict):
|
||||
board_config = normalize_modem_config(board_config)
|
||||
|
||||
@overload
|
||||
def _parse_int(value, *, default: None = None) -> Optional[int]: ...
|
||||
@@ -661,25 +665,25 @@ def get_radio_for_board(board_config: dict):
|
||||
|
||||
return radio
|
||||
|
||||
elif radio_type == "pymc_tcp":
|
||||
elif radio_type == "modem_tcp":
|
||||
try:
|
||||
from openhop_core.hardware.tcp_radio import TCPLoRaRadio
|
||||
except ImportError:
|
||||
raise RuntimeError(
|
||||
"pymc_tcp radio requires openhop-core >= the release that includes "
|
||||
"modem_tcp radio requires openhop-core >= the release that includes "
|
||||
"the openhop-core release with TCP modem support. "
|
||||
"Reinstall the [hardware] extra to pick it up."
|
||||
) from None
|
||||
|
||||
tcp_cfg = board_config.get("pymc_tcp")
|
||||
tcp_cfg = board_config.get("modem_tcp")
|
||||
if not tcp_cfg:
|
||||
raise ValueError(
|
||||
"Missing 'pymc_tcp' section in configuration file for radio_type: pymc_tcp"
|
||||
"Missing 'modem_tcp' section in configuration file for radio_type: modem_tcp"
|
||||
)
|
||||
|
||||
host = tcp_cfg.get("host")
|
||||
if not host:
|
||||
raise ValueError("Missing 'host' in 'pymc_tcp' section (modem hostname or LAN IP)")
|
||||
raise ValueError("Missing 'host' in 'modem_tcp' section (modem hostname or LAN IP)")
|
||||
|
||||
radio_cfg = board_config.get("radio") or {}
|
||||
radio = TCPLoRaRadio(
|
||||
@@ -701,29 +705,29 @@ def get_radio_for_board(board_config: dict):
|
||||
try:
|
||||
radio.begin()
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to initialize pymc_tcp radio: {e}") from e
|
||||
raise RuntimeError(f"Failed to initialize modem_tcp radio: {e}") from e
|
||||
|
||||
return BaselineCrcCounterRadio(radio)
|
||||
|
||||
elif radio_type == "pymc_usb":
|
||||
elif radio_type == "modem_usb":
|
||||
try:
|
||||
from openhop_core.hardware.usb_radio import USBLoRaRadio
|
||||
except ImportError:
|
||||
raise RuntimeError(
|
||||
"pymc_usb radio requires openhop-core >= the release that includes "
|
||||
"modem_usb radio requires openhop-core >= the release that includes "
|
||||
"the openhop-core release with TCP modem support. "
|
||||
"Reinstall the [hardware] extra to pick it up."
|
||||
) from None
|
||||
|
||||
usb_cfg = board_config.get("pymc_usb")
|
||||
usb_cfg = board_config.get("modem_usb")
|
||||
if not usb_cfg:
|
||||
raise ValueError(
|
||||
"Missing 'pymc_usb' section in configuration file for radio_type: pymc_usb"
|
||||
"Missing 'modem_usb' section in configuration file for radio_type: modem_usb"
|
||||
)
|
||||
|
||||
port = usb_cfg.get("port")
|
||||
if not port:
|
||||
raise ValueError("Missing 'port' in 'pymc_usb' section (e.g. /dev/ttyACM0)")
|
||||
raise ValueError("Missing 'port' in 'modem_usb' section (e.g. /dev/ttyACM0)")
|
||||
|
||||
radio_cfg = board_config.get("radio") or {}
|
||||
radio = USBLoRaRadio(
|
||||
@@ -743,13 +747,13 @@ def get_radio_for_board(board_config: dict):
|
||||
try:
|
||||
radio.begin()
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to initialize pymc_usb radio: {e}") from e
|
||||
raise RuntimeError(f"Failed to initialize modem_usb radio: {e}") from e
|
||||
|
||||
return BaselineCrcCounterRadio(radio)
|
||||
|
||||
raise RuntimeError(
|
||||
f"Unknown radio type: {radio_type}. "
|
||||
"Supported: sx1262, sx1262_ch341, kiss (or kiss-modem), pymc_tcp, pymc_usb"
|
||||
"Supported: sx1262, sx1262_ch341, kiss (or kiss-modem), modem_tcp, modem_usb"
|
||||
)
|
||||
|
||||
|
||||
@@ -773,8 +777,8 @@ def _merge_radio_entry(global_config: dict, entry: dict) -> dict:
|
||||
"sx1262",
|
||||
"ch341",
|
||||
"kiss",
|
||||
"pymc_tcp",
|
||||
"pymc_usb",
|
||||
"modem_tcp",
|
||||
"modem_usb",
|
||||
):
|
||||
if key in entry:
|
||||
merged[key] = entry[key]
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Any, Dict, List, Optional
|
||||
import yaml
|
||||
|
||||
from repeater.logging_utils import normalize_log_level
|
||||
from repeater.modem_config import normalize_modem_config_in_place
|
||||
|
||||
logger = logging.getLogger("ConfigManager")
|
||||
|
||||
@@ -242,6 +243,7 @@ class ConfigManager:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
normalize_modem_config_in_place(self.config)
|
||||
os.makedirs(os.path.dirname(self.config_path), exist_ok=True)
|
||||
with open(self.config_path, "w") as f:
|
||||
# Use safe_dump with explicit width to prevent line wrapping
|
||||
|
||||
@@ -283,7 +283,7 @@ class NMEAParser:
|
||||
self.ingest_sentence(line)
|
||||
|
||||
def ingest_modem_payload(self, payload: Dict[str, Any]) -> bool:
|
||||
"""Ingest a generic pyMC modem HTTP GPS payload.
|
||||
"""Ingest a generic openHop Modem HTTP GPS payload.
|
||||
|
||||
This is for modems that expose parsed GPS JSON instead of raw NMEA.
|
||||
The payload may be the whole /api/stats response or the nested gps dict.
|
||||
|
||||
+7
-4
@@ -33,6 +33,7 @@ from openhop_core.protocol.packet_utils import (
|
||||
|
||||
from repeater.airtime import AirtimeManager
|
||||
from repeater.data_acquisition import StorageCollector
|
||||
from repeater.modem_config import normalize_modem_config, redact_modem_tokens_in_place
|
||||
from repeater.neighbour_links import NeighbourLinkTracker
|
||||
from repeater.policy_engine import PolicyDecision, PolicyEngine
|
||||
|
||||
@@ -1659,6 +1660,8 @@ class RepeaterHandler(BaseHandler):
|
||||
return self._cached_noise_floor
|
||||
|
||||
def get_stats(self) -> dict:
|
||||
runtime_config = normalize_modem_config(self.config, warn=False)
|
||||
redact_modem_tokens_in_place(runtime_config)
|
||||
|
||||
uptime_seconds = time.time() - self.start_time
|
||||
|
||||
@@ -1737,14 +1740,14 @@ class RepeaterHandler(BaseHandler):
|
||||
"radio": self.config.get(
|
||||
"radio", {}
|
||||
), # Read from live config, not cached radio_config
|
||||
"radios": self.config.get("radios") or [],
|
||||
"radios": runtime_config.get("radios") or [],
|
||||
"fabric": self.config.get("fabric") or {},
|
||||
"radio_type": self.config.get("radio_type"),
|
||||
"radio_type": runtime_config.get("radio_type"),
|
||||
"sx1262": self.config.get("sx1262", {}),
|
||||
"ch341": self.config.get("ch341", {}),
|
||||
"kiss": self.config.get("kiss", {}),
|
||||
"pymc_usb": self.config.get("pymc_usb", {}),
|
||||
"pymc_tcp": self.config.get("pymc_tcp", {}),
|
||||
"modem_usb": runtime_config.get("modem_usb", {}),
|
||||
"modem_tcp": runtime_config.get("modem_tcp", {}),
|
||||
"duty_cycle": {
|
||||
"max_airtime_percent": max_duty_cycle_percent,
|
||||
"enforcement_enabled": duty_cycle_config.get("enforcement_enabled", True),
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Compatibility boundary for openHop Modem transport configuration names."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
LEGACY_MODEM_RADIO_TYPES = {
|
||||
"pymc_tcp": "modem_tcp",
|
||||
"pymc_usb": "modem_usb",
|
||||
}
|
||||
|
||||
LEGACY_MODEM_SENSOR_TYPES = {
|
||||
"pymc_modem": "openhop_modem",
|
||||
}
|
||||
|
||||
LEGACY_MODEM_GPS_SOURCES = {
|
||||
"pymc_modem": "modem_http",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_board_config(config: dict[str, Any], *, warn: bool) -> None:
|
||||
radio_type = config.get("radio_type")
|
||||
if isinstance(radio_type, str):
|
||||
normalized_type = radio_type.lower().strip()
|
||||
if normalized_type in LEGACY_MODEM_RADIO_TYPES:
|
||||
config["radio_type"] = LEGACY_MODEM_RADIO_TYPES[normalized_type]
|
||||
|
||||
for legacy_key, canonical_key in LEGACY_MODEM_RADIO_TYPES.items():
|
||||
if legacy_key not in config:
|
||||
continue
|
||||
if canonical_key in config:
|
||||
if warn:
|
||||
logger.warning(
|
||||
"Both legacy configuration key '%s' and canonical key '%s' are present; canonical key wins",
|
||||
legacy_key,
|
||||
canonical_key,
|
||||
)
|
||||
else:
|
||||
config[canonical_key] = config[legacy_key]
|
||||
del config[legacy_key]
|
||||
|
||||
|
||||
def _normalize_sensor_and_gps_aliases(config: dict[str, Any]) -> None:
|
||||
sensors = config.get("sensors")
|
||||
if isinstance(sensors, dict):
|
||||
for definitions_key in ("definitions", "sensors"):
|
||||
definitions = sensors.get(definitions_key)
|
||||
if not isinstance(definitions, list):
|
||||
continue
|
||||
for definition in definitions:
|
||||
if not isinstance(definition, dict):
|
||||
continue
|
||||
sensor_type = definition.get("type")
|
||||
if isinstance(sensor_type, str):
|
||||
normalized_type = sensor_type.strip().lower()
|
||||
canonical_type = LEGACY_MODEM_SENSOR_TYPES.get(normalized_type)
|
||||
if canonical_type is not None:
|
||||
definition["type"] = canonical_type
|
||||
|
||||
gps = config.get("gps")
|
||||
if isinstance(gps, dict):
|
||||
source = gps.get("source")
|
||||
if isinstance(source, str):
|
||||
normalized_source = source.strip().lower()
|
||||
canonical_source = LEGACY_MODEM_GPS_SOURCES.get(normalized_source)
|
||||
if canonical_source is not None:
|
||||
gps["source"] = canonical_source
|
||||
|
||||
|
||||
def redact_modem_tokens_in_place(config: dict, *, replacement: Any = None) -> dict:
|
||||
"""Remove or replace modem TCP tokens in top-level and multi-radio sections."""
|
||||
if not isinstance(config, dict):
|
||||
return config
|
||||
|
||||
sections = [config.get("modem_tcp")]
|
||||
radios = config.get("radios")
|
||||
if isinstance(radios, list):
|
||||
sections.extend(entry.get("modem_tcp") for entry in radios if isinstance(entry, dict))
|
||||
|
||||
for section in sections:
|
||||
if not isinstance(section, dict) or "token" not in section:
|
||||
continue
|
||||
if replacement is None:
|
||||
section.pop("token", None)
|
||||
else:
|
||||
section["token"] = replacement
|
||||
return config
|
||||
|
||||
|
||||
def normalize_modem_config_in_place(config: dict, *, warn: bool = True) -> dict:
|
||||
"""Canonicalize modem transport keys in place without replacing unrelated objects."""
|
||||
if not isinstance(config, dict):
|
||||
return config
|
||||
|
||||
_normalize_board_config(config, warn=warn)
|
||||
radios = config.get("radios")
|
||||
if isinstance(radios, list):
|
||||
for entry in radios:
|
||||
if isinstance(entry, dict):
|
||||
_normalize_board_config(entry, warn=warn)
|
||||
_normalize_sensor_and_gps_aliases(config)
|
||||
return config
|
||||
|
||||
|
||||
def normalize_modem_config(config: dict, *, warn: bool = True) -> dict:
|
||||
"""Return a deep-copied canonical runtime config; canonical values win conflicts."""
|
||||
if not isinstance(config, dict):
|
||||
return config
|
||||
|
||||
normalized = copy.deepcopy(config)
|
||||
normalize_modem_config_in_place(normalized, warn=warn)
|
||||
return normalized
|
||||
@@ -0,0 +1,266 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any, Dict, Optional
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
from .base import SensorBase
|
||||
from .registry import SensorRegistry
|
||||
|
||||
|
||||
def _single_cell_voltage_to_percent(voltage_v: float) -> int:
|
||||
"""Piecewise linear SoC estimate for a single Li-ion/LiPo cell."""
|
||||
if voltage_v >= 4.20:
|
||||
return 100
|
||||
if voltage_v >= 4.00:
|
||||
return int(85 + (voltage_v - 4.00) / 0.20 * 15)
|
||||
if voltage_v >= 3.80:
|
||||
return int(60 + (voltage_v - 3.80) / 0.20 * 25)
|
||||
if voltage_v >= 3.70:
|
||||
return int(40 + (voltage_v - 3.70) / 0.10 * 20)
|
||||
if voltage_v >= 3.50:
|
||||
return int(15 + (voltage_v - 3.50) / 0.20 * 25)
|
||||
if voltage_v >= 3.00:
|
||||
return int((voltage_v - 3.00) / 0.50 * 15)
|
||||
return 0
|
||||
|
||||
|
||||
@SensorRegistry.register("openhop_modem")
|
||||
class OpenHopModemSensor(SensorBase):
|
||||
"""Read diagnostics exposed by an openHop Modem HTTP API."""
|
||||
|
||||
sensor_type = "openhop_modem"
|
||||
|
||||
def __init__(self, name: str, config: Optional[Dict[str, Any]] = None, log=None):
|
||||
super().__init__(name=name, config=config, log=log)
|
||||
self.poll_interval_seconds = float(self.settings.get("poll_interval_seconds", 60.0))
|
||||
self.timeout_seconds = float(self.settings.get("timeout_seconds", 2.0))
|
||||
self.endpoint = str(self.settings.get("endpoint", "/api/stats") or "/api/stats")
|
||||
self.url = self._build_url()
|
||||
self.username = str(self.settings.get("username", "admin") or "admin")
|
||||
self.password = self.settings.get("password")
|
||||
|
||||
def _build_url(self) -> str:
|
||||
base_url = self.settings.get("base_url")
|
||||
if base_url:
|
||||
base = str(base_url).rstrip("/") + "/"
|
||||
return self._validate_url(urljoin(base, self.endpoint.lstrip("/")))
|
||||
|
||||
host = str(self.settings.get("host", "") or "").strip()
|
||||
if not host:
|
||||
raise ValueError("openhop_modem requires settings.host or settings.base_url")
|
||||
scheme = str(self.settings.get("scheme", "http") or "http").lower()
|
||||
if scheme not in {"http", "https"}:
|
||||
raise ValueError("openhop_modem scheme must be http or https")
|
||||
port = self.settings.get("port")
|
||||
netloc = host
|
||||
if port not in (None, ""):
|
||||
netloc = f"{host}:{int(port)}"
|
||||
return self._validate_url(
|
||||
f"{scheme}://{netloc}{self.endpoint if self.endpoint.startswith('/') else '/' + self.endpoint}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _validate_url(url: str) -> str:
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in {"http", "https"}:
|
||||
raise ValueError("openhop_modem URL scheme must be http or https")
|
||||
if not parsed.netloc:
|
||||
raise ValueError("openhop_modem URL must include a host")
|
||||
return url
|
||||
|
||||
def _read(self) -> Dict[str, Any]:
|
||||
request = urllib.request.Request(self.url, headers={"Accept": "application/json"})
|
||||
if self.password not in (None, ""):
|
||||
raw = f"{self.username}:{self.password}".encode("utf-8")
|
||||
request.add_header("Authorization", "Basic " + base64.b64encode(raw).decode("ascii"))
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=self.timeout_seconds) as response: # nosec B310
|
||||
status = int(getattr(response, "status", 200) or 200)
|
||||
body = response.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise RuntimeError(f"openHop Modem HTTP {exc.code} reading {self.url}") from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise RuntimeError(f"openHop Modem request failed: {exc.reason}") from exc
|
||||
|
||||
if status < 200 or status >= 300:
|
||||
raise RuntimeError(f"openHop Modem HTTP {status} reading {self.url}")
|
||||
|
||||
try:
|
||||
payload = json.loads(body.decode("utf-8"))
|
||||
except Exception as exc:
|
||||
raise RuntimeError("openHop Modem response was not valid JSON") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise RuntimeError("openHop Modem response was not a JSON object")
|
||||
|
||||
return self._normalize_payload(payload)
|
||||
|
||||
def _normalize_payload(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
raw_gps = payload.get("gps")
|
||||
gps: Dict[str, Any] = raw_gps if isinstance(raw_gps, dict) else {}
|
||||
system = self._first_dict(payload.get("system"))
|
||||
counters = self._first_dict(payload.get("counters"))
|
||||
radio = self._first_dict(payload.get("radio"))
|
||||
network = self._first_dict(payload.get("network"))
|
||||
position = self._first_dict(
|
||||
gps.get("position"),
|
||||
gps.get("gps_position"),
|
||||
gps.get("location"),
|
||||
payload.get("gps_position"),
|
||||
payload.get("position"),
|
||||
payload,
|
||||
)
|
||||
fix = self._first_dict(gps.get("fix"), payload.get("fix"))
|
||||
satellites = self._first_dict(gps.get("satellites"), payload.get("satellites"))
|
||||
time_data = self._first_dict(
|
||||
gps.get("time"), gps.get("time_data"), payload.get("time_data")
|
||||
)
|
||||
motion = self._first_dict(gps.get("motion"), payload.get("motion"))
|
||||
nmea = self._first_dict(gps.get("nmea"), payload.get("nmea"))
|
||||
die_temperature_c = self._float(
|
||||
system.get("die_temperature_c", payload.get("die_temperature_c"))
|
||||
)
|
||||
|
||||
out: Dict[str, Any] = {
|
||||
"source": "openhop_modem",
|
||||
"url": self.url,
|
||||
"system_board": system.get("board"),
|
||||
"system_firmware": system.get("firmware"),
|
||||
"system_uptime": system.get("uptime"),
|
||||
"uptime_seconds": self._int(system.get("uptime_sec", payload.get("uptime_sec"))),
|
||||
# The modem reports its MCU die temperature. Keep the precise field
|
||||
# and provide the generic sensor convention used by RepeaterUI.
|
||||
"die_temperature_c": die_temperature_c,
|
||||
"temperature_c": die_temperature_c,
|
||||
"rx_packets": self._int(counters.get("rx_packets", payload.get("rx_count"))),
|
||||
"tx_packets": self._int(counters.get("tx_packets", payload.get("tx_count"))),
|
||||
"crc_errors": self._int(counters.get("crc_errors", payload.get("crc_errors"))),
|
||||
"last_rssi_dbm": self._float(counters.get("last_rssi_dbm", payload.get("last_rssi"))),
|
||||
"last_snr_db": self._float(counters.get("last_snr_db", payload.get("last_snr"))),
|
||||
"noise_floor_dbm": self._float(
|
||||
counters.get("noise_floor_dbm", payload.get("noise_floor"))
|
||||
),
|
||||
"radio_state": radio.get("state"),
|
||||
"radio_standby": self._bool_or_none(radio.get("standby")),
|
||||
"frequency_hz": self._int(radio.get("frequency_hz")),
|
||||
"frequency_mhz": self._float(radio.get("frequency_mhz")),
|
||||
"bandwidth_hz": self._int(radio.get("bandwidth_hz")),
|
||||
"bandwidth_khz": self._float(radio.get("bandwidth_khz")),
|
||||
"spreading_factor": self._int(radio.get("spreading_factor")),
|
||||
"coding_rate": self._int(radio.get("coding_rate")),
|
||||
"tx_power_dbm": self._int(radio.get("tx_power_dbm")),
|
||||
"preamble_length": self._int(radio.get("preamble_len")),
|
||||
"syncword": radio.get("syncword"),
|
||||
"syncword_value": self._int(radio.get("syncword_value")),
|
||||
"auto_cad_enabled": self._bool_or_none(radio.get("auto_cad_enabled")),
|
||||
"network_interface": network.get("interface"),
|
||||
"network_mode": network.get("mode"),
|
||||
"network_live": self._bool_or_none(network.get("live")),
|
||||
"network_current_ip": network.get("current_ip"),
|
||||
"tcp_port": self._int(network.get("tcp_port")),
|
||||
"token_configured": self._bool_or_none(
|
||||
network.get("token_set", network.get("pymc_token_set"))
|
||||
),
|
||||
"gps_available": self._bool_or_none(gps.get("available")),
|
||||
"gps_enabled": self._bool_or_none(gps.get("enabled")),
|
||||
"gps_seen": self._bool_or_none(gps.get("seen")),
|
||||
"latitude": self._float(position.get("latitude")),
|
||||
"longitude": self._float(position.get("longitude")),
|
||||
"altitude_m": self._float(position.get("altitude_m")),
|
||||
"fix_valid": self._bool_or_none(fix.get("valid")),
|
||||
"fix_quality": self._int(fix.get("quality")),
|
||||
"satellites_used": self._int(
|
||||
satellites.get("used_count", satellites.get("satellites_used"))
|
||||
),
|
||||
"satellites_in_view": self._int(
|
||||
satellites.get("in_view_count", satellites.get("satellites_in_view"))
|
||||
),
|
||||
"datetime_utc": time_data.get("datetime_utc") or payload.get("datetime_utc"),
|
||||
"speed_kmh": self._float(motion.get("speed_kmh", payload.get("speed_kmh"))),
|
||||
"course_degrees": self._float(
|
||||
motion.get("course_degrees", payload.get("course_degrees"))
|
||||
),
|
||||
"gps_nmea_age_ms": self._int(nmea.get("age_ms")),
|
||||
"gps_nmea_valid_sentences": self._int(nmea.get("valid_sentence_count")),
|
||||
"gps_nmea_invalid_checksums": self._int(nmea.get("invalid_checksum_count")),
|
||||
"gps_nmea_raw_bytes": self._int(nmea.get("raw_byte_count")),
|
||||
"gps_nmea_last_sentence": nmea.get("last_sentence_type"),
|
||||
}
|
||||
|
||||
for key in (
|
||||
"battery_voltage_mv",
|
||||
"battery_voltage_v",
|
||||
"battery_percent",
|
||||
"battery_percentage",
|
||||
"solar_charge_rate_percent_per_hour",
|
||||
):
|
||||
if key in payload:
|
||||
out[key] = payload[key]
|
||||
|
||||
for key in ("battery_voltage_mv", "battery_voltage_v"):
|
||||
if out.get(key) is None and system.get(key) is not None:
|
||||
out[key] = system[key]
|
||||
|
||||
if "battery_percent" not in out:
|
||||
battery_voltage_v = self._float(out.get("battery_voltage_v"))
|
||||
if battery_voltage_v is None:
|
||||
battery_voltage_mv = self._float(out.get("battery_voltage_mv"))
|
||||
if battery_voltage_mv is not None:
|
||||
battery_voltage_v = battery_voltage_mv / 1000.0
|
||||
if battery_voltage_v is not None:
|
||||
out["battery_percent"] = _single_cell_voltage_to_percent(battery_voltage_v)
|
||||
|
||||
return {key: value for key, value in out.items() if value is not None}
|
||||
|
||||
def _battery_voltage_v(self, payload: Dict[str, Any]) -> Optional[float]:
|
||||
voltage_v = self._float(payload.get("battery_voltage_v"))
|
||||
if voltage_v is not None:
|
||||
return voltage_v
|
||||
voltage_mv = self._float(payload.get("battery_voltage_mv"))
|
||||
if voltage_mv is not None:
|
||||
return voltage_mv / 1000.0
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _first_dict(*values: Any) -> Dict[str, Any]:
|
||||
for value in values:
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _float(value: Any) -> Optional[float]:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
try:
|
||||
result = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _int(value: Any) -> Optional[int]:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _bool_or_none(value: Any) -> Optional[bool]:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
lowered = value.strip().lower()
|
||||
if lowered in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
if lowered in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
return bool(value)
|
||||
@@ -1,207 +1,15 @@
|
||||
from __future__ import annotations
|
||||
"""Compatibility alias for the renamed openHop Modem sensor."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any, Dict, Optional
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
from .base import SensorBase
|
||||
from . import openhop_modem as _implementation
|
||||
from .openhop_modem import OpenHopModemSensor
|
||||
from .registry import SensorRegistry
|
||||
|
||||
# Keep the old module-level urllib seam used by downstream tests/integrations.
|
||||
urllib = _implementation.urllib
|
||||
|
||||
def _single_cell_voltage_to_percent(voltage_v: float) -> int:
|
||||
"""Piecewise linear SoC estimate for a single Li-ion/LiPo cell."""
|
||||
if voltage_v >= 4.20:
|
||||
return 100
|
||||
if voltage_v >= 4.00:
|
||||
return int(85 + (voltage_v - 4.00) / 0.20 * 15)
|
||||
if voltage_v >= 3.80:
|
||||
return int(60 + (voltage_v - 3.80) / 0.20 * 25)
|
||||
if voltage_v >= 3.70:
|
||||
return int(40 + (voltage_v - 3.70) / 0.10 * 20)
|
||||
if voltage_v >= 3.50:
|
||||
return int(15 + (voltage_v - 3.50) / 0.20 * 25)
|
||||
if voltage_v >= 3.00:
|
||||
return int((voltage_v - 3.00) / 0.50 * 15)
|
||||
return 0
|
||||
SensorRegistry.register("pymc_modem", OpenHopModemSensor)
|
||||
|
||||
# Preserve the old import path/class name for existing integrations.
|
||||
PymcModemSensor = OpenHopModemSensor
|
||||
|
||||
@SensorRegistry.register("pymc_modem")
|
||||
class PymcModemSensor(SensorBase):
|
||||
"""Read diagnostics exposed by a pyMC modem HTTP API."""
|
||||
|
||||
sensor_type = "pymc_modem"
|
||||
|
||||
def __init__(self, name: str, config: Optional[Dict[str, Any]] = None, log=None):
|
||||
super().__init__(name=name, config=config, log=log)
|
||||
self.poll_interval_seconds = float(self.settings.get("poll_interval_seconds", 60.0))
|
||||
self.timeout_seconds = float(self.settings.get("timeout_seconds", 2.0))
|
||||
self.endpoint = str(self.settings.get("endpoint", "/api/stats") or "/api/stats")
|
||||
self.url = self._build_url()
|
||||
self.username = str(self.settings.get("username", "admin") or "admin")
|
||||
self.password = self.settings.get("password")
|
||||
|
||||
def _build_url(self) -> str:
|
||||
base_url = self.settings.get("base_url")
|
||||
if base_url:
|
||||
base = str(base_url).rstrip("/") + "/"
|
||||
return self._validate_url(urljoin(base, self.endpoint.lstrip("/")))
|
||||
|
||||
host = str(self.settings.get("host", "") or "").strip()
|
||||
if not host:
|
||||
raise ValueError("pymc_modem requires settings.host or settings.base_url")
|
||||
scheme = str(self.settings.get("scheme", "http") or "http").lower()
|
||||
if scheme not in {"http", "https"}:
|
||||
raise ValueError("pymc_modem scheme must be http or https")
|
||||
port = self.settings.get("port")
|
||||
netloc = host
|
||||
if port not in (None, ""):
|
||||
netloc = f"{host}:{int(port)}"
|
||||
return self._validate_url(
|
||||
f"{scheme}://{netloc}{self.endpoint if self.endpoint.startswith('/') else '/' + self.endpoint}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _validate_url(url: str) -> str:
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in {"http", "https"}:
|
||||
raise ValueError("pymc_modem URL scheme must be http or https")
|
||||
if not parsed.netloc:
|
||||
raise ValueError("pymc_modem URL must include a host")
|
||||
return url
|
||||
|
||||
def _read(self) -> Dict[str, Any]:
|
||||
request = urllib.request.Request(self.url, headers={"Accept": "application/json"})
|
||||
if self.password not in (None, ""):
|
||||
raw = f"{self.username}:{self.password}".encode("utf-8")
|
||||
request.add_header("Authorization", "Basic " + base64.b64encode(raw).decode("ascii"))
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=self.timeout_seconds) as response: # nosec B310
|
||||
status = int(getattr(response, "status", 200) or 200)
|
||||
body = response.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise RuntimeError(f"pyMC modem HTTP {exc.code} reading {self.url}") from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise RuntimeError(f"pyMC modem request failed: {exc.reason}") from exc
|
||||
|
||||
if status < 200 or status >= 300:
|
||||
raise RuntimeError(f"pyMC modem HTTP {status} reading {self.url}")
|
||||
|
||||
try:
|
||||
payload = json.loads(body.decode("utf-8"))
|
||||
except Exception as exc:
|
||||
raise RuntimeError("pyMC modem response was not valid JSON") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise RuntimeError("pyMC modem response was not a JSON object")
|
||||
|
||||
return self._normalize_payload(payload)
|
||||
|
||||
def _normalize_payload(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
raw_gps = payload.get("gps")
|
||||
gps: Dict[str, Any] = raw_gps if isinstance(raw_gps, dict) else {}
|
||||
position = self._first_dict(
|
||||
gps.get("position"),
|
||||
gps.get("gps_position"),
|
||||
gps.get("location"),
|
||||
payload.get("gps_position"),
|
||||
payload.get("position"),
|
||||
payload,
|
||||
)
|
||||
fix = self._first_dict(gps.get("fix"), payload.get("fix"))
|
||||
satellites = self._first_dict(gps.get("satellites"), payload.get("satellites"))
|
||||
time_data = self._first_dict(
|
||||
gps.get("time"), gps.get("time_data"), payload.get("time_data")
|
||||
)
|
||||
motion = self._first_dict(gps.get("motion"), payload.get("motion"))
|
||||
|
||||
out: Dict[str, Any] = {
|
||||
"source": "pymc_modem",
|
||||
"url": self.url,
|
||||
"gps_enabled": self._bool_or_none(gps.get("enabled")),
|
||||
"gps_seen": self._bool_or_none(gps.get("seen")),
|
||||
"latitude": self._float(position.get("latitude")),
|
||||
"longitude": self._float(position.get("longitude")),
|
||||
"altitude_m": self._float(position.get("altitude_m")),
|
||||
"fix_valid": self._bool_or_none(fix.get("valid")),
|
||||
"fix_quality": self._int(fix.get("quality")),
|
||||
"satellites_used": self._int(
|
||||
satellites.get("used_count", satellites.get("satellites_used"))
|
||||
),
|
||||
"satellites_in_view": self._int(
|
||||
satellites.get("in_view_count", satellites.get("satellites_in_view"))
|
||||
),
|
||||
"datetime_utc": time_data.get("datetime_utc") or payload.get("datetime_utc"),
|
||||
"speed_kmh": self._float(motion.get("speed_kmh", payload.get("speed_kmh"))),
|
||||
"course_degrees": self._float(
|
||||
motion.get("course_degrees", payload.get("course_degrees"))
|
||||
),
|
||||
}
|
||||
|
||||
for key in (
|
||||
"battery_voltage_mv",
|
||||
"battery_voltage_v",
|
||||
"battery_percent",
|
||||
"battery_percentage",
|
||||
"solar_charge_rate_percent_per_hour",
|
||||
):
|
||||
if key in payload:
|
||||
out[key] = payload[key]
|
||||
|
||||
if "battery_percent" not in out:
|
||||
battery_voltage_v = self._battery_voltage_v(payload)
|
||||
if battery_voltage_v is not None:
|
||||
out["battery_percent"] = _single_cell_voltage_to_percent(battery_voltage_v)
|
||||
|
||||
return {key: value for key, value in out.items() if value is not None}
|
||||
|
||||
def _battery_voltage_v(self, payload: Dict[str, Any]) -> Optional[float]:
|
||||
voltage_v = self._float(payload.get("battery_voltage_v"))
|
||||
if voltage_v is not None:
|
||||
return voltage_v
|
||||
voltage_mv = self._float(payload.get("battery_voltage_mv"))
|
||||
if voltage_mv is not None:
|
||||
return voltage_mv / 1000.0
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _first_dict(*values: Any) -> Dict[str, Any]:
|
||||
for value in values:
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _float(value: Any) -> Optional[float]:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
try:
|
||||
result = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _int(value: Any) -> Optional[int]:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _bool_or_none(value: Any) -> Optional[bool]:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
lowered = value.strip().lower()
|
||||
if lowered in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
if lowered in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
return bool(value)
|
||||
__all__ = ["PymcModemSensor"]
|
||||
+167
-68
@@ -27,6 +27,11 @@ from repeater.companion.utils import (
|
||||
)
|
||||
from repeater.config import resolve_storage_dir
|
||||
from repeater.handler_helpers.acl import role_name as acl_role_name
|
||||
from repeater.modem_config import (
|
||||
LEGACY_MODEM_RADIO_TYPES,
|
||||
normalize_modem_config,
|
||||
redact_modem_tokens_in_place,
|
||||
)
|
||||
from repeater.policy_engine import PolicyEngine
|
||||
from repeater.service_utils import get_buildroot_image_info
|
||||
from repeater.utils_packet import create_scoped_advert_packet
|
||||
@@ -38,6 +43,7 @@ from .companion_endpoints import CompanionAPIEndpoints
|
||||
from .update_endpoints import UpdateAPIEndpoints
|
||||
|
||||
logger = logging.getLogger("HTTPServer")
|
||||
REDACTED_CONFIG_VALUE = "*** REDACTED ***"
|
||||
|
||||
POLICY_GROUP_KINDS = {
|
||||
"channel_hash": "channel_hashes",
|
||||
@@ -1432,17 +1438,40 @@ class APIEndpoints:
|
||||
for dev in glob.glob(pattern):
|
||||
devices.append({"device": str(dev), "description": str(dev)})
|
||||
|
||||
# De-duplicate by device path while preserving the first description.
|
||||
# Docker deployments may mount the host device namespace separately.
|
||||
import glob
|
||||
|
||||
if os.path.isdir("/host/dev"):
|
||||
for pattern in (
|
||||
"/host/dev/ttyACM*",
|
||||
"/host/dev/ttyUSB*",
|
||||
"/host/dev/serial/by-id/*",
|
||||
):
|
||||
try:
|
||||
for dev in glob.glob(pattern):
|
||||
devices.append({"device": str(dev), "description": str(dev)})
|
||||
except (OSError, PermissionError):
|
||||
continue
|
||||
|
||||
# De-duplicate by resolved target. Prefer stable host by-id aliases.
|
||||
dedup = {}
|
||||
for item in devices:
|
||||
dev = item.get("device")
|
||||
if dev and dev not in dedup:
|
||||
if not dev or (os.path.islink(dev) and not os.path.exists(dev)):
|
||||
continue
|
||||
try:
|
||||
target = os.path.realpath(dev)
|
||||
except (OSError, PermissionError):
|
||||
continue
|
||||
preferred = str(dev).startswith("/host/dev/serial/by-id/")
|
||||
existing = dedup.get(target)
|
||||
if existing is None or preferred:
|
||||
# Docker reads `devices:` entries as host:container[:perms] and
|
||||
# cannot escape a colon inside the path, so a /dev/serial/by-id/
|
||||
# name carrying an ESP32-S3 MAC serial is unusable in a
|
||||
# container. Flag it so the UI can offer a symlink instead.
|
||||
item["docker_safe"] = ":" not in dev
|
||||
dedup[dev] = item
|
||||
dedup[target] = item
|
||||
|
||||
sorted_devices = sorted(dedup.values(), key=lambda x: x["device"])
|
||||
return self._success(sorted_devices)
|
||||
@@ -1484,6 +1513,7 @@ class APIEndpoints:
|
||||
return {"success": False, "error": "Node name too long (max 31 bytes in UTF-8)"}
|
||||
|
||||
hardware_key = data.get("hardware_key", "").strip()
|
||||
hardware_key = LEGACY_MODEM_RADIO_TYPES.get(hardware_key, hardware_key)
|
||||
if not hardware_key:
|
||||
return {"success": False, "error": "Hardware selection is required"}
|
||||
|
||||
@@ -1565,47 +1595,58 @@ class APIEndpoints:
|
||||
)
|
||||
if "preamble_length" not in config_yaml["radio"]:
|
||||
config_yaml["radio"]["preamble_length"] = 32
|
||||
elif hardware_key == "pymc_usb":
|
||||
# pymc_usb modem: external SX1262 board over USB-CDC.
|
||||
# Accept pymc_usb_port / pymc_usb_baudrate from the request body
|
||||
elif hardware_key == "modem_usb":
|
||||
# modem_usb modem: external SX1262 board over USB-CDC.
|
||||
# Accept modem_usb_port / modem_usb_baudrate from the request body
|
||||
# (mirrors the KISS pattern) so a future SPA can expose inputs;
|
||||
# fall back to /dev/ttyACM0 at 921600 baud, which matches the
|
||||
# firmware default and the typical USB-CDC modem device on Linux.
|
||||
config_yaml["radio_type"] = "pymc_usb"
|
||||
usb_port = (data.get("pymc_usb_port") or "").strip() or "/dev/ttyACM0"
|
||||
usb_baud = int(data.get("pymc_usb_baudrate", data.get("pymc_usb_baud", 921600)))
|
||||
pymc_usb_section = config_yaml.setdefault("pymc_usb", {})
|
||||
pymc_usb_section["port"] = usb_port
|
||||
pymc_usb_section["baudrate"] = usb_baud
|
||||
pymc_usb_section.setdefault("lbt_enabled", True)
|
||||
pymc_usb_section.setdefault("lbt_max_attempts", 5)
|
||||
config_yaml["radio_type"] = "modem_usb"
|
||||
usb_port = (
|
||||
data.get("modem_usb_port") or data.get("pymc_usb_port") or ""
|
||||
).strip() or "/dev/ttyACM0"
|
||||
usb_baud = int(
|
||||
data.get(
|
||||
"modem_usb_baudrate",
|
||||
data.get("pymc_usb_baudrate", data.get("pymc_usb_baud", 921600)),
|
||||
)
|
||||
)
|
||||
modem_usb_section = config_yaml.setdefault("modem_usb", {})
|
||||
modem_usb_section["port"] = usb_port
|
||||
modem_usb_section["baudrate"] = usb_baud
|
||||
modem_usb_section.setdefault("lbt_enabled", True)
|
||||
modem_usb_section.setdefault("lbt_max_attempts", 5)
|
||||
if tx_power_preset is not None:
|
||||
config_yaml["radio"]["tx_power"] = tx_power_preset
|
||||
elif "tx_power" in hw_config:
|
||||
config_yaml["radio"]["tx_power"] = hw_config.get("tx_power", 22)
|
||||
if "preamble_length" in hw_config:
|
||||
config_yaml["radio"]["preamble_length"] = hw_config.get("preamble_length", 32)
|
||||
elif hardware_key == "pymc_tcp":
|
||||
# pymc_tcp modem: external SX1262 board exposed as TCP over Wi-Fi/Ethernet.
|
||||
elif hardware_key == "modem_tcp":
|
||||
# modem_tcp modem: external SX1262 board exposed as TCP over Wi-Fi/Ethernet.
|
||||
# 'host' has no sensible default — must be the modem's LAN address or
|
||||
# mDNS name. Accept it from the request body if the SPA provides it,
|
||||
# otherwise write a clearly-placeholder hostname so the file is valid
|
||||
# YAML and the user gets a startup error pointing them at the right
|
||||
# section to edit (see config.py: ValueError 'Missing host …').
|
||||
config_yaml["radio_type"] = "pymc_tcp"
|
||||
tcp_host = (data.get("pymc_tcp_host") or "").strip() or "REPLACE_WITH_MODEM_HOST"
|
||||
tcp_port = int(data.get("pymc_tcp_port", 5055))
|
||||
pymc_tcp_section = config_yaml.setdefault("pymc_tcp", {})
|
||||
pymc_tcp_section["host"] = tcp_host
|
||||
pymc_tcp_section["port"] = tcp_port
|
||||
tcp_token = data.get("pymc_tcp_token")
|
||||
config_yaml["radio_type"] = "modem_tcp"
|
||||
tcp_host = (
|
||||
data.get("modem_tcp_host") or data.get("pymc_tcp_host") or ""
|
||||
).strip() or "REPLACE_WITH_MODEM_HOST"
|
||||
tcp_port = int(data.get("modem_tcp_port", data.get("pymc_tcp_port", 5055)))
|
||||
modem_tcp_section = config_yaml.setdefault("modem_tcp", {})
|
||||
modem_tcp_section["host"] = tcp_host
|
||||
modem_tcp_section["port"] = tcp_port
|
||||
tcp_token = data.get("modem_tcp_token")
|
||||
if tcp_token is None:
|
||||
tcp_token = data.get("pymc_tcp_token")
|
||||
if tcp_token is not None:
|
||||
pymc_tcp_section["token"] = str(tcp_token)
|
||||
modem_tcp_section["token"] = str(tcp_token)
|
||||
else:
|
||||
pymc_tcp_section.setdefault("token", "")
|
||||
pymc_tcp_section.setdefault("connect_timeout", 5.0)
|
||||
pymc_tcp_section.setdefault("lbt_enabled", True)
|
||||
pymc_tcp_section.setdefault("lbt_max_attempts", 5)
|
||||
modem_tcp_section.setdefault("token", "")
|
||||
modem_tcp_section.setdefault("connect_timeout", 5.0)
|
||||
modem_tcp_section.setdefault("lbt_enabled", True)
|
||||
modem_tcp_section.setdefault("lbt_max_attempts", 5)
|
||||
if tx_power_preset is not None:
|
||||
config_yaml["radio"]["tx_power"] = tx_power_preset
|
||||
elif "tx_power" in hw_config:
|
||||
@@ -1681,7 +1722,8 @@ class APIEndpoints:
|
||||
config_yaml["sx1262"]["use_gpiod_backend"] = hw_config.get(
|
||||
"use_gpiod_backend", False
|
||||
)
|
||||
# Write updated config
|
||||
# Explicit setup writes canonical names only.
|
||||
config_yaml = normalize_modem_config(config_yaml)
|
||||
with open(self._config_path, "w") as f:
|
||||
yaml.dump(config_yaml, f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
@@ -1719,14 +1761,14 @@ class APIEndpoints:
|
||||
if hardware_key == "kiss":
|
||||
result_config["kiss_port"] = config_yaml.get("kiss", {}).get("port")
|
||||
result_config["kiss_baud_rate"] = config_yaml.get("kiss", {}).get("baud_rate")
|
||||
elif hardware_key == "pymc_usb":
|
||||
pymc_usb_cfg = config_yaml.get("pymc_usb", {})
|
||||
result_config["pymc_usb_port"] = pymc_usb_cfg.get("port")
|
||||
result_config["pymc_usb_baudrate"] = pymc_usb_cfg.get("baudrate")
|
||||
elif hardware_key == "pymc_tcp":
|
||||
pymc_tcp_cfg = config_yaml.get("pymc_tcp", {})
|
||||
result_config["pymc_tcp_host"] = pymc_tcp_cfg.get("host")
|
||||
result_config["pymc_tcp_port"] = pymc_tcp_cfg.get("port")
|
||||
elif hardware_key == "modem_usb":
|
||||
modem_usb_cfg = config_yaml.get("modem_usb", {})
|
||||
result_config["modem_usb_port"] = modem_usb_cfg.get("port")
|
||||
result_config["modem_usb_baudrate"] = modem_usb_cfg.get("baudrate")
|
||||
elif hardware_key == "modem_tcp":
|
||||
modem_tcp_cfg = config_yaml.get("modem_tcp", {})
|
||||
result_config["modem_tcp_host"] = modem_tcp_cfg.get("host")
|
||||
result_config["modem_tcp_port"] = modem_tcp_cfg.get("port")
|
||||
# token deliberately omitted from response (sensitive)
|
||||
return {
|
||||
"success": True,
|
||||
@@ -1749,15 +1791,17 @@ class APIEndpoints:
|
||||
def stats(self):
|
||||
try:
|
||||
stats = self.stats_getter() if self.stats_getter else {}
|
||||
runtime_config = normalize_modem_config(self.config, warn=False)
|
||||
redact_modem_tokens_in_place(runtime_config)
|
||||
# Include active radio configuration in stats so UI can hydrate
|
||||
# directly from this endpoint without additional config fetches.
|
||||
stats["radio_type"] = self.config.get("radio_type")
|
||||
stats["sx1262"] = self.config.get("sx1262", {})
|
||||
stats["ch341"] = self.config.get("ch341", {})
|
||||
stats["kiss"] = self.config.get("kiss", {})
|
||||
stats["pymc_usb"] = self.config.get("pymc_usb", {})
|
||||
stats["pymc_tcp"] = self.config.get("pymc_tcp", {})
|
||||
stats["radios"] = self.config.get("radios") or []
|
||||
stats["radio_type"] = runtime_config.get("radio_type")
|
||||
stats["sx1262"] = runtime_config.get("sx1262", {})
|
||||
stats["ch341"] = runtime_config.get("ch341", {})
|
||||
stats["kiss"] = runtime_config.get("kiss", {})
|
||||
stats["modem_usb"] = runtime_config.get("modem_usb", {})
|
||||
stats["modem_tcp"] = runtime_config.get("modem_tcp", {})
|
||||
stats["radios"] = runtime_config.get("radios") or []
|
||||
stats["fabric"] = self.config.get("fabric") or {}
|
||||
meta = {}
|
||||
daemon = getattr(self, "daemon_instance", None)
|
||||
@@ -2983,6 +3027,7 @@ class APIEndpoints:
|
||||
config_yaml = None
|
||||
|
||||
if isinstance(config_yaml, dict):
|
||||
config_yaml = normalize_modem_config(config_yaml)
|
||||
repeater = config_yaml.get("repeater")
|
||||
if not isinstance(repeater, dict):
|
||||
add_error("repeater", "Missing required section 'repeater'")
|
||||
@@ -3015,8 +3060,8 @@ class APIEndpoints:
|
||||
"sx1262",
|
||||
"sx1262_ch341",
|
||||
"kiss",
|
||||
"pymc_tcp",
|
||||
"pymc_usb",
|
||||
"modem_tcp",
|
||||
"modem_usb",
|
||||
"none",
|
||||
"null",
|
||||
"disabled",
|
||||
@@ -3027,7 +3072,7 @@ class APIEndpoints:
|
||||
if radio_type not in known_radio_types:
|
||||
add_error(
|
||||
"radio_type",
|
||||
"Unsupported radio_type. Supported: sx1262, sx1262_ch341, kiss, pymc_tcp, pymc_usb, none/null",
|
||||
"Unsupported radio_type. Supported: sx1262, sx1262_ch341, kiss, modem_tcp, modem_usb, none/null",
|
||||
)
|
||||
|
||||
radio_disabled = radio_type in ("", "none", "null", "disabled", "off", "no_radio")
|
||||
@@ -3160,45 +3205,45 @@ class APIEndpoints:
|
||||
elif baud <= 0:
|
||||
add_error("kiss.baud_rate", "KISS baud_rate must be greater than zero")
|
||||
|
||||
if radio_type == "pymc_usb":
|
||||
usb_cfg = config_yaml.get("pymc_usb")
|
||||
if radio_type == "modem_usb":
|
||||
usb_cfg = config_yaml.get("modem_usb")
|
||||
if not isinstance(usb_cfg, dict):
|
||||
add_error(
|
||||
"pymc_usb",
|
||||
"Missing required section 'pymc_usb' for radio_type pymc_usb",
|
||||
"modem_usb",
|
||||
"Missing required section 'modem_usb' for radio_type modem_usb",
|
||||
)
|
||||
usb_cfg = {}
|
||||
port = (usb_cfg.get("port") if isinstance(usb_cfg, dict) else "") or ""
|
||||
if not str(port).strip():
|
||||
add_error("pymc_usb.port", "pymc_usb.port is required")
|
||||
baud = as_int((usb_cfg or {}).get("baudrate"), "pymc_usb.baudrate")
|
||||
add_error("modem_usb.port", "modem_usb.port is required")
|
||||
baud = as_int((usb_cfg or {}).get("baudrate"), "modem_usb.baudrate")
|
||||
if baud is not None and baud <= 0:
|
||||
add_error(
|
||||
"pymc_usb.baudrate", "pymc_usb.baudrate must be greater than zero"
|
||||
"modem_usb.baudrate", "modem_usb.baudrate must be greater than zero"
|
||||
)
|
||||
|
||||
if radio_type == "pymc_tcp":
|
||||
tcp_cfg = config_yaml.get("pymc_tcp")
|
||||
if radio_type == "modem_tcp":
|
||||
tcp_cfg = config_yaml.get("modem_tcp")
|
||||
if not isinstance(tcp_cfg, dict):
|
||||
add_error(
|
||||
"pymc_tcp",
|
||||
"Missing required section 'pymc_tcp' for radio_type pymc_tcp",
|
||||
"modem_tcp",
|
||||
"Missing required section 'modem_tcp' for radio_type modem_tcp",
|
||||
)
|
||||
tcp_cfg = {}
|
||||
host = (tcp_cfg.get("host") if isinstance(tcp_cfg, dict) else "") or ""
|
||||
host_str = str(host).strip()
|
||||
if not host_str:
|
||||
add_error("pymc_tcp.host", "pymc_tcp.host is required")
|
||||
add_error("modem_tcp.host", "modem_tcp.host is required")
|
||||
elif host_str == "REPLACE_WITH_MODEM_HOST":
|
||||
add_error(
|
||||
"pymc_tcp.host",
|
||||
"modem_tcp.host",
|
||||
"Replace placeholder host with your modem hostname or IP",
|
||||
)
|
||||
port = as_int((tcp_cfg or {}).get("port"), "pymc_tcp.port")
|
||||
port = as_int((tcp_cfg or {}).get("port"), "modem_tcp.port")
|
||||
if port is None:
|
||||
add_error("pymc_tcp.port", "pymc_tcp.port is required")
|
||||
add_error("modem_tcp.port", "modem_tcp.port is required")
|
||||
elif port < 1 or port > 65535:
|
||||
add_error("pymc_tcp.port", "pymc_tcp.port must be 1-65535")
|
||||
add_error("modem_tcp.port", "modem_tcp.port must be 1-65535")
|
||||
|
||||
if radio_disabled:
|
||||
add_warning("radio_type", "Radio is disabled (radio_type none/null/off)")
|
||||
@@ -7763,7 +7808,7 @@ class APIEndpoints:
|
||||
import copy
|
||||
|
||||
full_backup = str(include_secrets).lower() in ("true", "1", "yes")
|
||||
exported = copy.deepcopy(self.config)
|
||||
exported = normalize_modem_config(copy.deepcopy(self.config))
|
||||
|
||||
if full_backup:
|
||||
# Convert binary identity key to hex for JSON serialisation
|
||||
@@ -7789,6 +7834,8 @@ class APIEndpoints:
|
||||
if "identity_key" in rep:
|
||||
del rep["identity_key"]
|
||||
|
||||
redact_modem_tokens_in_place(exported, replacement=REDACTED_CONFIG_VALUE)
|
||||
|
||||
# Redact identity keys in companion / room_server configs
|
||||
for section in ("room_servers", "companions"):
|
||||
entries = exported.get("identities", {}).get(section, []) or []
|
||||
@@ -7881,6 +7928,54 @@ class APIEndpoints:
|
||||
if not imported_config or not isinstance(imported_config, dict):
|
||||
return self._error("Missing or invalid 'config' object in request body")
|
||||
|
||||
imported_config = normalize_modem_config(imported_config)
|
||||
|
||||
# Redacted exports and browser-originated partial modem updates are
|
||||
# intentionally safe to re-import. Preserve the currently configured
|
||||
# token when the incoming section carries the sentinel or omits the
|
||||
# field; a real incoming token still replaces it.
|
||||
current_config = normalize_modem_config(self.config, warn=False)
|
||||
imported_tcp = imported_config.get("modem_tcp")
|
||||
current_tcp = current_config.get("modem_tcp")
|
||||
if isinstance(imported_tcp, dict) and (
|
||||
"token" not in imported_tcp or imported_tcp.get("token") == REDACTED_CONFIG_VALUE
|
||||
):
|
||||
if isinstance(current_tcp, dict) and "token" in current_tcp:
|
||||
imported_tcp["token"] = current_tcp["token"]
|
||||
else:
|
||||
imported_tcp.pop("token", None)
|
||||
|
||||
current_radios = current_config.get("radios")
|
||||
if not isinstance(current_radios, list):
|
||||
current_radios = []
|
||||
current_radios_by_id = {
|
||||
entry.get("id"): entry
|
||||
for entry in current_radios
|
||||
if isinstance(entry, dict) and entry.get("id") is not None
|
||||
}
|
||||
imported_radios = imported_config.get("radios")
|
||||
if isinstance(imported_radios, list):
|
||||
for index, entry in enumerate(imported_radios):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
imported_radio_tcp = entry.get("modem_tcp")
|
||||
if not isinstance(imported_radio_tcp, dict) or (
|
||||
"token" in imported_radio_tcp
|
||||
and imported_radio_tcp.get("token") != REDACTED_CONFIG_VALUE
|
||||
):
|
||||
continue
|
||||
current_entry = current_radios_by_id.get(entry.get("id"))
|
||||
if current_entry is None and index < len(current_radios):
|
||||
candidate = current_radios[index]
|
||||
current_entry = candidate if isinstance(candidate, dict) else None
|
||||
current_radio_tcp = (
|
||||
current_entry.get("modem_tcp") if isinstance(current_entry, dict) else None
|
||||
)
|
||||
if isinstance(current_radio_tcp, dict) and "token" in current_radio_tcp:
|
||||
imported_radio_tcp["token"] = current_radio_tcp["token"]
|
||||
else:
|
||||
imported_radio_tcp.pop("token", None)
|
||||
|
||||
# Sections we allow to be imported
|
||||
ALLOWED_SECTIONS = {
|
||||
"repeater",
|
||||
@@ -7891,8 +7986,10 @@ class APIEndpoints:
|
||||
"sx1262",
|
||||
"ch341",
|
||||
"kiss",
|
||||
"pymc_usb",
|
||||
"pymc_tcp",
|
||||
"modem_usb",
|
||||
"modem_tcp",
|
||||
"sensors",
|
||||
"gps",
|
||||
"mqtt_brokers",
|
||||
"mqtt",
|
||||
"identities",
|
||||
@@ -7955,8 +8052,10 @@ class APIEndpoints:
|
||||
"sx1262",
|
||||
"ch341",
|
||||
"kiss",
|
||||
"pymc_usb",
|
||||
"pymc_tcp",
|
||||
"modem_usb",
|
||||
"modem_tcp",
|
||||
"sensors",
|
||||
"gps",
|
||||
"radio_type",
|
||||
"mqtt_brokers",
|
||||
"letsmesh",
|
||||
|
||||
Reference in new issue
Block a user