From 64a928d18fc48835c20b4871e90607f652745ef4 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Sat, 17 Oct 2026 14:19:42 -0700 Subject: [PATCH] Reduce Indicator radio expander traffic with guarded IQ reuse --- .github/workflows/run-unit-tests.yml | 1 + src/helpers/radiolib/CustomSX1262.h | 59 +++- .../radiolib/SX1262ProfileSwitchState.h | 3 + test/test_indicator_radio_hal.py | 188 +++++++++++++ test/test_profile_switch_fast_policy.py | 161 +++++++++++ test/test_profile_sx1262_iq_cache.py | 253 ++++++++++++++++++ test/test_sx1262_batched_modulation.py | 2 +- tools/hil/profile_switch.cpp | 63 ++++- tools/hil/profile_switch.ini | 4 +- tools/hil/profile_switch_README.md | 96 +++++-- tools/hil/profile_switch_packets.h | 11 +- .../IndicatorRadioHal.cpp | 30 ++- .../IndicatorRadioHal.h | 17 +- .../sensecap_indicator-espnow/platformio.ini | 3 + 14 files changed, 851 insertions(+), 40 deletions(-) create mode 100644 test/test_indicator_radio_hal.py create mode 100644 test/test_profile_switch_fast_policy.py create mode 100644 test/test_profile_sx1262_iq_cache.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 9d3dd2fa1..974c18b55 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -444,6 +444,7 @@ jobs: python3 -B test/test_mercer_mt_flash.py -v python3 -B test/test_mercer_nrf_app_flash.py -v python3 -B test/test_indicator_repeater_hil_recipe.py -v + python3 -B test/test_indicator_radio_hal.py -v python3 -B test/test_mota_usb_seeder_hil.py -v python3 -B test/test_mota_espnow_chain_hil.py -v python3 -B test/test_meshtext_auto_radio2_hil.py -v diff --git a/src/helpers/radiolib/CustomSX1262.h b/src/helpers/radiolib/CustomSX1262.h index c5a348b85..ebe3156e0 100644 --- a/src/helpers/radiolib/CustomSX1262.h +++ b/src/helpers/radiolib/CustomSX1262.h @@ -25,7 +25,12 @@ #define MC_SX1262_FAST_PACKET_CACHE MC_SX1262_FAST_PROFILE_SWITCH #endif -#if (MC_SX1262_FAST_FS || MC_SX1262_FAST_PACKET_CACHE) && !MC_SX1262_FAST_PROFILE_SWITCH +#ifndef MC_SX1262_FAST_IQ_CACHE +// An additional optimization, enabled per board only after hardware testing. +#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" #endif @@ -75,8 +80,9 @@ class CustomSX1262 : public SX1262 { // Same-image A/B overrides; these default off on every HIL reboot. bool hilUseFsOnRetune = false; bool hilSkipUnchangedPacket = false; + bool hilSkipUnchangedIq = false; float hilFrequencyMHz() const { return freqMHz; } - uint32_t hilFsEntries = 0, hilPacketSkips = 0; + uint32_t hilFsEntries = 0, hilPacketSkips = 0, hilIqWorkaroundSkips = 0; #endif int16_t restoreTcxoAfterSleep() { @@ -555,6 +561,38 @@ class CustomSX1262 : public SX1262 { return RADIOLIB_ERR_NONE; } +#if MC_SX1262_FAST_IQ_CACHE || defined(MC_SX1262_HIL) + // Firmware-level direct register/packet writes are not an acknowledged + // owned RX tuple, even when they fail or restore the same software IQ. + int16_t writeRegister(uint16_t address, const uint8_t* data, uint8_t size) { + _profileSwitch.invalidatePacket(); + return SX1262::writeRegister(address, data, size); + } + int16_t setPacketParams(uint16_t preamble, uint8_t crc, uint8_t payload, + uint8_t header, uint8_t iq) { + _profileSwitch.invalidatePacket(); + return SX1262::setPacketParams(preamble, crc, payload, header, iq); + } + private: + int16_t writeProfilePacketParams(bool reuseIq) { + if (!reuseIq) { + return setPacketParams(preambleLengthLoRa, crcTypeLoRa, implicitLen, + headerType, invertIQEnabled); + } + // Only the acknowledged IQ workaround is retained. A changed preamble + // still needs the COMPLETE ordinary SetPacketParams command. Preserve + // its on-wire field order and Module BUSY/status/error handling. + const uint8_t data[] = { + static_cast(preambleLengthLoRa >> 8), + static_cast(preambleLengthLoRa), headerType, + static_cast(implicitLen), + crcTypeLoRa, invertIQEnabled}; + return getMod()->SPIwriteStream(RADIOLIB_SX126X_CMD_SET_PACKET_PARAMS, + data, sizeof(data)); + } + public: +#endif + int16_t startReceive() override { if (_profileSwitch.canResumeFast()) { // Existing continuous RX already established buffer bases and the @@ -567,14 +605,31 @@ class CustomSX1262 : public SX1262 { skipPacket = skipPacket && _profileSwitch.matchesPacket( preambleLengthLoRa, crcTypeLoRa, implicitLen, headerType, invertIQEnabled); +#if MC_SX1262_FAST_IQ_CACHE || defined(MC_SX1262_HIL) + bool reuseIq = MC_SX1262_FAST_IQ_CACHE != 0; +#ifdef MC_SX1262_HIL + reuseIq = hilSkipUnchangedIq; +#endif + // Capture this capability BEFORE revoking rxValid for the command + // sequence. Packet/IQ setters, protocol changes, sleep, reset, TX/CAD, + // duty cycling and command failures revoke the acknowledged tuple. + reuseIq = reuseIq && _profileSwitch.matchesPacketIq(invertIQEnabled); +#endif _profileSwitch.rxValid = false; // fail closed on any command error int16_t rc = clearIrqStatus(); #ifdef MC_SX1262_HIL if (rc == RADIOLIB_ERR_NONE && skipPacket) ++hilPacketSkips; #endif if (rc == RADIOLIB_ERR_NONE && !skipPacket) { +#if MC_SX1262_FAST_IQ_CACHE || defined(MC_SX1262_HIL) + rc = writeProfilePacketParams(reuseIq); +#ifdef MC_SX1262_HIL + if (rc == RADIOLIB_ERR_NONE && reuseIq) ++hilIqWorkaroundSkips; +#endif +#else rc = setPacketParams(preambleLengthLoRa, crcTypeLoRa, implicitLen, headerType, invertIQEnabled); +#endif _profileSwitch.packetResult(rc, preambleLengthLoRa, crcTypeLoRa, implicitLen, headerType, invertIQEnabled); } diff --git a/src/helpers/radiolib/SX1262ProfileSwitchState.h b/src/helpers/radiolib/SX1262ProfileSwitchState.h index 07b4b9cb7..5dfed042e 100644 --- a/src/helpers/radiolib/SX1262ProfileSwitchState.h +++ b/src/helpers/radiolib/SX1262ProfileSwitchState.h @@ -64,6 +64,9 @@ struct SX1262ProfileSwitchState { && packetCrc == crc && packetImplicitLen == implicitLen && packetHeader == header && packetIq == iq; } + bool matchesPacketIq(uint8_t iq) const { + return canResumeFast() && packetValid && packetIq == iq; + } void packetResult(int16_t result, size_t preamble, uint8_t crc, uint8_t implicitLen, uint8_t header, uint8_t iq) { if (result != 0) { diff --git a/test/test_indicator_radio_hal.py b/test/test_indicator_radio_hal.py new file mode 100644 index 000000000..e0c3e824d --- /dev/null +++ b/test/test_indicator_radio_hal.py @@ -0,0 +1,188 @@ +"""Compile the actual Indicator HAL against an error-injectable I2C device.""" +from pathlib import Path +import os +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] + + +class IndicatorRadioHalTest(unittest.TestCase): + def test_port_zero_transactions_fresh_busy_and_shared_irq_errors(self): + arduino = r''' +#pragma once +#include +#include +constexpr uint32_t RADIOLIB_NC = 0xffffffff; +constexpr int LOW=0, HIGH=1, INPUT_PULLUP=2; +constexpr int PIN_BOARD_SDA=39, PIN_BOARD_SCL=40, P_LORA_BUSY=66; +extern int nativeLevel, nativeWrites, nativeModes; +inline void pinMode(uint32_t, uint32_t) { ++nativeModes; } +inline int digitalRead(uint32_t) { return nativeLevel; } +''' + buffered = r''' +#pragma once +#include +#include +struct ArduinoHal { + enum { GpioModeOutput=1, GpioModeInput=0, GpioLevelLow=0, GpioLevelHigh=1, + GpioInterruptRising=2, GpioInterruptFalling=3 }; + virtual ~ArduinoHal() = default; + virtual void pinMode(uint32_t, uint32_t) { ++nativeModes; } + virtual void digitalWrite(uint32_t, uint32_t) { ++nativeWrites; } + virtual uint32_t digitalRead(uint32_t) { return nativeLevel; } + virtual void attachInterrupt(uint32_t, void (*)(), uint32_t) {} + virtual void detachInterrupt(uint32_t) {} + virtual uint32_t pinToInterrupt(uint32_t pin) { return pin + 100; } +}; +struct ESP32BufferedRadioHal : ArduinoHal { + explicit ESP32BufferedRadioHal(SPIClass&) {} +}; +constexpr uint32_t RISING = ArduinoHal::GpioInterruptRising; +''' + gfx = r''' +#pragma once +#include +#include +namespace lgfx { namespace i2c { +struct Result { bool ok; bool has_value() const { return ok; } }; +Result readRegister(uint8_t, uint8_t, uint8_t, uint8_t*, size_t, uint32_t); +Result transactionWrite(uint8_t, uint8_t, const uint8_t*, size_t, uint32_t); +Result init(uint8_t, uint8_t, uint8_t); +} } +''' + fixture = r''' +#include +#include +#include +#include "@ROOT@/variants/sensecap_indicator-espnow/IndicatorRadioHal.cpp" +int nativeLevel=HIGH, nativeWrites=0, nativeModes=0; +uint8_t registers[8] = {0,0,0xff,0xff,0,0,0xff,0xff}; +struct Transfer { bool write; uint8_t reg; size_t size; }; +std::vector transfers; +bool failRead=false, failWrite=false, failInit=false; +unsigned callbacks=0, inits=0; +void irq() { ++callbacks; } +namespace lgfx { namespace i2c { +Result readRegister(uint8_t port,uint8_t address,uint8_t reg,uint8_t* data, + size_t size,uint32_t frequency) { + assert(port==0 && address==0x20 && frequency==400000); + assert((size==1 || size==2) && reg+size<=8); + transfers.push_back({false,reg,size}); + if(failRead) return {false}; + std::copy(registers+reg,registers+reg+size,data);return {true}; +} +Result transactionWrite(uint8_t port,uint8_t address,const uint8_t* data, + size_t size,uint32_t frequency) { + assert(port==0 && address==0x20 && frequency==400000); + assert((size==2 || size==3) && data[0]+size-1<=8); + transfers.push_back({true,data[0],size}); + if(failWrite) return {false}; + std::copy(data+1,data+size,registers+data[0]);return {true}; +} +Result init(uint8_t port,uint8_t sda,uint8_t scl) { + assert(port==0 && sda==39 && scl==40);++inits;return {!failInit}; +} +} } +void last(bool write,uint8_t reg,size_t size) { + assert(!transfers.empty());auto t=transfers.back(); + assert(t.write==write && t.reg==reg && t.size==size); +} +int main() { + SPIClass spi;IndicatorRadioHal hal(spi); + assert(hal.beginExpander() && transfers.size()==3); + for(auto t:transfers) assert(!t.write && t.size==2); + assert(hal.beginExpander() && transfers.size()==3); + + // Low-bank pin updates send only that bank, leaving unrelated Port 1 alone. + registers[7]=0x5a; + hal.pinMode(64,ArduinoHal::GpioModeOutput);last(true,6,2); + assert(registers[6]==0xfe && registers[7]==0x5a); + registers[3]=0x5a; + hal.digitalWrite(64,ArduinoHal::GpioLevelLow);last(true,2,2); + assert(registers[2]==0xfe && registers[3]==0x5a); + size_t count=transfers.size();hal.digitalWrite(64,0); + assert(transfers.size()==count); // unchanged latch still needs no write + + // Failed writes must not poison the software shadow or suppress the retry. + failWrite=true;hal.digitalWrite(64,1);last(true,2,2); + assert(registers[2]==0xfe);failWrite=false; + hal.digitalWrite(64,1);assert(registers[2]==0xff); + failWrite=true;hal.pinMode(65,ArduinoHal::GpioModeOutput); + assert(registers[6]==0xfe);failWrite=false; + hal.pinMode(65,ArduinoHal::GpioModeOutput);assert(registers[6]==0xfc); + + // Preserve generic Port 1 support and its original whole-word transactions. + hal.digitalWrite(72,0);last(true,2,3);assert(registers[3]==0xfe); + hal.pinMode(72,ArduinoHal::GpioModeOutput);last(true,6,3); + registers[1]=1;assert(hal.digitalRead(72)==1);last(false,0,2); + + // BUSY must be read over I2C every time, even with expander INT inactive. + nativeLevel=HIGH;registers[0]=4; + assert(hal.digitalRead(66)==1);last(false,0,1); + count=transfers.size();registers[0]=0; + assert(hal.digitalRead(66)==0 && transfers.size()==count+1);last(false,0,1); +#ifdef MC_SX1262_HIL + // Only HIL can restore the old two-bank bus-width control in this image. + hal.hilUsePort0Only=false; + assert(hal.digitalRead(66)==0);last(false,0,2); + hal.digitalWrite(64,0);last(true,2,3); + hal.pinMode(65,ArduinoHal::GpioModeInput);last(true,6,3); + hal.hilUsePort0Only=true; + assert(hal.digitalRead(66)==0);last(false,0,1); + hal.digitalWrite(64,1);last(true,2,2); + hal.pinMode(65,ArduinoHal::GpioModeOutput);last(true,6,2); +#endif + failRead=true;assert(hal.digitalRead(66)==1 && hal.digitalRead(67)==0); + failRead=false; + + // IRQ service still reads BOTH banks, clearing all shared expander sources. + hal.attachInterrupt(67,irq,ArduinoHal::GpioInterruptRising);last(false,0,2); + registers[0]=8;nativeLevel=LOW; + hal.serviceInterrupt();last(false,0,2);assert(callbacks==1); + hal.serviceInterrupt();assert(callbacks==1); + registers[0]=0;assert(hal.digitalRead(66)==0);assert(callbacks==1); + failRead=true;registers[0]=8;hal.serviceInterrupt();assert(callbacks==1); + failRead=false;hal.serviceInterrupt();assert(callbacks==2); + hal.detachInterrupt(67);count=transfers.size();hal.serviceInterrupt(); + assert(count==transfers.size()); + + // Native and invalid pins do not accidentally address the expander. + count=transfers.size();hal.digitalWrite(5,1);hal.pinMode(5,1); + assert(hal.digitalRead(5)==LOW && nativeWrites==1); + assert(hal.pinToInterrupt(5)==105 && hal.pinToInterrupt(67)==67); + assert(hal.digitalRead(80)==0);hal.digitalWrite(80,1);hal.pinMode(80,1); + assert(count==transfers.size()); + + // Initial bus recovery remains whole-word and fails closed when unavailable. + IndicatorRadioHal failed(spi);failRead=true; + assert(!failed.beginExpander() && inits==1); + assert(failed.digitalRead(66)==1);failRead=false; + assert(failed.beginExpander()); +} +'''.replace("@ROOT@", str(ROOT)) + with tempfile.TemporaryDirectory() as folder: + base = Path(folder) + (base / "helpers/radiolib").mkdir(parents=True) + (base / "Arduino.h").write_text(arduino) + (base / "SPI.h").write_text("#pragma once\nstruct SPIClass {};\n") + (base / "LovyanGFX.hpp").write_text(gfx) + (base / "helpers/radiolib/ESP32BufferedRadioHal.h").write_text(buffered) + source, binary = base / "fixture.cpp", base / "fixture" + source.write_text(fixture) + for definitions in ([], ["-DMC_SX1262_HIL"]): + with self.subTest(definitions=definitions): + built = subprocess.run([os.environ.get("CXX", "g++"), "-std=c++17", + "-Wall", "-Wextra", "-Werror", "-fsanitize=address,undefined", + "-fno-omit-frame-pointer", "-no-pie", "-DSENSECAP_INDICATOR_LORA", + *definitions, "-I", str(base), str(source), "-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) + self.assertEqual(ran.returncode, 0, ran.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_profile_switch_fast_policy.py b/test/test_profile_switch_fast_policy.py new file mode 100644 index 000000000..7c6c8782b --- /dev/null +++ b/test/test_profile_switch_fast_policy.py @@ -0,0 +1,161 @@ +"""Execute HIL fast-policy parsing/admission without touching radio hardware.""" +from pathlib import Path +import configparser +import re +import unittest + +import test_sx1262_batched_modulation as compiler +import test_radio_profile_clock as clock +from test_radio_receive_contract import method + + +ROOT = Path(__file__).resolve().parents[1] + + +class ProfileFastPolicyTest(unittest.TestCase): + compile_run = compiler.BatchedModulationTests.compile_run + + def test_hil_radiolib_matches_current_production_dependency(self): + config = configparser.ConfigParser(interpolation=None, strict=False, + inline_comment_prefixes=(";",)) + config.read([ROOT / "platformio.ini", ROOT / "tools/hil/profile_switch.ini"]) + + def resolve(section, option, depth=0): + self.assertLess(depth, 30, "recursive HIL recipe interpolation") + text = config.get(section, option) + return re.sub(r"\$\{([^}]+)\}", lambda match: + resolve(*match[1].split(".", 1), depth + 1), text) + + pattern = r"https://github\.com/jgromes/RadioLib\.git#[0-9a-f]{40}" + production = re.findall(pattern, resolve("arduino_base", "lib_deps")) + self.assertEqual(len(production), 1) + environments = [section for section in config.sections() + if section.startswith("env:profile_switch_")] + self.assertGreaterEqual(len(environments), 6) + for section in environments: + with self.subTest(environment=section): + self.assertEqual(re.findall(pattern, resolve(section, "lib_deps")), production) + + def test_policy_selection_is_exact_and_quiet_receiver_only(self): + source = (ROOT / "tools/hil/profile_switch.cpp").read_text() + policy = method(source, "bool selectProfileFastPolicy(") + fixture = r''' +#include +#include +#include +#include +struct { bool active=false; } stationaryBaseline,preambleDiagnostic,channelSweep; +bool packetListening=false; +struct { bool hilUseFsOnRetune=false,hilSkipUnchangedPacket=false,hilSkipUnchangedIq=false; } chip; +struct { bool hilUsePort0Only=true; } radioHal; +struct SerialMock { + std::string text; + void println(const char* s) { text=s; } + void printf(const char* format,...) { + char bytes[256];va_list args;va_start(args,format); + vsnprintf(bytes,sizeof(bytes),format,args);va_end(args);text=bytes; + } +} Serial; +@POLICY@ +int main() { + assert(selectProfileFastPolicy("fs 1") && chip.hilUseFsOnRetune); + assert(Serial.text=="{\"fs\":true}\n"); + assert(selectProfileFastPolicy("packetcache 1") && chip.hilSkipUnchangedPacket); + assert(Serial.text=="{\"packet_cache\":true}\n"); + assert(selectProfileFastPolicy("fs 0") && !chip.hilUseFsOnRetune); + assert(chip.hilSkipUnchangedPacket); + for(const char* invalid:{"fs 2","fs -1","fs 1 junk","fs 1 0","fs", + "packetcache 2","packetcache 1 junk","packetcache", "info"}) { + Serial.text.clear();assert(!selectProfileFastPolicy(invalid)); + assert(Serial.text.empty() && !chip.hilUseFsOnRetune && chip.hilSkipUnchangedPacket); + } + bool* guards[]={&packetListening,&stationaryBaseline.active, + &preambleDiagnostic.active,&channelSweep.active}; + for(bool* guard:guards) { + *guard=true; + assert(selectProfileFastPolicy("fs 1") && !chip.hilUseFsOnRetune); + assert(Serial.text.find("error")!=std::string::npos); + assert(selectProfileFastPolicy("packetcache 0") && chip.hilSkipUnchangedPacket); + assert(Serial.text.find("error")!=std::string::npos); + *guard=false; + } + assert(selectProfileFastPolicy("packetcache 0") && !chip.hilSkipUnchangedPacket); + assert(selectProfileFastPolicy("iqcache 1") && chip.hilSkipUnchangedIq); + assert(Serial.text=="{\"iq_cache\":true}\n"); + for(bool* guard:guards) { + *guard=true;assert(selectProfileFastPolicy("iqcache 0")); + assert(chip.hilSkipUnchangedIq && Serial.text.find("error")!=std::string::npos); + *guard=false; + } + assert(selectProfileFastPolicy("iqcache 0") && !chip.hilSkipUnchangedIq); + for(const char* invalid:{"iqcache 2","iqcache -1","iqcache 1 junk","iqcache"}) + assert(!selectProfileFastPolicy(invalid) && !chip.hilSkipUnchangedIq); +#ifdef HIL_INDICATOR + assert(selectProfileFastPolicy("port0 0") && !radioHal.hilUsePort0Only); + assert(Serial.text=="{\"port0\":false}\n"); + for(bool* guard:guards) { + *guard=true;assert(selectProfileFastPolicy("port0 1")); + assert(!radioHal.hilUsePort0Only && Serial.text.find("error")!=std::string::npos); + *guard=false; + } + assert(selectProfileFastPolicy("port0 1") && radioHal.hilUsePort0Only); + for(const char* invalid:{"port0 2","port0 -1","port0 0 junk","port0"}) + assert(!selectProfileFastPolicy(invalid) && radioHal.hilUsePort0Only); +#else + assert(!selectProfileFastPolicy("port0 0") && radioHal.hilUsePort0Only); +#endif +} +'''.replace("@POLICY@", policy) + self.compile_run(fixture) + self.compile_run("#define HIL_INDICATOR\n" + fixture) + + def test_receive_timestamp_retains_esp32_microsecond_units_and_wrap(self): + source = (ROOT / "tools/hil/profile_switch.cpp").read_text() + getter = method(source, "uint32_t receiveStartedUs()") + self.assertNotIn("_profile_visit_us", getter) + self.assertIn("#if !defined(ESP32_PLATFORM)", getter) + self.assertIn('#error "profile_switch.cpp requires the ESP32 microsecond profile clock"', getter) + fixture = r''' +#include +#include +#define ESP32_PLATFORM +uint32_t clockUs=0; +uint32_t micros() { return clockUs; } +@CLOCK@ +struct BenchWrapper { uint32_t _profile_visit_stamp=0; @GETTER@ }; +int main() { + BenchWrapper driver; + clockUs=0xfffffff0; + driver._profile_visit_stamp=profileTimestamp(true); + assert(driver.receiveStartedUs()==clockUs); + clockUs=0x15; + assert(uint32_t(micros()-driver.receiveStartedUs())==37); + assert(profileElapsedUs(driver.receiveStartedUs(),profileTimestamp(true),true)==37); + clockUs=1234567;driver._profile_visit_stamp=profileTimestamp(false); + assert(driver.receiveStartedUs()==1234567); +} +'''.replace("@CLOCK@", clock.CLOCK).replace("@GETTER@", getter) + self.compile_run(fixture) + + def test_timing_and_rf_readiness_report_exercised_fast_operations(self): + sources = (("profile_switch.cpp", "void run("), + ("profile_switch_packets.h", "void listenPacket(")) + for name, signature in sources: + with self.subTest(source=name): + source = (ROOT / "tools/hil" / name).read_text() + body = method(source, signature) + for field in ("fs", "packet_cache", "fs_entries", "packet_skips", "iq_cache", "iq_workaround_skips"): + self.assertIn('\\"' + field + '\\"', body) + self.assertIn("chip.hilFsEntries-fsStart", body) + self.assertIn("chip.hilPacketSkips-packetStart", body) + self.assertIn("chip.hilUseFsOnRetune", body) + self.assertIn("chip.hilSkipUnchangedPacket", body) + self.assertIn("chip.hilIqWorkaroundSkips-iqStart", body) + chip = (ROOT / "src/helpers/radiolib/CustomSX1262.h").read_text() + self.assertIn("bool hilUseFsOnRetune = false;", chip) + self.assertIn("bool hilSkipUnchangedPacket = false;", chip) + self.assertIn("bool hilSkipUnchangedIq = false;", chip) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_profile_sx1262_iq_cache.py b/test/test_profile_sx1262_iq_cache.py new file mode 100644 index 000000000..4f2113746 --- /dev/null +++ b/test/test_profile_sx1262_iq_cache.py @@ -0,0 +1,253 @@ +"""Execute owned IQ-workaround reuse, wire bytes, revocation and failures.""" +from pathlib import Path +import os +import re +import subprocess +import tempfile +import unittest + +from test_radio_receive_contract import method + + +ROOT = Path(__file__).resolve().parents[1] + + +class ProfileIqCacheTest(unittest.TestCase): + def test_only_qualified_indicator_recipe_opts_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"]) + + def compile_run(self, source): + with tempfile.TemporaryDirectory() as folder: + cpp, binary = Path(folder) / "test.cpp", Path(folder) / "test" + cpp.write_text(source, encoding="ascii") + 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)], + capture_output=True, text=True) + self.assertEqual(built.returncode, 0, built.stderr) + ran = subprocess.run([str(binary)], capture_output=True, text=True) + self.assertEqual(ran.returncode, 0, ran.stderr) + + def test_owned_iq_ack_wire_payload_setters_lifecycle_and_errors(self): + source = (ROOT / "src/helpers/radiolib/CustomSX1262.h").read_text() + state = (ROOT / "src/helpers/radiolib/SX1262ProfileSwitchState.h").read_text().replace("#pragma once", "") + signatures = ("void setProfileSwitchOptimization(", "void beginProfileSwitch(", + "void endProfileSwitch(", "int16_t reset(bool", "int16_t sleep(bool", + "int16_t stageMode(", "int16_t setPreambleLength(", "int16_t setCRC(", + "int16_t implicitHeader(", "int16_t explicitHeader()", "int16_t invertIQ(", + "int16_t setProfileProtocol(", "int16_t writeRegister(", + "int16_t setPacketParams(", "int16_t writeProfilePacketParams(", + "int16_t startReceive()", "int16_t startReceiveDutyCycle(") + methods = "\n".join(method(source, signature).replace(" override", "") for signature in signatures) + fixture = r''' +#include +#include +#include +#include +#include +@DEFINES@ +#define MC_SX1262_FAST_PACKET_CACHE 0 +#define RADIOLIB_ERR_NONE 0 +#define RADIOLIB_SX126X_STANDBY_RC 0 +#define RADIOLIB_SX126X_RX_TIMEOUT_INF 0xffffff +#define RADIOLIB_SX126X_PACKET_TYPE_LORA 1 +#define RADIOLIB_SX126X_CMD_SET_PACKET_PARAMS 0x8c +#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_SX126X_LORA_CRC_ON 1 +#define RADIOLIB_SX126X_LORA_HEADER_EXPLICIT 0 +#define RADIOLIB_SX126X_LORA_IQ_STANDARD 0 +using RadioModeType_t=int; +using RadioLibIrqFlags_t=uint32_t; +struct RadioModeConfig_t {}; +@STATE@ +struct Module { + enum { MODE_RX=1 }; + std::string* trace;char* fail;std::vector packet; + int16_t SPIwriteStream(uint16_t command,const uint8_t* data,size_t size) { + assert(command==0x8c && size==6);packet.assign(data,data+size); + *trace+='P';return *fail=='P' ? -100:0; + } + void setRfSwitchState(int mode) { assert(mode==MODE_RX);*trace+='W'; } +}; +struct SX1262 { + std::string trace;char fail=0;int modem=1;bool standbyXOSC=true; + uint16_t preambleLengthLoRa=32; + uint8_t crcTypeLoRa=1,headerType=0,invertIQEnabled=0; + size_t implicitLen=255; + uint32_t rxTimeout=0;int stagedMode=3;float tcxoVoltage=1.8f; + Module module{&trace,&fail,{}};Module* mod=&module; + int16_t call(char c) { trace+=c;return c==fail ? -100:0; } + Module* getMod() { return mod; } + int16_t standby(uint8_t) { return call('S'); } + int16_t sleep(bool) { return call('L'); } + int16_t reset(bool) { return call('Z'); } + virtual int16_t stageMode(RadioModeType_t,RadioModeConfig_t*) { return call('G'); } + int16_t setPacketParams(uint16_t p,uint8_t crc,uint8_t len,uint8_t hdr,uint8_t iq) { + int16_t rc=call('I');if(!rc) rc=call('i'); + const uint8_t data[]={uint8_t(p>>8),uint8_t(p),hdr,len,crc,iq}; + if(!rc) rc=module.SPIwriteStream(0x8c,data,sizeof(data));return rc; + } + virtual int16_t setPreambleLength(size_t p) { + preambleLengthLoRa=p;return setPacketParams(p,crcTypeLoRa,implicitLen,headerType,invertIQEnabled); + } + int16_t setCRC(uint8_t crc,uint16_t,uint16_t,bool) { + crcTypeLoRa=crc;return setPacketParams(preambleLengthLoRa,crc,implicitLen,headerType,invertIQEnabled); + } + int16_t implicitHeader(size_t n) { + implicitLen=n;headerType=1; + return setPacketParams(preambleLengthLoRa,crcTypeLoRa,implicitLen,headerType,invertIQEnabled); + } + int16_t explicitHeader() { + implicitLen=255;headerType=0; + return setPacketParams(preambleLengthLoRa,crcTypeLoRa,implicitLen,headerType,invertIQEnabled); + } + virtual int16_t invertIQ(bool enable) { + invertIQEnabled=enable; + return setPacketParams(preambleLengthLoRa,crcTypeLoRa,implicitLen,headerType,invertIQEnabled); + } + int16_t setSyncWord(uint8_t) { return call('Y'); } + int16_t writeRegister(uint16_t,const uint8_t*,uint8_t) { return call('J'); } + int16_t startReceive(uint32_t,uint32_t,uint32_t,size_t) { + int16_t rc=stageMode(1,nullptr);if(!rc) rc=call('F'); + if(!rc) rc=setPacketParams(preambleLengthLoRa,crcTypeLoRa,implicitLen,headerType,invertIQEnabled); + if(!rc) rc=call('R');return rc; + } + int16_t startReceiveDutyCycle(uint32_t,uint32_t,uint32_t,uint32_t) { return call('D'); } + uint8_t getPacketType() { call('Q');return modem; } + int16_t clearIrqStatus() { return call('C'); } + int16_t setRx(uint32_t timeout) { assert(timeout==0xffffff);return call('R'); } +}; +struct Radio : SX1262 { + SX1262ProfileSwitchState _profileSwitch{true}; + bool _coldStandby=true,_tcxoWakePending=false,_rx_ps_rf_rx_disabled=false; + uint32_t _profileFastResumes=0; +#ifdef MC_SX1262_HIL + bool hilSkipUnchangedPacket=false,hilSkipUnchangedIq=true; + uint32_t hilPacketSkips=0,hilIqWorkaroundSkips=0; +#endif + @METHODS@ +}; +void prime(Radio& r) { + assert(r.startReceive()==0); + assert(r.trace.find("IiP")!=std::string::npos && r.trace.back()=='Q'); + assert(r._profileSwitch.rxValid && r._profileSwitch.packetValid); + assert(!r._profileSwitch.matchesPacketIq(r.invertIQEnabled)); + r.trace.clear(); +} +void arm(Radio& r) { + r.beginProfileSwitch(true); + assert(!r._profileSwitch.matchesPacketIq(r.invertIQEnabled)); + r._profileSwitch.standbyResult(0); +} +int main() { + // Only IQ is reused: changing every other packet field still emits its full + // six-byte command in the exact SX126x wire order, including both pre bytes. + for(uint8_t iq:{0,1}) { + Radio r;r.invertIQEnabled=iq;prime(r);arm(r); + assert(r._profileSwitch.matchesPacketIq(iq)); + r.preambleLengthLoRa=0x1234;r.crcTypeLoRa=2;r.implicitLen=73;r.headerType=1; + assert(r.startReceive()==0 && r.trace=="CPWR"); + assert(r.module.packet==std::vector({0x12,0x34,1,73,2,iq})); + assert(r._profileSwitch.packetValid && r._profileSwitch.packetPreamble==0x1234); + r.endProfileSwitch(true); + } + // A changed IQ or an unacknowledged packet must run the original workaround. + for(int gate=0;gate<3;++gate) { + Radio r;prime(r);arm(r); + if(gate==0) r.invertIQEnabled=1; + if(gate==1) r._profileSwitch.invalidatePacket(); +#ifdef MC_SX1262_HIL + if(gate==2) r.hilSkipUnchangedIq=false; +#else + if(gate==2) continue; +#endif + assert(r.startReceive()==0 && r.trace=="CIiPWR"); + } + // Owned preamble staging is the intended fast case, unlike an ordinary + // preamble command. Every ordinary packet/direct-register path revokes IQ. + { Radio r;prime(r);arm(r);assert(r.setPreambleLength(64)==0 && r.trace.empty()); + assert(r.startReceive()==0 && r.trace=="CPWR"); } + for(int setter=0;setter<8;++setter) for(bool failure:{false,true}) { + Radio r;prime(r); + if(setter!=4) arm(r); + r.fail=failure ? (setter==5?'Y':setter==6?'J':'P'):0; + uint8_t value=4; + switch(setter) { + case 0:r.setCRC(2);break; + case 1:r.implicitHeader(73);break; + case 2:r.explicitHeader();break; + case 3:r.invertIQ(false);break; + case 4:r.setPreambleLength(64);break; + case 5:r.setProfileProtocol(0x2b);break; + case 6:r.writeRegister(0x736,&value,1);break; + case 7:r.setPacketParams(64,1,255,0,0);break; + } + assert(!r._profileSwitch.packetValid); + r.fail=0;r.trace.clear(); + if(failure && setter==5) { + assert(!r._profileSwitch.rxValid);continue; + } + if(setter==4) arm(r); + assert(!r._profileSwitch.matchesPacketIq(r.invertIQEnabled)); + assert(r.startReceive()==0 && r.trace=="CIiPWR"); + } + // The acknowledged tuple cannot survive TX/CAD staging, either sleep, + // reset, RX duty cycling, explicit disable, rollback, or a failed RX command. + for(int event=0;event<8;++event) for(bool failure:{false,true}) { + Radio r;prime(r);arm(r);r.fail=failure?'G':0; + switch(event) { + case 0:r.stageMode(2,nullptr);break; + case 1:r.stageMode(3,nullptr);break; + case 2:r.sleep(true);break; + case 3:r.sleep(false);break; + case 4:r.reset();break; + case 5:r.startReceiveDutyCycle(1000,1000);break; + case 6:r.setProfileSwitchOptimization(false);break; + case 7:r.endProfileSwitch(false);break; + } + assert(!r._profileSwitch.packetValid && !r._profileSwitch.rxValid); + assert(!r._profileSwitch.matchesPacketIq(r.invertIQEnabled)); + } + for(char failure:std::string("CIiPR")) { + Radio r;prime(r);arm(r); + if(failure=='I' || failure=='i') r._profileSwitch.invalidatePacket(); + r.fail=failure;assert(r.startReceive()==-100 && r.trace.back()==failure); + assert(!r._profileSwitch.rxValid && !r._profileSwitch.packetValid); + r.endProfileSwitch(false);r.fail=0;r.trace.clear();prime(r); + } + for(int gate=0;gate<4;++gate) { + Radio r;prime(r); + switch(gate) { + case 0:r.beginProfileSwitch(false);r._profileSwitch.standbyResult(0);break; + case 1:r.standbyXOSC=false;arm(r);break; + case 2:r.beginProfileSwitch(true);break; + case 3:r._profileSwitch.packetResult(-100,32,1,255,0,0);break; + } + assert(!r._profileSwitch.matchesPacketIq(0)); + } +} +'''.replace("@STATE@", state).replace("@METHODS@", methods) + for definitions in ("#define MC_SX1262_FAST_IQ_CACHE 1", + "#define MC_SX1262_FAST_IQ_CACHE 0\n#define MC_SX1262_HIL"): + with self.subTest(definitions=definitions): + self.compile_run(fixture.replace("@DEFINES@", definitions)) + + 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) + helper = method(source, "int16_t writeProfilePacketParams(") + self.assertIn("return getMod()->SPIwriteStream(RADIOLIB_SX126X_CMD_SET_PACKET_PARAMS,", helper) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_sx1262_batched_modulation.py b/test/test_sx1262_batched_modulation.py index d5e72de84..e59f7e853 100644 --- a/test/test_sx1262_batched_modulation.py +++ b/test/test_sx1262_batched_modulation.py @@ -59,7 +59,7 @@ struct Radio { int16_t result=0; uint8_t getPacketType() { ++queries; return packetType; } int16_t setModulationParams(uint8_t sf,uint8_t bw,uint8_t cr,uint8_t ldro) { - // RadioLib 187ef247 reads these caches, not just the command arguments, + // The pinned RadioLib reads these caches, not just the command arguments, // when deciding LDRO. Assert they are synchronized before that call. assert(spreadingFactor==sf && bandwidth==bw && codingRate==cr); if (ldroAuto) ldrOptimize=((1U<=16.0f; diff --git a/tools/hil/profile_switch.cpp b/tools/hil/profile_switch.cpp index f34033ea6..8cabc8a38 100644 --- a/tools/hil/profile_switch.cpp +++ b/tools/hil/profile_switch.cpp @@ -75,7 +75,13 @@ class BenchWrapper : public CustomSX1262Wrapper { uint32_t detourFirstMod=0,detourFinalMod=0; uint32_t lastFirstPassUs=0,lastSecondPassUs=0,secondPassBlocked=0; using CustomSX1262Wrapper::CustomSX1262Wrapper; - uint32_t receiveStartedUs() const { return _profile_visit_us; } + uint32_t receiveStartedUs() const { +#if !defined(ESP32_PLATFORM) +#error "profile_switch.cpp requires the ESP32 microsecond profile clock" +#endif + // ESP32 profile stamps are micros(), not the nRF52 RTC2 tick clock. + return _profile_visit_stamp; + } mesh::RadioParamApplyResult hop(uint8_t target) { const bool detour=firstPassDetour && hopPasses==2; const auto desired=_profiles.params(target); @@ -231,6 +237,9 @@ void run(bool warm, int sf, unsigned count, bool batched, unsigned mask, unsigne uint32_t skipped = 0, failed = 0, modeErrors = 0, cacheErrors = 0, timeouts = 0, completed = 0; const uint32_t start = millis(); const uint32_t fastStart = chip.getOptimizedProfileSwitches(); + const uint32_t fsStart = chip.hilFsEntries; + const uint32_t packetStart = chip.hilPacketSkips; + const uint32_t iqStart = chip.hilIqWorkaroundSkips; for (unsigned attempt = 0; completed < count + 8 && attempt < count * 4 + 32; ++attempt) { if (uint32_t(millis() - start) > 30000) break; drain(); @@ -261,10 +270,19 @@ void run(bool warm, int sf, unsigned count, bool batched, unsigned mask, unsigne lastRunResult.text[0]=0; appendBenchResult(lastRunResult,"{\"result\":%u,\"warm\":%s,\"batched\":%s,\"mask\":%u,\"spi_mhz\":%u,\"sf500\":%d,\"requested\":%u," "\"skipped_busy\":%u,\"failures\":%u,\"busy_timeouts\":%u,\"rx_mode_errors\":%u," - "\"cache_errors\":%u,\"tcxo_delay_us\":%u,\"optimized_rx_resumes\":%u,\"directions\":[", + "\"cache_errors\":%u,\"tcxo_delay_us\":%u,\"optimized_rx_resumes\":%u," + "\"fs\":%s,\"packet_cache\":%s,\"fs_entries\":%u,\"packet_skips\":%u," + "\"iq_cache\":%s,\"iq_workaround_skips\":%u,", sequence,warm ? "true" : "false", batched ? "true" : "false", mask, spiMHz, sf, count, skipped, failed, timeouts, modeErrors, cacheErrors, - unsigned(chip.tcxoDelay),unsigned(chip.getOptimizedProfileSwitches()-fastStart)); + unsigned(chip.tcxoDelay),unsigned(chip.getOptimizedProfileSwitches()-fastStart), + chip.hilUseFsOnRetune ? "true" : "false", chip.hilSkipUnchangedPacket ? "true" : "false", + unsigned(chip.hilFsEntries-fsStart),unsigned(chip.hilPacketSkips-packetStart), + chip.hilSkipUnchangedIq ? "true" : "false",unsigned(chip.hilIqWorkaroundSkips-iqStart)); +#ifdef HIL_INDICATOR + appendBenchResult(lastRunResult,"\"port0\":%s,",radioHal.hilUsePort0Only ? "true" : "false"); +#endif + appendBenchResult(lastRunResult,"\"directions\":["); for (int i = 0; i < 2; ++i) { if (i) appendBenchResult(lastRunResult,","); appendBenchResult(lastRunResult,"{\"to_profile\":%d,", i); @@ -293,6 +311,43 @@ void run(bool warm, int sf, unsigned count, bool batched, unsigned mask, unsigne #include "profile_preamble_diagnostic.h" #include "profile_stationary_baseline.h" +// HIL-only, same-image controls for the existing production state machine. +// They do not relax ownership, BUSY polling, or command-status checks, and +// CustomSX1262 resets all three fast policies to false on benchmark reboot. +bool selectProfileFastPolicy(const char* line) { + unsigned value; + char trailing; + unsigned policy; + if (sscanf(line, "fs %u %c", &value, &trailing) == 1 && value <= 1) { + policy = 0; + } else if (sscanf(line, "packetcache %u %c", &value, &trailing) == 1 + && value <= 1) { + policy = 1; + } else if (sscanf(line, "iqcache %u %c", &value, &trailing) == 1 && value <= 1) { + policy = 3; +#ifdef HIL_INDICATOR + } else if (sscanf(line, "port0 %u %c", &value, &trailing) == 1 && value <= 1) { + policy = 2; +#endif + } else { + return false; + } + if (packetListening || stationaryBaseline.active || preambleDiagnostic.active + || channelSweep.active) { + Serial.println("{\"error\":\"stop receiver before changing fast policy\"}"); + } else { + if (policy == 0) chip.hilUseFsOnRetune = value; + else if (policy == 1) chip.hilSkipUnchangedPacket = value; + else if (policy == 3) chip.hilSkipUnchangedIq = value; +#ifdef HIL_INDICATOR + else radioHal.hilUsePort0Only = value; +#endif + Serial.printf("{\"%s\":%s}\n", policy == 0 ? "fs" : policy == 1 ? "packet_cache" : policy == 3 ? "iq_cache" : "port0", + value ? "true" : "false"); + } + return true; +} + void setup() { #ifndef HIL_INDICATOR Serial.setTxBufferSize(4096); @@ -455,6 +510,8 @@ void loop() { stopChannelSweep(); } else if (channelSweep.active && strcmp(line,"info") && strncmp(line,"result ",7) && strcmp(line,"reboot")) { Serial.println("{\"error\":\"stop channel sweep before other experiments\"}"); + } else if (selectProfileFastPolicy(line)) { + // The helper also rejects an active single-packet probe. } else if (sscanf(line, "run %d %d %u %d %u %u %u", &warm, &sf, &count, &batched, &mask, &spiMHz, &seq) == 7 && seq && (warm == 0 || warm == 1) && (batched == 0 || batched == 1) && mask <= 1023 && (spiMHz == 2 || spiMHz == 4 || spiMHz == 8) diff --git a/tools/hil/profile_switch.ini b/tools/hil/profile_switch.ini index ecec7c227..5d9231355 100644 --- a/tools/hil/profile_switch.ini +++ b/tools/hil/profile_switch.ini @@ -8,10 +8,12 @@ platform = platformio/espressif32@6.11.0 board = seeed_xiao_esp32s3 ; Native USB ignores UART baud; avoid the legacy uploader's baud-change step. upload_speed = 115200 +; Match production's RadioLib pin (host recipe test prevents drift), without +; pulling unrelated RTC/I2C-device dependencies into this radio-only harness. lib_deps = SPI Wire - https://github.com/jgromes/RadioLib.git#187ef24791c3d844939b2be13a68bd890bd04e4c + https://github.com/jgromes/RadioLib.git#2325a38a89dd0f0e402c19f67fe910111da1e246 rweather/Crypto @ ^0.4.0 extra_scripts = pre:scripts/esp32_wifi_scan_fix.py diff --git a/tools/hil/profile_switch_README.md b/tools/hil/profile_switch_README.md index 49a4617fe..82ca10657 100644 --- a/tools/hil/profile_switch_README.md +++ b/tools/hil/profile_switch_README.md @@ -84,9 +84,9 @@ into a matched A/B comparison; executable layout also affects small timings. These frequencies do not exercise image recalibration for a large frequency jump. Other boards, especially ones with I/O expanders, require separate tests. -The production 6 ms switch budget and 4 ms loop budget remain unchanged. -The Indicator still exceeds 6 ms; do not shorten common preambles based on -the XIAO result. Timing the wrapper excludes the full application's scheduling. +Production uses the on-device switch self-test plus its 10 percent guard and +the separate loop allowance. Do not shorten common preambles based on another +board's result. Timing the wrapper excludes the full application's scheduling. ## Production implementation and recovery (V8) @@ -111,11 +111,71 @@ fixture, not separately qualified for every board now using them. Fast RX reuses IRQ mapping and buffer bases only after successful continuous LoRa RX, within an owned warm retune. It retains stale-IRQ clearing, RF-switch -control, SetRx and BUSY waits. Packet-parameter/IQ programming remains on the -Indicator and on any changed or untrusted packet tuple. TX, CAD, ordinary +control, SetRx and BUSY waits. Full packet-parameter programming remains on the +Indicator; only matching, acknowledged IQ workaround state may be reused there. +Changed or untrusted IQ uses the original workaround. TX, CAD, ordinary standby, sleep, reset, duty cycling and failed commands invalidate reuse. A failed RX resume rolls back the old tuple/profile through full setup. +The HIL-only `fs 0|1` and `packetcache 0|1` commands now select these two +existing shortcuts independently in the same executable, including on the +Indicator. Both start disabled on every HIL reboot. Set them before `run`, +`listen`, or a channel sweep; an active receiver rejects policy changes. +Timing and packet-readiness replies include `fs`, `packet_cache`, `fs_entries`, +and `packet_skips`. Require the corresponding counters to advance before +claiming that a trial exercised a shortcut. For example, the existing +`run 1 7 64 1 768 8 SEQUENCE` trial can be repeated with each policy pair. +These benchmark controls do not enable the shortcuts in Indicator production +artifacts; on-device status, lifecycle recovery, and packet tests must qualify +them before changing that board's defaults. + +The Indicator HAL clocks only Port 0 for its radio-pin reads and writes; +all four radio control pins are in that bank. BUSY remains a fresh I2C read, +not an interrupt-based cache. Initialization and shared-interrupt servicing +still read both banks, and failed BUSY reads remain busy. This saves one +unrelated byte per radio-pin transaction without increasing the shared bus +above the TCA9535's 400 kHz limit. It does not remove the expander's latency. + +### Stock Indicator qualification (2026-10-17) + +Production enables `MC_SX1262_FAST_IQ_CACHE=1` only for the Indicator. Within an +owned RX hop, an acknowledged packet tuple with the same IQ lets RX startup +omit RadioLib's redundant IQ-register read/write. It still sends all six bytes +of SetPacketParams, including changed preambles, and retains the live modem +query, IRQ clearing, RF switching, BUSY waits and command-status checks. Packet +setters, direct register/packet writes, protocol changes and lifecycle/error +paths revoke reuse. Other boards keep the old path compiled without this +additional optimization. Arbitrary base-qualified GODMODE/SPI writes are not +part of the owned firmware API; such callers must revoke ownership first. + +Same-image controls `port0 0|1` and `iqcache 0|1` isolated both savings on the +stock board, using production RadioLib pin +`2325a38a89dd0f0e402c19f67fe910111da1e246`. FS and whole-packet reuse stayed off. +Two rounds reversed policy and SF order, comparing primary SF7/BW62.5 at +909.5 MHz with secondary SF7/8/9/BW500 at 910.5 MHz, CR4/5 and 8 MHz SPI. +Each of 18 trials excluded eight warmups and measured 128 hops, 64 per direction. + +| Policy | Mean total | Maximum total | Measured hops | +| --- | ---: | ---: | ---: | +| Original two-port I2C accesses | 8553.835 us | 8621 us | 768 | +| Port 0 only | 7892.758 us | 7951 us | 768 | +| Port 0 plus acknowledged IQ reuse | 5999.056 us | 6040 us | 768 | + +The combined reduction was 29.87 percent for these same-protocol pairs. All +2,304 timed hops had zero switch, BUSY, RX-mode or cache errors; every IQ trial +exercised 136 IQ-workaround skips including warmups. A separate tagged RF check +received 36/36 packets bidirectionally against XIAO S3 WIO, across both profiles, +SF7/8/9 and 16/64/255-byte payloads at -9 dBm, with no RF retries. The timing +image SHA256 was +`ae51df8415762c222b56fd61554dea815ced6b6b984c46cc2a1fcdac5d6dc030`. + +Mixed MeshCore/Meshtext protocol changes deliberately lose IQ reuse; do not +apply the 29.87 percent figure to them. These are short-range harness results, +not packet-error-rate, sensitivity, long-soak, full-UI scheduling or current +consumption qualification. Firmware still derives its own preamble allowance. +The HIL `iqcache` switch starts off on reboot; timing/readiness replies report +`iq_cache` and `iq_workaround_skips` so an unexercised shortcut is not a pass. + The recovery collector exercises 20 USB reopen cycles, deliberately lost timing/TX replies, stale TX acknowledgements, and 18 radio lifecycle cases: TX, CAD, retained sleep, reset/init, RC-policy sleep and explicitly reconfigured-TCXO sleep, @@ -797,12 +857,12 @@ The checked FS-mode fast profile switch is enabled by default in `CustomLR1110` for all MeshCore LR1110 targets: T1000-E, Wio WM1110, ThinkNode M3/M7/M9, and Minewsemi ME25LS01. Their radio SPI paths now use 16 MHz buffered transfers. Each board keeps its existing TCXO startup delay; -the 0.6 ms combined-hop goal is **not** a 600 µs TCXO delay. Only the T1000-E +the 0.6 ms combined-hop goal is **not** a 600 us TCXO delay. Only the T1000-E has on-device timing validation so far. Other boards require measured retune timing, packet reception, and TX checks before claiming the 0.6 ms goal. `profile_switch_t1000_lr1110` uses the production `CustomLR1110Wrapper` and -the same 200 µs TCXO/fast-retune settings as the T1000-E variant. It never +the same 200 us TCXO/fast-retune settings as the T1000-E variant. It never transmits, loads an identity, or writes saved settings. Its only frequencies are 909.5 and 909.75 MHz; the `mod`, `freq`, `both`, and `preamble` modes change SF7/SF8, frequency, both, or 32/48 preamble symbols respectively. A run first @@ -833,32 +893,32 @@ compares the corrected XOSC standby with the default FS-mode retune. `clear 0|1|2` compares always-clear, never-clear (unsafe diagnostic only), and the default checked-clear path. `inject 1` puts a one-shot RX-timeout IRQ into the next run to verify checked clearing. `tcxo` -accepts 50, 100, 150–200 in 10 µs steps, 400, 800, and 1600 µs for RAM-only -experiments; reboot restores the compiled 200 µs. Each JSON result reports +accepts 50, 100, 150-200 in 10 us steps, 400, 800, and 1600 us for RAM-only +experiments; reboot restores the compiled 200 us. Each JSON result reports command counts, busy deferrals, drained test packets, failures, RX/cache/RSSI errors, and both timing directions. The RX-only harness discards pending packets before retrying a deferred hop; otherwise an unprocessed interrupt can stall the test even though production firmware would consume it. -On the Pi-attached T1000-E, 16 MHz buffered SPI and default 200 µs TCXO, -FS-mode retune plus checked IRQ clearing averaged about 0.504–0.508 ms for +On the Pi-attached T1000-E, 16 MHz buffered SPI and default 200 us TCXO, +FS-mode retune plus checked IRQ clearing averaged about 0.504-0.508 ms for simultaneous frequency/SF changes, 0.384 ms for frequency only, 0.409 ms for SF only, and 0.361 ms for a preamble change. Three boot/run cycles with the final default HIL image each completed 1,008 combined hops; earlier runs completed three more combined cycles plus 1,008 of each single-change mode. No completed run had a radio, cache, RSSI, or BUSY-timeout error. An injected stale timeout IRQ was detected and cleared before RX re-entry. A corrected XOSC standby path -with checked IRQ clearing took about 0.568–0.575 ms; always clearing took about +with checked IRQ clearing took about 0.568-0.575 ms; always clearing took about 0.60 ms. The old RadioLib `STANDBY_XOSC` constant is erroneously zero (RC mode), -whose previous combined result was about 0.87 ms at a requested 180 µs. The +whose previous combined result was about 0.87 ms at a requested 180 us. The unbuffered 2 MHz RC-standby baseline was about 1.36 ms. RadioLib converts the requested TCXO delay to 32.768 kHz ticks by truncating -`delay / 30.52`, so requests of 160, 170, and 180 µs all programmed five ticks -(about 153 µs), while 200 µs programs six (about 183 µs). The earlier 150 µs -trial failed after reset; the 160/170 µs runs developed deferrals, but those +`delay / 30.52`, so requests of 160, 170, and 180 us all programmed five ticks +(about 153 us), while 200 us programs six (about 183 us). The earlier 150 us +trial failed after reset; the 160/170 us runs developed deferrals, but those differences do not establish a precise hardware threshold. With true XOSC or -FS-mode retuning, 180 and 200 µs produced indistinguishable warm-hop timing; -200 µs remains the selected startup setting. These are room-temperature, +FS-mode retuning, 180 and 200 us produced indistinguishable warm-hop timing; +200 us remains the selected startup setting. These are room-temperature, single-unit, RX-only retune/command-health measurements, not over-the-air packet reception, TX operation, or voltage/temperature-corner qualification. diff --git a/tools/hil/profile_switch_packets.h b/tools/hil/profile_switch_packets.h index ef85a3563..5d5180c14 100644 --- a/tools/hil/profile_switch_packets.h +++ b/tools/hil/profile_switch_packets.h @@ -28,6 +28,8 @@ void listenPacket(unsigned sf, unsigned target, unsigned seq, unsigned mask, uns chip.productionPath = chip.experiment == 0; chip.setProfileSwitchOptimization((mask & 512) != 0); const uint32_t fastStart=chip.getOptimizedProfileSwitches(); + const uint32_t fsStart=chip.hilFsEntries, packetStart=chip.hilPacketSkips; + const uint32_t iqStart=chip.hilIqWorkaroundSkips; radioHal.bulkTransfer = (mask & 256) != 0; mesh::RadioProfileParams primary; primary.freq=909.5f;primary.bw=62.5f;primary.sf=7;primary.cr=5; @@ -47,8 +49,13 @@ void listenPacket(unsigned sf, unsigned target, unsigned seq, unsigned mask, uns packetSequence=seq;packetTarget=target;packetSf=sf;packetListening=true; packetErrorsAtStart=driver.getPacketsRecvErrors(); packetDeadline=millis()+5000; - recordBenchResult(lastListenResult,seq,"{\"listening\":%u,\"profile\":%u,\"sf500\":%u,\"preamble\":%u,\"optimized_rx_resumes\":%u}\n", - seq,target,sf,driver.profilePreamble(target),unsigned(chip.getOptimizedProfileSwitches()-fastStart)); + recordBenchResult(lastListenResult,seq,"{\"listening\":%u,\"profile\":%u,\"sf500\":%u,\"preamble\":%u,\"optimized_rx_resumes\":%u," + "\"fs\":%s,\"packet_cache\":%s,\"fs_entries\":%u,\"packet_skips\":%u," + "\"iq_cache\":%s,\"iq_workaround_skips\":%u}\n", + seq,target,sf,driver.profilePreamble(target),unsigned(chip.getOptimizedProfileSwitches()-fastStart), + chip.hilUseFsOnRetune ? "true" : "false", chip.hilSkipUnchangedPacket ? "true" : "false", + unsigned(chip.hilFsEntries-fsStart),unsigned(chip.hilPacketSkips-packetStart), + chip.hilSkipUnchangedIq ? "true" : "false",unsigned(chip.hilIqWorkaroundSkips-iqStart)); } void sendPacket(unsigned sf, unsigned target, unsigned seq, unsigned len, unsigned preamble, int power, unsigned spiMHz, bool bulk) { diff --git a/variants/sensecap_indicator-espnow/IndicatorRadioHal.cpp b/variants/sensecap_indicator-espnow/IndicatorRadioHal.cpp index d9aabb731..f894be35e 100644 --- a/variants/sensecap_indicator-espnow/IndicatorRadioHal.cpp +++ b/variants/sensecap_indicator-espnow/IndicatorRadioHal.cpp @@ -12,10 +12,11 @@ uint8_t IndicatorRadioHal::expanderIndex(uint32_t pin) { return static_cast(pin & ~EXPANDER_FLAG); } -bool IndicatorRadioHal::readRegister(uint8_t reg, uint16_t& value) { - uint8_t data[2]; +bool IndicatorRadioHal::readRegister(uint8_t reg, uint16_t& value, + bool firstPortOnly) { + uint8_t data[2] = {}; if (!lgfx::i2c::readRegister(I2C_PORT, EXPANDER_ADDRESS, reg, data, - sizeof(data), I2C_FREQUENCY) + firstPortOnly ? 1 : sizeof(data), I2C_FREQUENCY) .has_value()) { return false; } @@ -24,14 +25,15 @@ bool IndicatorRadioHal::readRegister(uint8_t reg, uint16_t& value) { return true; } -bool IndicatorRadioHal::writeRegister(uint8_t reg, uint16_t value) { +bool IndicatorRadioHal::writeRegister(uint8_t reg, uint16_t value, + bool firstPortOnly) { uint8_t data[] = { reg, static_cast(value), static_cast(value >> 8), }; return lgfx::i2c::transactionWrite(I2C_PORT, EXPANDER_ADDRESS, data, - sizeof(data), I2C_FREQUENCY) + firstPortOnly ? 2 : sizeof(data), I2C_FREQUENCY) .has_value(); } @@ -58,8 +60,9 @@ bool IndicatorRadioHal::beginExpander() { return true; } -bool IndicatorRadioHal::readInputs(uint16_t& value) { - if (!beginExpander() || !readRegister(INPUT_REGISTER, value)) return false; +bool IndicatorRadioHal::readInputs(uint16_t& value, bool firstPortOnly) { + if (!beginExpander() + || !readRegister(INPUT_REGISTER, value, firstPortOnly)) return false; processInterruptLevel(value); return true; } @@ -100,7 +103,9 @@ void IndicatorRadioHal::pinMode(uint32_t pin, uint32_t mode) { } else { next |= 1U << index; } - if (next != _config && writeRegister(CONFIG_REGISTER, next)) _config = next; + if (next != _config && writeRegister(CONFIG_REGISTER, next, useFirstPortOnly(index))) { + _config = next; + } } void IndicatorRadioHal::digitalWrite(uint32_t pin, uint32_t value) { @@ -113,7 +118,9 @@ void IndicatorRadioHal::digitalWrite(uint32_t pin, uint32_t value) { if (index >= 16 || !beginExpander()) return; uint16_t next = value == GpioLevelHigh ? _output | (1U << index) : _output & ~(1U << index); - if (next != _output && writeRegister(OUTPUT_REGISTER, next)) _output = next; + if (next != _output && writeRegister(OUTPUT_REGISTER, next, useFirstPortOnly(index))) { + _output = next; + } } uint32_t IndicatorRadioHal::digitalRead(uint32_t pin) { @@ -121,7 +128,10 @@ uint32_t IndicatorRadioHal::digitalRead(uint32_t pin) { uint8_t index = expanderIndex(pin); uint16_t inputs; - if (index >= 16 || !readInputs(inputs)) { + // All four radio signals occupy Port 0. Do not clock the unrelated Port 1 + // byte for each BUSY check. This is still a fresh I2C sample every time; + // the expander's INT level is not a safe replacement for reading BUSY. + if (index >= 16 || !readInputs(inputs, useFirstPortOnly(index))) { // A failed BUSY read must prevent an unsafe SPI command. Other failed // reads are treated as inactive. return pin == P_LORA_BUSY ? GpioLevelHigh : GpioLevelLow; diff --git a/variants/sensecap_indicator-espnow/IndicatorRadioHal.h b/variants/sensecap_indicator-espnow/IndicatorRadioHal.h index 3887a488d..339c18c98 100644 --- a/variants/sensecap_indicator-espnow/IndicatorRadioHal.h +++ b/variants/sensecap_indicator-espnow/IndicatorRadioHal.h @@ -16,6 +16,10 @@ public: bool beginExpander(); void serviceInterrupt(); +#ifdef MC_SX1262_HIL + // Same-image bus-width A/B only; production always uses the narrow port. + bool hilUsePort0Only = true; +#endif void pinMode(uint32_t pin, uint32_t mode) override; void digitalWrite(uint32_t pin, uint32_t value) override; @@ -45,8 +49,15 @@ private: static bool isExpanderPin(uint32_t pin); static uint8_t expanderIndex(uint32_t pin); - bool readRegister(uint8_t reg, uint16_t& value); - bool writeRegister(uint8_t reg, uint16_t value); - bool readInputs(uint16_t& value); + bool useFirstPortOnly(uint8_t index) const { + return index < 8 +#ifdef MC_SX1262_HIL + && hilUsePort0Only +#endif + ; + } + bool readRegister(uint8_t reg, uint16_t& value, bool firstPortOnly = false); + bool writeRegister(uint8_t reg, uint16_t value, bool firstPortOnly = false); + bool readInputs(uint16_t& value, bool firstPortOnly = false); void processInterruptLevel(uint16_t inputs); }; diff --git a/variants/sensecap_indicator-espnow/platformio.ini b/variants/sensecap_indicator-espnow/platformio.ini index 97ab09ed5..fa8a06640 100644 --- a/variants/sensecap_indicator-espnow/platformio.ini +++ b/variants/sensecap_indicator-espnow/platformio.ini @@ -95,6 +95,9 @@ build_flags = ; enabled owned-RX path, but not the direct-I/O SetFs/packet shortcuts. -D MC_SX1262_FAST_FS=0 -D MC_SX1262_FAST_PACKET_CACHE=0 + ; Reuse only acknowledged IQ workaround state, not complete packet params. + ; Qualified on the stock I2C-expander board; protocol changes revoke reuse. + -D MC_SX1262_FAST_IQ_CACHE=1 -D MC_SX1262_SPI_HZ=8000000 -D RADIO_CLASS=CustomSX1262 -D WRAPPER_CLASS=IndicatorSX1262Wrapper