mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-11-03 11:52:22 +01:00
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.
1318 lines
50 KiB
Python
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
|