Merge branch 'pr-397' into dev

This commit is contained in:
Rightup committed 2026-08-24 14:21:50 +01:00
commit 24a45ba641
20 files changed
+1367 -396

No files matched your search

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