mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-10-30 19:51:59 +01:00
fix(config): overlay per-radio config sections on global defaults
Multi-radio entries currently replace radio, sx1262, and transport configuration sections wholesale. Partial sx1262 entries therefore discard board settings supplied by the top-level hardware preset, including TCXO control, DIO2 RF switching, and radio enable pins. Merge mapping sections key-by-key instead, treating the top-level sections as defaults and radios[] values as per-radio overrides. Keep radio_type as a scalar override so heterogeneous fabrics continue to work. Overlay makes two spellings of one setting reachable together, where the entry's could lose to an inherited default it never wrote: en_pins beats en_pin in SX1262Radio, and address/serial_number beat device_address/serial in get_radio_for_board. An entry naming either spelling now displaces the other, so the failure mode is not a radio that silently never powers up. This also corrects duty-cycle metering. It builds its air profiles through _merge_radio_entry, so a radios[] entry that set only its frequency was metered on library defaults -- 62.5 kHz / SF8 / CR8 -- rather than on the node's own top-level radio block, and reported no tx_power at all. _inherited_air_profile's docstring already described the overlay semantics this commit implements. Inheritance reaches the MQTT radio map the same way, and there it needed a guard. The map is rebuilt from config on every status publish, so once a partial entry inherits the top-level radio block, saving the default radio's bandwidth moved the reported bandwidth of every radio that omits one -- while only the default radio is retuned, the rest keeping their band until a restart. Observers join that map against rx_radio_id to decide which band carried a packet, so it would have attributed those radios' packets to a band they were never on. build_radio_status_entries now takes the map built when the radios were last really configured and keeps every non-default entry from it, which is the line AirtimeBudgets._effective_air already holds for metering; MeshCoreToMqttPusher seeds it on its first build. Log each resolved radio config at debug level, redacting modem_tcp.token via redact_modem_tokens_in_place, since what an entry inherited is otherwise invisible and a wrong answer surfaces only as a radio that will not transmit. Add regression coverage for TCXO/RF-switch inheritance, explicit false overrides, partial radio air settings, CH341 device selection, per-radio radio_type overrides, alias displacement, metering inheritance, token redaction, and a live default-radio save leaving other radios' published air settings alone. Fixes #485
This commit is contained in:
1 parent
7161ba4724
commit
6a7a4ba117
5 files changed
+744
-33
No files matched your search
+38
-4
@@ -417,9 +417,21 @@ identities:
|
||||
# Multi-radio / LoRa backhaul (optional)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Leave ``radios:`` unset to keep the legacy single-radio config below.
|
||||
# When present, each entry is built with the same fields as the top-level
|
||||
# single-radio config (radio_type + radio air settings + hardware section).
|
||||
# Different entries may use different frequency / SF / modem type.
|
||||
# When present, each entry defines one physical radio.
|
||||
#
|
||||
# Top-level radio/hardware sections act as defaults for each entry.
|
||||
# Mapping sections under radios[] overlay those defaults key-by-key:
|
||||
# omitted values are inherited, while values explicitly present in an
|
||||
# entry override the top-level setting.
|
||||
#
|
||||
# This allows board-specific settings such as use_dio3_tcxo,
|
||||
# use_dio2_rf, dio3_tcxo_voltage, en_pin/en_pins, GPIO settings, etc.
|
||||
# to remain in the top-level hardware configuration unless a particular
|
||||
# radio needs a different value.
|
||||
#
|
||||
# radio_type may also be overridden per entry, so different transport
|
||||
# types can be mixed in the same fabric. Different entries may use
|
||||
# different frequency / SF / modem type.
|
||||
#
|
||||
# fabric:
|
||||
# default_radio: local # TX default (first entry if omitted)
|
||||
@@ -515,7 +527,29 @@ identities:
|
||||
# txen_pin: -1
|
||||
# rxen_pin: -1
|
||||
#
|
||||
# Radio hardware type (legacy single-radio; ignored for construction when radios: is set)
|
||||
# Entries only need the values that differ. The sx1262: section further down
|
||||
# holds board-wide properties such as TCXO / RF-switch control and enable
|
||||
# pins; those are inherited by every entry that does not override them:
|
||||
#
|
||||
# radios:
|
||||
# - id: local
|
||||
# radio:
|
||||
# frequency: 910525000
|
||||
# sx1262:
|
||||
# cs_id: 0
|
||||
#
|
||||
# - id: link
|
||||
# radio:
|
||||
# frequency: 914500000
|
||||
# sx1262:
|
||||
# cs_id: 1
|
||||
# reset_pin: 27
|
||||
# irq_pin: 18
|
||||
#
|
||||
# Default radio hardware type.
|
||||
# In legacy single-radio mode this selects the physical radio directly.
|
||||
# With radios: configured, entries inherit this value unless they specify
|
||||
# their own radio_type.
|
||||
# Supported:
|
||||
# - sx1262 (Linux spidev + system GPIO)
|
||||
# - sx1262_ch341 (CH341 USB-to-SPI + CH341 GPIO 0-7)
|
||||
|
||||
+112
-13
@@ -1,4 +1,5 @@
|
||||
import base64
|
||||
import copy
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
@@ -9,7 +10,11 @@ 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.modem_config import (
|
||||
LEGACY_MODEM_RADIO_TYPES,
|
||||
normalize_modem_config,
|
||||
redact_modem_tokens_in_place,
|
||||
)
|
||||
from repeater.policy_engine import default_policy_engine_config
|
||||
|
||||
logger = logging.getLogger("Config")
|
||||
@@ -762,11 +767,28 @@ def get_radio_for_board(board_config: dict):
|
||||
)
|
||||
|
||||
|
||||
# Keys within one section that are alternative spellings of a single setting.
|
||||
# Whole-section replacement kept these from meeting; key-by-key overlay lets an
|
||||
# inherited spelling sit next to the entry's, and downstream each pair has a
|
||||
# fixed winner -- en_pins beats en_pin in SX1262Radio, "address" beats
|
||||
# "device_address" and "serial_number" beats "serial" in get_radio_for_board.
|
||||
# So an entry naming one spelling drops the other rather than losing to a
|
||||
# default it never wrote, which would otherwise fail silently: a radio that
|
||||
# never powers up, or one opened on the wrong CH341 adapter.
|
||||
_RADIO_SECTION_ALIASES = {
|
||||
"sx1262": (("en_pin", "en_pins"),),
|
||||
"ch341": (("address", "device_address"), ("serial_number", "serial")),
|
||||
}
|
||||
|
||||
|
||||
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.
|
||||
Top-level radio and hardware sections act as defaults for multi-radio
|
||||
entries. Mapping sections are overlaid key-by-key, so an entry only needs
|
||||
to specify values that differ from the top-level configuration. Explicit
|
||||
values, including False/None, override inherited values. ``radio_type``
|
||||
is a scalar override.
|
||||
"""
|
||||
if not isinstance(entry, dict):
|
||||
raise ValueError("each radios[] entry must be a mapping")
|
||||
@@ -776,8 +798,15 @@ def _merge_radio_entry(global_config: dict, entry: dict) -> dict:
|
||||
raise ValueError("each radios[] entry requires an 'id'")
|
||||
|
||||
merged = dict(global_config)
|
||||
|
||||
# radio_type is scalar: a per-radio value replaces the global value.
|
||||
if "radio_type" in entry:
|
||||
merged["radio_type"] = entry["radio_type"]
|
||||
|
||||
# Configuration sections use shallow overlay semantics. This is
|
||||
# intentionally one level deep: the known sections currently contain
|
||||
# scalar values and simple lists rather than nested configuration trees.
|
||||
for key in (
|
||||
"radio_type",
|
||||
"radio",
|
||||
"sx1262",
|
||||
"ch341",
|
||||
@@ -785,8 +814,21 @@ def _merge_radio_entry(global_config: dict, entry: dict) -> dict:
|
||||
"modem_tcp",
|
||||
"modem_usb",
|
||||
):
|
||||
if key in entry:
|
||||
merged[key] = entry[key]
|
||||
if key not in entry:
|
||||
continue
|
||||
|
||||
base = global_config.get(key)
|
||||
override = entry[key]
|
||||
|
||||
if isinstance(base, dict) and isinstance(override, dict):
|
||||
inherited = dict(base)
|
||||
for group in _RADIO_SECTION_ALIASES.get(key, ()):
|
||||
if any(alias in override for alias in group):
|
||||
for alias in group:
|
||||
inherited.pop(alias, None)
|
||||
merged[key] = {**inherited, **override}
|
||||
else:
|
||||
merged[key] = override
|
||||
|
||||
if "radio_type" not in entry and "radio_type" not in global_config:
|
||||
raise ValueError(f"radios[] entry {radio_id!r} missing radio_type")
|
||||
@@ -1269,7 +1311,22 @@ def build_metering_profiles(config: dict) -> list:
|
||||
return profiles
|
||||
|
||||
|
||||
def build_radio_status_entries(config: dict) -> list:
|
||||
def _default_radio_id(config: dict, radio_ids: list) -> Optional[str]:
|
||||
"""The radio a live config save actually retunes.
|
||||
|
||||
Mirrors build_radio_stack's TX default -- ``fabric.default_radio`` when it
|
||||
names a radio that exists, else the first configured one. A default naming
|
||||
a radio that is not there cannot be the one that got retuned, so it falls
|
||||
back rather than freezing every entry.
|
||||
"""
|
||||
fabric_cfg = config.get("fabric") if isinstance(config.get("fabric"), dict) else {}
|
||||
default_radio = fabric_cfg.get("default_radio") or fabric_cfg.get("default_radio_id")
|
||||
if default_radio and str(default_radio) in radio_ids:
|
||||
return str(default_radio)
|
||||
return radio_ids[0] if radio_ids else None
|
||||
|
||||
|
||||
def build_radio_status_entries(config: dict, *, applied: Optional[dict] = None) -> list:
|
||||
"""Map every radio id to its air settings, for the MQTT status message.
|
||||
|
||||
Observers join this against the ``rx_radio_id`` / ``tx_radio_ids`` fields
|
||||
@@ -1285,12 +1342,24 @@ def build_radio_status_entries(config: dict) -> list:
|
||||
the air settings it never voids the map: power does not decide which band a
|
||||
packet was on, so an entry without it still attributes the packet correctly.
|
||||
|
||||
Every value here is what the node is **configured** with, not a reading from
|
||||
the hardware. Two consequences worth knowing before trusting a figure: a
|
||||
driver may clamp what it was given (an SX1262 holds -9..22 dBm), and editing
|
||||
a ``radios[]`` entry does not reconfigure that radio live -- ``config_manager``
|
||||
only applies the top-level ``radio`` section -- so after such an edit the map
|
||||
leads the hardware until the service restarts.
|
||||
These are the settings each radio was actually configured with, not a
|
||||
reading from the hardware -- a driver may still clamp what it was given (an
|
||||
SX1262 holds -9..22 dBm).
|
||||
|
||||
``applied`` maps radio id to the entry built when that radio was last really
|
||||
configured, and makes the map survive a live save. Only the default radio is
|
||||
retuned without a restart: a change to any other sits in ``radios[]`` marked
|
||||
restart-required. Because ``radios[]`` entries inherit the top-level
|
||||
``radio`` block key by key, a save that edits the default radio also moves
|
||||
the *configured* values of every radio whose entry omits that key, so
|
||||
rebuilding all of them from config would publish a bandwidth the hardware
|
||||
was never given and mis-attribute that radio's packets to a band it is not
|
||||
on. Entries other than the default therefore keep their applied values until
|
||||
the service restarts. ``AirtimeBudgets._effective_air`` holds the same line
|
||||
for duty-cycle metering.
|
||||
|
||||
Passing no ``applied`` map rebuilds every entry from config, which is what a
|
||||
fresh start wants.
|
||||
|
||||
Returns an empty list when any radio's settings are unreadable: a partial
|
||||
map would silently attribute a packet to the wrong band, which is worse
|
||||
@@ -1316,9 +1385,37 @@ def build_radio_status_entries(config: dict) -> list:
|
||||
if tx_power is not None:
|
||||
entry["tx_power"] = tx_power
|
||||
entries.append(entry)
|
||||
|
||||
if applied:
|
||||
default_id = _default_radio_id(config, [entry["id"] for entry in entries])
|
||||
entries = [
|
||||
entry if entry["id"] == default_id else applied.get(entry["id"], entry)
|
||||
for entry in entries
|
||||
]
|
||||
return entries
|
||||
|
||||
|
||||
_RADIO_LOG_SECTIONS = ("radio", "sx1262", "ch341", "kiss", "modem_tcp", "modem_usb")
|
||||
|
||||
|
||||
def _describe_radio_config(board_config: dict) -> str:
|
||||
"""Render a resolved board_config for debug logging, tokens redacted.
|
||||
|
||||
What a radio inherited is otherwise invisible: the config file shows the
|
||||
entry, not the merge, and a wrong answer surfaces as a radio that does not
|
||||
transmit. ``modem_tcp.token`` is the one credential these sections carry,
|
||||
and ``redact_modem_tokens_in_place`` runs over a copy so the live config is
|
||||
untouched.
|
||||
"""
|
||||
snapshot = {
|
||||
key: copy.deepcopy(board_config[key]) for key in _RADIO_LOG_SECTIONS if key in board_config
|
||||
}
|
||||
redact_modem_tokens_in_place(snapshot, replacement="***")
|
||||
parts = [f"type={board_config.get('radio_type')!r}"]
|
||||
parts.extend(f"{key}={snapshot[key]!r}" for key in _RADIO_LOG_SECTIONS if key in snapshot)
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def build_radio_stack(config: dict):
|
||||
"""Build single- or multi-radio stack for the repeater.
|
||||
|
||||
@@ -1367,6 +1464,8 @@ def build_radio_stack(config: dict):
|
||||
for entry in radios_cfg:
|
||||
merged = _merge_radio_entry(config, entry)
|
||||
rid = merged.pop("_radio_id")
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("Resolved radio %r: %s", rid, _describe_radio_config(merged))
|
||||
physical = get_radio_for_board(merged)
|
||||
pairs.append((physical, rid))
|
||||
profile = _radio_air_profile(merged, rid)
|
||||
|
||||
@@ -669,6 +669,10 @@ class MeshCoreToMqttPusher:
|
||||
self._shutdown_requested = False
|
||||
self._lock = threading.Lock()
|
||||
self._connect_timers: List[threading.Timer] = []
|
||||
# The radio map as built when the radios were last really configured.
|
||||
# Seeded on the first build, which is this process's start -- see
|
||||
# _radio_map for why a later rebuild must not replace it wholesale.
|
||||
self._applied_radio_map: Optional[dict] = None
|
||||
|
||||
# Initialize brokers list
|
||||
mqtt_brokers_config = config.get("mqtt_brokers", {})
|
||||
@@ -1048,15 +1052,24 @@ class MeshCoreToMqttPusher:
|
||||
when there is genuinely more than one band to tell apart.
|
||||
|
||||
Rebuilt per status publish rather than cached at construction, because
|
||||
air settings can be changed from the web UI while the node runs.
|
||||
air settings can be changed from the web UI while the node runs. Only
|
||||
the default radio is retuned by such a save, though, and radios[] entries
|
||||
inherit the top-level ``radio`` block key by key -- so a rebuild that
|
||||
took every entry from the current config would report a new bandwidth
|
||||
for radios still transmitting on the old one, and observers would
|
||||
attribute their packets to a band they are not on. The first build is
|
||||
kept and handed back in as ``applied`` so every non-default radio keeps
|
||||
what it was really configured with until the service restarts.
|
||||
"""
|
||||
from ..config import build_radio_status_entries
|
||||
|
||||
try:
|
||||
entries = build_radio_status_entries(self.config)
|
||||
entries = build_radio_status_entries(self.config, applied=self._applied_radio_map)
|
||||
except Exception as exc: # pragma: no cover - reporting must not break status
|
||||
logger.debug(f"Could not build radio map for status: {exc}")
|
||||
return []
|
||||
if self._applied_radio_map is None and entries:
|
||||
self._applied_radio_map = {entry["id"]: entry for entry in entries}
|
||||
return entries if len(entries) > 1 else []
|
||||
|
||||
def publish(self, subtopic: str, payload: dict, retain: bool = False, qos: int = 0):
|
||||
|
||||
+468
-14
@@ -2,14 +2,18 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
import sys
|
||||
import types
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from repeater.config import (
|
||||
NullRadio,
|
||||
_apply_fabric_tx_mode,
|
||||
_describe_radio_config,
|
||||
_merge_radio_entry,
|
||||
build_metering_profiles,
|
||||
build_radio_stack,
|
||||
fabric_selects_by_ingress_radio,
|
||||
)
|
||||
@@ -67,26 +71,171 @@ class _FakeRadio:
|
||||
return 5.0
|
||||
|
||||
|
||||
def test_merge_radio_entry_inherits_and_overrides():
|
||||
def test_merge_radio_entry_overlays_radio_sections():
|
||||
"""A partial entry section inherits the top-level keys it omits."""
|
||||
global_cfg = {
|
||||
"radio_type": "sx1262",
|
||||
"radio": {"frequency": 1, "tx_power": 10},
|
||||
"sx1262": {"bus_id": 0},
|
||||
"radio": {
|
||||
"frequency": 869618000,
|
||||
"tx_power": 10,
|
||||
"bandwidth": 62500,
|
||||
"spreading_factor": 8,
|
||||
"coding_rate": 8,
|
||||
},
|
||||
"sx1262": {
|
||||
"bus_id": 0,
|
||||
"cs_id": 0,
|
||||
"use_dio3_tcxo": True,
|
||||
"dio3_tcxo_voltage": 1.8,
|
||||
"use_dio2_rf": True,
|
||||
"en_pins": [12, 13],
|
||||
},
|
||||
}
|
||||
entry = {
|
||||
"id": "local",
|
||||
"radio": {
|
||||
"frequency": 910525000,
|
||||
"tx_power": 22,
|
||||
},
|
||||
"sx1262": {
|
||||
"cs_id": 1,
|
||||
"reset_pin": 24,
|
||||
},
|
||||
}
|
||||
|
||||
merged = _merge_radio_entry(global_cfg, entry)
|
||||
|
||||
assert merged["_radio_id"] == "local"
|
||||
assert merged["radio_type"] == "sx1262"
|
||||
|
||||
assert merged["radio"]["frequency"] == 910525000
|
||||
assert merged["radio"]["tx_power"] == 22
|
||||
assert merged["radio"]["bandwidth"] == 62500
|
||||
assert merged["radio"]["spreading_factor"] == 8
|
||||
assert merged["radio"]["coding_rate"] == 8
|
||||
|
||||
assert merged["sx1262"]["cs_id"] == 1
|
||||
assert merged["sx1262"]["reset_pin"] == 24
|
||||
assert merged["sx1262"]["bus_id"] == 0
|
||||
assert merged["sx1262"]["use_dio3_tcxo"] is True
|
||||
assert merged["sx1262"]["dio3_tcxo_voltage"] == 1.8
|
||||
assert merged["sx1262"]["use_dio2_rf"] is True
|
||||
assert merged["sx1262"]["en_pins"] == [12, 13]
|
||||
|
||||
|
||||
def test_merge_radio_entry_inherits_sx1262_tcxo_and_power_settings():
|
||||
"""RAK6421/RAK1330x regression: pin-only entry keeps TCXO/RF-switch setup."""
|
||||
global_cfg = {
|
||||
"radio_type": "sx1262",
|
||||
"radio": {
|
||||
"frequency": 910525000,
|
||||
"tx_power": 22,
|
||||
},
|
||||
"sx1262": {
|
||||
"bus_id": 0,
|
||||
"cs_id": 0,
|
||||
"cs_pin": -1,
|
||||
"reset_pin": 16,
|
||||
"busy_pin": 24,
|
||||
"irq_pin": 22,
|
||||
"txen_pin": -1,
|
||||
"rxen_pin": -1,
|
||||
"en_pins": [12, 13],
|
||||
"use_dio2_rf": True,
|
||||
"use_dio3_tcxo": True,
|
||||
"dio3_tcxo_voltage": 1.8,
|
||||
},
|
||||
}
|
||||
entry = {
|
||||
"id": "local",
|
||||
"sx1262": {
|
||||
"bus_id": 0,
|
||||
"cs_id": 0,
|
||||
"cs_pin": -1,
|
||||
"reset_pin": 16,
|
||||
"busy_pin": 24,
|
||||
"irq_pin": 22,
|
||||
"txen_pin": -1,
|
||||
"rxen_pin": -1,
|
||||
},
|
||||
}
|
||||
|
||||
merged = _merge_radio_entry(global_cfg, entry)
|
||||
|
||||
assert merged["sx1262"]["use_dio3_tcxo"] is True
|
||||
assert merged["sx1262"]["dio3_tcxo_voltage"] == 1.8
|
||||
assert merged["sx1262"]["use_dio2_rf"] is True
|
||||
assert merged["sx1262"]["en_pins"] == [12, 13]
|
||||
|
||||
|
||||
def test_merge_radio_entry_explicit_false_overrides_inherited_true():
|
||||
"""Presence, not truthiness, decides whether an entry value overrides."""
|
||||
global_cfg = {
|
||||
"radio_type": "sx1262",
|
||||
"sx1262": {
|
||||
"use_dio3_tcxo": True,
|
||||
"use_dio2_rf": True,
|
||||
"is_waveshare": True,
|
||||
},
|
||||
}
|
||||
entry = {
|
||||
"id": "link",
|
||||
"radio_type": "modem_usb",
|
||||
"radio": {"frequency": 2, "tx_power": 22},
|
||||
"modem_usb": {"port": "/dev/ttyACM0"},
|
||||
"sx1262": {
|
||||
"use_dio3_tcxo": False,
|
||||
"use_dio2_rf": False,
|
||||
"is_waveshare": False,
|
||||
},
|
||||
}
|
||||
|
||||
merged = _merge_radio_entry(global_cfg, entry)
|
||||
assert merged["_radio_id"] == "link"
|
||||
|
||||
assert merged["sx1262"]["use_dio3_tcxo"] is False
|
||||
assert merged["sx1262"]["use_dio2_rf"] is False
|
||||
assert merged["sx1262"]["is_waveshare"] is False
|
||||
|
||||
|
||||
def test_merge_radio_entry_can_override_radio_type():
|
||||
global_cfg = {
|
||||
"radio_type": "sx1262",
|
||||
"radio": {
|
||||
"frequency": 910525000,
|
||||
"bandwidth": 62500,
|
||||
},
|
||||
"sx1262": {
|
||||
"bus_id": 0,
|
||||
},
|
||||
}
|
||||
entry = {
|
||||
"id": "backhaul",
|
||||
"radio_type": "modem_usb",
|
||||
"modem_usb": {
|
||||
"port": "/dev/ttyACM0",
|
||||
},
|
||||
}
|
||||
|
||||
merged = _merge_radio_entry(global_cfg, entry)
|
||||
|
||||
assert merged["radio_type"] == "modem_usb"
|
||||
assert merged["radio"]["frequency"] == 2
|
||||
assert merged["modem_usb"]["port"] == "/dev/ttyACM0"
|
||||
assert merged["radio"]["frequency"] == 910525000
|
||||
assert "sx1262" in merged # inherited leftover ok; factory uses radio_type
|
||||
|
||||
|
||||
def test_merge_radio_entry_non_mapping_section_replaces_base():
|
||||
global_cfg = {
|
||||
"radio_type": "sx1262",
|
||||
"sx1262": {"use_dio3_tcxo": True},
|
||||
}
|
||||
entry = {
|
||||
"id": "local",
|
||||
"sx1262": None,
|
||||
}
|
||||
|
||||
merged = _merge_radio_entry(global_cfg, entry)
|
||||
|
||||
assert merged["sx1262"] is None
|
||||
|
||||
|
||||
def test_build_radio_stack_legacy_single():
|
||||
cfg = {"radio_type": "none"}
|
||||
radio, meta = build_radio_stack(cfg)
|
||||
@@ -373,20 +522,38 @@ def test_origin_tx_conflicting_with_former_name_rejected():
|
||||
factory.assert_not_called()
|
||||
|
||||
|
||||
def test_merge_radio_entry_preserves_per_radio_ch341():
|
||||
def test_merge_radio_entry_overlays_per_radio_ch341_selection():
|
||||
"""Shared adapter parameters stay global; only device identity differs."""
|
||||
global_cfg = {
|
||||
"radio_type": "sx1262_ch341",
|
||||
"ch341": {"vid": 0x1A86, "pid": 0x5512, "bus": 1, "address": 5},
|
||||
"radio": {"frequency": 1},
|
||||
"ch341": {
|
||||
"vid": 0x1A86,
|
||||
"pid": 0x5512,
|
||||
"bus": 1,
|
||||
"address": 5,
|
||||
},
|
||||
"radio": {"frequency": 869618000},
|
||||
"sx1262": {"bus_id": 0},
|
||||
}
|
||||
entry = {
|
||||
"id": "link",
|
||||
"ch341": {"vid": 0x1A86, "pid": 0x5512, "bus": 1, "address": 8},
|
||||
"radio": {"frequency": 2},
|
||||
"ch341": {
|
||||
"address": 8,
|
||||
},
|
||||
"radio": {
|
||||
"frequency": 864200000,
|
||||
},
|
||||
}
|
||||
|
||||
merged = _merge_radio_entry(global_cfg, entry)
|
||||
|
||||
assert merged["ch341"]["vid"] == 0x1A86
|
||||
assert merged["ch341"]["pid"] == 0x5512
|
||||
assert merged["ch341"]["bus"] == 1
|
||||
assert merged["ch341"]["address"] == 8
|
||||
|
||||
assert merged["radio"]["frequency"] == 864200000
|
||||
|
||||
assert merged["_ch341_per_instance"] is True
|
||||
|
||||
|
||||
@@ -452,3 +619,290 @@ def test_a_current_core_is_given_the_selector_that_routes_by_ingress_radio():
|
||||
assert fabric.resolve_tx_radio_id(b"frame", rx_radio_id="link") == "local"
|
||||
# Originated here, so there is no side of the bridge to come from.
|
||||
assert fabric.resolve_tx_radio_id(b"frame") == "local"
|
||||
|
||||
|
||||
def test_build_radio_stack_multi_inherits_top_level_hardware_defaults():
|
||||
"""End-to-end: a partial radios[] entry reaches the radio factory complete."""
|
||||
seen = []
|
||||
|
||||
def fake_get(board):
|
||||
seen.append(dict(board))
|
||||
return _FakeRadio(str(len(seen)))
|
||||
|
||||
cfg = {
|
||||
"radio_type": "sx1262",
|
||||
"sx1262": {
|
||||
"bus_id": 0,
|
||||
"cs_id": 0,
|
||||
"cs_pin": -1,
|
||||
"reset_pin": 16,
|
||||
"busy_pin": 24,
|
||||
"irq_pin": 22,
|
||||
"txen_pin": -1,
|
||||
"rxen_pin": -1,
|
||||
"en_pins": [12, 13],
|
||||
"use_dio3_tcxo": True,
|
||||
"dio3_tcxo_voltage": 1.8,
|
||||
"use_dio2_rf": True,
|
||||
},
|
||||
"radios": [
|
||||
{
|
||||
"id": "local",
|
||||
"radio": {"frequency": 910525000},
|
||||
"sx1262": {"cs_id": 0, "reset_pin": 16, "busy_pin": 24, "irq_pin": 22},
|
||||
},
|
||||
{
|
||||
"id": "remote",
|
||||
"radio_type": "modem_tcp",
|
||||
"modem_tcp": {"host": "remote-radio.local"},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
with patch("repeater.config.get_radio_for_board", side_effect=fake_get):
|
||||
_radio, meta = build_radio_stack(cfg)
|
||||
|
||||
assert meta["radio_ids"] == ["local", "remote"]
|
||||
|
||||
local, remote = seen
|
||||
assert local["radio_type"] == "sx1262"
|
||||
assert local["sx1262"]["en_pins"] == [12, 13]
|
||||
assert local["sx1262"]["use_dio3_tcxo"] is True
|
||||
assert local["sx1262"]["dio3_tcxo_voltage"] == 1.8
|
||||
assert local["sx1262"]["use_dio2_rf"] is True
|
||||
assert local["radio"]["frequency"] == 910525000
|
||||
|
||||
assert remote["radio_type"] == "modem_tcp"
|
||||
assert remote["modem_tcp"]["host"] == "remote-radio.local"
|
||||
|
||||
|
||||
def test_merge_radio_entry_does_not_mutate_global_config():
|
||||
global_cfg = {
|
||||
"radio_type": "sx1262",
|
||||
"sx1262": {"bus_id": 0, "use_dio3_tcxo": True},
|
||||
}
|
||||
entry = {"id": "link", "sx1262": {"cs_id": 1}}
|
||||
|
||||
_merge_radio_entry(global_cfg, entry)
|
||||
|
||||
assert global_cfg["sx1262"] == {"bus_id": 0, "use_dio3_tcxo": True}
|
||||
assert entry["sx1262"] == {"cs_id": 1}
|
||||
|
||||
|
||||
def test_merge_radio_entry_entry_en_pin_displaces_inherited_en_pins():
|
||||
"""en_pins beats en_pin in SX1262Radio, so an inherited en_pins must not
|
||||
shadow the single pin this entry asked for."""
|
||||
global_cfg = {
|
||||
"radio_type": "sx1262",
|
||||
"sx1262": {"bus_id": 0, "en_pins": [12, 13]},
|
||||
}
|
||||
entry = {"id": "link", "sx1262": {"en_pin": 26}}
|
||||
|
||||
merged = _merge_radio_entry(global_cfg, entry)
|
||||
|
||||
assert merged["sx1262"]["en_pin"] == 26
|
||||
assert "en_pins" not in merged["sx1262"]
|
||||
assert merged["sx1262"]["bus_id"] == 0 # unrelated keys still inherited
|
||||
|
||||
|
||||
def test_merge_radio_entry_entry_en_pins_displaces_inherited_en_pin():
|
||||
global_cfg = {
|
||||
"radio_type": "sx1262",
|
||||
"sx1262": {"bus_id": 0, "en_pin": 26},
|
||||
}
|
||||
entry = {"id": "link", "sx1262": {"en_pins": [12, 13]}}
|
||||
|
||||
merged = _merge_radio_entry(global_cfg, entry)
|
||||
|
||||
assert merged["sx1262"]["en_pins"] == [12, 13]
|
||||
assert "en_pin" not in merged["sx1262"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"inherited_key, entry_key",
|
||||
[
|
||||
("address", "device_address"),
|
||||
("device_address", "address"),
|
||||
],
|
||||
)
|
||||
def test_merge_radio_entry_ch341_address_spelling_displaces_the_other(inherited_key, entry_key):
|
||||
"""get_radio_for_board reads address before device_address, so whichever
|
||||
spelling the entry uses has to displace the inherited one -- otherwise an
|
||||
inherited address silently selects the wrong USB adapter."""
|
||||
global_cfg = {
|
||||
"radio_type": "sx1262_ch341",
|
||||
"ch341": {"vid": 0x1A86, "pid": 0x5512, "bus": 1, inherited_key: 5},
|
||||
}
|
||||
entry = {"id": "link", "ch341": {entry_key: 8}}
|
||||
|
||||
merged = _merge_radio_entry(global_cfg, entry)
|
||||
|
||||
assert merged["ch341"][entry_key] == 8
|
||||
assert inherited_key not in merged["ch341"]
|
||||
assert merged["ch341"]["bus"] == 1 # unrelated keys still inherited
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"inherited_key, entry_key",
|
||||
[
|
||||
("serial_number", "serial"),
|
||||
("serial", "serial_number"),
|
||||
],
|
||||
)
|
||||
def test_merge_radio_entry_ch341_serial_spelling_displaces_the_other(inherited_key, entry_key):
|
||||
"""A truthy serial_number wins over serial downstream, so an inherited one
|
||||
would outrank the entry's choice whichever name the entry used."""
|
||||
global_cfg = {
|
||||
"radio_type": "sx1262_ch341",
|
||||
"ch341": {"vid": 0x1A86, inherited_key: "AAA111"},
|
||||
}
|
||||
entry = {"id": "link", "ch341": {entry_key: "BBB222"}}
|
||||
|
||||
merged = _merge_radio_entry(global_cfg, entry)
|
||||
|
||||
assert merged["ch341"][entry_key] == "BBB222"
|
||||
assert inherited_key not in merged["ch341"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cleared", [0, None, ""])
|
||||
def test_merge_radio_entry_ch341_falsy_entry_value_still_displaces(cleared):
|
||||
"""Displacement is presence-based. An entry clearing its adapter selector
|
||||
must not have the inherited one reinstated under the other spelling."""
|
||||
global_cfg = {
|
||||
"radio_type": "sx1262_ch341",
|
||||
"ch341": {"vid": 0x1A86, "address": 5, "serial_number": "AAA111"},
|
||||
}
|
||||
entry = {"id": "link", "ch341": {"device_address": cleared}}
|
||||
|
||||
merged = _merge_radio_entry(global_cfg, entry)
|
||||
|
||||
assert merged["ch341"]["device_address"] == cleared
|
||||
assert "address" not in merged["ch341"]
|
||||
# A different alias group is untouched by this entry, so it still inherits.
|
||||
assert merged["ch341"]["serial_number"] == "AAA111"
|
||||
|
||||
|
||||
def test_merge_radio_entry_ch341_entry_naming_both_spellings_is_left_alone():
|
||||
"""Both spellings in one entry is a pre-existing accepted configuration.
|
||||
The merge must not rewrite or reject it -- get_radio_for_board's own
|
||||
precedence (address before device_address) still decides."""
|
||||
global_cfg = {
|
||||
"radio_type": "sx1262_ch341",
|
||||
"ch341": {"vid": 0x1A86, "address": 5, "device_address": 6},
|
||||
}
|
||||
entry = {"id": "link", "ch341": {"address": 8, "device_address": 9}}
|
||||
|
||||
merged = _merge_radio_entry(global_cfg, entry)
|
||||
|
||||
assert merged["ch341"]["address"] == 8
|
||||
assert merged["ch341"]["device_address"] == 9
|
||||
assert merged["ch341"]["vid"] == 0x1A86
|
||||
|
||||
|
||||
def test_merge_radio_entry_keeps_inherited_alias_when_entry_names_neither():
|
||||
"""The alias guard only fires when the entry actually picks a spelling."""
|
||||
global_cfg = {
|
||||
"radio_type": "sx1262",
|
||||
"sx1262": {"en_pins": [12, 13], "bus_id": 0},
|
||||
}
|
||||
entry = {"id": "link", "sx1262": {"cs_id": 1}}
|
||||
|
||||
merged = _merge_radio_entry(global_cfg, entry)
|
||||
|
||||
assert merged["sx1262"]["en_pins"] == [12, 13]
|
||||
|
||||
|
||||
def test_metering_profile_inherits_top_level_air_settings():
|
||||
"""A partial radios[] air block used to meter on library defaults rather
|
||||
than on the node's own top-level radio settings."""
|
||||
cfg = {
|
||||
"radio_type": "sx1262",
|
||||
"radio": {
|
||||
"frequency": 906875000,
|
||||
"bandwidth": 250000,
|
||||
"spreading_factor": 10,
|
||||
"coding_rate": 5,
|
||||
"preamble_length": 16,
|
||||
"tx_power": 22,
|
||||
},
|
||||
"sx1262": {"bus_id": 0},
|
||||
"radios": [{"id": "local", "radio": {"frequency": 910525000}}],
|
||||
}
|
||||
|
||||
(profile,) = build_metering_profiles(cfg)
|
||||
|
||||
assert profile["radio_id"] == "local"
|
||||
assert profile["frequency_hz"] == 910525000
|
||||
assert profile["bandwidth_hz"] == 250000
|
||||
assert profile["spreading_factor"] == 10
|
||||
assert profile["coding_rate"] == 5
|
||||
assert profile["tx_power"] == 22
|
||||
|
||||
|
||||
def test_describe_radio_config_redacts_modem_token():
|
||||
board = {
|
||||
"radio_type": "modem_tcp",
|
||||
"radio": {"frequency": 910525000},
|
||||
"modem_tcp": {"host": "remote.local", "token": "s3cret-value"},
|
||||
}
|
||||
|
||||
described = _describe_radio_config(board)
|
||||
|
||||
assert "s3cret-value" not in described
|
||||
assert "***" in described
|
||||
assert "remote.local" in described
|
||||
assert "type='modem_tcp'" in described
|
||||
# The live config keeps its token; only the log copy is redacted.
|
||||
assert board["modem_tcp"]["token"] == "s3cret-value"
|
||||
|
||||
|
||||
def test_ch341_alias_displacement_reaches_the_usb_transport():
|
||||
"""The merge-level tests above pin the dict; this pins the consequence.
|
||||
|
||||
get_radio_for_board selects the adapter with
|
||||
``ch341.get("address", ch341.get("device_address"))``, so without
|
||||
displacement an inherited ``address`` would open a different physical
|
||||
adapter than the entry asked for, on a node with two CH341 sticks.
|
||||
"""
|
||||
ch341_module = types.ModuleType("openhop_core.hardware.transports.ch341_spi_transport")
|
||||
ch341_module.CH341SPITransport = MagicMock(name="CH341SPITransport")
|
||||
|
||||
config = {
|
||||
"radio_type": "sx1262_ch341",
|
||||
"ch341": {"vid": 0x1A86, "pid": 0x5512, "bus": 1, "address": 5},
|
||||
"radio": {
|
||||
"frequency": 869618000,
|
||||
"tx_power": 14,
|
||||
"bandwidth": 62500,
|
||||
"spreading_factor": 8,
|
||||
"coding_rate": 8,
|
||||
"preamble_length": 32,
|
||||
},
|
||||
"sx1262": {
|
||||
"bus_id": 0,
|
||||
"cs_id": 0,
|
||||
"cs_pin": 0,
|
||||
"reset_pin": 1,
|
||||
"busy_pin": 2,
|
||||
"irq_pin": 3,
|
||||
"txen_pin": -1,
|
||||
"rxen_pin": -1,
|
||||
},
|
||||
"radios": [{"id": "link", "ch341": {"device_address": 8}}],
|
||||
}
|
||||
|
||||
with patch.dict(
|
||||
sys.modules,
|
||||
{"openhop_core.hardware.transports.ch341_spi_transport": ch341_module},
|
||||
):
|
||||
with patch("openhop_core.hardware.sx1262_wrapper.SX1262Radio"):
|
||||
with patch("openhop_core.hardware.lora.LoRaRF.SX126x.set_spi_transport") as set_spi:
|
||||
build_radio_stack(config)
|
||||
|
||||
(call,) = ch341_module.CH341SPITransport.call_args_list
|
||||
assert call.kwargs["address"] == 8 # the entry's adapter, not the inherited 5
|
||||
assert call.kwargs["bus"] == 1 # still inherited
|
||||
assert call.kwargs["vid"] == 0x1A86
|
||||
# Multi-radio must not install the process-global SPI transport.
|
||||
set_spi.assert_not_called()
|
||||
assert call.kwargs["set_as_global_gpio"] is False
|
||||
@@ -11,6 +11,7 @@ Single-radio nodes must publish exactly what they published before any of this
|
||||
existed.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
from unittest.mock import MagicMock, patch
|
||||
@@ -454,3 +455,113 @@ def test_collector_omits_radio_ids_on_a_single_radio_node(tmp_path):
|
||||
|
||||
assert "rx_radio_id" not in payload
|
||||
assert "tx_radio_ids" not in payload
|
||||
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
# A live save must not move a radio that was not retuned
|
||||
# --------------------------------------------------------------------
|
||||
def test_applied_map_holds_non_default_radios_across_a_live_edit():
|
||||
"""Entries inherit the top-level radio block key by key, so editing the
|
||||
default radio changes what a partial entry is *configured* with. Only the
|
||||
default radio is actually retuned, so the rest must keep reporting the band
|
||||
they are still transmitting on."""
|
||||
config = {
|
||||
"radio_type": "sx1262",
|
||||
"radio": {
|
||||
"frequency": 869618000,
|
||||
"bandwidth": 62500,
|
||||
"spreading_factor": 8,
|
||||
"coding_rate": 8,
|
||||
"preamble_length": 32,
|
||||
},
|
||||
"fabric": {"default_radio": "local"},
|
||||
"radios": [
|
||||
{"id": "local", "radio": {"frequency": 869618000}},
|
||||
# Omits bandwidth/SF/CR, so it inherits them.
|
||||
{"id": "link", "radio": {"frequency": 864200000}},
|
||||
],
|
||||
}
|
||||
|
||||
applied = {entry["id"]: entry for entry in build_radio_status_entries(config)}
|
||||
assert applied["link"]["radio"] == "864.2,62.5,8,8"
|
||||
|
||||
# What api_endpoints._set_radio_param does for the default radio: write the
|
||||
# target entry, and mirror it into the legacy top-level block.
|
||||
config["radios"][0]["radio"]["bandwidth"] = 500000
|
||||
config["radio"]["bandwidth"] = 500000
|
||||
|
||||
entries = build_radio_status_entries(config, applied=applied)
|
||||
by_id = {entry["id"]: entry for entry in entries}
|
||||
|
||||
assert by_id["local"]["radio"] == "869.618,500.0,8,8" # really retuned
|
||||
assert by_id["link"]["radio"] == "864.2,62.5,8,8" # still on 62.5 kHz
|
||||
|
||||
# Without the applied map, link would follow the mirrored top-level value
|
||||
# onto a band its hardware was never given -- the bug this guards.
|
||||
naive = {e["id"]: e["radio"] for e in build_radio_status_entries(config)}
|
||||
assert naive["link"] == "864.2,500.0,8,8"
|
||||
|
||||
|
||||
def test_applied_map_is_ignored_for_a_radio_the_config_has_just_added():
|
||||
"""A radio with no applied entry has no band to preserve, so it reports
|
||||
what it is configured with."""
|
||||
config = {
|
||||
"radio_type": "sx1262",
|
||||
"radio": LOCAL_RADIO["radio"],
|
||||
"fabric": {"default_radio": "local"},
|
||||
"radios": [LOCAL_RADIO, LINK_RADIO],
|
||||
}
|
||||
applied = {"local": {"id": "local", "radio": "869.618,62.5,8,8"}}
|
||||
|
||||
entries = build_radio_status_entries(config, applied=applied)
|
||||
|
||||
assert entries[1] == {"id": "link", "radio": "864.2,62.5,11,8"}
|
||||
|
||||
|
||||
def test_no_applied_map_rebuilds_every_entry_from_config():
|
||||
"""A fresh start has nothing to preserve."""
|
||||
config = {"radios": [LOCAL_RADIO, LINK_RADIO], "fabric": {"default_radio": "local"}}
|
||||
|
||||
assert build_radio_status_entries(config) == build_radio_status_entries(config, applied=None)
|
||||
|
||||
|
||||
def _publish_status_across_a_live_edit(config: dict, mutate) -> tuple:
|
||||
"""Two status publishes from one pusher, with a config edit between them."""
|
||||
pusher = MeshCoreToMqttPusher(local_identity=_FakeIdentity("AB" * 32), config=config)
|
||||
conn = pusher.connections[0]
|
||||
captured = []
|
||||
conn._running = True
|
||||
conn.client = MagicMock()
|
||||
conn.client.publish = lambda topic, payload, retain=False, qos=0: captured.append(payload)
|
||||
|
||||
pusher.publish_status(state="online")
|
||||
mutate(config)
|
||||
pusher.publish_status(state="online")
|
||||
|
||||
assert len(captured) == 2
|
||||
return tuple(json.loads(payload) for payload in captured)
|
||||
|
||||
|
||||
def test_published_status_holds_a_non_retuned_radio_across_a_live_edit():
|
||||
"""The handler-level version of the same guarantee: an observer must not be
|
||||
told a radio moved band when only the default radio was retuned."""
|
||||
local = copy.deepcopy(LOCAL_RADIO)
|
||||
config = _make_config(radios=[local, {"id": "link", "radio": {"frequency": 864200000}}])
|
||||
config["radio"] = copy.deepcopy(LOCAL_RADIO["radio"])
|
||||
config["fabric"]["default_radio"] = "local"
|
||||
|
||||
def widen_the_default_radio(cfg):
|
||||
# Mirrors api_endpoints._set_radio_param for the default radio.
|
||||
cfg["radios"][0]["radio"]["bandwidth"] = 500000
|
||||
cfg["radio"]["bandwidth"] = 500000
|
||||
|
||||
first, second = _publish_status_across_a_live_edit(config, widen_the_default_radio)
|
||||
|
||||
assert {e["id"]: e["radio"] for e in first["radios"]} == {
|
||||
"local": "869.618,62.5,8,8",
|
||||
"link": "864.2,62.5,8,8",
|
||||
}
|
||||
assert {e["id"]: e["radio"] for e in second["radios"]} == {
|
||||
"local": "869.618,500.0,8,8", # retuned
|
||||
"link": "864.2,62.5,8,8", # untouched, still reported as it is
|
||||
}
|
||||
Reference in new issue
Block a user