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:
1 parent
9538bfedce
commit
dee4674942
8 files changed
+149
-10
No files matched your search
+1
-2
@@ -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
@@ -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)}")
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 == []
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user