Files
pyMC_Repeater/repeater/config.py
T
agessaman d4aca403a5 fix(duty-cycle): meter each radio against its own channel
A duty cycle is a limit on a channel, not on a node, and one AirtimeManager
charging every radio at the top-level radio block's modulation is wrong twice
over on a bridge. The same packet occupies a 62.5 kHz channel about eight times
longer than a 500 kHz one, so whichever bandwidth that block names, the other
radio is mis-charged by that ratio; and the two share one allowance, so a busy
local radio spends the backhaul's airtime on a band it never transmits on.

There is now one budget per channel, built from each radio's own profile and
its own band's limit -- 868.0-868.6 MHz allows 1% where 869.4-869.65 allows
10%, and one number cannot describe both. Two radios tuned alike share a
budget, because they contend for the same spectrum. duty_cycle.shared_budget
restores a single node-wide allowance for anyone who wants the conservative
reading, and a single-radio node is metered exactly as it was.

Metering a channel means knowing which radio will carry a packet, and the
engine used to work that out by restating openhop_core's dispatch rules. It
asks now. openhop_core passes a packet's ingress radio to the TX policy, so the
choice is a function of the packet rather than of whatever arrived most
recently; the repeater asks before scheduling and gets the answer the send will
reach. That also closes a race that predates this: sticky and bridge read the
node's last RX at send time, which after a retransmit delay on a busy node is
another packet's arrival, and moves a reply onto the wrong band.

The core change is optional. The keyword in resolve_tx_radio_id's signature is
the version check, and against a core without it the tx_mode selectors are
registered in their original one-argument form and behave exactly as they did.
What changes there is the gate: with no way to know which radio will transmit,
every radio the fabric could pick is held to its own budget, so a node cannot
transmit past a legal limit by guessing the quiet channel. Which mode is in
force is logged at startup.

Also fixed, each reproduced by an adversarial review of the earlier attempt:

- A node with one radios[] entry had its modulation and limit overwritten by
  the stale top-level block on every radio save from the web UI. The refresh
  path now branches on whether profiles were read, not on how many radios
  there are.
- build_radio_profiles is all-or-nothing by design -- it feeds the UI -- so one
  radio_type: none collapsed metering onto the top-level block for the radio
  still transmitting. Metering has its own builder that degrades per radio.
- Carrying the whole node's spend onto an unrecognised channel compounded over
  repeated rebuilds (100 -> 400 -> 1600 ms) and wedged transmission for the
  rest of the window. Carry-over is keyed by channel, which makes an unchanged
  rebuild idempotent, and an unrecognised one starts from the busiest single
  channel rather than the sum of them.
- A rebuild worked the previous default manager out from the config again,
  which by then already named the new default -- preserving the wrong object
  and orphaning the one NeighborScopeHelper holds for the life of the process.
  It is remembered rather than recomputed.
- Every caller that reports one airtime figure was reporting the default
  radio's channel: the MeshCore wire field total_air_time_secs, companion
  stats, the RRD and SQLite history, and /stats. They read a node total again
  -- totals summed across channels, utilisation the highest of them, because a
  node at its limit on one channel is at a limit however quiet the other is.

tests/test_fabric_fanout.py's rig never bound a tx_mode selector, so all 49 of
those tests ran against a fabric with no TX policy at all. It binds the real
one now; that gap is why an on-air change went unnoticed through two reviews.
2026-09-13 10:45:58 -07:00

1318 lines
50 KiB
Python

import base64
import inspect
import logging
import os
from functools import lru_cache
from pathlib import Path
from typing import Any, Dict, Optional, overload
import yaml
from repeater.exceptions import ConfigurationError
from repeater.modem_config import LEGACY_MODEM_RADIO_TYPES, normalize_modem_config
from repeater.policy_engine import default_policy_engine_config
logger = logging.getLogger("Config")
def _resolve_policy_config_path(config: Dict[str, Any], config_path: str) -> Path:
policy_section = config.get("policy", {}) if isinstance(config.get("policy"), dict) else {}
configured = policy_section.get("policy_file") or "policy.yaml"
base_dir = Path(config_path).expanduser().resolve().parent
p = Path(str(configured)).expanduser()
if not p.is_absolute():
p = (base_dir / p).resolve()
return p
def _load_policy_engine_config(config: Dict[str, Any], config_path: str) -> Dict[str, Any]:
policy_path = _resolve_policy_config_path(config, config_path)
defaults = default_policy_engine_config()
if not policy_path.exists():
logger.info("Policy file not found at %s, policy engine disabled", policy_path)
config["policy_engine"] = defaults
config["policy_file_path"] = str(policy_path)
return config
try:
with open(policy_path) as f:
loaded = yaml.safe_load(f) or {}
if isinstance(loaded, dict) and isinstance(loaded.get("policy_engine"), dict):
policy_cfg = loaded.get("policy_engine")
elif isinstance(loaded, dict):
policy_cfg = loaded
else:
policy_cfg = {}
merged = dict(defaults)
if isinstance(policy_cfg, dict):
merged.update(policy_cfg)
if not isinstance(merged.get("rules"), list):
merged["rules"] = []
if not isinstance(merged.get("objects"), dict):
merged["objects"] = {}
config["policy_engine"] = merged
config["policy_file_path"] = str(policy_path)
logger.info("Loaded policy config from %s", policy_path)
return config
except Exception as e:
logger.warning(
"Failed to load policy config from %s: %s. Policy engine disabled.",
policy_path,
e,
)
config["policy_engine"] = defaults
config["policy_file_path"] = str(policy_path)
return config
class NullRadio:
"""No-op radio used when radio_type disables hardware initialization."""
def __init__(self):
self._rx_callback = None
def begin(self):
return True
async def send(self, data: bytes):
raise RuntimeError("Radio is disabled (radio_type is null/none)")
async def wait_for_rx(self) -> bytes:
import asyncio
while True:
await asyncio.sleep(3600)
def sleep(self):
return None
def get_last_rssi(self) -> int:
return 0
def get_last_snr(self) -> float:
return 0.0
def set_rx_callback(self, callback):
self._rx_callback = callback
def check_radio_health(self):
return True
class BaselineCrcCounterRadio:
"""Radio proxy that exposes CRC errors relative to repeater startup.
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.
"""
def __init__(self, radio):
self._radio = radio
self._crc_baseline = self._read_raw_crc_count()
def __getattr__(self, name: str):
return getattr(self._radio, name)
@property
def crc_error_count(self) -> int:
current = self._read_raw_crc_count()
if self._crc_baseline <= 0 and current > 0:
self._crc_baseline = current
return 0
return max(0, current - self._crc_baseline)
@crc_error_count.setter
def crc_error_count(self, value: Any) -> None:
setattr(self._radio, "crc_error_count", value)
def _read_raw_crc_count(self) -> int:
try:
return int(getattr(self._radio, "crc_error_count", 0) or 0)
except (TypeError, ValueError):
return 0
def resolve_storage_dir(
config: Dict[str, Any],
*,
config_path: Optional[str] = None,
default: str = "/var/lib/openhop_repeater",
) -> Path:
storage_dir_cfg = (
config.get("storage", {}).get("storage_dir") or config.get("storage_dir") or default
)
storage_dir = Path(str(storage_dir_cfg)).expanduser()
if not storage_dir.is_absolute():
if config_path:
base_dir = Path(config_path).expanduser().resolve().parent
storage_dir = (base_dir / storage_dir).resolve()
else:
storage_dir = storage_dir.resolve()
return storage_dir
def get_node_info(config: Dict[str, Any]) -> Dict[str, Any]:
"""
Extract node name, radio configuration, and MQTT settings from config.
Args:
config: Configuration dictionary
Returns:
Dictionary with node_name, radio_config, and MQTT configuration
"""
node_name = config.get("repeater", {}).get("node_name", "PyMC-Repeater")
radio_config = config.get("radio", {})
radio_freq = radio_config.get("frequency", 0.0)
radio_bw = radio_config.get("bandwidth", 0.0)
radio_sf = radio_config.get("spreading_factor", 7)
radio_cr = radio_config.get("coding_rate", 5)
# Format frequency in MHz and bandwidth in kHz
radio_freq_mhz = radio_freq / 1_000_000
radio_bw_khz = radio_bw / 1_000
radio_config_str = f"{radio_freq_mhz},{radio_bw_khz},{radio_sf},{radio_cr}"
# Handle getting the config from mqtt brokers, falling back to letsmesh if it doesn't exist
mqtt_config = config.get("mqtt_brokers", config.get("letsmesh", {}))
return {
"node_name": node_name,
"radio_config": radio_config_str,
"iata_code": mqtt_config.get("iata_code", "TEST"),
"status_interval": mqtt_config.get("status_interval", 60),
"model": mqtt_config.get("model", "PyMC-Repeater"),
"email": mqtt_config.get("email", ""),
"owner": mqtt_config.get("owner", ""),
}
def load_config(config_path: Optional[str] = None) -> Dict[str, Any]:
if config_path is None:
config_path = os.getenv(
"OPENHOP_REPEATER_CONFIG",
os.getenv("PYMC_REPEATER_CONFIG", "/etc/openhop_repeater/config.yaml"),
)
# Check if config file exists
if not Path(config_path).exists():
raise ConfigurationError(
f"Configuration file not found: {config_path}\n"
f"Please create a config file. Example: \n"
f" sudo cp {Path(config_path).parent}/config.yaml.example {config_path}\n"
f" sudo nano {config_path}"
)
# Load from file - no defaults, all settings must be in config file
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
storage_dir = resolve_storage_dir(config, config_path=config_path)
if "storage" not in config or not isinstance(config.get("storage"), dict):
config["storage"] = {}
config["storage"]["storage_dir"] = str(storage_dir)
if config.get("storage_dir"):
logger.warning(
"Deprecated config key 'storage_dir' detected; prefer 'storage.storage_dir'."
)
if "mesh" not in config:
config["mesh"] = {}
if "http" not in config:
config["http"] = {
"enabled": True,
"host": "0.0.0.0", # nosec B104 - intentional LAN bind default
"port": 8000,
}
if "glass" not in config:
config["glass"] = {
"enabled": False,
"base_url": "http://localhost:8080",
"inform_interval_seconds": 30,
"request_timeout_seconds": 10,
"verify_tls": True,
"api_token": None,
"cert_store_dir": "/etc/openhop_repeater/glass",
}
if "gps" not in config:
config["gps"] = {
"enabled": False,
"api_fallback_to_config_location": True,
"advertise_gps_location": False,
"location_precision_digits": None,
"source": "serial",
"device": "/dev/serial0",
"baud_rate": 9600,
"read_timeout_seconds": 1.0,
"reconnect_interval_seconds": 5.0,
"host": "",
"port": 80,
"endpoint": "/api/stats",
"scheme": "http",
"username": "admin",
"password": None,
"stale_after_seconds": 10.0,
"retain_sentences": 25,
"validate_checksum": True,
"require_checksum": False,
"time_sync_enabled": True,
"time_sync_interval_seconds": 3600.0,
"time_sync_min_offset_seconds": 1.0,
"time_sync_min_valid_year": 2020,
"persist_gps_fix_to_config": False,
"persist_gps_fix_interval_seconds": 600.0,
}
if "sensors" not in config:
config["sensors"] = {
"enabled": False,
"poll_interval_seconds": 30.0,
"auto_install_packages": False,
"definitions": [],
}
# Ensure repeater.security exists with defaults for upgrades from older configs
if "repeater" not in config:
config["repeater"] = {}
if "security" not in config["repeater"]:
logger.warning(
"No 'security' section found under 'repeater' in config. "
"Adding secure placeholders — complete setup wizard before login."
)
config["repeater"]["security"] = {
"max_clients": 1,
"admin_password": None,
"guest_password": None,
"allow_read_only": False,
"jwt_secret": None,
"jwt_expiry_minutes": 60,
}
# Only auto-generate identity_key if not provided under repeater section
if "identity_key" not in config["repeater"]:
# Check if identity_file is specified
identity_file = config["repeater"].get("identity_file")
if identity_file:
config["repeater"]["identity_key"] = _load_or_create_identity_key(path=identity_file)
else:
config["repeater"]["identity_key"] = _load_or_create_identity_key()
env_log_level = os.getenv("OPENHOP_REPEATER_LOG_LEVEL", os.getenv("PYMC_REPEATER_LOG_LEVEL"))
if env_log_level:
if "logging" not in config:
config["logging"] = {}
config["logging"]["level"] = env_log_level
config = _load_policy_engine_config(config, config_path)
return config
def save_config(config_data: Dict[str, Any], config_path: Optional[str] = None) -> bool:
"""
Save configuration to YAML file.
Args:
config_data: Configuration dictionary to save
config_path: Path to config file (uses default if None)
Returns:
True if successful, False otherwise
"""
if config_path is None:
config_path = os.getenv(
"OPENHOP_REPEATER_CONFIG",
os.getenv("PYMC_REPEATER_CONFIG", "/etc/openhop_repeater/config.yaml"),
)
try:
config_data = normalize_modem_config(config_data)
# Create backup of existing config
config_file = Path(config_path)
if config_file.exists():
backup_path = config_file.with_suffix(".yaml.backup")
config_file.rename(backup_path)
logger.info(f"Created backup at {backup_path}")
# Save new config (allow_unicode=True so emojis etc. are not escaped as \U0001F47E)
with open(config_path, "w", encoding="utf-8") as f:
yaml.safe_dump(
config_data,
f,
default_flow_style=False,
sort_keys=False,
allow_unicode=True,
width=1000000,
)
logger.info(f"Saved configuration to {config_path}")
return True
except Exception as e:
logger.error(f"Failed to save configuration: {e}")
return False
def update_unscoped_flood_policy(allow: bool, config_path: Optional[str] = None) -> bool:
"""
Update the unscoped flood policy in the configuration.
Args:
allow: True to allow unscoped flooding, False to deny
config_path: Path to config file (uses default if None)
Returns:
True if successful, False otherwise
"""
try:
# Load current config
config = load_config(config_path)
# Ensure mesh section exists
if "mesh" not in config:
config["mesh"] = {}
# Set global flood policy
config["mesh"]["global_flood_allow"] = allow
config["mesh"]["unscoped_flood_allow"] = allow
# Save updated config
return save_config(config, config_path)
except Exception as e:
logger.error(f"Failed to update unscoped flood policy: {e}")
return False
def _load_or_create_identity_key(path: Optional[str] = None) -> bytes:
if path is None:
# Check system-wide location first (matches config.yaml location)
system_key_path = Path("/etc/openhop_repeater/identity.key")
if system_key_path.exists():
key_path = system_key_path
else:
# Follow XDG spec
xdg_config_home = os.environ.get("XDG_CONFIG_HOME")
if xdg_config_home:
config_dir = Path(xdg_config_home) / "openhop_repeater"
else:
config_dir = Path.home() / ".config" / "openhop_repeater"
key_path = config_dir / "identity.key"
else:
key_path = Path(path)
key_path.parent.mkdir(parents=True, exist_ok=True)
if key_path.exists():
try:
with open(key_path, "rb") as f:
encoded = f.read()
key = base64.b64decode(encoded)
if len(key) not in (32, 64):
raise ValueError(f"Invalid key length: {len(key)}, expected 32 or 64")
logger.info(f"Loaded existing identity key from {key_path}")
return key
except Exception as e:
logger.warning(f"Failed to load identity key: {e}")
# Generate new key via MeshCore-compatible keygen; store 32-byte private scalar.
from repeater.keygen import generate_meshcore_keypair
_, private_key = generate_meshcore_keypair()
key = private_key[:32]
# Save it
try:
with open(key_path, "wb") as f:
f.write(base64.b64encode(key))
os.chmod(key_path, 0o600) # Restrict permissions
logger.info(f"Generated and stored new identity key at {key_path}")
except Exception as e:
logger.warning(f"Failed to save identity key: {e}")
return key
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]: ...
@overload
def _parse_int(value, *, default: int) -> int: ...
def _parse_int(value, *, default=None):
if value is None:
return default
if isinstance(value, int):
return value
if isinstance(value, str):
return int(value.strip().rstrip(","), 0)
raise ValueError(f"Invalid int value type: {type(value)}")
def _parse_int_list(value):
if value is None:
return None
if isinstance(value, (list, tuple)):
return [_parse_int(item) for item in value]
if isinstance(value, str):
stripped = value.strip()
if not stripped:
return []
if stripped[0] == "[" and stripped[-1] == "]":
stripped = stripped[1:-1]
return [_parse_int(item) for item in stripped.split(",") if item.strip()]
raise ValueError(f"Invalid int list value type: {type(value)}")
radio_type_raw = board_config.get("radio_type")
if radio_type_raw is None:
radio_type = "none"
else:
radio_type = str(radio_type_raw).lower().strip()
if radio_type in ("", "none", "null", "disabled", "off", "no_radio"):
logger.warning("Radio disabled by configuration (radio_type=%r)", radio_type_raw)
return NullRadio()
if radio_type == "kiss-modem":
radio_type = "kiss"
if radio_type in ("sx1262", "sx1262_ch341"):
from openhop_core.hardware.sx1262_wrapper import SX1262Radio
# Get radio and SPI configuration - all settings must be in config file
spi_config = board_config.get("sx1262")
if not spi_config:
raise ValueError("Missing 'sx1262' section in configuration file")
radio_config = board_config.get("radio")
if not radio_config:
raise ValueError("Missing 'radio' section in configuration file")
# CH341 integration: per-radio USB adapter selection.
# For multi-radio, pass distinct bus/address/serial_number so each
# SX1262Radio owns its own CH341 SPI+GPIO pair (no process singleton).
ch341_spi = None
ch341_gpio = None
if radio_type == "sx1262_ch341":
ch341_cfg = board_config.get("ch341")
if not ch341_cfg:
raise ValueError("Missing 'ch341' section in configuration file")
from openhop_core.hardware.lora.LoRaRF.SX126x import set_spi_transport
from openhop_core.hardware.transports.ch341_spi_transport import CH341SPITransport
vid = _parse_int(ch341_cfg.get("vid"), default=0x1A86)
pid = _parse_int(ch341_cfg.get("pid"), default=0x5512)
bus = _parse_int(ch341_cfg.get("bus"), default=None)
address = _parse_int(
ch341_cfg.get("address", ch341_cfg.get("device_address")),
default=None,
)
serial_number = ch341_cfg.get("serial_number") or ch341_cfg.get("serial")
if serial_number is not None:
serial_number = str(serial_number).strip() or None
# Multi-radio entries should not stomp the process-global defaults.
# Legacy single-radio keeps global install for older code paths.
multi_context = bool(board_config.get("_radio_id")) or bool(
board_config.get("_ch341_per_instance")
)
ch341_spi = CH341SPITransport(
vid=vid,
pid=pid,
bus=bus,
address=address,
serial_number=serial_number,
auto_setup_gpio=True,
set_as_global_gpio=not multi_context,
)
ch341_gpio = ch341_spi.gpio_manager
if not multi_context:
set_spi_transport(ch341_spi)
combined_config = {
"bus_id": _parse_int(spi_config["bus_id"]),
"cs_id": _parse_int(spi_config["cs_id"]),
"cs_pin": _parse_int(spi_config["cs_pin"]),
"reset_pin": _parse_int(spi_config["reset_pin"]),
"busy_pin": _parse_int(spi_config["busy_pin"]),
"irq_pin": _parse_int(spi_config["irq_pin"]),
"txen_pin": _parse_int(spi_config["txen_pin"]),
"rxen_pin": _parse_int(spi_config["rxen_pin"]),
"txled_pin": _parse_int(spi_config.get("txled_pin", -1), default=-1),
"rxled_pin": _parse_int(spi_config.get("rxled_pin", -1), default=-1),
"use_dio3_tcxo": spi_config.get("use_dio3_tcxo", False),
"dio3_tcxo_voltage": float(spi_config.get("dio3_tcxo_voltage", 1.8)),
"use_dio2_rf": spi_config.get("use_dio2_rf", False),
"is_waveshare": spi_config.get("is_waveshare", False),
"frequency": int(radio_config["frequency"]),
"tx_power": radio_config["tx_power"],
"spreading_factor": radio_config["spreading_factor"],
"bandwidth": int(radio_config["bandwidth"]),
"coding_rate": radio_config["coding_rate"],
"preamble_length": radio_config["preamble_length"],
"sync_word": _parse_int(radio_config.get("sync_word", 0x12)),
}
en_pin = _parse_int(spi_config.get("en_pin"), default=None)
en_pins = _parse_int_list(spi_config.get("en_pins"))
if en_pin is not None:
combined_config["en_pin"] = en_pin
if en_pins is not None:
combined_config["en_pins"] = en_pins
# Add optional GPIO parameters if specified in config
# These wont be supported by older versions of openhop_core
if "gpio_chip" in spi_config:
combined_config["gpio_chip"] = _parse_int(spi_config["gpio_chip"], default=0)
if "use_gpiod_backend" in spi_config:
combined_config["use_gpiod_backend"] = spi_config["use_gpiod_backend"]
if "radio_timing_delay" in spi_config:
combined_config["radio_timing_delay"] = float(spi_config["radio_timing_delay"])
# Always construct a fresh instance so multi-radio configs do not
# share/reuse a singleton SX1262 handle.
if ch341_spi is not None:
combined_config["spi_transport"] = ch341_spi
if ch341_gpio is not None:
combined_config["gpio_manager"] = ch341_gpio
radio = SX1262Radio(**combined_config)
if hasattr(radio, "_initialized") and not radio._initialized:
try:
radio.begin()
except RuntimeError as e:
raise RuntimeError(f"Failed to initialize SX1262 radio: {e}") from e
return radio
elif radio_type == "kiss":
try:
from openhop_core.hardware.kiss_modem_wrapper import KissModemWrapper
except ImportError:
try:
from openhop_core.hardware.kiss_serial_wrapper import (
KissSerialWrapper as KissModemWrapper,
)
except ImportError:
raise RuntimeError(
"KISS modem support requires openhop-core with KISS support. "
"Install your fork with: pip install -e /path/to/openhop-core"
) from None
kiss_config = board_config.get("kiss")
if not kiss_config:
raise ValueError("Missing 'kiss' section in configuration file for radio_type: kiss")
port = kiss_config.get("port")
if not port:
raise ValueError("Missing 'port' in 'kiss' section (e.g. /dev/ttyUSB0)")
baudrate = int(kiss_config.get("baud_rate", 115200))
radio_cfg = board_config.get("radio") or {}
radio_config = {
"frequency": int(radio_cfg.get("frequency", 869618000)),
"bandwidth": int(radio_cfg.get("bandwidth", 62500)),
"spreading_factor": int(radio_cfg.get("spreading_factor", 8)),
"coding_rate": int(radio_cfg.get("coding_rate", 8)),
"tx_power": int(radio_cfg.get("tx_power", 14)),
"preamble_length": int(radio_cfg.get("preamble_length", 32)),
}
# Optional KISS key-up / CSMA tuning, forwarded to the modem firmware (via
# SetHardware) only when present so the wrapper keeps its own defaults otherwise.
# For a host-managed repeater the engine already staggers retransmits, so the
# firmware's p-persistent CSMA backoff is usually redundant; set
# kiss_persistence: 255 to transmit as soon as the channel is clear.
for _key in ("tx_delay_ms", "kiss_persistence", "kiss_slottime_ms", "kiss_txtail_ms"):
if kiss_config.get(_key) is not None:
radio_config[_key] = int(kiss_config[_key])
if kiss_config.get("kiss_full_duplex") is not None:
radio_config["kiss_full_duplex"] = bool(kiss_config["kiss_full_duplex"])
radio = KissModemWrapper(
port=port,
baudrate=baudrate,
radio_config=radio_config,
auto_configure=True,
)
if hasattr(radio, "begin"):
try:
radio.begin()
except Exception as e:
raise RuntimeError(f"Failed to initialize KISS modem: {e}") from e
return radio
elif radio_type == "modem_tcp":
try:
from openhop_core.hardware.tcp_radio import TCPLoRaRadio
except ImportError:
raise RuntimeError(
"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("modem_tcp")
if not tcp_cfg:
raise ValueError(
"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 'modem_tcp' section (modem hostname or LAN IP)")
radio_cfg = board_config.get("radio") or {}
radio = TCPLoRaRadio(
host=host,
port=int(tcp_cfg.get("port", 5055)),
token=tcp_cfg.get("token", ""),
connect_timeout=float(tcp_cfg.get("connect_timeout", 5.0)),
frequency=int(radio_cfg.get("frequency", 869618000)),
bandwidth=int(radio_cfg.get("bandwidth", 62500)),
spreading_factor=int(radio_cfg.get("spreading_factor", 8)),
coding_rate=int(radio_cfg.get("coding_rate", 8)),
tx_power=int(radio_cfg.get("tx_power", 22)),
sync_word=_parse_int(radio_cfg.get("sync_word", 0x12), default=0x12),
preamble_length=int(radio_cfg.get("preamble_length", 16)),
lbt_enabled=bool(tcp_cfg.get("lbt_enabled", True)),
lbt_max_attempts=int(tcp_cfg.get("lbt_max_attempts", 5)),
)
try:
radio.begin()
except Exception as e:
raise RuntimeError(f"Failed to initialize modem_tcp radio: {e}") from e
return BaselineCrcCounterRadio(radio)
elif radio_type == "modem_usb":
try:
from openhop_core.hardware.usb_radio import USBLoRaRadio
except ImportError:
raise RuntimeError(
"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("modem_usb")
if not usb_cfg:
raise ValueError(
"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 'modem_usb' section (e.g. /dev/ttyACM0)")
radio_cfg = board_config.get("radio") or {}
radio = USBLoRaRadio(
port=port,
baudrate=int(usb_cfg.get("baudrate", 921600)),
frequency=int(radio_cfg.get("frequency", 869618000)),
bandwidth=int(radio_cfg.get("bandwidth", 62500)),
spreading_factor=int(radio_cfg.get("spreading_factor", 8)),
coding_rate=int(radio_cfg.get("coding_rate", 8)),
tx_power=int(radio_cfg.get("tx_power", 22)),
sync_word=_parse_int(radio_cfg.get("sync_word", 0x12), default=0x12),
preamble_length=int(radio_cfg.get("preamble_length", 16)),
lbt_enabled=bool(usb_cfg.get("lbt_enabled", True)),
lbt_max_attempts=int(usb_cfg.get("lbt_max_attempts", 5)),
)
try:
radio.begin()
except Exception as 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), modem_tcp, modem_usb"
)
def _merge_radio_entry(global_config: dict, entry: dict) -> dict:
"""Build a board_config dict for one radios[] entry.
Entry may override radio_type and hardware sections; shared top-level
``radio`` air settings are inherited unless the entry supplies its own.
"""
if not isinstance(entry, dict):
raise ValueError("each radios[] entry must be a mapping")
radio_id = entry.get("id") or entry.get("radio_id")
if not radio_id:
raise ValueError("each radios[] entry requires an 'id'")
merged = dict(global_config)
for key in (
"radio_type",
"radio",
"sx1262",
"ch341",
"kiss",
"modem_tcp",
"modem_usb",
):
if key in entry:
merged[key] = entry[key]
if "radio_type" not in entry and "radio_type" not in global_config:
raise ValueError(f"radios[] entry {radio_id!r} missing radio_type")
merged["_radio_id"] = str(radio_id)
# Hint for CH341 path: do not install process-global SPI/GPIO defaults.
merged["_ch341_per_instance"] = True
return merged
@lru_cache(maxsize=32)
def _names_rx_radio_id(func) -> bool:
"""Signature check, cached: this runs once per forwarded packet."""
try:
params = inspect.signature(func).parameters
except (TypeError, ValueError): # pragma: no cover - C callables and stubs
return False
param = params.get("rx_radio_id")
return param is not None and param.kind is not inspect.Parameter.POSITIONAL_ONLY
def fabric_selects_by_ingress_radio(fabric) -> bool:
"""Whether this openhop_core hands a TX selector the frame's ingress radio.
The keyword is named in ``resolve_tx_radio_id``'s signature from the release
that added it, and absent before, so the signature is the version check.
Everything downstream of this -- which selector gets registered, and whether
the duty-cycle gate can name the egress radio before the send -- follows
from the answer, so a node running an older core keeps working exactly as it
did rather than failing on a keyword nothing accepts.
"""
resolve = getattr(fabric, "resolve_tx_radio_id", None)
if resolve is None:
return False
# Cached on the underlying function, which is per class rather than per
# fabric, so a long-running node does not rebuild a Signature per packet.
return _names_rx_radio_id(getattr(resolve, "__func__", resolve))
def _apply_fabric_tx_mode(fabric, mode: str) -> None:
"""Map simple tx_mode strings onto RFFabric.set_tx_selector.
Modes:
- default: always TX ``default_radio`` (or first registered).
- sticky: TX on the radio that received this packet (reply on same band).
- bridge: TX on a *different* radio than the one that received this packet
(dual-freq backhaul). Designed for two radios (local <-> link).
With 3+ radios, uses the first other id in registration order.
What "this packet" means depends on the core underneath, and the difference
is worth stating plainly.
A core that passes the ingress radio to the selector gets selectors that
route by it: the answer is a function of the packet, so it is the same
whenever it is asked, and the repeater can ask before the send to know which
channel to charge. A packet this node originated has no ingress radio and
goes out on ``default_radio``.
An older core passes only the frame bytes, so the selector reads the
fabric's most recent RX at send time instead -- which, after a retransmit
delay on a busy node, may be a different packet's arrival, and for locally
originated traffic is whichever radio last heard anything. That is the
behaviour these modes have always had; it is kept unchanged rather than
approximated, and the duty-cycle gate compensates by holding every radio the
fabric could pick to its own budget.
"""
mode_l = (mode or "default").strip().lower()
if mode_l in ("", "default"):
fabric.set_tx_selector(None)
return
by_ingress = fabric_selects_by_ingress_radio(fabric)
if by_ingress:
logger.info("fabric.tx_mode=%s routes by each packet's ingress radio", mode_l)
else:
logger.warning(
"fabric.tx_mode=%s routes by the node's most recent RX: this openhop-core "
"does not pass a packet's ingress radio to the TX policy. Behaviour is "
"unchanged from before, and the duty-cycle gate holds every radio the node "
"could pick to its own budget rather than guessing which one will transmit.",
mode_l,
)
if mode_l == "sticky":
if by_ingress:
def _sticky(_data: bytes, rx_radio_id):
if rx_radio_id and rx_radio_id in fabric.radios:
return rx_radio_id
return fabric.default_radio_id
else:
def _sticky(_data: bytes):
rid = getattr(fabric, "_last_rx_radio_id", None)
if rid and rid in fabric.radios:
return rid
return fabric.default_radio_id
fabric.set_tx_selector(_sticky)
return
if mode_l == "bridge":
if by_ingress:
def _bridge(_data: bytes, rx_radio_id):
ids = list(fabric.radios.keys())
if len(ids) < 2:
return fabric.default_radio_id
if rx_radio_id and rx_radio_id in fabric.radios:
for rid in ids:
if rid != rx_radio_id:
return rid
return fabric.default_radio_id
else:
def _bridge(_data: bytes):
ids = list(fabric.radios.keys())
if len(ids) < 2:
return fabric.default_radio_id
last = getattr(fabric, "_last_rx_radio_id", None)
if last and last in fabric.radios:
for rid in ids:
if rid != last:
return rid
return fabric.default_radio_id
fabric.set_tx_selector(_bridge)
return
raise ValueError(f"Unknown fabric.tx_mode={mode!r}. Supported: default, sticky, bridge")
FABRIC_ORIGIN_TX_MODES = ("default", "all")
# ``local_tx_mode`` is the option's original name, still read so configs written
# before the rename keep their behaviour. ``origin_tx`` wins when both are set.
_FABRIC_ORIGIN_TX_KEYS = ("origin_tx", "local_tx_mode")
def _fabric_origin_tx_entries(fabric_cfg) -> list:
"""Return ``[(key, raw_value)]`` for each origin_tx spelling that is set."""
if not isinstance(fabric_cfg, dict):
return []
return [
(key, fabric_cfg[key]) for key in _FABRIC_ORIGIN_TX_KEYS if fabric_cfg.get(key) is not None
]
def _normalise_origin_tx(raw) -> str:
return str(raw).strip().lower() or "default"
def fabric_origin_tx(fabric_cfg) -> str:
"""Return the normalised ``fabric.origin_tx`` mode, without validating it."""
entries = _fabric_origin_tx_entries(fabric_cfg)
return _normalise_origin_tx(entries[0][1]) if entries else "default"
def parse_fabric_fanout_options(fabric_cfg) -> tuple:
"""Return ``(repeat_on_ingress, origin_tx)`` from a ``fabric:`` mapping.
Both default to the pre-fan-out behaviour (``False`` / ``"default"``).
Raises ValueError for values with no defined meaning, and when ``origin_tx``
and its old name ``local_tx_mode`` are both set to different modes.
"""
if not isinstance(fabric_cfg, dict):
return False, "default"
repeat_on_ingress = fabric_cfg.get("repeat_on_ingress", False)
if repeat_on_ingress is None:
repeat_on_ingress = False
if not isinstance(repeat_on_ingress, bool):
raise ValueError(
f"fabric.repeat_on_ingress must be true or false, got {repeat_on_ingress!r}"
)
entries = _fabric_origin_tx_entries(fabric_cfg)
for key, raw_mode in entries:
if _normalise_origin_tx(raw_mode) not in FABRIC_ORIGIN_TX_MODES:
raise ValueError(
f"Unknown fabric.{key}={raw_mode!r}. Supported: "
+ ", ".join(FABRIC_ORIGIN_TX_MODES)
)
origin_tx = fabric_origin_tx(fabric_cfg)
if len(entries) > 1 and _normalise_origin_tx(entries[1][1]) != origin_tx:
raise ValueError(
f"fabric.origin_tx={entries[0][1]!r} conflicts with "
f"fabric.local_tx_mode={entries[1][1]!r}, its former name; set only origin_tx"
)
return repeat_on_ingress, origin_tx
def _validate_fabric_fanout(fabric_cfg, tx_mode: str, radio_count: int) -> tuple:
"""Reject fan-out options that have no defined meaning for this radio layout.
Fan-out is defined only for an exactly-two-radio Fabric: ``repeat_on_ingress``
extends the deterministic A<->B bridge, and ``origin_tx: all`` sends once
per radio. Returns the parsed ``(repeat_on_ingress, origin_tx)``.
"""
repeat_on_ingress, origin_tx = parse_fabric_fanout_options(fabric_cfg)
if repeat_on_ingress:
mode_l = (tx_mode or "default").strip().lower()
if mode_l != "bridge":
raise ValueError(
f"fabric.repeat_on_ingress requires fabric.tx_mode=bridge (got {tx_mode!r})"
)
if radio_count != 2:
raise ValueError(
f"fabric.repeat_on_ingress requires exactly two radios (got {radio_count})"
)
if origin_tx == "all" and radio_count != 2:
key = _fabric_origin_tx_entries(fabric_cfg)[0][0]
raise ValueError(f"fabric.{key}=all requires exactly two radios (got {radio_count})")
return repeat_on_ingress, origin_tx
# Air-setting defaults per radio_type, mirroring ``get_radio_for_board``. The
# SX1262 paths require every field in the config, so their entries only matter
# for a malformed config that never reaches hardware.
_RADIO_AIR_DEFAULTS = {
"sx1262": {"preamble_length": 16},
"sx1262_ch341": {"preamble_length": 16},
"kiss": {"preamble_length": 32},
"modem_tcp": {"preamble_length": 16},
"modem_usb": {"preamble_length": 16},
}
_RADIO_AIR_COMMON_DEFAULTS = {
"frequency": 869618000,
"bandwidth": 62500,
"spreading_factor": 8,
"coding_rate": 8,
}
def _radio_air_profile(board_config: dict, radio_id: str) -> Optional[dict]:
"""Effective LoRa air settings for one built radio, or None when disabled.
Mirrors the field reads in ``get_radio_for_board`` so the reported profile
is the one the hardware was actually configured with. Reporting-only: a
field that cannot be read as an integer is returned as ``None`` rather than
guessed, so the UI can say the profile is unavailable instead of drawing a
chart from a default that was never on the air.
"""
radio_type_raw = board_config.get("radio_type")
radio_type = "none" if radio_type_raw is None else str(radio_type_raw).lower().strip()
if radio_type in ("", "none", "null", "disabled", "off", "no_radio"):
return None
if radio_type == "kiss-modem":
radio_type = "kiss"
radio_type = LEGACY_MODEM_RADIO_TYPES.get(radio_type, radio_type)
if radio_type not in _RADIO_AIR_DEFAULTS:
# get_radio_for_board rejects this type, so no hardware was built from
# it. Reporting a default-backed profile would describe a radio that is
# not on the air.
logger.warning(
"No airtime profile for radio %s: unsupported radio_type=%r", radio_id, radio_type_raw
)
return None
radio_cfg = board_config.get("radio")
if not isinstance(radio_cfg, dict):
radio_cfg = {}
defaults = dict(_RADIO_AIR_COMMON_DEFAULTS)
defaults.update(_RADIO_AIR_DEFAULTS[radio_type])
def _field(name: str) -> Optional[int]:
value = radio_cfg.get(name, defaults.get(name))
try:
return int(value)
except (TypeError, ValueError):
logger.warning(
"radio %s: unreadable %s=%r; reporting profile field as unknown",
radio_id,
name,
value,
)
return None
return {
"radio_id": str(radio_id),
"radio_type": radio_type,
"frequency_hz": _field("frequency"),
"bandwidth_hz": _field("bandwidth"),
"spreading_factor": _field("spreading_factor"),
"coding_rate": _field("coding_rate"),
"preamble_length": _field("preamble_length"),
}
def build_radio_profiles(config: dict) -> list:
"""Report the air settings of every configured radio, in configured order.
Uses the same id and inheritance rules as ``build_radio_stack`` so the
profiles line up one-for-one with the radios it builds, without touching
hardware. Callers that need current settings (the airtime endpoint) can
rebuild from the live config dict rather than reading a boot snapshot.
"""
if not isinstance(config, dict):
return []
try:
normalized = normalize_modem_config(config, warn=False)
except Exception: # pragma: no cover - normalisation is defensive here
normalized = config
radios_cfg = normalized.get("radios")
fabric_cfg = normalized.get("fabric") if isinstance(normalized.get("fabric"), dict) else {}
default_radio = fabric_cfg.get("default_radio") or fabric_cfg.get("default_radio_id")
profiles = []
if isinstance(radios_cfg, list) and len(radios_cfg) > 0:
for entry in radios_cfg:
try:
merged = _merge_radio_entry(normalized, entry)
except ValueError as exc:
logger.warning("Cannot profile radios[] entry: %s", exc)
continue
profile = _radio_air_profile(merged, merged["_radio_id"])
if profile is not None:
profiles.append(profile)
if len(profiles) != len(radios_cfg):
# A partial list would read as a smaller Fabric, and a one-element
# list would claim every packet belongs to that radio. Report
# nothing instead and let the caller fall back to what was built.
logger.warning(
"Profiled %d of %d configured radios; reporting no profiles",
len(profiles),
len(radios_cfg),
)
return []
return profiles
rid = str(default_radio) if (default_radio and fabric_cfg.get("use_fabric")) else "radio0"
profile = _radio_air_profile(normalized, rid)
return [profile] if profile is not None else []
def _inherited_air_profile(board_config: dict, radio_id: str) -> dict:
"""A profile read straight from a board config's ``radio`` block.
The stand-in for a radio the profiler will not describe -- a disabled one,
or a radio_type it does not recognise. ``_merge_radio_entry`` has already
laid that entry's own ``radio:`` block over the top-level one, so a disabled
radio that still states its frequency and bandwidth is metered on them, and
one that states nothing inherits the settings the node would have used
anyway. Either way it lands in the same channel budget as any radio that
names that channel explicitly, rather than in a budget of its own.
"""
radio_cfg = board_config.get("radio")
if not isinstance(radio_cfg, dict):
radio_cfg = {}
def _num(name: str) -> Optional[int]:
value = radio_cfg.get(name, _RADIO_AIR_COMMON_DEFAULTS.get(name))
try:
return int(value)
except (TypeError, ValueError):
return None
return {
"radio_id": str(radio_id),
"radio_type": None,
"frequency_hz": _num("frequency"),
"bandwidth_hz": _num("bandwidth"),
"spreading_factor": _num("spreading_factor"),
"coding_rate": _num("coding_rate"),
"preamble_length": _num("preamble_length"),
}
def build_metering_profiles(config: dict) -> list:
"""Air settings for duty-cycle metering: one entry per configured radio.
``build_radio_profiles`` is all-or-nothing on purpose. It feeds the UI,
where a partial list would draw a smaller Fabric than the node has and a
one-element list would claim every packet belonged to that radio.
Metering cannot accept that answer. A node whose second radio is
``radio_type: none`` still boots a two-radio fabric -- ``get_radio_for_board``
hands back a NullRadio -- so reporting nothing collapses every channel onto
the top-level ``radio`` block, and the surviving radio is metered on a
modulation and a limit that are not its own. On the 62.5 kHz side of a live
bridge that charges 26.7 ms where the truth is 213.5, against 3600 where its
own entry says 600.
So each entry degrades on its own: a radio that cannot be profiled is
metered against the top-level block under its own id, and every radio that
can be profiled keeps its own modulation and its own band's limit.
"""
if not isinstance(config, dict):
return []
try:
normalized = normalize_modem_config(config, warn=False)
except Exception: # pragma: no cover - normalisation is defensive here
normalized = config
radios_cfg = normalized.get("radios")
if not isinstance(radios_cfg, list) or len(radios_cfg) == 0:
# No radios[] block: one radio, and build_radio_profiles already names
# it the way build_radio_stack does.
return build_radio_profiles(config)
profiles = []
for index, entry in enumerate(radios_cfg):
radio_id = None
profile = None
merged = None
try:
merged = _merge_radio_entry(normalized, entry)
radio_id = merged["_radio_id"]
profile = _radio_air_profile(merged, radio_id)
except ValueError as exc:
logger.warning("Cannot profile radios[] entry %d for metering: %s", index, exc)
if profile is None:
if radio_id is None:
raw_id = (
entry.get("id") or entry.get("radio_id") if isinstance(entry, dict) else None
)
radio_id = str(raw_id or f"radio{index}")
logger.info(
"Radio %s has no air profile; metering it on the air settings it inherits",
radio_id,
)
profile = _inherited_air_profile(merged or normalized, radio_id)
profiles.append(profile)
return profiles
def build_radio_stack(config: dict):
"""Build single- or multi-radio stack for the repeater.
- No ``radios:`` -> legacy ``get_radio_for_board(config)``.
- ``radios:`` list -> FabricRadio over N physical radios.
- ``fabric:`` knobs: default_radio, tx_mode (default|sticky|bridge), use_fabric,
repeat_on_ingress, origin_tx (default|all; formerly local_tx_mode).
Returns (radio, meta).
"""
radios_cfg = config.get("radios")
fabric_cfg = config.get("fabric") if isinstance(config.get("fabric"), dict) else {}
use_fabric = bool(fabric_cfg.get("use_fabric", False))
tx_mode = str(fabric_cfg.get("tx_mode", "default"))
default_radio = fabric_cfg.get("default_radio") or fabric_cfg.get("default_radio_id")
# Validate before opening any hardware so a bad combination fails fast.
radio_count = len(radios_cfg) if isinstance(radios_cfg, list) and radios_cfg else 1
repeat_on_ingress, origin_tx = _validate_fabric_fanout(fabric_cfg, tx_mode, radio_count)
meta = {
"mode": "single",
"radio_ids": [],
"default_radio": None,
"tx_mode": tx_mode,
"repeat_on_ingress": repeat_on_ingress,
"origin_tx": origin_tx,
"fabric": False,
# Air settings per built radio, in configured order. Reporting consumers
# (airtime attribution, /stats) read these instead of assuming the
# top-level ``radio`` section applies to every radio in a Fabric.
"radio_profiles": [],
}
if isinstance(radios_cfg, list) and len(radios_cfg) > 0:
try:
from openhop_core.rf_fabric import FabricRadio
except ImportError as exc:
raise RuntimeError(
"radios: requires openhop-core with RF Fabric support. "
"Install/upgrade openhop-core (Phase 2+)."
) from exc
pairs = []
profiles = []
for entry in radios_cfg:
merged = _merge_radio_entry(config, entry)
rid = merged.pop("_radio_id")
physical = get_radio_for_board(merged)
pairs.append((physical, rid))
profile = _radio_air_profile(merged, rid)
if profile is not None:
profiles.append(profile)
default_id = str(default_radio) if default_radio else pairs[0][1]
radio = FabricRadio(radios=pairs, default_radio_id=default_id)
_apply_fabric_tx_mode(radio.fabric, tx_mode)
meta.update(
{
"mode": "multi",
"radio_ids": [rid for _, rid in pairs],
"default_radio": default_id,
"fabric": True,
"radio_profiles": profiles,
}
)
return radio, meta
physical = get_radio_for_board(config)
meta["radio_ids"] = ["radio0"]
meta["default_radio"] = "radio0"
single_profile = _radio_air_profile(normalize_modem_config(config, warn=False), "radio0")
meta["radio_profiles"] = [single_profile] if single_profile is not None else []
if use_fabric:
try:
from openhop_core.rf_fabric import FabricRadio
except ImportError as exc:
raise RuntimeError(
"fabric.use_fabric requires openhop-core with RF Fabric support."
) from exc
rid = str(default_radio or "radio0")
radio = FabricRadio(radio=physical, radio_id=rid, default_radio_id=rid)
_apply_fabric_tx_mode(radio.fabric, tx_mode)
if single_profile is not None:
single_profile = dict(single_profile, radio_id=rid)
meta.update(
{
"mode": "single_fabric",
"radio_ids": [rid],
"default_radio": rid,
"fabric": True,
"radio_profiles": [single_profile] if single_profile is not None else [],
}
)
return radio, meta
return physical, meta