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:
agessaman committed 2026-09-12 15:06:47 -07:00
1 parent b9fdeb56f1
commit 071196cb2b
6 files changed
+121 -58

No files matched your search

+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+32 -11
View File
@@ -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",