The SPI radio listens for the preamble the mesh actually sends

MeshCore transmits a 32-symbol preamble up to SF8 (16 above), but the
node and the bridge configured the SX1262 for the library's fixed 12.
The chip waits for the sync word only about as long as the preamble it
expects, so it locked onto each packet, gave up, and locked on again
further along the same preamble - decoding a packet only when it
happened to catch the tail. On the uConsole that meant 0 of 5 detected
packets decoded after a send; with 32 symbols, 4 of 4.

The node now derives the preamble from the spreading factor as MeshCore
does, and re-sends it to the listening chip when Device config changes
the SF. preamble_length leaves the SPI wiring table (it was protocol,
never wiring) and is retired; the bridge defaults to the same rule.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5.5 committed 2026-09-24 03:12:52 -04:00
1 parent 9538bfedce
commit dee4674942
8 files changed
+149 -10

No files matched your search

+48
View File
@@ -134,3 +134,51 @@ def test_an_identity_is_minted_once_and_then_kept(tmp_path: Path) -> None:
again = radionode.load_identity_seed(tmp_path, lambda: next(minted))
assert first == again == b"\x11" * 32
assert (tmp_path / "identity.key").read_bytes() == first
@pytest.mark.parametrize(("sf", "symbols"), [(5, 32), (7, 32), (8, 32), (9, 16), (12, 16)])
def test_the_preamble_follows_meshcore(sf: int, symbols: int) -> None:
"""MeshCore sends 32 symbols up to SF8 and 16 above, and the receiver must expect it."""
assert radionode.preamble_for_sf(sf) == symbols
def test_the_radio_is_built_with_the_meshcore_preamble() -> None:
"""The chip comes up expecting the preamble the mesh sends, never the library's 12."""
prefs = type(
"P",
(),
{
"frequency_hz": 910_525_000,
"bandwidth_hz": 62_500,
"spreading_factor": 7,
"coding_rate": 5,
"tx_power_dbm": 22,
},
)()
kwargs = radionode.radio_kwargs({"preamble_length", "spreading_factor"}, {}, prefs)
assert kwargs == {"preamble_length": 32, "spreading_factor": 7}
class _Chip:
"""The calls ``apply_preamble`` makes on the driver, recorded in order."""
STANDBY_RC, HEADER_EXPLICIT, CRC_ON, IQ_STANDARD, RX_CONTINUOUS = 0, 0, 1, 0, 0xFFFFFF
def __init__(self) -> None:
self.calls: list[tuple] = []
def __getattr__(self, name: str): # noqa: ANN204
return lambda *args: self.calls.append((name, *args))
def test_a_retune_resends_the_preamble_to_the_listening_chip() -> None:
"""Reception keeps its last packet parameters, so a new preamble must be sent to it."""
chip = _Chip()
radio = type("R", (), {"preamble_length": 32, "lora": chip})()
radionode.apply_preamble(radio, 16)
assert radio.preamble_length == 16
assert [c[0] for c in chip.calls] == ["setStandby", "setPacketParamsLoRa", "request"]
assert chip.calls[1][1] == 16
chip.calls.clear()
radionode.apply_preamble(radio, 16) # unchanged: the chip is left listening undisturbed
assert chip.calls == []
+20 -3
View File
@@ -27,6 +27,7 @@ class _FakeRadio:
def __init__(self, **kwargs: object) -> None:
self.kwargs = kwargs
self.preamble_length = kwargs.get("preamble_length", 12)
self.cleaned = False
_FakeRadio.instances.append(self)
@@ -64,9 +65,23 @@ class _FakeRadio:
return 5.0
def _fake_init(self, bus_id=1, reset_pin=25, frequency=0, tx_power=0, **kwargs): # noqa: ANN001, ANN202
def _fake_init( # noqa: ANN202
self, # noqa: ANN001
bus_id=1, # noqa: ANN001
reset_pin=25, # noqa: ANN001
frequency=0, # noqa: ANN001
tx_power=0, # noqa: ANN001
preamble_length=12, # noqa: ANN001 - the library's own default, which must never survive
**kwargs, # noqa: ANN003
):
_FakeRadio.__init__(
self, bus_id=bus_id, reset_pin=reset_pin, frequency=frequency, tx_power=tx_power, **kwargs
self,
bus_id=bus_id,
reset_pin=reset_pin,
frequency=frequency,
tx_power=tx_power,
preamble_length=preamble_length,
**kwargs,
)
@@ -145,8 +160,10 @@ async def test_channels_name_and_radio_survive_a_restart(
assert channel is not None and channel.get("channel_name") == "Lakeside ops"
assert bytes(channel.get("channel_secret") or b"")[:16] == secret
assert info.get("name") == "Lakeside"
# The chip is brought up on the saved radio, not the seed.
# The chip is brought up on the saved radio, not the seed — and at SF11 with the
# preamble MeshCore sends there.
assert _FakeRadio.instances[-1].kwargs["frequency"] == 869_525_000
assert _FakeRadio.instances[-1].preamble_length == 16
@pytest.mark.asyncio
+6
View File
@@ -58,6 +58,12 @@ def test_a_wiring_mistake_is_refused_not_ignored(table: dict, complaint: str) ->
SpiWiring.from_toml(table)
def test_the_retired_preamble_key_is_refused_with_its_reason() -> None:
"""A config still carrying the old fixed preamble is told why, not left deaf."""
with pytest.raises(ValueError, match="preamble_length is no longer a wiring key"):
SpiWiring.from_toml({"preamble_length": 12})
def test_a_profile_with_an_spi_table_is_an_spi_profile(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None: