mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-10-31 04:01:59 +01:00
refactor(fabric): rename local_tx_mode to origin_tx
openhop-dev/openhop_core#123 settled on fabric.origin_tx: default | all as the name for sending locally originated packets out on every radio, and openhop-dev/openhop_core#124 with openhop-dev/openhop_repeater#412 implement that knob in core. Use the same key here so a Fabric config means one thing whichever implementation lands. local_tx_mode is still read, so a config written against the first version of this branch keeps its behaviour. If both keys are set to different modes, startup rejects the config before any hardware is opened, since there is no way to tell which one the operator meant. While running, the engine reads the option through the same helper and lets origin_tx win. Validation errors name the key the operator actually wrote. The radio stack meta and the startup log report origin_tx.
This commit is contained in:
1 parent
b9fdeb56f1
commit
071196cb2b
6 files changed
+121
-58
No files matched your search
+2
-1
@@ -440,7 +440,8 @@ identities:
|
||||
# # default: existing Fabric TX selection (default_radio / tx_mode)
|
||||
# # all: transmit once on every Fabric radio, default_radio first
|
||||
# # (requires exactly two radios)
|
||||
# local_tx_mode: default
|
||||
# # Formerly local_tx_mode, which is still accepted.
|
||||
# origin_tx: default
|
||||
#
|
||||
# # Fan-out never runs MeshCore forwarding twice: each packet is validated,
|
||||
# # deduplicated and path-updated once, then the same bytes are sent through
|
||||
|
||||
+49
-22
@@ -838,14 +838,38 @@ def _apply_fabric_tx_mode(fabric, mode: str) -> None:
|
||||
raise ValueError(f"Unknown fabric.tx_mode={mode!r}. Supported: default, sticky, bridge")
|
||||
|
||||
|
||||
FABRIC_LOCAL_TX_MODES = ("default", "all")
|
||||
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, local_tx_mode)`` from a ``fabric:`` mapping.
|
||||
"""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.
|
||||
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"
|
||||
@@ -858,26 +882,30 @@ def parse_fabric_fanout_options(fabric_cfg) -> tuple:
|
||||
f"fabric.repeat_on_ingress must be true or false, got {repeat_on_ingress!r}"
|
||||
)
|
||||
|
||||
raw_mode = fabric_cfg.get("local_tx_mode", "default")
|
||||
local_tx_mode = str(raw_mode if raw_mode is not None else "default").strip().lower()
|
||||
if local_tx_mode == "":
|
||||
local_tx_mode = "default"
|
||||
if local_tx_mode not in FABRIC_LOCAL_TX_MODES:
|
||||
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"Unknown fabric.local_tx_mode={raw_mode!r}. Supported: "
|
||||
+ ", ".join(FABRIC_LOCAL_TX_MODES)
|
||||
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, local_tx_mode
|
||||
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 ``local_tx_mode: all`` sends once
|
||||
per radio. Returns the parsed ``(repeat_on_ingress, local_tx_mode)``.
|
||||
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, local_tx_mode = parse_fabric_fanout_options(fabric_cfg)
|
||||
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":
|
||||
@@ -888,11 +916,10 @@ def _validate_fabric_fanout(fabric_cfg, tx_mode: str, radio_count: int) -> tuple
|
||||
raise ValueError(
|
||||
f"fabric.repeat_on_ingress requires exactly two radios (got {radio_count})"
|
||||
)
|
||||
if local_tx_mode == "all" and radio_count != 2:
|
||||
raise ValueError(
|
||||
f"fabric.local_tx_mode=all requires exactly two radios (got {radio_count})"
|
||||
)
|
||||
return repeat_on_ingress, local_tx_mode
|
||||
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
|
||||
@@ -1023,7 +1050,7 @@ def build_radio_stack(config: dict):
|
||||
- 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, local_tx_mode (default|all).
|
||||
repeat_on_ingress, origin_tx (default|all; formerly local_tx_mode).
|
||||
|
||||
Returns (radio, meta).
|
||||
"""
|
||||
@@ -1035,7 +1062,7 @@ def build_radio_stack(config: dict):
|
||||
|
||||
# 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, local_tx_mode = _validate_fabric_fanout(fabric_cfg, tx_mode, radio_count)
|
||||
repeat_on_ingress, origin_tx = _validate_fabric_fanout(fabric_cfg, tx_mode, radio_count)
|
||||
|
||||
meta = {
|
||||
"mode": "single",
|
||||
@@ -1043,7 +1070,7 @@ def build_radio_stack(config: dict):
|
||||
"default_radio": None,
|
||||
"tx_mode": tx_mode,
|
||||
"repeat_on_ingress": repeat_on_ingress,
|
||||
"local_tx_mode": local_tx_mode,
|
||||
"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
|
||||
|
||||
+5
-5
@@ -35,6 +35,7 @@ from openhop_core.protocol.packet_utils import (
|
||||
)
|
||||
|
||||
from repeater.airtime import AirtimeManager
|
||||
from repeater.config import fabric_origin_tx
|
||||
from repeater.data_acquisition import StorageCollector
|
||||
from repeater.modem_config import normalize_modem_config, redact_modem_tokens_in_place
|
||||
from repeater.neighbour_links import NeighbourLinkTracker
|
||||
@@ -377,7 +378,7 @@ class RepeaterHandler(BaseHandler):
|
||||
tx_radio_ids_sent = None
|
||||
if local_transmission and not rx_radio_id:
|
||||
# Originated here: companion, repeater/room identity, protocol reply.
|
||||
tx_radio_ids = self._resolve_local_tx_radio_ids()
|
||||
tx_radio_ids = self._resolve_origin_tx_radio_ids()
|
||||
else:
|
||||
# Heard on RF and being repeated. A helper re-emitting a received
|
||||
# packet through the local path (TRACE forwarding) still carries its
|
||||
@@ -1949,15 +1950,14 @@ class RepeaterHandler(BaseHandler):
|
||||
radio_ids = self._resolve_relay_tx_radio_ids(rx_radio_id)
|
||||
return radio_ids[0] if radio_ids else None
|
||||
|
||||
def _resolve_local_tx_radio_ids(self) -> Optional[Tuple[str, ...]]:
|
||||
def _resolve_origin_tx_radio_ids(self) -> Optional[Tuple[str, ...]]:
|
||||
"""Egress radios for a packet this node originates.
|
||||
|
||||
With fabric.local_tx_mode=all and exactly two radios, every radio, the
|
||||
With fabric.origin_tx=all and exactly two radios, every radio, the
|
||||
default radio first. Otherwise None: the existing fabric selection
|
||||
(default radio / tx_mode selector) stays authoritative.
|
||||
"""
|
||||
mode = self._fabric_cfg().get("local_tx_mode", "default")
|
||||
if str(mode if mode is not None else "default").strip().lower() != "all":
|
||||
if fabric_origin_tx(self._fabric_cfg()) != "all":
|
||||
return None
|
||||
fabric, ids = self._fabric_endpoints()
|
||||
if ids is None or len(ids) != 2:
|
||||
|
||||
+2
-2
@@ -392,13 +392,13 @@ class RepeaterDaemon:
|
||||
if meta.get("fabric"):
|
||||
logger.info(
|
||||
"RF fabric active: mode=%s radios=%s default=%s tx_mode=%s "
|
||||
"repeat_on_ingress=%s local_tx_mode=%s",
|
||||
"repeat_on_ingress=%s origin_tx=%s",
|
||||
meta.get("mode"),
|
||||
meta.get("radio_ids"),
|
||||
meta.get("default_radio"),
|
||||
meta.get("tx_mode"),
|
||||
meta.get("repeat_on_ingress"),
|
||||
meta.get("local_tx_mode"),
|
||||
meta.get("origin_tx"),
|
||||
)
|
||||
|
||||
# Physical radios for per-device setup (CAD, event loop).
|
||||
|
||||
+31
-17
@@ -437,7 +437,7 @@ async def test_fanout_transmissions_never_overlap():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_monitor_mode_blocks_relay_fanout_but_not_local_fanout():
|
||||
rig = _Rig({"repeat_on_ingress": True, "local_tx_mode": "all"}, mode="monitor")
|
||||
rig = _Rig({"repeat_on_ingress": True, "origin_tx": "all"}, mode="monitor")
|
||||
|
||||
assert await rig.handler(_flood(rx="local"), _rx("local")) is False
|
||||
assert rig.air.frames == []
|
||||
@@ -454,7 +454,7 @@ async def test_monitor_mode_blocks_relay_fanout_but_not_local_fanout():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_local_origin_default_mode_keeps_single_fabric_send():
|
||||
rig = _Rig({"repeat_on_ingress": True}) # local_tx_mode defaults to "default"
|
||||
rig = _Rig({"repeat_on_ingress": True}) # origin_tx defaults to "default"
|
||||
assert await rig.handler(_flood(), {}, local_transmission=True) is True
|
||||
assert rig.air.order == ["local"]
|
||||
assert rig.sent_radio_ids == [None] # the fabric picks, as before
|
||||
@@ -466,7 +466,7 @@ async def test_local_origin_default_mode_keeps_single_fabric_send():
|
||||
"default_radio, expected", [("local", ["local", "link"]), ("link", ["link", "local"])]
|
||||
)
|
||||
async def test_local_origin_all_mode_sends_on_every_radio_default_first(default_radio, expected):
|
||||
rig = _Rig({"local_tx_mode": "all", "default_radio": default_radio})
|
||||
rig = _Rig({"origin_tx": "all", "default_radio": default_radio})
|
||||
pkt = _direct(path=bytes([0x42, 0x43]))
|
||||
pkt._injected_origin_hash = "0x1a"
|
||||
|
||||
@@ -491,6 +491,20 @@ async def test_local_origin_all_mode_sends_on_every_radio_default_first(default_
|
||||
assert rig.handler.sent_direct_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"fabric, expected",
|
||||
[
|
||||
({"local_tx_mode": "all"}, ["local", "link"]), # the option's former name
|
||||
({"origin_tx": "default", "local_tx_mode": "all"}, ["local"]), # new name wins
|
||||
],
|
||||
)
|
||||
async def test_origin_tx_old_name_still_selects_the_mode(fabric, expected):
|
||||
rig = _Rig(fabric)
|
||||
assert await rig.handler(_flood(), {}, local_transmission=True) is True
|
||||
assert rig.air.order == expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_local_resend_of_a_received_packet_resolves_as_a_relay():
|
||||
"""TRACE forwarding re-injects the received packet, still tagged with its ingress."""
|
||||
@@ -502,7 +516,7 @@ async def test_local_resend_of_a_received_packet_resolves_as_a_relay():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_local_fanout_evaluates_link_health_per_radio():
|
||||
rig = _Rig({"local_tx_mode": "all"})
|
||||
rig = _Rig({"origin_tx": "all"})
|
||||
rig.handler.config["delays"]["local_tx_link_wait_seconds"] = 0.6
|
||||
rig.link.is_connected = False
|
||||
rig.link.is_degraded = True
|
||||
@@ -520,7 +534,7 @@ async def test_local_fanout_evaluates_link_health_per_radio():
|
||||
@pytest.mark.asyncio
|
||||
async def test_down_primary_does_not_hold_up_the_healthy_radio():
|
||||
"""Primary-first ordering applies when both radios are ready, never as a wait."""
|
||||
rig = _Rig({"local_tx_mode": "all"}) # "local" is the primary egress
|
||||
rig = _Rig({"origin_tx": "all"}) # "local" is the primary egress
|
||||
rig.handler.config["delays"]["local_tx_link_wait_seconds"] = 0.6
|
||||
rig.local.is_connected = False
|
||||
rig.local.is_degraded = True
|
||||
@@ -538,7 +552,7 @@ async def test_down_primary_does_not_hold_up_the_healthy_radio():
|
||||
@pytest.mark.asyncio
|
||||
async def test_every_egress_carries_the_same_scope_and_hash_mode():
|
||||
"""TX-time normalisation is decided once, so a live change mid-fan-out cannot split it."""
|
||||
rig = _Rig({"local_tx_mode": "all"})
|
||||
rig = _Rig({"origin_tx": "all"})
|
||||
rig.dispatcher.default_flood_transport_key = get_auto_key_for("#fanout")
|
||||
rig.dispatcher.set_default_path_hash_mode(1)
|
||||
|
||||
@@ -562,7 +576,7 @@ async def test_every_egress_carries_the_same_scope_and_hash_mode():
|
||||
@pytest.mark.asyncio
|
||||
async def test_scoped_fanout_meters_the_normalised_length():
|
||||
"""Scoping adds transport codes; every duty-cycle gate must see those bytes."""
|
||||
rig = _Rig({"local_tx_mode": "all"})
|
||||
rig = _Rig({"origin_tx": "all"})
|
||||
rig.dispatcher.default_flood_transport_key = get_auto_key_for("#fanout")
|
||||
mgr = rig.handler.airtime_mgr
|
||||
pkt = _flood(payload=b"\x5a\x01\x02\x03", payload_type=PAYLOAD_TYPE_GRP_TXT)
|
||||
@@ -581,7 +595,7 @@ async def test_scoped_fanout_meters_the_normalised_length():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancelling_a_fanout_stops_pending_egresses_and_frees_the_radio():
|
||||
rig = _Rig({"local_tx_mode": "all"})
|
||||
rig = _Rig({"origin_tx": "all"})
|
||||
rig.local.airtime_s = 0.2
|
||||
task = await rig.handler.schedule_retransmit_fanout(
|
||||
_flood(), 0.0, 0.0, ("local", "link"), local_transmission=True
|
||||
@@ -654,7 +668,7 @@ async def _until(predicate, timeout=3.0):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_companion_injection_fans_out_as_one_logical_packet():
|
||||
rig = _Rig({"local_tx_mode": "all"})
|
||||
rig = _Rig({"origin_tx": "all"})
|
||||
router = _router_for(rig)
|
||||
pkt = _flood(payload=b"\x5a\x01\x02\x03", payload_type=PAYLOAD_TYPE_GRP_TXT)
|
||||
|
||||
@@ -674,7 +688,7 @@ async def test_companion_injection_fans_out_as_one_logical_packet():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_originating_companion_never_receives_its_fanned_out_packet():
|
||||
rig = _Rig({"local_tx_mode": "all"})
|
||||
rig = _Rig({"origin_tx": "all"})
|
||||
router = _router_for(rig)
|
||||
origin, other = _bridge(), _bridge()
|
||||
router.daemon.companion_bridges = {0x1A: origin, 0x2B: other}
|
||||
@@ -699,7 +713,7 @@ async def test_originating_companion_never_receives_its_fanned_out_packet():
|
||||
)
|
||||
async def test_non_companion_local_origins_fan_out(payload_type, route, path):
|
||||
"""Adverts and helper replies reach the air the way main.py and helpers send them."""
|
||||
rig = _Rig({"local_tx_mode": "all"})
|
||||
rig = _Rig({"origin_tx": "all"})
|
||||
router = _router_for(rig)
|
||||
pkt = _packet(route, payload_type, bytes(range(1, 33)), path)
|
||||
|
||||
@@ -712,7 +726,7 @@ async def test_non_companion_local_origins_fan_out(payload_type, route, path):
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("ack_radio", ["local", "link"])
|
||||
async def test_directed_message_waits_for_one_ack_from_either_radio(ack_radio):
|
||||
rig = _Rig({"local_tx_mode": "all"})
|
||||
rig = _Rig({"origin_tx": "all"})
|
||||
router = _router_for(rig)
|
||||
crc = 0x1234ABCD
|
||||
registered = []
|
||||
@@ -739,7 +753,7 @@ async def test_directed_message_waits_for_one_ack_from_either_radio(ack_radio):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ack_via_link_completes_send_when_local_tx_failed():
|
||||
rig = _Rig({"local_tx_mode": "all"})
|
||||
rig = _Rig({"origin_tx": "all"})
|
||||
rig.local.fail = True
|
||||
router = _router_for(rig)
|
||||
crc = 0x0BADF00D
|
||||
@@ -757,7 +771,7 @@ async def test_ack_via_link_completes_send_when_local_tx_failed():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_ack_wait_when_every_radio_failed():
|
||||
rig = _Rig({"local_tx_mode": "all"})
|
||||
rig = _Rig({"origin_tx": "all"})
|
||||
rig.local.fail = rig.link.fail = True
|
||||
router = _router_for(rig)
|
||||
|
||||
@@ -777,7 +791,7 @@ async def test_no_ack_wait_when_every_radio_failed():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ack_timeout_is_one_timeout_not_one_per_radio():
|
||||
rig = _Rig({"local_tx_mode": "all"})
|
||||
rig = _Rig({"origin_tx": "all"})
|
||||
router = _router_for(rig)
|
||||
|
||||
with patch.object(rig.dispatcher, "wait_for_ack", wraps=rig.dispatcher.wait_for_ack) as wait:
|
||||
@@ -797,7 +811,7 @@ async def test_ack_timeout_is_one_timeout_not_one_per_radio():
|
||||
@pytest.mark.asyncio
|
||||
async def test_ack_heard_during_the_second_radio_send_is_not_lost():
|
||||
"""The reply can land before the single ACK wait starts; the dispatcher cache holds it."""
|
||||
rig = _Rig({"local_tx_mode": "all"})
|
||||
rig = _Rig({"origin_tx": "all"})
|
||||
router = _router_for(rig)
|
||||
crc = 0x600DCAFE
|
||||
|
||||
@@ -821,7 +835,7 @@ async def test_early_ack_outlives_the_dispatcher_ack_cache_prune():
|
||||
The link wait (5 s default, up to 30 s) can outlast the dispatcher's 5 s
|
||||
cache of unclaimed ACKs, so the send's waiter must exist before the TX.
|
||||
"""
|
||||
rig = _Rig({"local_tx_mode": "all"})
|
||||
rig = _Rig({"origin_tx": "all"})
|
||||
rig.handler.config["delays"]["local_tx_link_wait_seconds"] = 0.4
|
||||
rig.link.is_connected = False
|
||||
rig.link.is_degraded = True
|
||||
|
||||
@@ -242,14 +242,14 @@ 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["local_tx_mode"] == "default"
|
||||
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["local_tx_mode"] == "default"
|
||||
assert meta["origin_tx"] == "default"
|
||||
|
||||
|
||||
def test_repeat_on_ingress_accepted_with_bridge_and_two_radios():
|
||||
@@ -300,24 +300,45 @@ def test_repeat_on_ingress_rejects_non_boolean():
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tx_mode", ["default", "sticky", "bridge"])
|
||||
def test_local_tx_mode_all_accepted_with_two_radios(tx_mode):
|
||||
_, meta, _ = _build_fanout(_fanout_cfg({"tx_mode": tx_mode, "local_tx_mode": "ALL"}))
|
||||
assert meta["local_tx_mode"] == "all"
|
||||
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"
|
||||
|
||||
|
||||
def test_local_tx_mode_all_rejected_with_one_radio():
|
||||
cfg = _fanout_cfg({"local_tx_mode": "all"}, radio_ids=("local",))
|
||||
with pytest.raises(ValueError, match="local_tx_mode=all requires exactly two radios"):
|
||||
@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_local_tx_mode_rejected(value):
|
||||
cfg = _fanout_cfg({"tx_mode": "bridge", "local_tx_mode": value})
|
||||
with pytest.raises(ValueError, match="Unknown fabric.local_tx_mode"):
|
||||
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",
|
||||
|
||||
Reference in new issue
Block a user