diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index e26814d8a..985a5f10a 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -341,6 +341,7 @@ jobs: python3 -B test/test_meshtracker_x1_battery.py -v python3 -B test/test_drv2605_power.py -v python3 -B test/test_radio_interrupt_recovery.py -v + python3 -B test/test_radio_rx_activity_epoch.py -v python3 -B test/test_forwarding_profile_timing.py -v python3 -B test/test_clock_transit_profile_timing.py -v python3 -B test/test_bridge_recovery.py -v diff --git a/docs/test-results/esp32_ble_radio2_sleep_2026-10-21.md b/docs/test-results/esp32_ble_radio2_sleep_2026-10-21.md index 0e6b5894d..59b57b9cf 100644 --- a/docs/test-results/esp32_ble_radio2_sleep_2026-10-21.md +++ b/docs/test-results/esp32_ble_radio2_sleep_2026-10-21.md @@ -9,9 +9,9 @@ reversibly stopping the Bluetooth host/controller, and allowing bounded MCU light-sleep during a settled secondary-radio receive visit. This report separates host/build coverage, SDK wait measurements and physical packet tests. -Bluetooth transitions and quiet-window SDK wait checks pass. Three strict RF trials with +Bluetooth transitions and quiet-window SDK wait checks pass. Three earlier strict RF trials with power saving on and -matched preambles received five of six packets, including the trial after the IRQ fix. They remain +matched preambles received five of six packets, including the trial after the first IRQ fix. They remain failures; radio2 is not fully qualified. The first V3 CPU-clock sweep stopped during setup, before any of its @@ -373,18 +373,131 @@ does not establish that WiFi deinitialization timed out. The raw SDK log bypassed disabled USB logging and broke strict Binary Companion framing. All three attempts restored checked settings/channels and verified a fresh MQTT PUBACK increase. The latest immutable diagnostic receipt is -`v4-7d0709bf-suffix-completed.private.json`. A fix is underway; these setup -failures supply no new V4 packet-delivery or firmware-fix qualification. +`v4-7d0709bf-suffix-completed.private.json`. These setup failures supply no +new V4 packet-delivery evidence. + +## SDK logging repair and V4 scanning controls + +The SDK logging fix in `d73599bc6` shipped in +[`3fd9b392`](https://github.com/mikecarper/MeshCore/commit/3fd9b392c8d216e2d5dd592587085c1a71fa738c). +It prevents raw SDK logs from bypassing the disabled USB-logging policy and +corrupting the shared Binary Companion stream. All 101 USB host tests pass. +The V4 application is 2,558,856 bytes, with SHA-256 +`ddfd4f40a97da40bdeda2c00fb18ebc2d4d365590d4bc63496a425d16975906d`. +Its installation passed full readback and checked configuration preservation. + +Two new scanning cases completed on this V4 image, with 20 new nonces per +profile, matched 32-symbol preambles and 2 dBm peer TX: + +| CPU | Manual CPU sleep | Primary received | Secondary received | SDK calls/returns | SDK calls in wholly contained RF-time buckets | +| --- | --- | --- | --- | --- | --- | +| 80 MHz | Off | 19/20 | 18/20 | 0/0 | 0 | +| 80 MHz | On | 19/20 | 15/20 | 4,121/4,121 | 3,511 | + +Both strict delivery checks failed. All 80 sends reached TX_DONE once, with +zero retries or duplicate deliveries. SDK errors, short returns and pending +waits were zero. The awake losses establish that CPU sleep is not required for +this symptom; these samples do not identify the cause of individual misses. + +The user redirected testing toward the scanner. The 160 MHz On case was +intentionally stopped during its USB host-loss grace period, before any RF +transmission; 160 MHz Off was not entered. This is not a completed four-case +comparison. The interrupt receipt remains recorded in +`v4-3fd9b392-user-steered-completed.private.json` (SHA-256 +`e102200af7e2503bb0362e65afad0d947d5c3aeb220425a589d0588032530c52`). +Full restoration and a fresh MQTT PUBACK increase passed after that stop. + +## Fixed-tuple controls and receive-path defect + +Two fixed-tuple controls on the same `3fd9b392` image each received **20/20** +unique packets with radio2 explicitly off. The primary physical radio was +temporarily tuned first to 910.525 MHz / BW62.5 / SF7 / CR5, then separately to +916 MHz / BW125 / SF9 / CR5. Both runs used 80 MHz, manual CPU sleep off, saved +power-saving intent on, RX power saving off, 32-symbol preambles and 2 dBm peer +TX. The target's USB data was absent after the same 121-second host-loss grace. +Each run retained the original seeded 40-slot cadence, leaving the opposite +profile's 20 slots silent. SDK calls, duplicate deliveries and RF retries were +zero. The immutable control report remains failed because the peer's original +22 dBm setting was not restored within three bounded busy-response attempts; +MQTT recovery passed. A separate recovery then restored 22 dBm and verified +full peer settings, credential hashes and channel semantics without another +RF transmission. The original failure is retained in +`fixed-tuples-v4-3fd9b392-completed.private.json`; successful recovery is in +`peer-tx22-restoration-verified.private.json`. + +These finite controls narrow the investigation toward scanning or overlapping +traffic. Radio2 time-shares one transceiver: a valid primary reception can +occupy it throughout a secondary packet's preamble. The idle-scan timing model +does not guarantee zero loss under simultaneous traffic. The controls do not +distinguish that limitation from a receive-path defect for a particular nonce. + +A separate host regression executing the real receive functions with +ASan/UBSan proved a stale-IRQ defect. Servicing a packet through its asserted +physical DIO could leave a late software event. Subsequent empty RX_DONE probes +could then restart continuous RX over a newer packet's preamble or valid +header. The narrow ESP32 SX126x continuous-RX guard in +[`a5293fa50`](https://github.com/mikecarper/MeshCore/commit/a5293fa50b2d957077c5fdcb9378b6e88370d9d5) +preserves that new reception while retaining normal activity timeouts. +All 15 interrupt-recovery, five receive-contract and six RX-retry tests pass. +The V4 build passes all 24 capability markers and its memory gate at 2,558,936 +application bytes. Its application SHA-256 is +`cc16e44ccd24e64724a2bd17f59f684117e411077e4c0a7d04611be328b7ccfc`. +The application-only installation passed complete readback, with the partition +table, OTA metadata and checked configuration preserved. The immutable receipt +is `flash-v4-a5293fa5-completed.private.json` (SHA-256 +`ef61cf8dfc77f8907d621f7f0493949ff6888b017c277eefb309247ccf88b278`). + +One new awake 80 MHz scanning run then sent 20 unique packets per profile on +this changed image, with manual CPU sleep off and the same matched preambles, +TX power and USB host-loss grace. The V4 received **19/20 primary and 20/20 +secondary packets**. All 40 sends reached TX_DONE once, with zero retries or +duplicate deliveries. SDK calls, returns and all time buckets were zero; core +error flags remained clear. The missing primary nonce keeps the strict RF +result failed. Full restoration passed, including a fresh MQTT PUBACK increase. +The immutable receipt is `v4-a5293fa5-awake-completed.private.json` (SHA-256 +`466bdf0bb08f10665007d156e1ec37c074e1c576e6ac5fc401bcd449179ee774`). + +The stale-IRQ regression proves a receive-path bug and the new guard repairs +that case. This physical sample does not establish which historical misses +encountered it or show that all scanning losses are fixed. A controlled +two-transmitter overlap diagnostic is pending; no result or cause is claimed +for that experiment here. + +### Receive activity across RX restarts + +A further host regression executing the production receive methods exposed a +separate scan hazard. Reading a completed packet and successfully rearming RX +cleared the hardware IRQs but retained the previous packet's `_activityAt` and +`_headerSeen` latches. At the next due scan, the detector could mistake the new +packet's preamble without a header for the old packet's consumed header. It +reported idle before processing that preamble, permitting a retune over the +new packet. The regression reproduces that sequence and shows that resetting +the latches on successful RX entry protects the next preamble. + +The change resets those existing fields only after successful receive +starts in `CustomSX1262`, `CustomSX1268`, `CustomLLCC68`, `CustomSTM32WLx`, +`CustomLR1110` and `CustomLR1121`. It covers normal starts and, where supported, +fast resumes and duty-cycle entry. Failed starts retain their prior state; +the change adds no fields and leaves existing activity timeouts and +receive/retune guards unchanged. +Independent source review passed. Five new regression methods cover 24 +ASan/UBSan variants, including reset-removal controls that reproduce the +retune over a new packet for all six handlers. All 19 affected host-fixture +tests and 26 interrupt-recovery, receive-contract and receive-retry checks +pass. The new suite runs in the required unit-test workflow. Physical RF +validation remains pending; this proof does not attribute historical missing +nonces or establish a complete RF pass. The installed versions and packet +counts above remain unchanged. ## Final handback and limits -Both V3 and V4 now run `7d0709bf0`. Its original radio, RX power-saving, Bluetooth, -WiFi, MQTT, logging, diagnostics and USB-watchdog settings were restored and -checked against the frozen baseline, including credential digests. Radio2 is -`off (automatic allowed)`, preserving its original automatic permission. -Bluetooth is on. WiFi reconnected and MQTT resumed with a verified fresh -broker PUBACK increase. -The peer's original 22 dBm power and logical channel state were restored. +V3 currently runs `7d0709bf0`; V4 runs `a5293fa50`, and the peer remains on +`ebb59d48`. The completed V3 campaign and V4 scanning controls, including the +new awake run, restored their checked radio, RX power-saving, +Bluetooth, WiFi, MQTT, logging, diagnostics and USB-watchdog settings, including +credential digests, original radio2 automatic permission and peer power/channel +state. Fresh MQTT PUBACK increases verified recovery. The fixed-tuple cleanup +power failure is retained, with the later peer recovery verified separately. No nRF52 board, hub power or other firmware was changed in this task. The Bluetooth hardware checks pass. SDK light-sleep calls are observed without diff --git a/src/helpers/radiolib/CustomLLCC68.h b/src/helpers/radiolib/CustomLLCC68.h index 3b638f2d5..4afb15425 100644 --- a/src/helpers/radiolib/CustomLLCC68.h +++ b/src/helpers/radiolib/CustomLLCC68.h @@ -124,10 +124,15 @@ class CustomLLCC68 : public LLCC68 { } int16_t startReceive() override { - return LLCC68::startReceive( + const int16_t status = LLCC68::startReceive( RADIOLIB_SX126X_RX_TIMEOUT_INF, RADIOLIB_IRQ_RX_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED), RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); + if (status == RADIOLIB_ERR_NONE) { + _activityAt = 0; + _headerSeen = false; + } + return status; } bool isReceiving() { diff --git a/src/helpers/radiolib/CustomLR1110.h b/src/helpers/radiolib/CustomLR1110.h index b9d0de1ba..d13b1ed1c 100644 --- a/src/helpers/radiolib/CustomLR1110.h +++ b/src/helpers/radiolib/CustomLR1110.h @@ -420,7 +420,12 @@ class CustomLR1110 : public LR1110 { // Send the already converted values. RadioLib 7.7.1 converts them again // with 32-bit arithmetic, which overflows for periods above about 131 ms. - return this->setRxDutyCycle(rxPeriodRaw, sleepPeriodRaw, RADIOLIB_LR11X0_RX_DUTY_CYCLE_MODE_RX); + state = this->setRxDutyCycle(rxPeriodRaw, sleepPeriodRaw, RADIOLIB_LR11X0_RX_DUTY_CYCLE_MODE_RX); + if (state == RADIOLIB_ERR_NONE) { + _activityAt = 0; + _headerSeen = false; + } + return state; } int16_t setRxBoostedGainMode(bool en) { @@ -479,7 +484,11 @@ class CustomLR1110 : public LR1110 { fastSetRxCycles += DWT->CYCCNT - segment; #endif stagedMode = RADIOLIB_RADIO_MODE_NONE; - if (rc == RADIOLIB_ERR_NONE) ++_profileFastResumes; + if (rc == RADIOLIB_ERR_NONE) { + ++_profileFastResumes; + _activityAt = 0; + _headerSeen = false; + } if (rc == RADIOLIB_ERR_NONE) _profilePacketDirty = false; _profileSwitch.rxResult(rc, true); return rc; @@ -490,6 +499,10 @@ class CustomLR1110 : public LR1110 { RADIOLIB_LR11X0_RX_TIMEOUT_INF, RADIOLIB_IRQ_RX_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED), RADIOLIB_IRQ_RX_DEFAULT_MASK | (1UL << RADIOLIB_IRQ_HEADER_ERR), 0); + if (rc == RADIOLIB_ERR_NONE) { + _activityAt = 0; + _headerSeen = false; + } _profileSwitch.rxResult(rc, true); if (rc == RADIOLIB_ERR_NONE) _profilePacketDirty = false; return rc; diff --git a/src/helpers/radiolib/CustomLR1121.h b/src/helpers/radiolib/CustomLR1121.h index e8c05fd3f..57316d56c 100644 --- a/src/helpers/radiolib/CustomLR1121.h +++ b/src/helpers/radiolib/CustomLR1121.h @@ -123,7 +123,12 @@ class CustomLR1121 : public LR1121 { int16_t startReceive() override { // include the PREAMBLE_DETECTED irq bit in reported flags. - return LR1121::startReceive(RADIOLIB_LR11X0_RX_TIMEOUT_INF, RADIOLIB_IRQ_RX_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED), RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); + const int16_t status = LR1121::startReceive(RADIOLIB_LR11X0_RX_TIMEOUT_INF, RADIOLIB_IRQ_RX_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED), RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); + if (status == RADIOLIB_ERR_NONE) { + _activityAt = 0; + _headerSeen = false; + } + return status; } bool isReceiving() { diff --git a/src/helpers/radiolib/CustomSTM32WLx.h b/src/helpers/radiolib/CustomSTM32WLx.h index 2a6eca10d..58b5507d4 100644 --- a/src/helpers/radiolib/CustomSTM32WLx.h +++ b/src/helpers/radiolib/CustomSTM32WLx.h @@ -33,7 +33,12 @@ class CustomSTM32WLx : public STM32WLx { int16_t startReceive() override { // include the PREAMBLE_DETECTED irq bit in reported flags - return STM32WLx::startReceive(RADIOLIB_SX126X_RX_TIMEOUT_INF, RADIOLIB_IRQ_RX_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED), RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); + const int16_t status = STM32WLx::startReceive(RADIOLIB_SX126X_RX_TIMEOUT_INF, RADIOLIB_IRQ_RX_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED), RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); + if (status == RADIOLIB_ERR_NONE) { + _activityAt = 0; + _headerSeen = false; + } + return status; } bool isReceiving() { diff --git a/src/helpers/radiolib/CustomSX1262.h b/src/helpers/radiolib/CustomSX1262.h index 3d6f68825..f4e40bd50 100644 --- a/src/helpers/radiolib/CustomSX1262.h +++ b/src/helpers/radiolib/CustomSX1262.h @@ -550,6 +550,10 @@ class CustomSX1262 : public SX1262 { // while the SX1262 alternates between its RX and sleep windows. this->mod->setRfSwitchState(Module::MODE_RX); } + if (state == RADIOLIB_ERR_NONE) { + _activityAt = 0; + _headerSeen = false; + } return state; } @@ -712,7 +716,11 @@ class CustomSX1262 : public SX1262 { rc = setRx(rxTimeout); // original BUSY waits and status checks } stagedMode = RADIOLIB_RADIO_MODE_NONE; - if (rc == RADIOLIB_ERR_NONE) ++_profileFastResumes; + if (rc == RADIOLIB_ERR_NONE) { + ++_profileFastResumes; + _activityAt = 0; + _headerSeen = false; + } _coldStandby = rc != RADIOLIB_ERR_NONE; _profileSwitch.rxResult(rc, true); return rc; @@ -723,6 +731,12 @@ class CustomSX1262 : public SX1262 { RADIOLIB_SX126X_RX_TIMEOUT_INF, RADIOLIB_IRQ_RX_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED), RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); + // Acknowledged RX entry cleared the previous hardware IRQs. Its local + // activity latches must not classify the next packet as old activity. + if (rc == RADIOLIB_ERR_NONE) { + _activityAt = 0; + _headerSeen = false; + } const bool reusable = rc == RADIOLIB_ERR_NONE && _profileSwitch.enabled && getPacketType() == RADIOLIB_SX126X_PACKET_TYPE_LORA; _coldStandby = rc != RADIOLIB_ERR_NONE; diff --git a/src/helpers/radiolib/CustomSX1268.h b/src/helpers/radiolib/CustomSX1268.h index d54d18688..ae3460ede 100644 --- a/src/helpers/radiolib/CustomSX1268.h +++ b/src/helpers/radiolib/CustomSX1268.h @@ -124,10 +124,15 @@ class CustomSX1268 : public SX1268 { } int16_t startReceive() override { - return SX1268::startReceive( + const int16_t status = SX1268::startReceive( RADIOLIB_SX126X_RX_TIMEOUT_INF, RADIOLIB_IRQ_RX_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED), RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); + if (status == RADIOLIB_ERR_NONE) { + _activityAt = 0; + _headerSeen = false; + } + return status; } bool isReceiving() { diff --git a/test/test_lr1110_packet_setter_cache.py b/test/test_lr1110_packet_setter_cache.py index 1b7848a78..8183a0c28 100644 --- a/test/test_lr1110_packet_setter_cache.py +++ b/test/test_lr1110_packet_setter_cache.py @@ -104,6 +104,7 @@ struct LR1110 { int16_t setRx(uint32_t timeout) { assert(timeout == 0xffffff); trace += 'R'; return 0; } }; struct Radio : LR1110 { + uint32_t _activityAt=0;bool _headerSeen=false; LR1110ProfileSwitchState _profileSwitch{true}; bool _profilePacketDirty = true; uint32_t _profileFastResumes = 0; diff --git a/test/test_lr1110_profile_switch.py b/test/test_lr1110_profile_switch.py index 027a58022..39310748f 100644 --- a/test/test_lr1110_profile_switch.py +++ b/test/test_lr1110_profile_switch.py @@ -150,6 +150,7 @@ struct LR1110 { int16_t setRx(uint32_t timeout) { assert(timeout==0xffffff);return call('R'); } }; struct Radio:LR1110 { + uint32_t _activityAt=0;bool _headerSeen=false; LR1110ProfileSwitchState _profileSwitch{true}; bool _profileStandbyWarm=false,_profilePacketDirty=false; uint32_t _profileFastResumes=0; diff --git a/test/test_profile_sx1262_iq_cache.py b/test/test_profile_sx1262_iq_cache.py index b90b6409e..642ed52c0 100644 --- a/test/test_profile_sx1262_iq_cache.py +++ b/test/test_profile_sx1262_iq_cache.py @@ -130,6 +130,7 @@ struct SX1262 { int16_t setRx(uint32_t timeout) { assert(timeout==0xffffff);return call('R'); } }; struct Radio : SX1262 { + uint32_t _activityAt=0;bool _headerSeen=false; SX1262ProfileSwitchState _profileSwitch{true}; bool _coldStandby=true,_tcxoWakePending=false,_rx_ps_rf_rx_disabled=false; uint32_t _profileFastResumes=0; diff --git a/test/test_radio_rx_activity_epoch.py b/test/test_radio_rx_activity_epoch.py new file mode 100644 index 000000000..d561928e8 --- /dev/null +++ b/test/test_radio_rx_activity_epoch.py @@ -0,0 +1,302 @@ +"""Execute actual RX entry, activity detection and due-scanner admission.""" +from pathlib import Path +import unittest +ROOT=Path(__file__).resolve().parents[1] +import test_radio_interrupt_recovery as irq +compile_run=irq.compile_run +from test_radio_receive_contract import method + +FAMILIES=('CustomSX1262','CustomSX1268','CustomLLCC68','CustomSTM32WLx','CustomLR1110','CustomLR1121') + + +def build_source(family='CustomSX1262',remove_reset=False,include_duty=False): + source=irq.RadioInterruptRecoveryTests().source(sx126x=family) + src=(ROOT/'src/helpers/radiolib/RadioLibWrappers.cpp').read_text() + chip=(ROOT/f'src/helpers/radiolib/{family}.h').read_text() + entry=method(chip,'int16_t startReceive()') + if remove_reset: + assert '_activityAt = 0;'in entry and '_headerSeen = false;'in entry + entry=entry.replace('_activityAt = 0;','').replace('_headerSeen = false;','') + if include_duty: + duty=method(chip,'int16_t startReceiveDutyCycle(') + if remove_reset:duty=duty.replace('_activityAt = 0;','').replace('_headerSeen = false;','') + entry+='\n'+duty + source=source.replace('#include ', '''#include +#include +#include +#include +#include +#include +#define MC_SX1262_FAST_PACKET_CACHE 0 +#define MC_SX1262_FAST_IQ_CACHE 0 +#define RADIOLIB_SX126X_RX_TIMEOUT_INF 0xffffff +#define RADIOLIB_IRQ_RX_DEFAULT_FLAGS 1 +#define RADIOLIB_IRQ_RX_DEFAULT_MASK 2 +#define RADIOLIB_SX126X_PACKET_TYPE_LORA 1 +#define RADIOLIB_RADIO_MODE_NONE 0 +#define RADIOLIB_LR11X0_RX_TIMEOUT_INF 0xffffff +#define RADIOLIB_LR11X0_IRQ_ALL 0xffffffff +#define RADIOLIB_LR11X0_MAX_PACKET_LENGTH 255 +#define RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED 16 +#define RADIOLIB_LR11X0_IRQ_SYNC_WORD_HEADER_VALID 32 +#define RADIOLIB_LR11X0_IRQ_HEADER_ERR 64 +#define RADIOLIB_IRQ_HEADER_ERR 6 +#define RADIOLIB_ERR_INVALID_SLEEP_PERIOD -98 +#define RADIOLIB_ERR_INVALID_RX_PERIOD -97 +#define RADIOLIB_LR11X0_STANDBY_RC 0 +#define RADIOLIB_LR11X0_LF_CLK_RC 1 +#define RADIOLIB_LR11X0_LF_BUSY_RELEASE_ENABLED 2 +#define RADIOLIB_RADIO_MODE_RX 1 +#define RADIOLIB_LR11X0_RX_DUTY_CYCLE_MODE_RX 0 +#define RADIOLIB_ASSERT(state) do { if((state)!=0)return(state); } while(0) +using RadioLibIrqFlags_t=uint32_t; +struct RadioModeConfig_t {struct {uint32_t timeout,irqFlags,irqMask;size_t len;}receive;}; +#define MAX_TRANS_UNIT 255 +#define NF_CONTINUOUS_TIMEOUT_MS 4000 +namespace mesh { enum class RadioParamApplyResult { APPLIED, BUSY, FAILED }; } +''') + source=source.replace('uint32_t millis(){return now_ms;}', '''uint32_t millis(){return now_ms;} +int64_t esp_timer_get_time(){return int64_t(now_ms)*1000;} +uint32_t profileTimestamp(bool){return now_ms*1000;} +uint32_t profileElapsedUs(uint32_t a,uint32_t b,bool){return b-a;}''') + source=source.replace('struct Module {', '''struct Module { + enum {MODE_RX=1};void setRfSwitchState(int){}''') + source=source.replace('struct Chip : LR2021 {', '''using SX1262=LR2021;using SX1268=LR2021;using LLCC68=LR2021;using STM32WLx=LR2021;using LR1110=LR2021;using LR1121=LR2021; +struct Chip : LR2021 { + SX1262ProfileSwitchState _profileSwitch{true};bool _coldStandby=true; + uint32_t _profileFastResumes=0; + bool _profilePacketDirty=false;Module* mod=&module; + int16_t snapshotIrqAfterRxExit(uint32_t* pending){*pending=irq;return snapshot_failure;} + int16_t clearIrqState(uint32_t flags){irq &= ~flags;rx_status=0;gpio_levels[module.irq]=false;return clear_status;} + int16_t setPacketParamsLoRa(size_t,uint8_t,uint8_t,uint8_t,uint8_t){return packet_failure;} + int snapshot_failure=0,packet_failure=0; + bool _rx_ps_rf_rx_disabled=false; + uint8_t spreadingFactor=7;float bandwidthKhz=62.5;uint32_t tcxoDelay=0; + int lf_failure=0,stage_failure=0; + int16_t configLfClock(int){return lf_failure;} + int16_t stageMode(int,RadioModeConfig_t*){return stage_failure;} + int16_t setRxDutyCycle(uint32_t,uint32_t,int){return startDuty();} + size_t preambleLengthLoRa=32;uint8_t crcTypeLoRa=1,implicitLen=255,headerType=0,invertIQEnabled=0; + uint32_t rxTimeout=0;int stagedMode=0; + int16_t clearIrqStatus(){irq=0;rx_status=0;gpio_levels[module.irq]=false;return clear_status;} + int16_t setPacketParams(size_t,int,int,int,int){return packet_failure;} + int16_t setRx(uint32_t){if(start_failure)return start_failure;++arms;stopped=false;return 0;} + uint8_t getPacketType(){return RADIOLIB_SX126X_PACKET_TYPE_LORA;} + '''+entry) + source=source.replace('bool stopped=false, inject_read_irq=false;', 'bool stopped=false, inject_read_irq=false;int start_failure=0,duty_failure=0;') + source=source.replace('int startTransmit(uint8_t*,int)', '''int16_t standby(uint8_t){return standby();} + int startDuty(){if(duty_failure)return duty_failure;irq=0;rx_status=0;++arms;return 0;} + int16_t startReceiveDutyCycle(uint32_t,uint32_t,uint32_t,uint32_t){return startDuty();} + int startTransmit(uint8_t*,int)''') + old='int16_t startReceive(uint32_t,uint32_t,uint32_t,int){assert(stopped);++arms;stopped=false;return 0;}' + new='''int16_t startReceive(uint32_t,uint32_t,uint32_t,int){ + ++standbys;stopped=true;irq=0;rx_status=0;gpio_levels[module.irq]=false; + if(start_failure)return start_failure;++arms;stopped=false;return 0; + }''' + assert old in source;source=source.replace(old,new) + fields=r''' + mesh::RadioProfiles _profiles; + bool _params_valid=true,_profile_rxps_suspended=true,_profile_saved_rxps=false; + bool _noise_floor_valid=true,_noise_floor_secondary_valid=true,_nf_refresh_requested=false; + bool _nf_window_armed=false,_nf_lazy_refresh=false,_nf_refresh_attempted=false; + uint32_t _profile_retry_at=0,_profile_generation=1,_profile_visit_stamp=0,_profile_scan_generation[2]={}; + uint8_t _active_profile=0,_cur_sf=7,_cur_cr=5; + uint16_t _physical_preamble=32; + float _cur_freq=910.525,_cur_bw=62.5; + uint64_t _esp32_profile_visit_started_us=0,_esp32_profile_idle_stamp_us=0; + uint32_t _esp32_profile_idle_visit_us=0,_esp32_profile_idle_switch_us=0; + uint16_t _esp32_profile_idle_preamble=0; + uint8_t _esp32_profile_idle_profile=0; + uint32_t _esp32_profile_idle_generation[2]={}; + uint32_t _nf_last_calib=0,_nf_calib_deadline=0; + int16_t _noise_floor=0;int32_t _noise_floor_centi_dbm=0,_noise_floor_secondary_centi_dbm=0; + NoiseFloorEstimator _floor_estimator,_secondary_floor_estimator; + uint8_t _startrx_fails=0;bool _wd_last_busy=false; + uint32_t _wd_last_transition=0,_wd_stuck_thresh=0,_wd_observe_ms=0; + uint32_t _rx_ps_rx_us=10000,_rx_ps_sleep_us=10000; + unsigned applies=0; + NoiseFloorEstimator& profileFloorEstimator(uint8_t p){return p?_secondary_floor_estimator:_floor_estimator;} + bool isInRecvMode()const{return state==STATE_RX;} + void beginProfileRetune(bool){}void endProfileRetune(bool){} + bool validateProfile(const mesh::RadioProfileParams&p)const{return mesh::RadioProfiles::valid(p);} + uint16_t profilePreamble(uint8_t p)const{return _profiles.preamble(p,32);} + bool applyParams(float,float,uint8_t,uint8_t){++applies;return true;} + void cacheParams(float f,float b,uint8_t s,uint8_t c){_cur_freq=f;_cur_bw=b;_cur_sf=s;_cur_cr=c;} + void setProfileStandbyWarm(bool){} + void recalibrateNoiseFloor(){} + bool restoreAfterDeepInit(){return false;} + uint32_t getEstAirtimeFor(int){return 1000;} + int startReceiveMode(){return chip.startReceive();} + void endReconfigure(bool); + void serviceProfileScan(); + mesh::RadioParamApplyResult tuneProfile(uint8_t); + ''' + source=source.replace('struct RadioLibWrapper {','struct RadioLibWrapper {'+fields) + old='void startRecv(){if(chip.startReceive()==0)state=STATE_RX;}' + assert old in source;source=source.replace(old,'void startRecv();') + source+='\n'+'\n'.join(method(src,sig)for sig in ( + 'void RadioLibWrapper::startRecv(', 'void RadioLibWrapper::endReconfigure(', + 'mesh::RadioParamApplyResult RadioLibWrapper::tuneProfile(', + 'void RadioLibWrapper::serviceProfileScan(')) + if family=='CustomLR1110': + source=source.replace('SX1262ProfileSwitchState _profileSwitch','LR1110ProfileSwitchState _profileSwitch') + if family.startswith('CustomLR'): + source+="\n#define ACTIVITY_PREAMBLE 16\n#define ACTIVITY_HEADER 32\n#define ACTIVITY_HEADER_ERROR 64\n" + else: + source+="\n#define ACTIVITY_PREAMBLE 4\n#define ACTIVITY_HEADER 16\n#define ACTIVITY_HEADER_ERROR 32\n" + return source + +CHECKS=r''' +int main(){ + uint8_t bytes[16]={};now_ms=100; + auto* w=new CustomSX1262Wrapper;assert(w->registerInterruptAction()); + w->_profiles.primary={910.525,62.5,32,7,5}; + mesh::RadioProfileConfig other;other.params={916,125,32,9,5};other.mode=mesh::RadioProfileMode::RxTx; + w->_profiles.setSecondary(other,false); + for(unsigned n=0;n<4;++n){w->_profiles.sampleSwitch(0,1,1000,now_ms);w->_profiles.sampleSwitch(1,0,1000,now_ms);} + assert(w->_profiles.switchTestReady()); + w->_profile_scan_generation[0]=w->_profiles.generation[0]; + w->_profile_scan_generation[1]=w->_profiles.generation[1]; + w->_profile_generation=w->_profiles.generation[0]; + // A due scan while packet A has a valid header defers and latches _headerSeen. + w->chip.rx_status=0;w->chip.irq=ACTIVITY_HEADER; + w->_esp32_profile_visit_started_us=0; + w->serviceProfileScan();assert(w->chip._headerSeen&&w->_active_profile==0); + const unsigned start_arms=w->chip.arms; + // Actual wrapper read/actual startRecv/actual selected chip startReceive. + w->chip.rx_status=1;w->chip.irq=ACTIVITY_HEADER|1;gpio_levels[3]=true;w->chip.callback(); + assert(w->recvRaw(bytes,sizeof(bytes))==4&&w->n_recv==1&&w->chip.arms==start_arms+1); + assert(w->chip.irq==0&&!w->hasPendingRadioInterrupt()); + const bool retired=!w->chip._headerSeen&&w->chip._activityAt==0; + // App/UI work may outlast one visit; then B begins its preamble before the + // next scanner pass, with no intermediate idle activity probe. + const uint32_t visit=w->_profiles.listenUs(0,w->profilePreamble(w->_profiles.slowerProfile())); + now_ms+=(visit+999)/1000+1;w->chip.irq=ACTIVITY_PREAMBLE; + const unsigned stands=w->chip.standbys; + w->serviceProfileScan(); + const bool aborted=w->chip.standbys!=stands; + assert(!aborted&&w->_active_profile==0&&w->chip.irq==ACTIVITY_PREAMBLE + &&"new preamble must survive the actual due scanner"); + assert(retired&&"RX entry must retire old activity latches"); + delete w; +} +''' + + +ENTRY_CHECKS=r''' +static void oldActivity(Chip& chip){chip._headerSeen=true;chip._activityAt=37;} +int main(){ + now_ms=100;Chip chip; + oldActivity(chip);assert(chip.startReceive()==0); + assert(!chip._headerSeen&&chip._activityAt==0&&"successful RX must reset activity"); + oldActivity(chip);chip.start_failure=-88; + assert(chip.startReceive()==-88&&chip._headerSeen&&chip._activityAt==37); + chip.start_failure=0;assert(chip.startReceive()==0); + // A fresh false preamble retains the normal finite timeout. + chip.irq=ACTIVITY_PREAMBLE;assert(chip.isReceiving()); + now_ms+=chip._preambleMillis;assert(chip.isReceiving()); + ++now_ms;assert(!chip.isReceiving()&&!(chip.irq&ACTIVITY_PREAMBLE)); + chip.irq=ACTIVITY_HEADER;assert(chip.isReceiving()); + now_ms+=chip._maxPayloadMillis;assert(chip.isReceiving()); + ++now_ms;assert(!chip.isReceiving()&&!chip._headerSeen); + chip.irq=ACTIVITY_HEADER|ACTIVITY_HEADER_ERROR; + assert(!chip.isReceiving()&&!chip._headerSeen&&chip._activityAt==0); +#if MESH_RADIO_PROFILE_CHIP == 1110 + // LR1110 deliberately leaves its error IRQ for wrapper-owned recovery. + assert(chip.irq&ACTIVITY_HEADER_ERROR); +#else + assert(!(chip.irq&ACTIVITY_HEADER_ERROR)); +#endif +} +''' + +FAST_CHECKS=r''' +static void fastContext(Chip& chip){ + chip._profileSwitch.rxResult(0,true); + chip._profileSwitch.begin(true,true);chip._profileSwitch.standbyResult(0); + assert(chip._profileSwitch.canResumeFast()); + chip._headerSeen=true;chip._activityAt=37;chip.irq=0; +} +int main(){ + Chip chip;fastContext(chip); + assert(chip.startReceive()==0&&!chip._headerSeen&&chip._activityAt==0); + assert(chip._profileFastResumes==1); + for(unsigned failure=0;failure<3;++failure){ + fastContext(chip); + if(failure==0){chip.irq=1;chip.clear_status=-88;} + if(failure==1){chip.packet_failure=-88;chip._profilePacketDirty=true;} + if(failure==2)chip.start_failure=-88; + assert(chip.startReceive()==-88&&chip._headerSeen&&chip._activityAt==37); + chip.clear_status=chip.packet_failure=chip.start_failure=0; + } +} +''' + +DUTY_CHECKS=r''' +int main(){ + Chip chip;chip._headerSeen=true;chip._activityAt=37; + assert(chip.startReceiveDutyCycle(100000,100000)==0); + assert(!chip._headerSeen&&chip._activityAt==0&&"acknowledged duty-cycle RX must reset activity"); + chip._headerSeen=true;chip._activityAt=37;chip.duty_failure=-88; + assert(chip.startReceiveDutyCycle(100000,100000)==-88); + assert(chip._headerSeen&&chip._activityAt==37); + chip.duty_failure=0; + assert(chip.startReceiveDutyCycle(100000,100000)==0&&!chip._headerSeen&&chip._activityAt==0); +#if MESH_RADIO_PROFILE_CHIP == 1110 + // Bounds and every preparatory command can reject duty-cycle entry. None + // acknowledged a new RX epoch, so none may retire the old local activity. + chip._headerSeen=true;chip._activityAt=37; + assert(chip.startReceiveDutyCycle(100000,0)==RADIOLIB_ERR_INVALID_SLEEP_PERIOD); + assert(chip._headerSeen&&chip._activityAt==37); + for(unsigned failure=0;failure<3;++failure){ + if(failure==0)chip.standby_status=-88; + if(failure==1)chip.lf_failure=-88; + if(failure==2)chip.stage_failure=-88; + assert(chip.startReceiveDutyCycle(100000,100000)==-88); + assert(chip._headerSeen&&chip._activityAt==37); + chip.standby_status=chip.lf_failure=chip.stage_failure=0; + } +#else + chip._rx_ps_rf_rx_disabled=true;chip._headerSeen=true;chip._activityAt=37; + assert(chip.startReceiveDutyCycle(100000,100000)==0&&!chip._headerSeen&&chip._activityAt==0); +#endif +} +''' + +class RadioRxActivityEpochTests(unittest.TestCase): + def test_actual_receive_rearm_preserves_new_preamble_at_due_scan(self): + for family in FAMILIES: + with self.subTest(family=family): + compile_run(build_source(family)+CHECKS,['-DESP32_PLATFORM=1','-DRADIO_CLASS='+family]) + + def test_removed_epoch_reset_aborts_new_packet_in_actual_due_scan(self): + for family in FAMILIES: + with self.subTest(family=family): + with self.assertRaises(AssertionError)as failure: + compile_run(build_source(family,remove_reset=True)+CHECKS, + ['-DESP32_PLATFORM=1','-DRADIO_CLASS='+family]) + self.assertIn('new preamble must survive the actual due scanner',str(failure.exception)) + + def test_actual_normal_receive_success_failure_activity_ttl_and_header_error(self): + for family in FAMILIES: + with self.subTest(family=family): + compile_run(build_source(family)+ENTRY_CHECKS,['-DESP32_PLATFORM=1','-DRADIO_CLASS='+family]) + + def test_actual_fast_receive_resets_only_after_acknowledged_entry(self): + for family in ('CustomSX1262','CustomLR1110'): + with self.subTest(family=family): + compile_run(build_source(family)+FAST_CHECKS,['-DESP32_PLATFORM=1','-DRADIO_CLASS='+family]) + + def test_actual_duty_cycle_entry_resets_only_after_acknowledged_entry(self): + for family in ('CustomSX1262','CustomLR1110'): + for remove in (False,True): + with self.subTest(family=family,removed_reset=remove): + source=build_source(family,remove_reset=remove,include_duty=True) + if remove: + with self.assertRaises(AssertionError)as failure: + compile_run(source+DUTY_CHECKS,['-DESP32_PLATFORM=1','-DRADIO_CLASS='+family]) + self.assertIn('acknowledged duty-cycle RX must reset activity',str(failure.exception)) + else:compile_run(source+DUTY_CHECKS,['-DESP32_PLATFORM=1','-DRADIO_CLASS='+family]) + + +if __name__=='__main__':unittest.main() diff --git a/test/test_sx1262_profile_switch.py b/test/test_sx1262_profile_switch.py index 9c9a70a41..0dd385f11 100644 --- a/test/test_sx1262_profile_switch.py +++ b/test/test_sx1262_profile_switch.py @@ -141,6 +141,7 @@ struct SX1262 { int16_t setRx(uint32_t timeout) { assert(timeout==0xffffff);return call('R'); } }; struct Radio: SX1262 { + uint32_t _activityAt=0;bool _headerSeen=false; SX1262ProfileSwitchState _profileSwitch{true};bool _rx_ps_rf_rx_disabled=false;uint32_t _profileFastResumes=0; bool _coldStandby=true; bool _tcxoWakePending=false; diff --git a/test/test_sx1262_protocol_batching.py b/test/test_sx1262_protocol_batching.py index 50ea98049..35ca4d32a 100644 --- a/test/test_sx1262_protocol_batching.py +++ b/test/test_sx1262_protocol_batching.py @@ -84,6 +84,7 @@ struct SX1262 { uint8_t getPacketType() { call('Q');return 1; } }; struct Radio: SX1262 { + uint32_t _activityAt=0;bool _headerSeen=false; SX1262ProfileSwitchState _profileSwitch{true}; bool _coldStandby=false;uint32_t _profileFastResumes=0; @METHODS@