diff --git a/docs/uconsole.md b/docs/uconsole.md index f8ab68f..760ca15 100644 --- a/docs/uconsole.md +++ b/docs/uconsole.md @@ -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 diff --git a/meshterm/core/config.py b/meshterm/core/config.py index 9d097a5..ada2d35 100644 --- a/meshterm/core/config.py +++ b/meshterm/core/config.py @@ -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..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)}") diff --git a/meshterm/core/radionode.py b/meshterm/core/radionode.py index 00d8e68..259d7c0 100644 --- a/meshterm/core/radionode.py +++ b/meshterm/core/radionode.py @@ -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)) diff --git a/meshterm/core/spiradio.py b/meshterm/core/spiradio.py index acd1576..cb870fd 100644 --- a/meshterm/core/spiradio.py +++ b/meshterm/core/spiradio.py @@ -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}, } diff --git a/scripts/uconsole/meshterm-spi-bridge b/scripts/uconsole/meshterm-spi-bridge index a67fb29..dd3bb7b 100755 --- a/scripts/uconsole/meshterm-spi-bridge +++ b/scripts/uconsole/meshterm-spi-bridge @@ -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), diff --git a/tests/test_radionode.py b/tests/test_radionode.py index 5baade2..43f3861 100644 --- a/tests/test_radionode.py +++ b/tests/test_radionode.py @@ -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 == [] diff --git a/tests/test_radionode_live.py b/tests/test_radionode_live.py index d1b6ce2..9256699 100644 --- a/tests/test_radionode_live.py +++ b/tests/test_radionode_live.py @@ -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 diff --git a/tests/test_spiradio.py b/tests/test_spiradio.py index 19b00e6..57a84f3 100644 --- a/tests/test_spiradio.py +++ b/tests/test_spiradio.py @@ -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: