From a782cc84139dcf1e8756919181858202dc6b3218 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Sun, 18 Oct 2026 23:18:13 -0700 Subject: [PATCH] Speed up T-Deck radio2 retunes with guarded command reuse Reuse the confirmed LoRa modem only inside acknowledged warm continuous-RX hops and revoke that capability before modem initialization or after failures. Opt the tested T-Deck into IQ workaround reuse and an exact 8,888,888 Hz SPI setting. Keep the existing BUSY, packet-ownership and recovery checks; other boards retain their modem-cache default. --- .github/workflows/run-unit-tests.yml | 1 + src/helpers/radiolib/CustomSX1262.h | 54 ++++++- test/test_profile_sx1262_iq_cache.py | 21 ++- test/test_radio_regulator_modes.py | 4 +- test/test_sx1262_modem_cache.py | 201 +++++++++++++++++++++++++++ variants/lilygo_tdeck/platformio.ini | 3 + 6 files changed, 271 insertions(+), 13 deletions(-) create mode 100644 test/test_sx1262_modem_cache.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index a272ec7ec..61cf3412e 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -611,6 +611,7 @@ jobs: run: >- python3 -B -m unittest -v test_sx1262_profile_switch test_sx1262_batched_modulation test_sx1262_protocol_batching + test_sx1262_modem_cache test_radio_chirp_math - name: Verify radio lab collectors and compiled experiment contracts diff --git a/src/helpers/radiolib/CustomSX1262.h b/src/helpers/radiolib/CustomSX1262.h index 624353009..3d6f68825 100644 --- a/src/helpers/radiolib/CustomSX1262.h +++ b/src/helpers/radiolib/CustomSX1262.h @@ -31,8 +31,14 @@ #define MC_SX1262_FAST_IQ_CACHE 0 #endif -#if (MC_SX1262_FAST_FS || MC_SX1262_FAST_PACKET_CACHE || MC_SX1262_FAST_IQ_CACHE) && !MC_SX1262_FAST_PROFILE_SWITCH -#error "SX1262 SetFs/packet reuse requires the owned fast profile-switch path" +#ifndef MC_SX1262_FAST_MODEM_CACHE +// Reuse the LoRa modem confirmation only inside an acknowledged owned RX hop. +// Keep this board-qualified optimization off until its hardware comparison. +#define MC_SX1262_FAST_MODEM_CACHE 0 +#endif + +#if (MC_SX1262_FAST_FS || MC_SX1262_FAST_PACKET_CACHE || MC_SX1262_FAST_IQ_CACHE || MC_SX1262_FAST_MODEM_CACHE) && !MC_SX1262_FAST_PROFILE_SWITCH +#error "SX1262 SetFs/packet/modem reuse requires the owned fast profile-switch path" #endif #ifndef SX126X_TX_BUSY_TIMEOUT_MS @@ -69,6 +75,27 @@ class CustomSX1262 : public SX1262 { _coldStandby = true; _tcxoWakePending = false; } + // These public initializers can change packet type before returning an + // error. Revoke ownership before entering RadioLib, rather than waiting + // for a later standby/reset to happen. Preserve the inherited float APIs. + using SX1262::beginFSK; + using SX1262::beginLRFHSS; + int16_t begin(const ConfigLoRa_t& config) override { + invalidateMorseModemCache(); + return SX1262::begin(config); + } + int16_t beginFSK(const ConfigFSK_t& config) override { + invalidateMorseModemCache(); + return SX1262::beginFSK(config); + } + int16_t beginLRFHSS(const ConfigLRFHSS_t& config) override { + invalidateMorseModemCache(); + return SX1262::beginLRFHSS(config); + } + int16_t setModem(ModemType_t modem) override { + invalidateMorseModemCache(); + return SX1262::setModem(modem); + } int16_t setMorseFastRssi() { return setSX126xMorseRssiWindow(this); } @@ -93,8 +120,10 @@ class CustomSX1262 : public SX1262 { bool hilUseFsOnRetune = false; bool hilSkipUnchangedPacket = false; bool hilSkipUnchangedIq = false; + bool hilSkipConfirmedModemQuery = false; float hilFrequencyMHz() const { return freqMHz; } uint32_t hilFsEntries = 0, hilPacketSkips = 0, hilIqWorkaroundSkips = 0; + uint32_t hilModemQuerySkips = 0; #endif int16_t restoreTcxoAfterSleep() { @@ -263,8 +292,25 @@ class CustomSX1262 : public SX1262 { if (index == sizeof(bandwidths) / sizeof(bandwidths[0])) { return RADIOLIB_ERR_INVALID_BANDWIDTH; } - if (getPacketType() != RADIOLIB_SX126X_PACKET_TYPE_LORA) { - _profileSwitch.invalidateModulation(); + bool queryModem = true; +#if MC_SX1262_FAST_MODEM_CACHE || defined(MC_SX1262_HIL) + bool reuseModem = MC_SX1262_FAST_MODEM_CACHE != 0; +#ifdef MC_SX1262_HIL + reuseModem = hilSkipConfirmedModemQuery; +#endif + // rxValid is established only after full RX setup confirms LoRa, or by + // resuming that same owned context. A successful owned standby, warm + // oscillator and continuous RX are required. TX/CAD staging, ordinary + // standby, sleep, reset, duty cycling and command errors revoke it. + // A caller using base-qualified GODMODE/raw SPI to change packet type + // must revoke ownership first, just as for the packet/IQ caches. + queryModem = !reuseModem || !_profileSwitch.canResumeFast(); +#ifdef MC_SX1262_HIL + if (!queryModem) ++hilModemQuerySkips; +#endif +#endif + if (queryModem && getPacketType() != RADIOLIB_SX126X_PACKET_TYPE_LORA) { + _profileSwitch.invalidate(); return RADIOLIB_ERR_WRONG_MODEM; } diff --git a/test/test_profile_sx1262_iq_cache.py b/test/test_profile_sx1262_iq_cache.py index 4f2113746..b90b6409e 100644 --- a/test/test_profile_sx1262_iq_cache.py +++ b/test/test_profile_sx1262_iq_cache.py @@ -7,27 +7,31 @@ import tempfile import unittest from test_radio_receive_contract import method +import test_sx1262_modem_cache as modem_cache ROOT = Path(__file__).resolve().parents[1] class ProfileIqCacheTest(unittest.TestCase): - def test_only_qualified_indicator_recipe_opts_in(self): + def test_only_qualified_recipes_opt_in(self): opt_ins = [] for recipe in (ROOT / "variants").glob("*/platformio.ini"): if re.search(r"^\s*-D\s+MC_SX1262_FAST_IQ_CACHE=1\s*$", recipe.read_text(), re.MULTILINE): opt_ins.append(recipe.relative_to(ROOT).as_posix()) - self.assertEqual(opt_ins, ["variants/sensecap_indicator-espnow/platformio.ini"]) + self.assertCountEqual(opt_ins, ["variants/sensecap_indicator-espnow/platformio.ini", + "variants/lilygo_tdeck/platformio.ini"]) def compile_run(self, source): with tempfile.TemporaryDirectory() as folder: - cpp, binary = Path(folder) / "test.cpp", Path(folder) / "test" + cpp = Path(folder) / "test.cpp" + binary = Path(folder) / ("test.exe" if os.name == "nt" else "test") cpp.write_text(source, encoding="ascii") + sanitizers = [] if os.name == "nt" else [ + "-fsanitize=address,undefined", "-fno-omit-frame-pointer", "-no-pie"] built = subprocess.run([os.environ.get("CXX", "g++"), "-std=c++17", - "-Wall", "-Wextra", "-fsanitize=address,undefined", - "-fno-omit-frame-pointer", "-no-pie", str(cpp), "-o", str(binary)], + "-Wall", "-Wextra", *sanitizers, str(cpp), "-o", str(binary)], capture_output=True, text=True) self.assertEqual(built.returncode, 0, built.stderr) ran = subprocess.run([str(binary)], capture_output=True, text=True) @@ -243,8 +247,11 @@ int main() { def test_unqualified_boards_keep_the_original_live_modem_query_and_iq_path(self): source = (ROOT / "src/helpers/radiolib/CustomSX1262.h").read_text() self.assertIn("#define MC_SX1262_FAST_IQ_CACHE 0", source) - modulation = method(source, "int16_t setLoRaModulationParams(") - self.assertIn("if (getPacketType() != RADIOLIB_SX126X_PACKET_TYPE_LORA)", modulation) + self.assertIn("#define MC_SX1262_FAST_MODEM_CACHE 0", source) + # Execute the shared production-method fixture: default-off builds must + # query the modem, while opted-in/HIL reuse must retain all ownership, + # initialization and failure gates. Do not depend on an if's spelling. + modem_cache.ModemCacheTests.test_confirmed_modem_requires_owned_warm_rx_and_survives_no_failure(self) helper = method(source, "int16_t writeProfilePacketParams(") self.assertIn("return getMod()->SPIwriteStream(RADIOLIB_SX126X_CMD_SET_PACKET_PARAMS,", helper) diff --git a/test/test_radio_regulator_modes.py b/test/test_radio_regulator_modes.py index 52c871ee5..cda366c5b 100644 --- a/test/test_radio_regulator_modes.py +++ b/test/test_radio_regulator_modes.py @@ -138,10 +138,10 @@ class RadioRegulatorTests(unittest.TestCase): initialize = method(source, 'bool std_init(') init_call = 'std_init() ? 0 : -1' else: - initialize = method(source, 'int16_t begin(') + initialize = method(source, 'int16_t begin(float') init_call = 'begin()' if chip == 'SX1262': - morse = method(source, 'int16_t beginFSK(') + morse = method(source, 'int16_t beginFSK(float') morse_call = 'beginFSK()' else: morse = method(source, 'int16_t beginMorseReceive(') diff --git a/test/test_sx1262_modem_cache.py b/test/test_sx1262_modem_cache.py new file mode 100644 index 000000000..90250f504 --- /dev/null +++ b/test/test_sx1262_modem_cache.py @@ -0,0 +1,201 @@ +"""Execute production modem-query reuse and initializer invalidation gates.""" +from pathlib import Path +import unittest + +from test_radio_receive_contract import method +import test_sx1262_batched_modulation as compiler + + +ROOT = Path(__file__).resolve().parents[1] + + +class ModemCacheTests(unittest.TestCase): + compile_run = compiler.BatchedModulationTests.compile_run + + def test_confirmed_modem_requires_owned_warm_rx_and_survives_no_failure(self): + source = (ROOT / "src/helpers/radiolib/CustomSX1262.h").read_text() + state = (ROOT / "src/helpers/radiolib/SX1262ProfileSwitchState.h").read_text().replace("#pragma once", "") + signatures = ("void invalidateMorseModemCache(", "void setProfileSwitchOptimization(", + "int16_t begin(const ConfigLoRa_t&", "int16_t beginFSK(const ConfigFSK_t&", + "int16_t beginLRFHSS(const ConfigLRFHSS_t&", "int16_t setModem(", + "int16_t setLoRaModulationParams(") + production = "\n".join(method(source, signature).replace(" override", "") for signature in signatures) + fixture = r''' +#include +#include +#include +#include +@DEFINES@ +@STATE@ +using std::isfinite; +#define RADIOLIB_ERR_NONE 0 +#define RADIOLIB_ERR_INVALID_SPREADING_FACTOR -1 +#define RADIOLIB_ERR_INVALID_CODING_RATE -2 +#define RADIOLIB_ERR_INVALID_BANDWIDTH -3 +#define RADIOLIB_ERR_WRONG_MODEM -4 +#define RADIOLIB_SX126X_PACKET_TYPE_LORA 1 +#define RADIOLIB_SX126X_LORA_BW_7_8 0 +#define RADIOLIB_SX126X_LORA_BW_10_4 8 +#define RADIOLIB_SX126X_LORA_BW_15_6 1 +#define RADIOLIB_SX126X_LORA_BW_20_8 9 +#define RADIOLIB_SX126X_LORA_BW_31_25 2 +#define RADIOLIB_SX126X_LORA_BW_41_7 10 +#define RADIOLIB_SX126X_LORA_BW_62_5 3 +#define RADIOLIB_SX126X_LORA_BW_125_0 4 +#define RADIOLIB_SX126X_LORA_BW_250_0 5 +#define RADIOLIB_SX126X_LORA_BW_500_0 6 +struct ConfigLoRa_t {}; +struct ConfigFSK_t {}; +struct ConfigLRFHSS_t {}; +using ModemType_t = int; +struct SX1262 { + SX1262ProfileSwitchState* cache = nullptr; + int result = 0, initializations = 0; + int init() { + // The capability must be revoked BEFORE any possibly failing base call. + assert(cache && !cache->rxValid && !cache->active); + assert(!cache->modulationValid && !cache->packetValid); + ++initializations; + return result; + } + int16_t begin(const ConfigLoRa_t&) { return init(); } + virtual int16_t beginFSK(const ConfigFSK_t&) { return init(); } + virtual int16_t beginLRFHSS(const ConfigLRFHSS_t&) { return init(); } + int16_t beginFSK(float) { return beginFSK(ConfigFSK_t{}); } + int16_t beginLRFHSS(float) { return beginLRFHSS(ConfigLRFHSS_t{}); } + int16_t setModem(ModemType_t) { return init(); } +}; +struct Radio : SX1262 { + SX1262ProfileSwitchState _profileSwitch{true}; + bool _coldStandby = false, _tcxoWakePending = true; + uint8_t spreadingFactor = 9, bandwidth = 4, codingRate = 3, ldrOptimize = 0; + float bandwidthKhz = 125; + bool ldroAuto = true; + unsigned queries = 0, writes = 0; + uint8_t packetType = 1; + int16_t writeResult = 0; +#ifdef MC_SX1262_HIL + bool hilSkipConfirmedModemQuery = false; + uint32_t hilModemQuerySkips = 0; +#endif + using SX1262::beginFSK; + using SX1262::beginLRFHSS; + Radio() { cache = &_profileSwitch; } + uint8_t getPacketType() { ++queries; return packetType; } + int16_t setModulationParams(uint8_t sf, uint8_t bw, uint8_t cr, uint8_t ldro) { + assert(spreadingFactor == sf && bandwidth == bw && codingRate == cr); + ldrOptimize = ldroAuto ? ((1U << sf) / bandwidthKhz >= 16.0f) : ldro; + ++writes; + return writeResult; + } + @PRODUCTION@ +}; +bool skipsModem(const Radio& r) { +#ifdef MC_SX1262_HIL + return r.hilSkipConfirmedModemQuery; +#else + (void)r; + return MC_SX1262_FAST_MODEM_CACHE != 0; +#endif +} +void selectCache(Radio& r, bool enabled) { +#ifdef MC_SX1262_HIL + r.hilSkipConfirmedModemQuery = enabled; +#else + (void)r; (void)enabled; +#endif +} +void prime(Radio& r) { + // Models the full RX path's successful live LoRa confirmation. The ordinary + // lifecycle tests separately execute startReceive's real confirmation path. + r._profileSwitch.end(true); + r._profileSwitch.rxResult(0, true); + r._profileSwitch.begin(true, true); + r._profileSwitch.standbyResult(0); +} +int main() { + for (int requested = 0; requested < 2; ++requested) { + Radio r; selectCache(r, requested); + // No existing RX context: even an opted-in image must perform the query. + assert(r.setLoRaModulationParams(125, 9, 7) == 0 && r.queries == 1); + prime(r); + assert(r.setLoRaModulationParams(125, 9, 7) == 0 && r.writes == 1); + assert(r.queries == (skipsModem(r) ? 1U : 2U)); + // A changed modulation tuple still writes the complete tuple. Only the + // confirmed packet-type read may disappear. + assert(r.setLoRaModulationParams(500, 8, 5) == 0 && r.writes == 2); + assert(r.queries == (skipsModem(r) ? 1U : 3U)); +#ifdef MC_SX1262_HIL + assert(r.hilModemQuerySkips == (requested ? 2U : 0U)); +#endif + // Every ownership gate independently restores the live query. + for (int gate = 0; gate < 9; ++gate) { + Radio x; selectCache(x, requested); prime(x); + switch (gate) { + case 0: x._profileSwitch.end(true); break; + case 1: x._profileSwitch.begin(true, true); break; // standby missing + case 2: x._profileSwitch.begin(false, true); break; // discontinuous RX + case 3: x._profileSwitch.begin(true, false); break; // cold oscillator + case 4: x.setProfileSwitchOptimization(false); break; + case 5: x._profileSwitch.rxResult(-707, true); break; + case 6: x._profileSwitch.standbyResult(-707); break; + case 7: x._profileSwitch.end(false); break; + case 8: x.invalidateMorseModemCache(); break; + } + assert(x.setLoRaModulationParams(125, 9, 7) == 0 && x.queries == 1); +#ifdef MC_SX1262_HIL + assert(x.hilModemQuerySkips == 0); +#endif + } + // Failed modulation must revoke query reuse before the restoration path. + Radio failed; selectCache(failed, requested); prime(failed); + failed.writeResult = -707; + assert(failed.setLoRaModulationParams(500, 8, 5) == -707); + assert(!failed._profileSwitch.rxValid && failed._profileSwitch.failed()); + const auto before = failed.queries; + failed.writeResult = 0; + assert(failed.setLoRaModulationParams(125, 9, 7) == 0); + assert(failed.queries == before + 1); + + // A live wrong-modem/error-byte observation revokes the WHOLE context. + // Reopening it without a fresh full RX confirmation cannot restore reuse. + Radio wrong; selectCache(wrong, requested); prime(wrong); + wrong._profileSwitch.end(true); wrong.packetType = 255; + assert(wrong.setLoRaModulationParams(125, 9, 7) == -4 && wrong.queries == 1); + assert(!wrong._profileSwitch.rxValid && !wrong._profileSwitch.modulationValid); + wrong._profileSwitch.begin(true, true); wrong._profileSwitch.standbyResult(0); + wrong.packetType = 1; + assert(wrong.setLoRaModulationParams(125, 9, 7) == 0 && wrong.queries == 2); + } + // Public modem initialization calls invalidate before success AND failure, + // including inherited float overloads dispatching to the config overrides. + for (int api = 0; api < 6; ++api) for (int failure = 0; failure < 2; ++failure) { + Radio r; selectCache(r, true); prime(r); + r._profileSwitch.modulationResult(0, 9, 4, 3, 0); + r._profileSwitch.packetResult(0, 32, 1, 255, 0, 0); + r.result = failure ? -707 : 0; + int rc = 0; + switch (api) { + case 0: rc = r.begin(ConfigLoRa_t{}); break; + case 1: rc = r.beginFSK(ConfigFSK_t{}); break; + case 2: rc = r.beginLRFHSS(ConfigLRFHSS_t{}); break; + case 3: rc = r.setModem(0); break; + case 4: rc = r.beginFSK(910.0f); break; + case 5: rc = r.beginLRFHSS(910.0f); break; + } + assert(rc == r.result && r.initializations == 1); + assert(r._coldStandby && !r._tcxoWakePending); + assert(r.setLoRaModulationParams(125, 9, 7) == 0 && r.queries == 1); + } +} +''' + for policy in ("#define MC_SX1262_FAST_MODEM_CACHE 0", + "#define MC_SX1262_FAST_MODEM_CACHE 1", + "#define MC_SX1262_FAST_MODEM_CACHE 0\n#define MC_SX1262_HIL 1"): + with self.subTest(policy=policy): + self.compile_run(fixture.replace("@DEFINES@", policy) + .replace("@STATE@", state).replace("@PRODUCTION@", production)) + + +if __name__ == "__main__": + unittest.main() diff --git a/variants/lilygo_tdeck/platformio.ini b/variants/lilygo_tdeck/platformio.ini index caefc27ef..81df9f3a6 100644 --- a/variants/lilygo_tdeck/platformio.ini +++ b/variants/lilygo_tdeck/platformio.ini @@ -12,6 +12,9 @@ build_flags = -D PIN_USER_BTN=0 ; Trackball button -D PIN_PERF_POWERON=10 ; Peripheral power pin -D USE_SX1262 + -D MC_SX1262_SPI_HZ=8888888 + -D MC_SX1262_FAST_MODEM_CACHE=1 + -D MC_SX1262_FAST_IQ_CACHE=1 -D RADIO_CLASS=CustomSX1262 -D WRAPPER_CLASS=CustomSX1262Wrapper -D LORA_TX_POWER=22