From 25299a77b3a4f741670ac0dc66ab5a72ef6e1b3c Mon Sep 17 00:00:00 2001 From: mikecarper Date: Mon, 19 Oct 2026 18:43:11 -0700 Subject: [PATCH] Invalidate LR1110 profile cache on packet changes Revoke fast-RX ownership before CRC, header and IQ setters, including failed writes. Cover normal RX rebuilds and unchanged defaults with production-method tests. --- .github/workflows/run-unit-tests.yml | 1 + src/helpers/radiolib/CustomLR1110.h | 20 +++ test/test_lr1110_packet_setter_cache.py | 201 ++++++++++++++++++++++++ 3 files changed, 222 insertions(+) create mode 100644 test/test_lr1110_packet_setter_cache.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 9df264fa0..c380448aa 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -617,6 +617,7 @@ jobs: test_sx1262_profile_switch test_sx1262_batched_modulation test_sx1262_protocol_batching test_sx1262_modem_cache test_esp32_retune_priority test_tdeck_spi_clock_timing test_radio_chirp_math + test_lr1110_profile_switch test_lr1110_packet_setter_cache - name: Verify radio lab collectors and compiled experiment contracts run: | diff --git a/src/helpers/radiolib/CustomLR1110.h b/src/helpers/radiolib/CustomLR1110.h index 5a45433da..b9d0de1ba 100644 --- a/src/helpers/radiolib/CustomLR1110.h +++ b/src/helpers/radiolib/CustomLR1110.h @@ -183,6 +183,26 @@ class CustomLR1110 : public LR1110 { return LR1110::setCodingRate(cr, longInterleave); } + // Ordinary packet setters revoke the acknowledged continuous-RX tuple, + // including failed writes. A later hop must rebuild normal RX first. + int16_t setCRC(uint8_t len, uint32_t initial = 0x00001D0FUL, + uint32_t polynomial = 0x00001021UL, bool inverted = true) { + _profileSwitch.invalidate(); + return LR1110::setCRC(len, initial, polynomial, inverted); + } + int16_t implicitHeader(size_t len) { + _profileSwitch.invalidate(); + return LR1110::implicitHeader(len); + } + int16_t explicitHeader() { + _profileSwitch.invalidate(); + return LR1110::explicitHeader(); + } + int16_t invertIQ(bool enabled) override { + _profileSwitch.invalidate(); + return LR1110::invertIQ(enabled); + } + // Protocol changes are outside the acknowledged fast-RX context. Use // checked RadioLib setters so cached packet fields and hardware agree, // including after a partial failure and a later MeshCore restoration. diff --git a/test/test_lr1110_packet_setter_cache.py b/test/test_lr1110_packet_setter_cache.py new file mode 100644 index 000000000..1b7848a78 --- /dev/null +++ b/test/test_lr1110_packet_setter_cache.py @@ -0,0 +1,201 @@ +"""Run LR1110 packet-setter cache regressions against production C++ methods. + +RadioLib's SPI operations are mocked; the actual setters, startReceive method +and ownership state are compiled as GNU++11. Linux runs also enable ASAN/UBSAN. +""" +from pathlib import Path +import os +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +def production_method(source, signature): + start = source.index(signature) + opening = source.index("{", start) + depth, end = 1, opening + 1 + while depth: + depth += (source[end] == "{") - (source[end] == "}") + end += 1 + return source[start:end] + + +class LR1110PacketSetterCacheTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + if not compiler: + raise RuntimeError("A native GNU++11 compiler is required") + source = (ROOT / "src/helpers/radiolib/CustomLR1110.h").read_text() + methods = "\n".join(production_method(source, signature) for signature in ( + "int16_t setCRC(", "int16_t implicitHeader(", + "int16_t explicitHeader(", "int16_t invertIQ(", "int16_t startReceive()", + )) + state = (ROOT / "src/helpers/radiolib/LR1110ProfileSwitchState.h").read_text() + harness = r""" +#include +#include +#include +#include +@STATE@ +#define RADIOLIB_ERR_NONE 0 +#define RADIOLIB_LR11X0_RX_TIMEOUT_INF 0xffffff +#define RADIOLIB_LR11X0_IRQ_ALL 0xffffffff +#define RADIOLIB_LR11X0_MAX_PACKET_LENGTH 255 +#define RADIOLIB_RADIO_MODE_NONE 0 +#define RADIOLIB_IRQ_RX_DEFAULT_FLAGS 1 +#define RADIOLIB_IRQ_RX_DEFAULT_MASK 2 +#define RADIOLIB_IRQ_PREAMBLE_DETECTED 3 +#define RADIOLIB_IRQ_HEADER_ERR 6 +struct Module { + enum { MODE_RX = 1 }; + std::string* trace; + explicit Module(std::string* value) : trace(value) {} + void setRfSwitchState(int mode) { assert(mode == MODE_RX); *trace += 'W'; } +}; +struct LR1110 { + LR1110ProfileSwitchState* ownership = nullptr; + int16_t setterResult = 0, normalResult = 0; + unsigned setterCalls = 0, normalRxCalls = 0; + uint8_t crcLength = 0; + uint32_t crcInitial = 0, crcPolynomial = 0; + bool crcInverted = false; + size_t implicitLength = 0, preambleLengthLoRa = 32; + uint8_t headerType = 0, crcTypeLoRa = 1, invertIQEnabled = 0; + uint32_t rxTimeout = 0; + int stagedMode = 7; + std::string trace; + Module module; + Module* mod; + LR1110() : module(&trace), mod(&module) {} + void ordinaryWrite(char operation) { + // Ownership must be revoked before issuing the ordinary RadioLib write. + assert(ownership && !ownership->canResumeFast() && !ownership->rxValid); + assert(!ownership->active && !ownership->standbyReady && !ownership->modulationValid); + ++setterCalls; trace += operation; + } + int16_t setCRC(uint8_t len, uint32_t initial, uint32_t polynomial, bool inverted) { + ordinaryWrite('C'); crcLength = len; crcInitial = initial; + crcPolynomial = polynomial; crcInverted = inverted; crcTypeLoRa = len; + return setterResult; + } + int16_t implicitHeader(size_t len) { + ordinaryWrite('I'); implicitLength = len; headerType = 1; return setterResult; + } + int16_t explicitHeader() { + ordinaryWrite('E'); headerType = 0; return setterResult; + } + virtual int16_t invertIQ(bool enabled) { + ordinaryWrite('Q'); invertIQEnabled = enabled; return setterResult; + } + virtual int16_t startReceive() { assert(false); return -99; } + int16_t startReceive(uint32_t timeout, uint32_t flags, uint32_t mask, size_t len) { + assert(timeout == 0xffffff && flags == (1 | (1UL << 3)) && mask == (2 | (1UL << 6)) && len == 0); + ++normalRxCalls; trace += 'N'; return normalResult; + } + int16_t setPacketParamsLoRa(size_t preamble, uint8_t header, uint8_t len, uint8_t crc, uint8_t iq) { + assert(preamble == preambleLengthLoRa && header == headerType && len == 255); + assert(crc == crcTypeLoRa && iq == invertIQEnabled); trace += 'P'; return 0; + } + int16_t clearIrqState(uint32_t) { assert(false); return -99; } + int16_t setRx(uint32_t timeout) { assert(timeout == 0xffffff); trace += 'R'; return 0; } +}; +struct Radio : LR1110 { + LR1110ProfileSwitchState _profileSwitch{true}; + bool _profilePacketDirty = true; + uint32_t _profileFastResumes = 0; + Radio() { ownership = &_profileSwitch; } + int16_t snapshotIrqAfterRxExit(uint32_t* irq) { *irq = 0; trace += 'S'; return 0; } + @METHODS@ +}; +void acknowledgeFastContext(Radio& radio) { + auto& state = radio._profileSwitch; + state.rxResult(0, true); + state.begin(true, true); state.standbyResult(0); + state.modulationResult(0, 8, 4, 1, 0); + assert(state.canResumeFast() && state.matchesModulation(8, 4, 1, 0)); +} +void callSetter(Radio& radio, const std::string& which, int16_t expected) { + int16_t result; + if (which == "crc") { + result = radio.setCRC(2, 0x12345678, 0x9abcdef0, false); + assert(radio.crcLength == 2 && radio.crcInitial == 0x12345678); + assert(radio.crcPolynomial == 0x9abcdef0 && !radio.crcInverted); + } else if (which == "implicit") { + result = radio.implicitHeader(137); assert(radio.implicitLength == 137); + } else if (which == "explicit") { + radio.headerType = 1; result = radio.explicitHeader(); assert(radio.headerType == 0); + } else { + assert(which == "iq"); + LR1110* polymorphic = &radio; + result = polymorphic->invertIQ(true); assert(radio.invertIQEnabled); + } + assert(result == expected && radio.setterCalls == 1); + assert(!radio._profileSwitch.canResumeFast() && !radio._profileSwitch.rxValid); + assert(!radio._profileSwitch.matchesModulation(8, 4, 1, 0)); +} +void rebuildNormalRx(Radio& radio) { + // Even a failed ordinary setter must force the checked normal RX setup. + radio.trace.clear(); + assert(radio.startReceive() == 0 && radio.trace == "N"); + assert(radio.normalRxCalls == 1 && radio._profileFastResumes == 0); + assert(radio._profileSwitch.rxValid && !radio._profilePacketDirty); + radio._profileSwitch.end(true); + radio._profileSwitch.begin(true, true); radio._profileSwitch.standbyResult(0); + radio.trace.clear(); + assert(radio.startReceive() == 0 && radio.trace == "SWR"); + assert(radio.normalRxCalls == 1 && radio._profileFastResumes == 1); +} +int main(int argc, char** argv) { + assert(argc == 2); const std::string which = argv[1]; + if (which == "defaults") { + Radio radio; acknowledgeFastContext(radio); + assert(radio.setCRC(2) == 0); + assert(radio.crcInitial == 0x1d0f && radio.crcPolynomial == 0x1021 && radio.crcInverted); + return 0; + } + for (int failed = 0; failed < 2; ++failed) { + Radio radio; acknowledgeFastContext(radio); + radio.setterResult = failed ? -77 : 0; + callSetter(radio, which, radio.setterResult); rebuildNormalRx(radio); + Radio retry; acknowledgeFastContext(retry); + callSetter(retry, which, 0); retry.normalResult = -88; retry.trace.clear(); + assert(retry.startReceive() == -88 && retry.trace == "N"); + assert(!retry._profileSwitch.rxValid && retry._profilePacketDirty); + retry._profileSwitch.end(false); + retry._profileSwitch.begin(true, true); retry._profileSwitch.standbyResult(0); + assert(!retry._profileSwitch.canResumeFast()); + retry.normalResult = 0; retry.normalRxCalls = 0; rebuildNormalRx(retry); + } +} +""" + cls.directory = tempfile.TemporaryDirectory(prefix="lr1110-packet-setter-") + cls.addClassCleanup(cls.directory.cleanup) + folder = Path(cls.directory.name) + cpp, cls.binary = folder / "test.cpp", folder / "test.exe" + cpp.write_text(harness.replace("@STATE@", state.replace("#pragma once", "")) + .replace("@METHODS@", methods)) + flags = [] if os.name == "nt" else ["-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-pie", "-no-pie"] + built = subprocess.run([compiler, "-std=gnu++11", "-Wall", "-Wextra", *flags, + str(cpp), "-o", str(cls.binary)], + capture_output=True, text=True, timeout=60) + if built.returncode: + raise AssertionError(built.stdout + built.stderr) + + def run_case(self, scenario): + ran = subprocess.run([str(self.binary), scenario], capture_output=True, text=True, timeout=10) + self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr) + + def test_crc_success_failure_and_normal_rx_rebuild(self): self.run_case("crc") + def test_implicit_header_success_failure_and_normal_rx_rebuild(self): self.run_case("implicit") + def test_explicit_header_success_failure_and_normal_rx_rebuild(self): self.run_case("explicit") + def test_virtual_iq_success_failure_and_normal_rx_rebuild(self): self.run_case("iq") + def test_crc_default_arguments_remain_unchanged(self): self.run_case("defaults") + + +if __name__ == "__main__": + unittest.main()