diff --git a/config.yaml.example b/config.yaml.example index 8440ef9..eb6ed49 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -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 diff --git a/repeater/config.py b/repeater/config.py index 6e4fc50..3e1b5bc 100644 --- a/repeater/config.py +++ b/repeater/config.py @@ -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 diff --git a/repeater/engine.py b/repeater/engine.py index d143771..cbaed45 100644 --- a/repeater/engine.py +++ b/repeater/engine.py @@ -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: diff --git a/repeater/main.py b/repeater/main.py index 8044b71..d26237c 100644 --- a/repeater/main.py +++ b/repeater/main.py @@ -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). diff --git a/tests/test_fabric_fanout.py b/tests/test_fabric_fanout.py index 854e326..14bcbc2 100644 --- a/tests/test_fabric_fanout.py +++ b/tests/test_fabric_fanout.py @@ -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 diff --git a/tests/test_multi_radio_stack.py b/tests/test_multi_radio_stack.py index f54fd2f..d177783 100644 --- a/tests/test_multi_radio_stack.py +++ b/tests/test_multi_radio_stack.py @@ -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",