Files
pyMC_Repeater/tests/test_multi_radio_stack.py
T
agessaman 42e7939447 test(duty-cycle): run the suite against an older openhop_core too
The tests that cover per-channel metering ask a fabric which radio will carry a
packet, which an openhop_core from before the selector change cannot answer. Run
against one, fourteen of them failed -- on a repeater whose whole point is that
the core change is optional.

Tests that genuinely need the newer core (they drive build_radio_stack through a
real FabricRadio) are skipped when it is absent. The engine tests instead use a
fabric double that implements core's resolve rule and nothing else: explicit
radio_id wins, then the selector, then default_radio. That keeps the engine
measured against the contract rather than against a convenience, and the real
RFFabric is still what runs wherever the installed core supports it.

One fan-out test asserted the engine names the egress radio. What must not
change is which radio transmits; whether the engine names it or leaves the
choice to the fabric depends on the core underneath, so it accepts either.

Against openhop_core c95a684: 2154 pass, 6 skipped. Against dev with the
selector change: 2157 pass, 3 skipped.
2026-09-13 13:38:59 -07:00

455 lines
15 KiB
Python

"""Tests for optional multi-radio fabric stack in the repeater."""
from __future__ import annotations
from unittest.mock import patch
import pytest
from repeater.config import (
NullRadio,
_apply_fabric_tx_mode,
_merge_radio_entry,
build_radio_stack,
fabric_selects_by_ingress_radio,
)
def _core_selects_by_ingress() -> bool:
from openhop_core.rf_fabric import RFFabric
return fabric_selects_by_ingress_radio(RFFabric())
needs_ingress_core = pytest.mark.skipif(
not _core_selects_by_ingress(),
reason="installed openhop_core does not pass a packet's ingress radio to the TX selector",
)
class _FakeRadio:
def __init__(self, name="r"):
self.name = name
self.rx_callback = None
self.sent = []
self.frequency = 869618000
self.spreading_factor = 8
self.bandwidth = 62500
self.coding_rate = 8
self.tx_power = 14
def set_rx_callback(self, cb):
self.rx_callback = cb
async def send(self, data: bytes):
self.sent.append(data)
return {"ok": True, "name": self.name}
def get_frequency(self):
return self.frequency / 1e6
def get_spreading_factor(self):
return self.spreading_factor
def get_bandwidth(self):
return self.bandwidth / 1e3
def get_coding_rate(self):
return self.coding_rate
def get_tx_power(self):
return self.tx_power
def get_last_rssi(self):
return -80
def get_last_snr(self):
return 5.0
def test_merge_radio_entry_inherits_and_overrides():
global_cfg = {
"radio_type": "sx1262",
"radio": {"frequency": 1, "tx_power": 10},
"sx1262": {"bus_id": 0},
}
entry = {
"id": "link",
"radio_type": "modem_usb",
"radio": {"frequency": 2, "tx_power": 22},
"modem_usb": {"port": "/dev/ttyACM0"},
}
merged = _merge_radio_entry(global_cfg, entry)
assert merged["_radio_id"] == "link"
assert merged["radio_type"] == "modem_usb"
assert merged["radio"]["frequency"] == 2
assert merged["modem_usb"]["port"] == "/dev/ttyACM0"
assert "sx1262" in merged # inherited leftover ok; factory uses radio_type
def test_build_radio_stack_legacy_single():
cfg = {"radio_type": "none"}
radio, meta = build_radio_stack(cfg)
assert isinstance(radio, NullRadio)
assert meta["mode"] == "single"
assert meta["fabric"] is False
def test_build_radio_stack_multi_wraps_fabric():
a = _FakeRadio("a")
b = _FakeRadio("b")
def fake_get(board):
# build_radio_stack pops _radio_id before calling get_radio_for_board
# so distinguish by radio air settings
freq = (board.get("radio") or {}).get("frequency")
return a if freq == 111 else b
cfg = {
"fabric": {"default_radio": "local", "tx_mode": "sticky"},
"radios": [
{
"id": "local",
"radio_type": "sx1262",
"radio": {"frequency": 111},
"sx1262": {"bus_id": 0},
},
{
"id": "link",
"radio_type": "sx1262",
"radio": {"frequency": 222},
"sx1262": {"bus_id": 0},
},
],
}
with patch("repeater.config.get_radio_for_board", side_effect=fake_get):
radio, meta = build_radio_stack(cfg)
assert meta["fabric"] is True
assert meta["mode"] == "multi"
assert meta["radio_ids"] == ["local", "link"]
assert meta["default_radio"] == "local"
# FabricRadio surface
assert hasattr(radio, "fabric")
assert list(radio.fabric.radios.keys()) == ["local", "link"]
assert radio.fabric.default_radio_id == "local"
# sticky selector installed
assert radio.fabric._tx_selector is not None
@needs_ingress_core
@pytest.mark.asyncio
async def test_sticky_tx_uses_the_packets_own_ingress_radio():
a = _FakeRadio("a")
b = _FakeRadio("b")
def fake_get(board):
freq = (board.get("radio") or {}).get("frequency")
return a if freq == 111 else b
cfg = {
"fabric": {"default_radio": "local", "tx_mode": "sticky"},
"radios": [
{"id": "local", "radio_type": "sx1262", "radio": {"frequency": 111}, "sx1262": {}},
{"id": "link", "radio_type": "sx1262", "radio": {"frequency": 222}, "sx1262": {}},
],
}
with patch("repeater.config.get_radio_for_board", side_effect=fake_get):
radio, meta = build_radio_stack(cfg)
# A packet heard on link replies on link.
b.rx_callback(b"hello", -70, 3.0)
await radio.send(b"reply", rx_radio_id="link")
assert b.sent == [b"reply"]
assert a.sent == []
# Nothing this node originated arrived anywhere, so it leaves by the default
# radio. It used to leave by whichever radio last heard anything, which is
# not a property of the packet being sent.
await radio.send(b"advert")
assert a.sent == [b"advert"]
assert b.sent == [b"reply"]
# And another node's traffic arriving in between does not move it.
a.rx_callback(b"noise", -70, 3.0)
await radio.send(b"reply-2", rx_radio_id="link")
assert b.sent == [b"reply", b"reply-2"]
def test_use_fabric_single_radio():
fake = _FakeRadio("solo")
cfg = {"radio_type": "sx1262", "fabric": {"use_fabric": True, "tx_mode": "default"}}
with patch("repeater.config.get_radio_for_board", return_value=fake):
radio, meta = build_radio_stack(cfg)
assert meta["fabric"] is True
assert meta["mode"] == "single_fabric"
assert hasattr(radio, "fabric")
def test_tx_mode_all_rejected():
a = _FakeRadio("a")
b = _FakeRadio("b")
def fake_get(board):
freq = (board.get("radio") or {}).get("frequency")
return a if freq == 111 else b
cfg = {
"fabric": {"default_radio": "local", "tx_mode": "all"},
"radios": [
{"id": "local", "radio_type": "sx1262", "radio": {"frequency": 111}, "sx1262": {}},
{"id": "link", "radio_type": "sx1262", "radio": {"frequency": 222}, "sx1262": {}},
],
}
with patch("repeater.config.get_radio_for_board", side_effect=fake_get):
with pytest.raises(ValueError, match="Unknown fabric.tx_mode"):
build_radio_stack(cfg)
@needs_ingress_core
@pytest.mark.asyncio
async def test_bridge_tx_crosses_to_other_radio():
"""RX on local -> TX on link; RX on link -> TX on local."""
a = _FakeRadio("a")
b = _FakeRadio("b")
def fake_get(board):
freq = (board.get("radio") or {}).get("frequency")
return a if freq == 111 else b
cfg = {
"fabric": {"default_radio": "local", "tx_mode": "bridge"},
"radios": [
{"id": "local", "radio_type": "sx1262", "radio": {"frequency": 111}, "sx1262": {}},
{"id": "link", "radio_type": "sx1262", "radio": {"frequency": 222}, "sx1262": {}},
],
}
with patch("repeater.config.get_radio_for_board", side_effect=fake_get):
radio, meta = build_radio_stack(cfg)
assert meta["tx_mode"] == "bridge"
# Heard on local neighborhood -> forward out link backhaul
a.rx_callback(b"from-local", -70, 3.0)
await radio.send(b"fwd-1", rx_radio_id="local")
assert a.sent == []
assert b.sent == [b"fwd-1"]
# Heard on link backhaul -> forward out local
b.rx_callback(b"from-link", -80, 2.0)
await radio.send(b"fwd-2", rx_radio_id="link")
assert a.sent == [b"fwd-2"]
assert b.sent == [b"fwd-1"]
# A locally originated packet has no side of the bridge to come from, so it
# goes out on the default radio rather than on whatever the node last heard.
await radio.send(b"advert")
assert a.sent == [b"fwd-2", b"advert"]
assert b.sent == [b"fwd-1"]
def _fanout_cfg(fabric: dict, radio_ids=("local", "link")) -> dict:
return {
"fabric": {"default_radio": radio_ids[0], **fabric},
"radios": [
{"id": rid, "radio_type": "sx1262", "radio": {"frequency": 100 + i}, "sx1262": {}}
for i, rid in enumerate(radio_ids)
],
}
def _build_fanout(cfg):
"""build_radio_stack with fake hardware; returns (radio, meta, factory_mock)."""
radios = {}
def fake_get(board):
freq = (board.get("radio") or {}).get("frequency")
return radios.setdefault(freq, _FakeRadio(str(freq)))
with patch("repeater.config.get_radio_for_board", side_effect=fake_get) as factory:
radio, meta = build_radio_stack(cfg)
return radio, meta, factory
def test_bridge_without_fanout_options_keeps_defaults():
_, meta, _ = _build_fanout(_fanout_cfg({"tx_mode": "bridge"}))
assert meta["tx_mode"] == "bridge"
assert meta["repeat_on_ingress"] is False
assert meta["origin_tx"] == "default"
def test_fanout_option_defaults_without_fabric_section():
with patch("repeater.config.get_radio_for_board", return_value=_FakeRadio()):
_, meta = build_radio_stack({"radio_type": "sx1262"})
assert meta["repeat_on_ingress"] is False
assert meta["origin_tx"] == "default"
def test_repeat_on_ingress_accepted_with_bridge_and_two_radios():
_, meta, _ = _build_fanout(_fanout_cfg({"tx_mode": "bridge", "repeat_on_ingress": True}))
assert meta["repeat_on_ingress"] is True
assert meta["radio_ids"] == ["local", "link"]
@pytest.mark.parametrize(
"cfg",
[
# one radio in a radios: list
_fanout_cfg({"tx_mode": "bridge", "repeat_on_ingress": True}, radio_ids=("local",)),
# use_fabric around a single radio
{
"radio_type": "sx1262",
"fabric": {"use_fabric": True, "tx_mode": "bridge", "repeat_on_ingress": True},
},
# legacy single radio, no fabric at all
{"radio_type": "sx1262", "fabric": {"tx_mode": "bridge", "repeat_on_ingress": True}},
],
ids=["radios-list", "use_fabric", "legacy"],
)
def test_repeat_on_ingress_rejected_with_one_radio(cfg):
with patch("repeater.config.get_radio_for_board", return_value=_FakeRadio()) as factory:
with pytest.raises(ValueError, match="exactly two radios"):
build_radio_stack(cfg)
factory.assert_not_called() # rejected before any hardware is opened
def test_repeat_on_ingress_rejected_with_three_radios():
cfg = _fanout_cfg({"tx_mode": "bridge", "repeat_on_ingress": True}, radio_ids=("a", "b", "c"))
with pytest.raises(ValueError, match="exactly two radios"):
_build_fanout(cfg)
@pytest.mark.parametrize("tx_mode", ["sticky", "default"])
def test_repeat_on_ingress_rejected_without_bridge(tx_mode):
cfg = _fanout_cfg({"tx_mode": tx_mode, "repeat_on_ingress": True})
with pytest.raises(ValueError, match="requires fabric.tx_mode=bridge"):
_build_fanout(cfg)
def test_repeat_on_ingress_rejects_non_boolean():
cfg = _fanout_cfg({"tx_mode": "bridge", "repeat_on_ingress": "sometimes"})
with pytest.raises(ValueError, match="repeat_on_ingress must be true or false"):
_build_fanout(cfg)
@pytest.mark.parametrize("tx_mode", ["default", "sticky", "bridge"])
def test_origin_tx_all_accepted_with_two_radios(tx_mode):
_, meta, _ = _build_fanout(_fanout_cfg({"tx_mode": tx_mode, "origin_tx": "ALL"}))
assert meta["origin_tx"] == "all"
@pytest.mark.parametrize("key", ["origin_tx", "local_tx_mode"])
def test_origin_tx_all_rejected_with_one_radio(key):
cfg = _fanout_cfg({key: "all"}, radio_ids=("local",))
with pytest.raises(ValueError, match=f"{key}=all requires exactly two radios"):
_build_fanout(cfg)
@pytest.mark.parametrize("key", ["origin_tx", "local_tx_mode"])
@pytest.mark.parametrize("value", ["both", "multicast", 3])
def test_invalid_origin_tx_rejected(key, value):
cfg = _fanout_cfg({"tx_mode": "bridge", key: value})
with pytest.raises(ValueError, match=f"Unknown fabric.{key}"):
_build_fanout(cfg)
def test_origin_tx_accepts_its_former_name():
_, meta, _ = _build_fanout(_fanout_cfg({"local_tx_mode": "all"}))
assert meta["origin_tx"] == "all"
assert "local_tx_mode" not in meta
def test_origin_tx_and_former_name_may_agree():
_, meta, _ = _build_fanout(_fanout_cfg({"origin_tx": "all", "local_tx_mode": " All "}))
assert meta["origin_tx"] == "all"
def test_origin_tx_conflicting_with_former_name_rejected():
cfg = _fanout_cfg({"origin_tx": "default", "local_tx_mode": "all"})
with patch("repeater.config.get_radio_for_board") as factory:
with pytest.raises(ValueError, match="conflicts with fabric.local_tx_mode"):
build_radio_stack(cfg)
factory.assert_not_called()
def test_merge_radio_entry_preserves_per_radio_ch341():
global_cfg = {
"radio_type": "sx1262_ch341",
"ch341": {"vid": 0x1A86, "pid": 0x5512, "bus": 1, "address": 5},
"radio": {"frequency": 1},
"sx1262": {"bus_id": 0},
}
entry = {
"id": "link",
"ch341": {"vid": 0x1A86, "pid": 0x5512, "bus": 1, "address": 8},
"radio": {"frequency": 2},
}
merged = _merge_radio_entry(global_cfg, entry)
assert merged["ch341"]["address"] == 8
assert merged["_ch341_per_instance"] is True
class _LegacyFabric:
"""A fabric from an openhop-core that predates the ingress-radio argument.
The only difference that matters is the signature of resolve_tx_radio_id,
which is how the repeater tells the two apart.
"""
def __init__(self):
self.radios = {"local": object(), "link": object()}
self.default_radio_id = "local"
self._last_rx_radio_id = None
self.selector = None
def resolve_tx_radio_id(self, data, radio_id=None):
return radio_id
def set_tx_selector(self, selector):
self.selector = selector
def test_an_older_core_is_given_the_one_argument_selector_it_can_call():
"""The fallback has to be callable by the core that gets it, or the node
raises TypeError on its first transmission."""
fabric = _LegacyFabric()
assert fabric_selects_by_ingress_radio(fabric) is False
_apply_fabric_tx_mode(fabric, "sticky")
fabric._last_rx_radio_id = "link"
assert fabric.selector(b"frame") == "link"
with pytest.raises(TypeError):
fabric.selector(b"frame", "local")
def test_an_older_core_keeps_the_behaviour_it_always_had():
"""It reads the node's most recent RX, including for locally originated
traffic. Unchanged, deliberately: approximating it would move packets on the
air on nodes that did not update their core."""
fabric = _LegacyFabric()
_apply_fabric_tx_mode(fabric, "bridge")
fabric._last_rx_radio_id = "local"
assert fabric.selector(b"frame") == "link"
fabric._last_rx_radio_id = None
assert fabric.selector(b"frame") == "local"
@needs_ingress_core
def test_a_current_core_is_given_the_selector_that_routes_by_ingress_radio():
from openhop_core.rf_fabric import RFFabric
fabric = RFFabric()
fabric.register_radio(object(), radio_id="local")
fabric.register_radio(object(), radio_id="link")
assert fabric_selects_by_ingress_radio(fabric) is True
_apply_fabric_tx_mode(fabric, "bridge")
assert fabric.resolve_tx_radio_id(b"frame", rx_radio_id="local") == "link"
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"