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

+1 -2
View File
@@ -274,7 +274,6 @@ en_pins = [27] # the AIO v2 needs this
| `en_pins` | `[]` | Power-enable lines to raise before the chip is touched. The **AIO v2 needs `[27]`.** |
| `use_dio2_rf`, `use_dio3_tcxo` | `true`, `true` | Whether DIO2 drives the RF switch and DIO3 a TCXO (both on for the AIO). |
| `is_waveshare` | `false` | The Waveshare HAT's wiring quirks, which the radio library knows about. |
| `preamble_length` | `12` | LoRa preamble symbols. |
| `python` | `""` (empty) | An interpreter to run the node under, when the one MeshTerm would find on its own isn't the one you want. Empty lets MeshTerm look. |
An unknown key, or one of the wrong type, stops MeshTerm at startup with the profile
@@ -561,7 +560,7 @@ Then `systemctl --user restart meshterm-spi-bridge.service`.
| `MESHCORE_SPREADING_FACTOR`, `MESHCORE_BANDWIDTH`, `MESHCORE_CODING_RATE` | the modem preset — all three must match the mesh you are joining. Whole numbers only: bandwidth in Hz (`62500`), coding rate as the denominator (`5` for 4/5). A decimal or `4/5` stops the bridge at startup. |
| `MESHCORE_TXEN_PIN`, `MESHCORE_RXEN_PIN`, `MESHCORE_EN_PINS` | the RF-switch and power-enable lines a board may need (`-1` for none; `EN_PINS` is a comma list — the AIO v2 wants `27`) |
| `MESHCORE_USE_DIO2_RF`, `MESHCORE_USE_DIO3_TCXO`, `MESHCORE_IS_WAVESHARE` | whether DIO2 drives the RF switch and DIO3 the TCXO (both on for the AIO), and a flag for a different vendor's wiring the runtime knows about |
| `MESHCORE_GPIO_CHIP`, `MESHCORE_USE_GPIOD_BACKEND`, `MESHCORE_PREAMBLE_LENGTH` | which gpiochip, whether to drive it through `gpiod`, and the LoRa preamble |
| `MESHCORE_GPIO_CHIP`, `MESHCORE_USE_GPIOD_BACKEND`, `MESHCORE_PREAMBLE_LENGTH` | which gpiochip, whether to drive it through `gpiod`, and the LoRa preamble (leave it unset: the bridge follows MeshCore, 32 symbols up to SF8 and 16 above, and a shorter one leaves the radio deaf to most of the mesh) |
| `MESHTERM_PYMC_PYTHON` | force a specific interpreter instead of letting the bridge discover one |
The bridge passes a knob only when the runtime's radio constructor accepts it, so the same
+13 -3
View File
@@ -20,7 +20,7 @@ import os
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from typing import Any, ClassVar
if sys.version_info >= (3, 11):
import tomllib
@@ -93,7 +93,6 @@ class SpiWiring:
use_dio2_rf: Whether DIO2 drives the RF switch.
use_dio3_tcxo: Whether DIO3 powers a TCXO.
is_waveshare: The Waveshare HAT's wiring quirks, which the radio library knows.
preamble_length: LoRa preamble symbols.
python: An interpreter to run the node under, when the one MeshTerm would find
is not the one you want. Empty to let MeshTerm look.
"""
@@ -112,9 +111,14 @@ class SpiWiring:
use_dio2_rf: bool = True
use_dio3_tcxo: bool = True
is_waveshare: bool = False
preamble_length: int = 12
python: str = ""
#: Keys this table once took and must never take again under another meaning (see
#: :data:`meshterm.core.preferences.RETIRED`). ``preamble_length`` was never wiring: it is
#: protocol, and a fixed 12 against a mesh sending 32 is what left the node deaf to
#: most traffic; the node now derives it from the spreading factor as MeshCore does.
RETIRED: ClassVar[frozenset[str]] = frozenset({"preamble_length"})
@classmethod
def from_toml(cls, table: dict[str, Any]) -> SpiWiring:
"""Build the wiring from a ``[profiles.<name>.spi]`` table, defaults for the rest.
@@ -127,6 +131,12 @@ class SpiWiring:
ValueError: On an unknown key or a value of the wrong type.
"""
known = {f.name: f for f in cls.__dataclass_fields__.values()}
retired = sorted(set(table) & cls.RETIRED)
if retired:
raise ValueError(
f"{', '.join(retired)} is no longer a wiring key — the node sets the "
"preamble from the spreading factor, as MeshCore does; delete the line"
)
unknown = sorted(set(table) - set(known))
if unknown:
raise ValueError(f"unknown SPI wiring key(s): {', '.join(unknown)}")
+48
View File
@@ -223,6 +223,20 @@ def saved_channels(saved: Any) -> list[tuple[int, str, bytes]]:
# --- the node ------------------------------------------------------------------------------
def preamble_for_sf(spreading_factor: int) -> int:
"""The LoRa preamble, in symbols, MeshCore uses at a spreading factor: 32 up to SF8, else 16.
This is MeshCore's own rule (``RadioLibWrapper::preambleLengthForSF``), and the receiver
has to follow it, not only the transmitter. The SX1262 waits for the sync word only about
as long as the preamble it was told to expect, so a node listening for 12 symbols against
a mesh sending 32 locks on, gives up, and locks on again further along the same preamble
— and decodes a packet only when it happens to lock on near the end. That was the whole
of "the uConsole misses replies other radios hear": the preamble was a fixed 12, the
library's default, and at SF7 the mesh sends 32.
"""
return 32 if spreading_factor <= 8 else 16
def radio_kwargs(signature_params, wiring: dict, prefs: Any) -> dict: # noqa: ANN001
"""The ``SX1262Radio`` constructor arguments: the board's wiring plus the saved radio.
@@ -231,6 +245,7 @@ def radio_kwargs(signature_params, wiring: dict, prefs: Any) -> dict: # noqa: A
"""
wanted = dict(wiring)
wanted.update(
preamble_length=preamble_for_sf(prefs.spreading_factor),
frequency=prefs.frequency_hz,
bandwidth=prefs.bandwidth_hz,
spreading_factor=prefs.spreading_factor,
@@ -415,6 +430,28 @@ async def run_node(config: dict, report) -> int: # noqa: ANN001 - (dict) -> Non
return 0
def apply_preamble(radio: Any, symbols: int) -> None:
"""Give a running radio a new preamble length, for both what it sends and what it hears.
The driver reads ``preamble_length`` afresh for every transmission, but reception keeps
the packet parameters it was last given, so those are re-sent from standby and the chip
put back to listening.
"""
if getattr(radio, "preamble_length", symbols) == symbols:
return
radio.preamble_length = symbols
lora = getattr(radio, "lora", None)
if lora is None:
return
try:
lora.setStandby(lora.STANDBY_RC)
lora.setPacketParamsLoRa(symbols, lora.HEADER_EXPLICIT, 64, lora.CRC_ON, lora.IQ_STANDARD)
lora.request(lora.RX_CONTINUOUS)
log.info("preamble set to %d symbols", symbols)
except Exception as exc: # noqa: BLE001 - the next transmission sets it regardless
log.warning("could not apply the new preamble while listening: %s", exc)
def _persistent_companion(base: type, state: Path) -> type:
"""``CompanionRadio`` with its preferences written to ``prefs.json`` on every change.
@@ -423,6 +460,17 @@ def _persistent_companion(base: type, state: Path) -> type:
"""
class PersistentCompanion(base): # type: ignore[misc, valid-type]
def set_radio_params(self, freq_hz: int, bw_hz: int, sf: int, cr: int) -> bool:
"""Retune, and bring the preamble along when the spreading factor moves it.
The library retunes the modulation but leaves the packet parameters alone, and
the preamble is one of them (see :func:`preamble_for_sf`).
"""
ok = super().set_radio_params(freq_hz, bw_hz, sf, cr)
if ok:
apply_preamble(self._radio, preamble_for_sf(sf))
return ok
def _save_prefs(self) -> None:
try:
_write_json(state / "prefs.json", prefs_to_json(self.prefs))
-1
View File
@@ -474,7 +474,6 @@ async def start_node(wiring: SpiWiring, state: Path, *, node_name: str) -> NodeP
"use_dio2_rf": wiring.use_dio2_rf,
"use_dio3_tcxo": wiring.use_dio3_tcxo,
"is_waveshare": wiring.is_waveshare,
"preamble_length": wiring.preamble_length,
},
"seed": {"node_name": node_name, **DEFAULT_SEED},
}
+13 -1
View File
@@ -1043,6 +1043,11 @@ def run_bridge(host: str, port: int, node_name: str) -> int:
hw = load_hardware_config_from_env()
log.info("radio config %s", hw.to_log_string())
radio = create_radio(SX1262Radio, hw, log.info)
# The same preamble rule as the direct path below: unless the environment
# names one, follow MeshCore (32 symbols up to SF8, 16 above) so the radio
# hears the mesh; begin() reads it, so this is in time.
if "MESHCORE_PREAMBLE_LENGTH" not in os.environ and hasattr(radio, "preamble_length"):
radio.preamble_length = 32 if hw.spreading_factor <= 8 else 16
key_path = identity_key_path()
if key_path.exists():
identity = LocalIdentity(key_path.read_bytes())
@@ -1094,7 +1099,14 @@ def run_bridge(host: str, port: int, node_name: str) -> int:
"spreading_factor": _int("MESHCORE_SPREADING_FACTOR", 7),
"bandwidth": _int("MESHCORE_BANDWIDTH", 62_500),
"coding_rate": _int("MESHCORE_CODING_RATE", 5),
"preamble_length": _int("MESHCORE_PREAMBLE_LENGTH", 12),
# MeshCore's own rule (RadioLibWrapper::preambleLengthForSF): 32 symbols up
# to SF8, 16 above. The receiver must match it, not just the sender — the
# SX1262 waits for the sync word only about as long as the preamble it expects,
# so the old fixed 12 against a mesh sending 32 decoded almost nothing.
"preamble_length": _int(
"MESHCORE_PREAMBLE_LENGTH",
32 if _int("MESHCORE_SPREADING_FACTOR", 7) <= 8 else 16,
),
"is_waveshare": _bool("MESHCORE_IS_WAVESHARE", False),
"use_dio2_rf": _bool("MESHCORE_USE_DIO2_RF", True),
"use_dio3_tcxo": _bool("MESHCORE_USE_DIO3_TCXO", True),
+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: