From 9511a8477f4e7f96cb7677d16d04ec1d3a1dd1e9 Mon Sep 17 00:00:00 2001 From: yellowcooln <12516003+yellowcooln@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:40:09 -0400 Subject: [PATCH 1/5] feat: migrate openHop modem transport names --- README.md | 4 +- config.yaml.example | 20 +- radio-settings.json | 16 +- repeater/config.py | 34 +-- repeater/config_manager.py | 2 + repeater/engine.py | 10 +- repeater/modem_config.py | 60 +++++ repeater/web/api_endpoints.py | 229 ++++++++++++------ tests/test_api_endpoints_core_coverage.py | 146 ++++++++++- tests/test_modem_config_migration.py | 154 ++++++++++++ tests/test_multi_radio_stack.py | 8 +- tests/test_radio_config.py | 80 ++++-- ...ard_pymc.py => test_setup_wizard_modem.py} | 126 ++++++---- 13 files changed, 699 insertions(+), 190 deletions(-) create mode 100644 repeater/modem_config.py create mode 100644 tests/test_modem_config_migration.py rename tests/{test_setup_wizard_pymc.py => test_setup_wizard_modem.py} (60%) diff --git a/README.md b/README.md index 479557f..4b767a3 100644 --- a/README.md +++ b/README.md @@ -65,8 +65,8 @@ openHop Repeater supports these radio backends: - **SX1262 over Linux SPI**: set `radio_type: sx1262` - **SX1262 over CH341 USB-to-SPI**: set `radio_type: sx1262_ch341` -- **pyMC TCP modem**: set `radio_type: pymc_tcp` -- **pyMC USB-CDC modem**: set `radio_type: pymc_usb` +- **openHop Modem over Wi-Fi/Ethernet**: set `radio_type: modem_tcp` +- **openHop Modem over USB-CDC**: set `radio_type: modem_usb` - **KISS serial modem**: set `radio_type: kiss` - **No radio hardware**: set `radio_type: null` for setup, testing, or API-only work diff --git a/config.yaml.example b/config.yaml.example index d82623b..a8471b2 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -320,10 +320,10 @@ sensors: # bus_number: 1 # I2C bus number (1 for Raspberry Pi default) # low_cell_mv: 3150 # Per-cell warning threshold in mV - # Example pyMC modem sensor. This reads a networked pyMC modem's + # Example openHop Modem sensor. This reads a networked openHop Modem's # HTTP /api/stats JSON API for modem diagnostics, including GPS fields # when the modem exposes them. If password is set to the same value as - # pymc_tcp.token, the repeater can use one shared modem secret. + # modem_tcp.token, the repeater can use one shared modem secret. # - type: pymc_modem # name: modem # enabled: true @@ -332,7 +332,7 @@ sensors: # port: 80 # endpoint: "/api/stats" # username: "admin" - # password: "" # modem HTTP password; may match pymc_tcp.token + # password: "" # modem HTTP password; may match modem_tcp.token # poll_interval_seconds: 60.0 # avoid contending with modem traffic # timeout_seconds: 2.0 @@ -486,8 +486,8 @@ identities: # - sx1262 (Linux spidev + system GPIO) # - sx1262_ch341 (CH341 USB-to-SPI + CH341 GPIO 0-7) # - kiss (KISS-modem over a serial port; alias: kiss-modem) -# - pymc_tcp (pymc_usb firmware modem over Wi-Fi/TCP) -# - pymc_usb (pymc_usb firmware modem over USB-CDC) +# - modem_tcp (openHop Modem over Wi-Fi/Ethernet TCP) +# - modem_usb (openHop Modem over USB-CDC) # - null/none (disable radio hardware; daemon starts without RF I/O) radio_type: null @@ -544,19 +544,19 @@ radio: # # kiss_txtail_ms: 0 # tail after TX (rarely needed) # # kiss_full_duplex: false # disable carrier-sense/CSMA entirely (not recommended) -# pymc_usb firmware modem over Wi-Fi/TCP (when radio_type: pymc_tcp). +# openHop Modem over Wi-Fi/Ethernet TCP (when radio_type: modem_tcp). # Requires openhop-core with the TCPLoRaRadio driver -# pymc_tcp: -# host: "pymc-3e2834.local" # modem hostname / mDNS name / LAN IP +# modem_tcp: +# host: "openhop-modem.local" # modem hostname / mDNS name / LAN IP # port: 5055 # firmware default # token: "" # empty = no auth; set to match firmware NVS token # connect_timeout: 5.0 # seconds; deferred-connect retries with backoff on failure # lbt_enabled: true # Listen-Before-Talk via firmware CAD # lbt_max_attempts: 5 -# pymc_usb firmware modem over USB-CDC (when radio_type: pymc_usb). +# openHop Modem over USB-CDC (when radio_type: modem_usb). # Requires openhop-core with the USBLoRaRadio driver -# pymc_usb: +# modem_usb: # port: "/dev/ttyACM0" # USB-CDC device; udev rule may symlink to /dev/lora-modem # baudrate: 921600 # must match firmware monitor_speed # lbt_enabled: true diff --git a/radio-settings.json b/radio-settings.json index e73537d..037fe23 100644 --- a/radio-settings.json +++ b/radio-settings.json @@ -440,19 +440,19 @@ "dio3_tcxo_voltage": 1.8, "preamble_length": 32 }, - "pymc_usb": { - "name": "pymc_usb modem (USB-CDC)", - "description": "ESP32-S3 / nRF52 board running pymc_usb firmware as a USB-CDC LoRa modem. Pick this if the modem is plugged into the host's USB port (e.g. /dev/ttyACM0). Edit the 'pymc_usb' section after first-run to point at the right serial device.", + "modem_usb": { + "name": "openHop Modem (USB-CDC)", + "description": "ESP32-S3 / nRF52 openHop Modem connected over USB-CDC. Pick this if the modem is plugged into the host's USB port (e.g. /dev/ttyACM0). Edit the 'modem_usb' section after first-run to point at the right serial device.", "connection_type": "usb", - "radio_type": "pymc_usb", + "radio_type": "modem_usb", "tx_power": 22, "preamble_length": 32 }, - "pymc_tcp": { - "name": "pymc_tcp modem (Wi-Fi / Ethernet)", - "description": "ESP32 board running pymc_usb firmware exposed as a TCP server over Wi-Fi or Ethernet. After first-run, set 'pymc_tcp.host' to the modem's LAN address or mDNS name (e.g. pymc-3e2834.local).", + "modem_tcp": { + "name": "openHop Modem (Wi-Fi / Ethernet)", + "description": "openHop Modem exposed as a TCP server over Wi-Fi or Ethernet. After first-run, set 'modem_tcp.host' to the modem's LAN address or mDNS name (e.g. openhop-modem.local).", "connection_type": "network", - "radio_type": "pymc_tcp", + "radio_type": "modem_tcp", "tx_power": 22, "preamble_length": 32 }, diff --git a/repeater/config.py b/repeater/config.py index 376d881..2379f6e 100644 --- a/repeater/config.py +++ b/repeater/config.py @@ -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") @@ -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] diff --git a/repeater/config_manager.py b/repeater/config_manager.py index b6dffef..01f261d 100644 --- a/repeater/config_manager.py +++ b/repeater/config_manager.py @@ -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 diff --git a/repeater/engine.py b/repeater/engine.py index 7000c5c..d55ce9d 100644 --- a/repeater/engine.py +++ b/repeater/engine.py @@ -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 from repeater.neighbour_links import NeighbourLinkTracker from repeater.policy_engine import PolicyDecision, PolicyEngine @@ -1659,6 +1660,7 @@ class RepeaterHandler(BaseHandler): return self._cached_noise_floor def get_stats(self) -> dict: + runtime_config = normalize_modem_config(self.config, warn=False) uptime_seconds = time.time() - self.start_time @@ -1737,14 +1739,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), diff --git a/repeater/modem_config.py b/repeater/modem_config.py new file mode 100644 index 0000000..082c6ad --- /dev/null +++ b/repeater/modem_config.py @@ -0,0 +1,60 @@ +"""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", +} + + +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_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) + 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 diff --git a/repeater/web/api_endpoints.py b/repeater/web/api_endpoints.py index fa6b480..02dbdcd 100644 --- a/repeater/web/api_endpoints.py +++ b/repeater/web/api_endpoints.py @@ -26,6 +26,7 @@ from repeater.companion.utils import ( validate_companion_config_capacity, ) from repeater.config import resolve_storage_dir +from repeater.modem_config import LEGACY_MODEM_RADIO_TYPES, normalize_modem_config from repeater.policy_engine import PolicyEngine from repeater.service_utils import get_buildroot_image_info from repeater.utils_packet import create_scoped_advert_packet @@ -1431,17 +1432,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: + 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) @@ -1483,6 +1507,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"} @@ -1564,47 +1589,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: @@ -1680,7 +1716,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) @@ -1718,14 +1755,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, @@ -1748,15 +1785,16 @@ class APIEndpoints: def stats(self): try: stats = self.stats_getter() if self.stats_getter else {} + runtime_config = normalize_modem_config(self.config, warn=False) # 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) @@ -2982,6 +3020,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'") @@ -3014,8 +3053,8 @@ class APIEndpoints: "sx1262", "sx1262_ch341", "kiss", - "pymc_tcp", - "pymc_usb", + "modem_tcp", + "modem_usb", "none", "null", "disabled", @@ -3026,7 +3065,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") @@ -3159,45 +3198,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)") @@ -7756,7 +7795,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 @@ -7782,6 +7821,16 @@ class APIEndpoints: if "identity_key" in rep: del rep["identity_key"] + tcp_cfg = exported.get("modem_tcp") + if isinstance(tcp_cfg, dict) and "token" in tcp_cfg: + tcp_cfg["token"] = "*** REDACTED ***" + for radio_entry in exported.get("radios") or []: + if not isinstance(radio_entry, dict): + continue + nested_tcp_cfg = radio_entry.get("modem_tcp") + if isinstance(nested_tcp_cfg, dict) and "token" in nested_tcp_cfg: + nested_tcp_cfg["token"] = "*** REDACTED ***" + # Redact identity keys in companion / room_server configs for section in ("room_servers", "companions"): entries = exported.get("identities", {}).get(section, []) or [] @@ -7874,6 +7923,50 @@ 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 are intentionally safe to re-import. Preserve the + # currently configured modem token instead of saving the sentinel. + 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 imported_tcp.get("token") == "*** REDACTED ***": + 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) + and imported_radio_tcp.get("token") == "*** REDACTED ***" + ): + 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", @@ -7884,8 +7977,8 @@ class APIEndpoints: "sx1262", "ch341", "kiss", - "pymc_usb", - "pymc_tcp", + "modem_usb", + "modem_tcp", "mqtt_brokers", "mqtt", "identities", @@ -7948,8 +8041,8 @@ class APIEndpoints: "sx1262", "ch341", "kiss", - "pymc_usb", - "pymc_tcp", + "modem_usb", + "modem_tcp", "radio_type", "mqtt_brokers", "letsmesh", diff --git a/tests/test_api_endpoints_core_coverage.py b/tests/test_api_endpoints_core_coverage.py index 9aed860..2100db5 100644 --- a/tests/test_api_endpoints_core_coverage.py +++ b/tests/test_api_endpoints_core_coverage.py @@ -430,7 +430,7 @@ def test_hardware_options_loads_installed_file(tmp_path): hardware_file = tmp_path / "radio-settings.json" hardware_file.write_text( - '{"hardware":{"pymc_usb":{"name":"USB","description":"desc","radio_type":"pymc_usb"}}}', + '{"hardware":{"modem_usb":{"name":"openHop Modem (USB-CDC)","description":"desc","radio_type":"modem_usb"}}}', encoding="utf-8", ) @@ -438,8 +438,22 @@ def test_hardware_options_loads_installed_file(tmp_path): result = api.hardware_options() assert len(result["hardware"]) == 1 - assert result["hardware"][0]["key"] == "pymc_usb" - assert result["hardware"][0]["name"] == "USB" + assert result["hardware"][0]["key"] == "modem_usb" + assert result["hardware"][0]["name"] == "openHop Modem (USB-CDC)" + + +def test_repository_hardware_options_use_canonical_modem_names(): + import json + + settings = json.loads( + (Path(__file__).parents[1] / "radio-settings.json").read_text(encoding="utf-8") + )["hardware"] + + assert "modem_usb" in settings and "modem_tcp" in settings + assert "pymc_usb" not in settings and "pymc_tcp" not in settings + assert settings["modem_usb"]["radio_type"] == "modem_usb" + assert settings["modem_usb"]["name"] == "openHop Modem (USB-CDC)" + assert settings["modem_tcp"]["name"] == "openHop Modem (Wi-Fi / Ethernet)" def test_radio_presets_returns_error_when_file_missing(tmp_path): @@ -568,6 +582,48 @@ def test_serial_ports_marks_ordinary_paths_docker_safe(cherrypy_ctx): assert result["data"][0]["docker_safe"] is True +def test_serial_ports_scans_host_dev_and_prefers_by_id_alias(cherrypy_ctx): + del cherrypy_ctx + api = _make_api() + numbered = "/host/dev/ttyACM0" + by_id = "/host/dev/serial/by-id/openhop-modem" + + def fake_glob(pattern): + return { + "/host/dev/ttyACM*": [numbered], + "/host/dev/ttyUSB*": [], + "/host/dev/serial/by-id/*": [by_id], + }.get(pattern, []) + + with ( + patch("serial.tools.list_ports.comports", return_value=[]), + patch("os.path.isdir", side_effect=lambda path: path == "/host/dev"), + patch("glob.glob", side_effect=fake_glob), + patch("os.path.realpath", side_effect=lambda path: "/host/dev/ttyACM0"), + ): + result = api.serial_ports() + + assert result["success"] is True + assert [item["device"] for item in result["data"]] == [by_id] + + +def test_serial_ports_tolerates_broken_host_alias(cherrypy_ctx): + del cherrypy_ctx + api = _make_api() + broken = "/host/dev/serial/by-id/broken" + + with ( + patch("serial.tools.list_ports.comports", return_value=[]), + patch("os.path.isdir", return_value=True), + patch("glob.glob", side_effect=lambda pattern: [broken] if "by-id" in pattern else []), + patch("os.path.realpath", side_effect=OSError("inaccessible")), + ): + result = api.serial_ports() + + assert result["success"] is True + assert result["data"] == [] + + def test_config_export_redacts_secrets_and_identity_keys(cherrypy_ctx): request, _ = cherrypy_ctx request.method = "GET" @@ -628,6 +684,37 @@ def test_config_export_full_backup_includes_hex_keys(cherrypy_ctx): assert result["data"]["meta"]["includes_secrets"] is True +def test_config_export_canonicalizes_modem_keys_and_redacts_token(cherrypy_ctx): + request, _ = cherrypy_ctx + request.method = "GET" + api = _make_api( + { + "radio_type": "pymc_tcp", + "pymc_tcp": {"host": "legacy.local", "token": "tcp-secret"}, + "radios": [ + {"id": "usb", "radio_type": "pymc_usb", "pymc_usb": {"port": "/dev/x"}}, + { + "id": "tcp", + "radio_type": "modem_tcp", + "modem_tcp": {"host": "nested.local", "token": "nested-secret"}, + }, + ], + } + ) + + redacted = api.config_export()["data"]["config"] + full = api.config_export(include_secrets="true")["data"]["config"] + + assert redacted["radio_type"] == "modem_tcp" + assert redacted["modem_tcp"]["token"] == "*** REDACTED ***" + assert "pymc_tcp" not in redacted + assert redacted["radios"][0]["radio_type"] == "modem_usb" + assert "pymc_usb" not in redacted["radios"][0] + assert redacted["radios"][1]["modem_tcp"]["token"] == "*** REDACTED ***" + assert full["modem_tcp"]["token"] == "tcp-secret" + assert full["radios"][1]["modem_tcp"]["token"] == "nested-secret" + + def test_config_import_rejects_missing_config_object(cherrypy_ctx): request, _ = cherrypy_ctx request.method = "POST" @@ -658,6 +745,18 @@ def test_config_import_updates_sections_and_preserves_redacted(cherrypy_ctx): {"name": "c1", "identity_key": bytes.fromhex("C0FFEE")}, ] }, + "modem_tcp": {"host": "current.local", "port": 5055, "token": "keep-token"}, + "radios": [ + { + "id": "tcp-link", + "radio_type": "modem_tcp", + "modem_tcp": { + "host": "current-nested.local", + "port": 5055, + "token": "keep-nested-token", + }, + } + ], } ) @@ -679,6 +778,22 @@ def test_config_import_updates_sections_and_preserves_redacted(cherrypy_ctx): }, "radio": {"frequency": 915000000}, "radio_type": "pymc_usb", + "pymc_tcp": { + "host": "restored.local", + "port": 6000, + "token": "*** REDACTED ***", + }, + "radios": [ + { + "id": "tcp-link", + "radio_type": "pymc_tcp", + "pymc_tcp": { + "host": "restored-nested.local", + "port": 6001, + "token": "*** REDACTED ***", + }, + } + ], "unknown": {"x": 1}, } } @@ -690,7 +805,14 @@ def test_config_import_updates_sections_and_preserves_redacted(cherrypy_ctx): assert result["success"] is True assert result["restart_required"] is True - assert set(result["sections_updated"]) == {"repeater", "identities", "radio", "radio_type"} + assert set(result["sections_updated"]) == { + "repeater", + "identities", + "radio", + "radios", + "radio_type", + "modem_tcp", + } sec = api.config["repeater"]["security"] assert sec["admin_password"] == "keep-admin" @@ -699,6 +821,16 @@ def test_config_import_updates_sections_and_preserves_redacted(cherrypy_ctx): assert api.config["repeater"]["identity_key"] == bytes.fromhex("AABBCC") assert "identity_file" not in api.config["repeater"] assert api.config["identities"]["companions"][0]["identity_key"] == bytes.fromhex("C0FFEE") + assert api.config["modem_tcp"] == { + "host": "restored.local", + "port": 6000, + "token": "keep-token", + } + assert api.config["radios"][0]["modem_tcp"] == { + "host": "restored-nested.local", + "port": 6001, + "token": "keep-nested-token", + } def test_openapi_success_sets_content_type(cherrypy_ctx): @@ -1282,7 +1414,7 @@ radio_type: weird_radio assert "radio_type" in paths -def test_validate_config_pymc_tcp_placeholder_and_bad_port(cherrypy_ctx, tmp_path): +def test_validate_config_legacy_tcp_reports_canonical_error_paths(cherrypy_ctx, tmp_path): request, _ = cherrypy_ctx request.method = "GET" api = _make_api() @@ -1313,8 +1445,8 @@ pymc_tcp: assert result["success"] is True assert result["data"]["valid"] is False paths = {e["path"] for e in result["data"]["errors"]} - assert "pymc_tcp.host" in paths - assert "pymc_tcp.port" in paths + assert "modem_tcp.host" in paths + assert "modem_tcp.port" in paths def test_validate_config_sx1262_ch341_missing_sections(cherrypy_ctx, tmp_path): diff --git a/tests/test_modem_config_migration.py b/tests/test_modem_config_migration.py new file mode 100644 index 0000000..69f2616 --- /dev/null +++ b/tests/test_modem_config_migration.py @@ -0,0 +1,154 @@ +import copy +import logging + +import yaml + +from repeater.modem_config import normalize_modem_config + + +def test_canonical_modem_config_is_unchanged_and_input_is_not_mutated(): + original = { + "radio_type": "modem_tcp", + "modem_tcp": {"host": "modem.local", "token": "secret"}, + "modem_usb": {"port": "/dev/ttyACM0"}, + } + before = copy.deepcopy(original) + + normalized = normalize_modem_config(original) + + assert normalized == before + assert original == before + assert normalized is not original + + +def test_legacy_top_level_type_and_section_are_canonicalized(): + normalized = normalize_modem_config( + {"radio_type": "pymc_tcp", "pymc_tcp": {"host": "modem.local"}} + ) + + assert normalized == { + "radio_type": "modem_tcp", + "modem_tcp": {"host": "modem.local"}, + } + + +def test_canonical_section_wins_conflict_without_logging_values(caplog): + config = { + "radio_type": "pymc_tcp", + "pymc_tcp": {"host": "legacy.example", "token": "legacy-secret"}, + "modem_tcp": {"host": "canonical.example", "token": "canonical-secret"}, + } + + with caplog.at_level(logging.WARNING): + normalized = normalize_modem_config(config) + + assert normalized["radio_type"] == "modem_tcp" + assert normalized["modem_tcp"]["host"] == "canonical.example" + assert "pymc_tcp" not in normalized + assert "pymc_tcp" in caplog.text and "modem_tcp" in caplog.text + assert "legacy.example" not in caplog.text + assert "canonical.example" not in caplog.text + assert "legacy-secret" not in caplog.text + assert "canonical-secret" not in caplog.text + + +def test_each_multi_radio_entry_is_normalized_independently(): + normalized = normalize_modem_config( + { + "radios": [ + {"id": "tcp", "radio_type": "pymc_tcp", "pymc_tcp": {"host": "x"}}, + { + "id": "usb", + "radio_type": "modem_usb", + "pymc_usb": {"port": "old"}, + "modem_usb": {"port": "new"}, + }, + "malformed", + ] + } + ) + + assert normalized["radios"][0] == { + "id": "tcp", + "radio_type": "modem_tcp", + "modem_tcp": {"host": "x"}, + } + assert normalized["radios"][1]["radio_type"] == "modem_usb" + assert normalized["radios"][1]["modem_usb"] == {"port": "new"} + assert "pymc_usb" not in normalized["radios"][1] + assert normalized["radios"][2] == "malformed" + + +def test_unrelated_and_malformed_values_are_preserved(): + config = { + "radio_type": "kiss", + "pymc_tcp": "not-a-mapping", + "modem_tcp": ["also", "not", "a", "mapping"], + "radios": None, + } + + normalized = normalize_modem_config(config) + + assert normalized["radio_type"] == "kiss" + assert normalized["modem_tcp"] == ["also", "not", "a", "mapping"] + assert "pymc_tcp" not in normalized + assert normalized["radios"] is None + + +def test_load_config_normalizes_in_memory_without_rewriting_file(tmp_path): + from repeater.config import load_config + + path = tmp_path / "config.yaml" + persisted = { + "radio_type": "pymc_usb", + "pymc_usb": {"port": "/dev/ttyACM0"}, + "storage": {"storage_dir": str(tmp_path / "data")}, + "repeater": {"security": {}}, + } + path.write_text(yaml.safe_dump(persisted), encoding="utf-8") + before = path.read_text(encoding="utf-8") + + loaded = load_config(str(path)) + + assert loaded["radio_type"] == "modem_usb" + assert loaded["modem_usb"]["port"] == "/dev/ttyACM0" + assert "pymc_usb" not in loaded + assert path.read_text(encoding="utf-8") == before + + +def test_config_manager_save_lazily_canonicalizes_in_memory_and_yaml(tmp_path): + from repeater.config_manager import ConfigManager + + path = tmp_path / "config.yaml" + config = { + "radio_type": "pymc_tcp", + "pymc_tcp": {"host": "legacy.local", "token": "secret"}, + "radios": [{"id": "usb", "radio_type": "pymc_usb", "pymc_usb": {"port": "/dev/x"}}], + } + + assert ConfigManager(str(path), config).save_to_file() is True + + written = yaml.safe_load(path.read_text(encoding="utf-8")) + assert config == written + assert written["radio_type"] == "modem_tcp" + assert "pymc_tcp" not in written + assert written["radios"][0]["radio_type"] == "modem_usb" + assert "pymc_usb" not in written["radios"][0] + + +def test_save_config_serializes_only_canonical_modem_keys(tmp_path): + from repeater.config import save_config + + path = tmp_path / "config.yaml" + config = { + "radio_type": "pymc_usb", + "pymc_usb": {"port": "/dev/ttyACM0"}, + } + + assert save_config(config, str(path)) is True + + written = yaml.safe_load(path.read_text(encoding="utf-8")) + assert written == { + "radio_type": "modem_usb", + "modem_usb": {"port": "/dev/ttyACM0"}, + } diff --git a/tests/test_multi_radio_stack.py b/tests/test_multi_radio_stack.py index 47ccc45..ac84271 100644 --- a/tests/test_multi_radio_stack.py +++ b/tests/test_multi_radio_stack.py @@ -61,15 +61,15 @@ def test_merge_radio_entry_inherits_and_overrides(): } entry = { "id": "link", - "radio_type": "pymc_usb", + "radio_type": "modem_usb", "radio": {"frequency": 2, "tx_power": 22}, - "pymc_usb": {"port": "/dev/ttyACM0"}, + "modem_usb": {"port": "/dev/ttyACM0"}, } merged = _merge_radio_entry(global_cfg, entry) assert merged["_radio_id"] == "link" - assert merged["radio_type"] == "pymc_usb" + assert merged["radio_type"] == "modem_usb" assert merged["radio"]["frequency"] == 2 - assert merged["pymc_usb"]["port"] == "/dev/ttyACM0" + assert merged["modem_usb"]["port"] == "/dev/ttyACM0" assert "sx1262" in merged # inherited leftover ok; factory uses radio_type diff --git a/tests/test_radio_config.py b/tests/test_radio_config.py index 7e6c17b..7f9c404 100644 --- a/tests/test_radio_config.py +++ b/tests/test_radio_config.py @@ -62,10 +62,10 @@ def test_get_radio_for_board_missing_radio_type_returns_null_radio(): assert type(radio).__name__ == "NullRadio" -# ─── pymc_tcp / pymc_usb branches ──────────────────────────────────── +# ─── modem_tcp / modem_usb branches ──────────────────────────────────── -def _pymc_radio_cfg(): +def _modem_radio_cfg(): """Common radio params for the pymc_* tests.""" return { "frequency": 869618000, @@ -78,7 +78,7 @@ def _pymc_radio_cfg(): } -def test_get_radio_for_board_pymc_tcp(monkeypatch): +def test_get_radio_for_board_modem_tcp(monkeypatch): pytest.importorskip("openhop_core.hardware.tcp_radio") captured = {} @@ -92,8 +92,8 @@ def test_get_radio_for_board_pymc_tcp(monkeypatch): ) board_config = { - "radio_type": "pymc_tcp", - "pymc_tcp": { + "radio_type": "modem_tcp", + "modem_tcp": { "host": "pymc-3e2834.local", "port": 5055, "token": "shared-secret", @@ -101,7 +101,7 @@ def test_get_radio_for_board_pymc_tcp(monkeypatch): "lbt_enabled": False, "lbt_max_attempts": 3, }, - "radio": _pymc_radio_cfg(), + "radio": _modem_radio_cfg(), } get_radio_for_board(board_config) @@ -116,7 +116,7 @@ def test_get_radio_for_board_pymc_tcp(monkeypatch): assert captured["lbt_max_attempts"] == 3 -def test_get_radio_for_board_pymc_tcp_requires_host(monkeypatch): +def test_get_radio_for_board_modem_tcp_requires_host(monkeypatch): pytest.importorskip("openhop_core.hardware.tcp_radio") monkeypatch.setattr( @@ -125,16 +125,16 @@ def test_get_radio_for_board_pymc_tcp_requires_host(monkeypatch): ) board_config = { - "radio_type": "pymc_tcp", - "pymc_tcp": {"port": 5055}, - "radio": _pymc_radio_cfg(), + "radio_type": "modem_tcp", + "modem_tcp": {"port": 5055}, + "radio": _modem_radio_cfg(), } with pytest.raises(ValueError, match="Missing 'host'"): get_radio_for_board(board_config) -def test_get_radio_for_board_pymc_usb(monkeypatch): +def test_get_radio_for_board_modem_usb(monkeypatch): pytest.importorskip("openhop_core.hardware.usb_radio") captured = {} @@ -148,12 +148,12 @@ def test_get_radio_for_board_pymc_usb(monkeypatch): ) board_config = { - "radio_type": "pymc_usb", - "pymc_usb": { + "radio_type": "modem_usb", + "modem_usb": { "port": "/dev/ttyACM0", "baudrate": 921600, }, - "radio": _pymc_radio_cfg(), + "radio": _modem_radio_cfg(), } get_radio_for_board(board_config) @@ -162,12 +162,12 @@ def test_get_radio_for_board_pymc_usb(monkeypatch): assert captured["baudrate"] == 921600 assert captured["frequency"] == 869618000 assert captured["sync_word"] == 0x12 - # LBT defaults preserved when omitted from pymc_usb section. + # LBT defaults preserved when omitted from modem_usb section. assert captured["lbt_enabled"] is True assert captured["lbt_max_attempts"] == 5 -def test_get_radio_for_board_pymc_usb_requires_port(monkeypatch): +def test_get_radio_for_board_modem_usb_requires_port(monkeypatch): pytest.importorskip("openhop_core.hardware.usb_radio") monkeypatch.setattr( @@ -176,18 +176,54 @@ def test_get_radio_for_board_pymc_usb_requires_port(monkeypatch): ) board_config = { - "radio_type": "pymc_usb", + "radio_type": "modem_usb", # Section present (baudrate set) but `port` deliberately omitted to # exercise the inner "Missing 'port'" guard rather than the outer - # "Missing 'pymc_usb' section" one. - "pymc_usb": {"baudrate": 921600}, - "radio": _pymc_radio_cfg(), + # "Missing 'modem_usb' section" one. + "modem_usb": {"baudrate": 921600}, + "radio": _modem_radio_cfg(), } with pytest.raises(ValueError, match="Missing 'port'"): get_radio_for_board(board_config) +@pytest.mark.parametrize( + ("legacy_type", "legacy_section", "module_name", "class_name", "settings"), + [ + ( + "pymc_tcp", + "pymc_tcp", + "openhop_core.hardware.tcp_radio", + "TCPLoRaRadio", + {"host": "legacy.local"}, + ), + ( + "pymc_usb", + "pymc_usb", + "openhop_core.hardware.usb_radio", + "USBLoRaRadio", + {"port": "/dev/ttyACM0"}, + ), + ], +) +def test_legacy_modem_aliases_still_construct_radio( + monkeypatch, legacy_type, legacy_section, module_name, class_name, settings +): + pytest.importorskip(module_name) + monkeypatch.setattr(f"{module_name}.{class_name}", lambda **kwargs: _DummyRadio()) + + radio = get_radio_for_board( + { + "radio_type": legacy_type, + legacy_section: settings, + "radio": _modem_radio_cfg(), + } + ) + + assert type(radio).__name__ == "BaselineCrcCounterRadio" + + # ─── kiss branch: optional CSMA / key-up tuning forwarding ──────────── @@ -220,7 +256,7 @@ def test_get_radio_for_board_kiss_forwards_csma_tuning(monkeypatch): "tx_delay_ms": 50, "kiss_full_duplex": True, }, - "radio": _pymc_radio_cfg(), + "radio": _modem_radio_cfg(), } get_radio_for_board(board_config) @@ -238,7 +274,7 @@ def test_get_radio_for_board_kiss_omits_unset_tuning(monkeypatch): board_config = { "radio_type": "kiss", "kiss": {"port": "/dev/ttyACM0", "baud_rate": 115200}, - "radio": _pymc_radio_cfg(), + "radio": _modem_radio_cfg(), } get_radio_for_board(board_config) diff --git a/tests/test_setup_wizard_pymc.py b/tests/test_setup_wizard_modem.py similarity index 60% rename from tests/test_setup_wizard_pymc.py rename to tests/test_setup_wizard_modem.py index c3f0a36..0bf25d8 100644 --- a/tests/test_setup_wizard_pymc.py +++ b/tests/test_setup_wizard_modem.py @@ -1,4 +1,4 @@ -"""Tests for setup_wizard pymc_usb / pymc_tcp branches. +"""Tests for setup_wizard modem_usb / modem_tcp branches. These verify that when the first-run /setup wizard is finished with one of the two pymc_* hardware tiles selected, api_endpoints.setup_wizard() writes @@ -44,15 +44,15 @@ def wizard_env(tmp_path, monkeypatch): radio_settings = { "hardware": { - "pymc_usb": { - "name": "pymc_usb modem (USB-CDC)", - "radio_type": "pymc_usb", + "modem_usb": { + "name": "openHop Modem (USB-CDC)", + "radio_type": "modem_usb", "tx_power": 22, "preamble_length": 16, }, - "pymc_tcp": { - "name": "pymc_tcp modem (Wi-Fi / Ethernet)", - "radio_type": "pymc_tcp", + "modem_tcp": { + "name": "openHop Modem (Wi-Fi / Ethernet)", + "radio_type": "modem_tcp", "tx_power": 22, "preamble_length": 16, }, @@ -91,42 +91,42 @@ def _read_yaml(path): return yaml.safe_load(f) -# ─── pymc_usb ───────────────────────────────────────────────────────── +# ─── modem_usb ───────────────────────────────────────────────────────── -def test_wizard_pymc_usb_defaults(wizard_env): +def test_wizard_modem_usb_defaults(wizard_env): tmp_path, config_path, endpoints, set_request = wizard_env - body = dict(_BASE_REQUEST, hardware_key="pymc_usb") + body = dict(_BASE_REQUEST, hardware_key="modem_usb") set_request(body) result = endpoints.setup_wizard() assert result["success"] is True - assert result["config"]["radio_type"] == "pymc_usb" - assert result["config"]["pymc_usb_port"] == "/dev/ttyACM0" - assert result["config"]["pymc_usb_baudrate"] == 921600 + assert result["config"]["radio_type"] == "modem_usb" + assert result["config"]["modem_usb_port"] == "/dev/ttyACM0" + assert result["config"]["modem_usb_baudrate"] == 921600 written = _read_yaml(config_path) - assert written["radio_type"] == "pymc_usb" - assert written["pymc_usb"]["port"] == "/dev/ttyACM0" - assert written["pymc_usb"]["baudrate"] == 921600 - assert written["pymc_usb"]["lbt_enabled"] is True - assert written["pymc_usb"]["lbt_max_attempts"] == 5 + assert written["radio_type"] == "modem_usb" + assert written["modem_usb"]["port"] == "/dev/ttyACM0" + assert written["modem_usb"]["baudrate"] == 921600 + assert written["modem_usb"]["lbt_enabled"] is True + assert written["modem_usb"]["lbt_max_attempts"] == 5 assert written["radio"]["tx_power"] == 22 assert written["radio"]["preamble_length"] == 16 - # config.py rejects pymc_usb if 'sx1262' / 'ch341' keys leak in — none here. + # config.py rejects modem_usb if 'sx1262' / 'ch341' keys leak in — none here. assert "sx1262" not in written -def test_wizard_pymc_usb_overrides_from_request(wizard_env): +def test_wizard_modem_usb_overrides_from_request(wizard_env): tmp_path, config_path, endpoints, set_request = wizard_env body = dict( _BASE_REQUEST, - hardware_key="pymc_usb", - pymc_usb_port="/dev/ttyUSB0", - pymc_usb_baudrate=115200, + hardware_key="modem_usb", + modem_usb_port="/dev/ttyUSB0", + modem_usb_baudrate=115200, ) set_request(body) @@ -134,48 +134,48 @@ def test_wizard_pymc_usb_overrides_from_request(wizard_env): assert result["success"] is True written = _read_yaml(config_path) - assert written["pymc_usb"]["port"] == "/dev/ttyUSB0" - assert written["pymc_usb"]["baudrate"] == 115200 + assert written["modem_usb"]["port"] == "/dev/ttyUSB0" + assert written["modem_usb"]["baudrate"] == 115200 -# ─── pymc_tcp ───────────────────────────────────────────────────────── +# ─── modem_tcp ───────────────────────────────────────────────────────── -def test_wizard_pymc_tcp_placeholder(wizard_env): +def test_wizard_modem_tcp_placeholder(wizard_env): """No host in request → wizard writes a sentinel placeholder. config.py - will then refuse to start with a clear error pointing at pymc_tcp.host.""" + will then refuse to start with a clear error pointing at modem_tcp.host.""" tmp_path, config_path, endpoints, set_request = wizard_env - body = dict(_BASE_REQUEST, hardware_key="pymc_tcp") + body = dict(_BASE_REQUEST, hardware_key="modem_tcp") set_request(body) result = endpoints.setup_wizard() assert result["success"] is True - assert result["config"]["radio_type"] == "pymc_tcp" - assert result["config"]["pymc_tcp_host"] == "REPLACE_WITH_MODEM_HOST" - assert result["config"]["pymc_tcp_port"] == 5055 + assert result["config"]["radio_type"] == "modem_tcp" + assert result["config"]["modem_tcp_host"] == "REPLACE_WITH_MODEM_HOST" + assert result["config"]["modem_tcp_port"] == 5055 written = _read_yaml(config_path) - assert written["radio_type"] == "pymc_tcp" - assert written["pymc_tcp"]["host"] == "REPLACE_WITH_MODEM_HOST" - assert written["pymc_tcp"]["port"] == 5055 - assert written["pymc_tcp"]["token"] == "" - assert written["pymc_tcp"]["connect_timeout"] == 5.0 - assert written["pymc_tcp"]["lbt_enabled"] is True + assert written["radio_type"] == "modem_tcp" + assert written["modem_tcp"]["host"] == "REPLACE_WITH_MODEM_HOST" + assert written["modem_tcp"]["port"] == 5055 + assert written["modem_tcp"]["token"] == "" + assert written["modem_tcp"]["connect_timeout"] == 5.0 + assert written["modem_tcp"]["lbt_enabled"] is True # token deliberately stripped from response. - assert "pymc_tcp_token" not in result["config"] + assert "modem_tcp_token" not in result["config"] -def test_wizard_pymc_tcp_full_fields(wizard_env): +def test_wizard_modem_tcp_full_fields(wizard_env): tmp_path, config_path, endpoints, set_request = wizard_env body = dict( _BASE_REQUEST, - hardware_key="pymc_tcp", - pymc_tcp_host="pymc-3e2834.local", - pymc_tcp_port=6000, - pymc_tcp_token="hunter2", + hardware_key="modem_tcp", + modem_tcp_host="pymc-3e2834.local", + modem_tcp_port=6000, + modem_tcp_token="hunter2", ) set_request(body) @@ -183,9 +183,35 @@ def test_wizard_pymc_tcp_full_fields(wizard_env): assert result["success"] is True written = _read_yaml(config_path) - assert written["pymc_tcp"]["host"] == "pymc-3e2834.local" - assert written["pymc_tcp"]["port"] == 6000 - assert written["pymc_tcp"]["token"] == "hunter2" + assert written["modem_tcp"]["host"] == "pymc-3e2834.local" + assert written["modem_tcp"]["port"] == 6000 + assert written["modem_tcp"]["token"] == "hunter2" + assert "modem_tcp_token" not in result["config"] + assert "hunter2" not in str(result) + + +def test_wizard_accepts_legacy_tcp_fields_but_emits_canonical_config(wizard_env): + _tmp_path, config_path, endpoints, set_request = wizard_env + set_request( + dict( + _BASE_REQUEST, + hardware_key="pymc_tcp", + pymc_tcp_host="legacy.local", + pymc_tcp_port=6001, + pymc_tcp_token="legacy-secret", + ) + ) + + result = endpoints.setup_wizard() + + written = _read_yaml(config_path) + assert result["success"] is True + assert result["config"]["hardware"] == "modem_tcp" + assert result["config"]["radio_type"] == "modem_tcp" + assert result["config"]["modem_tcp_host"] == "legacy.local" + assert "pymc_tcp" not in written + assert written["modem_tcp"]["token"] == "legacy-secret" + assert "legacy-secret" not in str(result) # ─── KISS regression guard ──────────────────────────────────────────── @@ -213,7 +239,7 @@ def test_wizard_rejected_after_setup_complete(wizard_env): configured = { "repeater": {"node_name": "already-set", "security": {"admin_password": "verysecret"}}, - "radio_type": "pymc_tcp", + "radio_type": "modem_tcp", "radio": { "frequency": 869618000, "spreading_factor": 8, @@ -224,7 +250,7 @@ def test_wizard_rejected_after_setup_complete(wizard_env): with open(config_path, "w") as f: yaml.safe_dump(configured, f) - body = dict(_BASE_REQUEST, hardware_key="pymc_tcp", pymc_tcp_host="modem.local") + body = dict(_BASE_REQUEST, hardware_key="modem_tcp", modem_tcp_host="modem.local") set_request(body) result = endpoints.setup_wizard() @@ -236,7 +262,7 @@ def test_wizard_rejected_after_setup_complete(wizard_env): def test_wizard_rejects_short_admin_password(wizard_env): _tmp_path, _config_path, endpoints, set_request = wizard_env - body = dict(_BASE_REQUEST, hardware_key="pymc_tcp", admin_password="short7") + body = dict(_BASE_REQUEST, hardware_key="modem_tcp", admin_password="short7") set_request(body) result = endpoints.setup_wizard() From 76e9a9c745dd9319ee40a85e24ed5b845dfcb288 Mon Sep 17 00:00:00 2001 From: yellowcooln <12516003+yellowcooln@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:17:26 -0400 Subject: [PATCH 2/5] feat: migrate openHop modem sensor --- config.yaml.example | 2 +- repeater/config.py | 2 +- repeater/data_acquisition/gps_service.py | 2 +- repeater/modem_config.py | 34 +++ repeater/sensors/openhop_modem.py | 264 +++++++++++++++++++++++ repeater/sensors/pymc_modem.py | 210 +----------------- tests/test_modem_config_migration.py | 18 ++ tests/test_openhop_modem_sensor.py | 147 +++++++++++++ tests/test_sensors.py | 4 +- 9 files changed, 477 insertions(+), 206 deletions(-) create mode 100644 repeater/sensors/openhop_modem.py create mode 100644 tests/test_openhop_modem_sensor.py diff --git a/config.yaml.example b/config.yaml.example index a8471b2..95c1ce0 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -324,7 +324,7 @@ sensors: # HTTP /api/stats JSON API for modem diagnostics, including GPS fields # when the modem exposes them. If password is set to the same value as # modem_tcp.token, the repeater can use one shared modem secret. - # - type: pymc_modem + # - type: openhop_modem # name: modem # enabled: true # settings: diff --git a/repeater/config.py b/repeater/config.py index 2379f6e..9911c98 100644 --- a/repeater/config.py +++ b/repeater/config.py @@ -106,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. diff --git a/repeater/data_acquisition/gps_service.py b/repeater/data_acquisition/gps_service.py index b6b8ce3..47d195a 100644 --- a/repeater/data_acquisition/gps_service.py +++ b/repeater/data_acquisition/gps_service.py @@ -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. diff --git a/repeater/modem_config.py b/repeater/modem_config.py index 082c6ad..07f207d 100644 --- a/repeater/modem_config.py +++ b/repeater/modem_config.py @@ -13,6 +13,14 @@ LEGACY_MODEM_RADIO_TYPES = { "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") @@ -36,6 +44,31 @@ def _normalize_board_config(config: dict[str, Any], *, warn: bool) -> None: 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() + definition["type"] = LEGACY_MODEM_SENSOR_TYPES.get( + normalized_type, normalized_type + ) + + gps = config.get("gps") + if isinstance(gps, dict): + source = gps.get("source") + if isinstance(source, str): + normalized_source = source.strip().lower() + gps["source"] = LEGACY_MODEM_GPS_SOURCES.get(normalized_source, normalized_source) + + 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): @@ -47,6 +80,7 @@ def normalize_modem_config_in_place(config: dict, *, warn: bool = True) -> dict: for entry in radios: if isinstance(entry, dict): _normalize_board_config(entry, warn=warn) + _normalize_sensor_and_gps_aliases(config) return config diff --git a/repeater/sensors/openhop_modem.py b/repeater/sensors/openhop_modem.py new file mode 100644 index 0000000..ea299f1 --- /dev/null +++ b/repeater/sensors/openhop_modem.py @@ -0,0 +1,264 @@ +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("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) diff --git a/repeater/sensors/pymc_modem.py b/repeater/sensors/pymc_modem.py index 39f3b4a..6330cab 100644 --- a/repeater/sensors/pymc_modem.py +++ b/repeater/sensors/pymc_modem.py @@ -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"] diff --git a/tests/test_modem_config_migration.py b/tests/test_modem_config_migration.py index 69f2616..570c35f 100644 --- a/tests/test_modem_config_migration.py +++ b/tests/test_modem_config_migration.py @@ -152,3 +152,21 @@ def test_save_config_serializes_only_canonical_modem_keys(tmp_path): "radio_type": "modem_usb", "modem_usb": {"port": "/dev/ttyACM0"}, } + + +def test_sensor_and_gps_aliases_migrate_to_canonical_names(): + normalized = normalize_modem_config( + { + "sensors": { + "definitions": [ + {"name": "modem", "type": "pymc_modem", "settings": {"host": "x"}}, + {"name": "system", "type": "hardware_stats"}, + ] + }, + "gps": {"source": "pymc_modem"}, + } + ) + + assert normalized["sensors"]["definitions"][0]["type"] == "openhop_modem" + assert normalized["sensors"]["definitions"][1]["type"] == "hardware_stats" + assert normalized["gps"]["source"] == "modem_http" diff --git a/tests/test_openhop_modem_sensor.py b/tests/test_openhop_modem_sensor.py new file mode 100644 index 0000000..1132b3b --- /dev/null +++ b/tests/test_openhop_modem_sensor.py @@ -0,0 +1,147 @@ +import json + +from repeater.sensors.openhop_modem import OpenHopModemSensor +from repeater.sensors.pymc_modem import PymcModemSensor +from repeater.sensors.registry import SensorRegistry + + +class _Response: + status = 200 + + def __init__(self, payload): + self.payload = payload + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def read(self): + return json.dumps(self.payload).encode() + + +def test_openhop_modem_sensor_exposes_full_flat_diagnostics(monkeypatch): + payload = { + "battery_voltage_mv": 4112, + "battery_voltage_v": 4.112, + "solar_charge_rate_percent_per_hour": 4.5, + "system": { + "board": "Photon", + "firmware": "1.2.3", + "uptime": "1d 2h", + "uptime_sec": 93600, + "die_temperature_c": 54, + }, + "counters": { + "rx_packets": 101, + "tx_packets": 22, + "crc_errors": 3, + "last_rssi_dbm": -91, + "last_snr_db": 7.5, + "noise_floor_dbm": -116.2, + }, + "radio": { + "state": "rx", + "standby": False, + "frequency_hz": 910525000, + "frequency_mhz": 910.525, + "bandwidth_hz": 62500, + "bandwidth_khz": 62.5, + "spreading_factor": 7, + "coding_rate": 5, + "tx_power_dbm": 18, + "preamble_len": 32, + "syncword": "0x12", + "syncword_value": 18, + "auto_cad_enabled": True, + }, + "network": { + "interface": "ethernet", + "mode": "static", + "live": True, + "current_ip": "192.0.2.10", + "tcp_port": 5055, + "pymc_token_set": True, + }, + "gps": { + "available": True, + "enabled": True, + "seen": True, + "fix": {"valid": True, "quality": 1}, + "position": {"latitude": 42.3, "longitude": -71.0, "altitude_m": 10.5}, + "satellites": {"used_count": 8, "in_view_count": 12}, + "time": {"datetime_utc": "2026-08-20T14:00:00Z"}, + "motion": {"speed_kmh": 1.2, "course_degrees": 180.0}, + "nmea": { + "age_ms": 250, + "valid_sentence_count": 400, + "invalid_checksum_count": 2, + "raw_byte_count": 12000, + "last_sentence_type": "GGA", + }, + }, + } + monkeypatch.setattr( + "repeater.sensors.openhop_modem.urllib.request.urlopen", + lambda *_args, **_kwargs: _Response(payload), + ) + + reading = OpenHopModemSensor( + "modem", {"settings": {"base_url": "http://openhop-modem.local"}} + ).read() + + assert reading["ok"] is True + data = reading["data"] + assert data["source"] == "openhop_modem" + assert data["die_temperature_c"] == 54 + assert data["temperature_c"] == 54 + assert data["system_board"] == "Photon" + assert data["system_firmware"] == "1.2.3" + assert data["system_uptime"] == "1d 2h" + assert data["uptime_seconds"] == 93600 + assert data["rx_packets"] == 101 + assert data["tx_packets"] == 22 + assert data["crc_errors"] == 3 + assert data["last_rssi_dbm"] == -91 + assert data["last_snr_db"] == 7.5 + assert data["noise_floor_dbm"] == -116.2 + assert data["radio_state"] == "rx" + assert data["radio_standby"] is False + assert data["frequency_hz"] == 910525000 + assert data["bandwidth_hz"] == 62500 + assert data["spreading_factor"] == 7 + assert data["coding_rate"] == 5 + assert data["tx_power_dbm"] == 18 + assert data["preamble_length"] == 32 + assert data["auto_cad_enabled"] is True + assert data["network_interface"] == "ethernet" + assert data["network_live"] is True + assert data["tcp_port"] == 5055 + assert data["token_configured"] is True + assert data["gps_available"] is True + assert data["gps_nmea_age_ms"] == 250 + assert data["gps_nmea_valid_sentences"] == 400 + assert data["gps_nmea_invalid_checksums"] == 2 + assert data["gps_nmea_raw_bytes"] == 12000 + assert data["gps_nmea_last_sentence"] == "GGA" + assert data["battery_voltage_mv"] == 4112 + assert data["solar_charge_rate_percent_per_hour"] == 4.5 + + +def test_legacy_pymc_modem_module_and_registry_create_canonical_sensor(): + assert PymcModemSensor is OpenHopModemSensor + legacy = SensorRegistry.create( + "pymc_modem", + name="legacy", + config={"settings": {"base_url": "http://legacy-modem.local"}}, + ) + canonical = SensorRegistry.create( + "openhop_modem", + name="canonical", + config={"settings": {"base_url": "http://openhop-modem.local"}}, + ) + + assert isinstance(legacy, OpenHopModemSensor) + assert legacy.sensor_type == "openhop_modem" + assert canonical.sensor_type == "openhop_modem" diff --git a/tests/test_sensors.py b/tests/test_sensors.py index fa51682..5607180 100644 --- a/tests/test_sensors.py +++ b/tests/test_sensors.py @@ -234,7 +234,7 @@ def test_pymc_modem_sensor_reads_modem_stats(monkeypatch): assert captured["url"] == "http://192.168.0.205/api/stats" assert captured["auth"].startswith("Basic ") assert captured["timeout"] == 3.5 - assert reading["data"]["source"] == "pymc_modem" + assert reading["data"]["source"] == "openhop_modem" assert reading["data"]["latitude"] == 42.360082 assert reading["data"]["longitude"] == -71.05888 assert reading["data"]["altitude_m"] == 12.5 @@ -281,7 +281,7 @@ def test_pymc_modem_sensor_accepts_stats_without_gps_coordinates(monkeypatch): ).read() assert reading["ok"] is True - assert reading["data"]["source"] == "pymc_modem" + assert reading["data"]["source"] == "openhop_modem" assert reading["data"]["battery_voltage_mv"] == 3681 assert reading["data"]["battery_voltage_v"] == 3.681 assert reading["data"]["battery_percent"] == 37 From 301ba2cce8197659c33929b4fdb355c24f8b06a9 Mon Sep 17 00:00:00 2001 From: yellowcooln <12516003+yellowcooln@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:21:51 -0400 Subject: [PATCH 3/5] fix: read canonical modem token status --- repeater/sensors/openhop_modem.py | 4 +++- tests/test_openhop_modem_sensor.py | 10 +++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/repeater/sensors/openhop_modem.py b/repeater/sensors/openhop_modem.py index ea299f1..866f043 100644 --- a/repeater/sensors/openhop_modem.py +++ b/repeater/sensors/openhop_modem.py @@ -162,7 +162,9 @@ class OpenHopModemSensor(SensorBase): "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("pymc_token_set")), + "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")), diff --git a/tests/test_openhop_modem_sensor.py b/tests/test_openhop_modem_sensor.py index 1132b3b..be2f2c9 100644 --- a/tests/test_openhop_modem_sensor.py +++ b/tests/test_openhop_modem_sensor.py @@ -62,7 +62,7 @@ def test_openhop_modem_sensor_exposes_full_flat_diagnostics(monkeypatch): "live": True, "current_ip": "192.0.2.10", "tcp_port": 5055, - "pymc_token_set": True, + "token_set": True, }, "gps": { "available": True, @@ -129,6 +129,14 @@ def test_openhop_modem_sensor_exposes_full_flat_diagnostics(monkeypatch): assert data["solar_charge_rate_percent_per_hour"] == 4.5 +def test_openhop_modem_sensor_accepts_legacy_token_flag(): + sensor = OpenHopModemSensor("modem", {"settings": {"base_url": "http://openhop-modem.local"}}) + + data = sensor._normalize_payload({"network": {"pymc_token_set": True}}) + + assert data["token_configured"] is True + + def test_legacy_pymc_modem_module_and_registry_create_canonical_sensor(): assert PymcModemSensor is OpenHopModemSensor legacy = SensorRegistry.create( From 4ec79ed1dc55d49a0e76128b1c34c303bd98d662 Mon Sep 17 00:00:00 2001 From: yellowcooln <12516003+yellowcooln@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:37:45 -0400 Subject: [PATCH 4/5] fix: centralize config redaction marker --- repeater/web/api_endpoints.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/repeater/web/api_endpoints.py b/repeater/web/api_endpoints.py index 02dbdcd..f6413fa 100644 --- a/repeater/web/api_endpoints.py +++ b/repeater/web/api_endpoints.py @@ -38,6 +38,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", @@ -7823,13 +7824,13 @@ class APIEndpoints: tcp_cfg = exported.get("modem_tcp") if isinstance(tcp_cfg, dict) and "token" in tcp_cfg: - tcp_cfg["token"] = "*** REDACTED ***" + tcp_cfg["token"] = REDACTED_CONFIG_VALUE for radio_entry in exported.get("radios") or []: if not isinstance(radio_entry, dict): continue nested_tcp_cfg = radio_entry.get("modem_tcp") if isinstance(nested_tcp_cfg, dict) and "token" in nested_tcp_cfg: - nested_tcp_cfg["token"] = "*** REDACTED ***" + nested_tcp_cfg["token"] = REDACTED_CONFIG_VALUE # Redact identity keys in companion / room_server configs for section in ("room_servers", "companions"): From f280bbdc5c813a19f50ca1b6f3cc57eef59a864e Mon Sep 17 00:00:00 2001 From: yellowcooln <12516003+yellowcooln@users.noreply.github.com> Date: Mon, 24 Aug 2026 09:00:50 -0400 Subject: [PATCH 5/5] fix: secure modem migration and backup paths --- docker-compose.yml | 4 +++ repeater/engine.py | 3 +- repeater/modem_config.py | 30 +++++++++++++--- repeater/web/api_endpoints.py | 23 ++++++------ tests/test_api_endpoints_core_coverage.py | 39 +++++++++++++++++++- tests/test_engine.py | 18 ++++++++++ tests/test_modem_config_migration.py | 43 ++++++++++++++++++++++- 7 files changed, 142 insertions(+), 18 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index ebdb3cd..6e28463 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,10 @@ services: volumes: - ${OPENHOP_CONFIG_VOLUME:-${PYMC_CONFIG_VOLUME:-openhop-repeater-config}}:/etc/openhop_repeater - ${OPENHOP_DATA_VOLUME:-${PYMC_DATA_VOLUME:-openhop-repeater-data}}:/var/lib/openhop_repeater + # Optional host-device discovery for the setup UI. This read-only mount + # only exposes names under /host/dev; the selected serial device must + # still be granted under `devices:` above before the container can open it. + # - /dev:/host/dev:ro volumes: openhop-repeater-config: diff --git a/repeater/engine.py b/repeater/engine.py index d55ce9d..bcd7463 100644 --- a/repeater/engine.py +++ b/repeater/engine.py @@ -33,7 +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 +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 @@ -1661,6 +1661,7 @@ class RepeaterHandler(BaseHandler): 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 diff --git a/repeater/modem_config.py b/repeater/modem_config.py index 07f207d..cdd06f0 100644 --- a/repeater/modem_config.py +++ b/repeater/modem_config.py @@ -57,16 +57,38 @@ def _normalize_sensor_and_gps_aliases(config: dict[str, Any]) -> None: sensor_type = definition.get("type") if isinstance(sensor_type, str): normalized_type = sensor_type.strip().lower() - definition["type"] = LEGACY_MODEM_SENSOR_TYPES.get( - normalized_type, normalized_type - ) + 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() - gps["source"] = LEGACY_MODEM_GPS_SOURCES.get(normalized_source, normalized_source) + 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: diff --git a/repeater/web/api_endpoints.py b/repeater/web/api_endpoints.py index f878997..f2e3f11 100644 --- a/repeater/web/api_endpoints.py +++ b/repeater/web/api_endpoints.py @@ -27,7 +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 +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 @@ -1453,7 +1457,7 @@ class APIEndpoints: dedup = {} for item in devices: dev = item.get("device") - if not dev: + if not dev or (os.path.islink(dev) and not os.path.exists(dev)): continue try: target = os.path.realpath(dev) @@ -1788,6 +1792,7 @@ class APIEndpoints: 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"] = runtime_config.get("radio_type") @@ -7829,15 +7834,7 @@ class APIEndpoints: if "identity_key" in rep: del rep["identity_key"] - tcp_cfg = exported.get("modem_tcp") - if isinstance(tcp_cfg, dict) and "token" in tcp_cfg: - tcp_cfg["token"] = REDACTED_CONFIG_VALUE - for radio_entry in exported.get("radios") or []: - if not isinstance(radio_entry, dict): - continue - nested_tcp_cfg = radio_entry.get("modem_tcp") - if isinstance(nested_tcp_cfg, dict) and "token" in nested_tcp_cfg: - nested_tcp_cfg["token"] = REDACTED_CONFIG_VALUE + redact_modem_tokens_in_place(exported, replacement=REDACTED_CONFIG_VALUE) # Redact identity keys in companion / room_server configs for section in ("room_servers", "companions"): @@ -7991,6 +7988,8 @@ class APIEndpoints: "kiss", "modem_usb", "modem_tcp", + "sensors", + "gps", "mqtt_brokers", "mqtt", "identities", @@ -8055,6 +8054,8 @@ class APIEndpoints: "kiss", "modem_usb", "modem_tcp", + "sensors", + "gps", "radio_type", "mqtt_brokers", "letsmesh", diff --git a/tests/test_api_endpoints_core_coverage.py b/tests/test_api_endpoints_core_coverage.py index 6342aec..ca2dc7f 100644 --- a/tests/test_api_endpoints_core_coverage.py +++ b/tests/test_api_endpoints_core_coverage.py @@ -617,16 +617,20 @@ def test_serial_ports_tolerates_broken_host_alias(cherrypy_ctx): api = _make_api() broken = "/host/dev/serial/by-id/broken" + realpath = MagicMock(return_value="/host/dev/missing-target") with ( patch("serial.tools.list_ports.comports", return_value=[]), patch("os.path.isdir", return_value=True), patch("glob.glob", side_effect=lambda pattern: [broken] if "by-id" in pattern else []), - patch("os.path.realpath", side_effect=OSError("inaccessible")), + patch("os.path.islink", side_effect=lambda path: path == broken), + patch("os.path.exists", return_value=False), + patch("os.path.realpath", realpath), ): result = api.serial_ports() assert result["success"] is True assert result["data"] == [] + realpath.assert_not_called() def test_config_export_redacts_secrets_and_identity_keys(cherrypy_ctx): @@ -689,6 +693,29 @@ def test_config_export_full_backup_includes_hex_keys(cherrypy_ctx): assert result["data"]["meta"]["includes_secrets"] is True +def test_stats_omits_top_level_and_multi_radio_modem_tokens(cherrypy_ctx): + del cherrypy_ctx + config = { + "modem_tcp": {"host": "root.local", "token": "root-secret"}, + "radios": [ + { + "id": "tcp", + "radio_type": "modem_tcp", + "modem_tcp": {"host": "nested.local", "token": "nested-secret"}, + } + ], + } + api = _make_api(config) + api.stats_getter = lambda: {} + + result = api.stats() + + assert result["modem_tcp"] == {"host": "root.local"} + assert result["radios"][0]["modem_tcp"] == {"host": "nested.local"} + assert config["modem_tcp"]["token"] == "root-secret" + assert config["radios"][0]["modem_tcp"]["token"] == "nested-secret" + + def test_config_export_canonicalizes_modem_keys_and_redacts_token(cherrypy_ctx): request, _ = cherrypy_ctx request.method = "GET" @@ -782,6 +809,12 @@ def test_config_import_updates_sections_and_preserves_redacted(cherrypy_ctx): ] }, "radio": {"frequency": 915000000}, + "sensors": { + "definitions": [ + {"name": "modem", "type": "pymc_modem", "settings": {"host": "m.local"}} + ] + }, + "gps": {"source": "pymc_modem", "host": "m.local"}, "radio_type": "pymc_usb", "pymc_tcp": { "host": "restored.local", @@ -814,6 +847,8 @@ def test_config_import_updates_sections_and_preserves_redacted(cherrypy_ctx): "repeater", "identities", "radio", + "sensors", + "gps", "radios", "radio_type", "modem_tcp", @@ -836,6 +871,8 @@ def test_config_import_updates_sections_and_preserves_redacted(cherrypy_ctx): "port": 6001, "token": "keep-nested-token", } + assert api.config["sensors"]["definitions"][0]["type"] == "openhop_modem" + assert api.config["gps"] == {"source": "modem_http", "host": "m.local"} def test_config_import_preserves_omitted_modem_tokens_but_accepts_replacements(cherrypy_ctx): diff --git a/tests/test_engine.py b/tests/test_engine.py index ad18183..4dc370c 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -1506,6 +1506,24 @@ class TestStatistics: stats = handler.get_stats() assert stats["local_hash"] == f"0x{LOCAL_HASH:02x}" + def test_get_stats_redacts_top_level_and_multi_radio_modem_tokens(self, handler): + handler.config["modem_tcp"] = {"host": "root.local", "token": "root-secret"} + handler.config["radios"] = [ + { + "id": "tcp", + "radio_type": "modem_tcp", + "modem_tcp": {"host": "nested.local", "token": "nested-secret"}, + } + ] + + with patch.object(handler, "storage", None): + stats = handler.get_stats() + + assert stats["config"]["modem_tcp"] == {"host": "root.local"} + assert stats["config"]["radios"][0]["modem_tcp"] == {"host": "nested.local"} + assert handler.config["modem_tcp"]["token"] == "root-secret" + assert handler.config["radios"][0]["modem_tcp"]["token"] == "nested-secret" + @pytest.mark.asyncio class TestNeighbourLinkObservation: diff --git a/tests/test_modem_config_migration.py b/tests/test_modem_config_migration.py index 570c35f..40ccd35 100644 --- a/tests/test_modem_config_migration.py +++ b/tests/test_modem_config_migration.py @@ -3,7 +3,7 @@ import logging import yaml -from repeater.modem_config import normalize_modem_config +from repeater.modem_config import normalize_modem_config, redact_modem_tokens_in_place def test_canonical_modem_config_is_unchanged_and_input_is_not_mutated(): @@ -170,3 +170,44 @@ def test_sensor_and_gps_aliases_migrate_to_canonical_names(): assert normalized["sensors"]["definitions"][0]["type"] == "openhop_modem" assert normalized["sensors"]["definitions"][1]["type"] == "hardware_stats" assert normalized["gps"]["source"] == "modem_http" + + +def test_unrelated_sensor_and_gps_names_keep_original_spelling(): + normalized = normalize_modem_config( + { + "sensors": {"definitions": [{"name": "custom", "type": "Vendor_Custom"}]}, + "gps": {"source": "Vendor_Source"}, + } + ) + + assert normalized["sensors"]["definitions"][0]["type"] == "Vendor_Custom" + assert normalized["gps"]["source"] == "Vendor_Source" + + +def test_redact_modem_tokens_handles_top_level_and_multi_radio_without_mutating_others(): + config = { + "modem_tcp": {"host": "root.local", "token": "root-secret"}, + "radios": [ + {"id": "tcp", "modem_tcp": {"host": "nested.local", "token": "nested-secret"}}, + {"id": "usb", "modem_usb": {"port": "/dev/ttyACM0"}}, + "malformed", + ], + } + + redact_modem_tokens_in_place(config) + + assert config["modem_tcp"] == {"host": "root.local"} + assert config["radios"][0]["modem_tcp"] == {"host": "nested.local"} + assert config["radios"][1]["modem_usb"] == {"port": "/dev/ttyACM0"} + + +def test_redact_modem_tokens_can_replace_tokens_with_export_sentinel(): + config = { + "modem_tcp": {"token": "root-secret"}, + "radios": [{"modem_tcp": {"token": "nested-secret"}}], + } + + redact_modem_tokens_in_place(config, replacement="*** REDACTED ***") + + assert config["modem_tcp"]["token"] == "*** REDACTED ***" + assert config["radios"][0]["modem_tcp"]["token"] == "*** REDACTED ***"