From 8874cf62cac7ef29b9fc860e5b1e34e17680be1f Mon Sep 17 00:00:00 2001 From: mikecarper Date: Sat, 17 Oct 2026 13:36:38 -0700 Subject: [PATCH] Fix W12 TX prearming without standby PA drain --- .github/workflows/run-unit-tests.yml | 5 + docs/power_saving_diagnostics.md | 19 + src/helpers/ESP32GpioHold.h | 6 +- src/helpers/radiolib/CustomLR2021.h | 132 ++++- test/test_esp32_gpio_hold.py | 108 +++- test/test_lr2021_tx_prearm.py | 474 ++++++++++++++++++ .../meshnology_w12/MeshnologyW12Board.cpp | 10 +- variants/meshnology_w12/platformio.ini | 1 + variants/meshnology_w12/target.cpp | 2 +- 9 files changed, 747 insertions(+), 10 deletions(-) create mode 100644 test/test_lr2021_tx_prearm.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index ec7831af3..343f7e41d 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -65,6 +65,10 @@ jobs: - name: Compile shared-slot Station G3 and legacy alias with buffered KISS # One PlatformIO process: these environments share the checkout build tree. run: pio run -e Station_G3_ESP32_repeater -e Station_G3_ESP32_r2_repeater -e Station_G3_ESP32_kiss_modem + - name: Compile W12 low-power FEM TX preparation in repeater and Full Companion + run: | + pio run -e meshnology_w12_repeater + bash build.sh build-firmware meshnology_w12_companion_radio_full --firmware-version v1.17.1.10-halo-keymind-cascade-dev --radio-preset usa-cascade-fixed --profile cascade --require-ota esp32-ethernet-companion-smoke: runs-on: ubuntu-latest @@ -228,6 +232,7 @@ jobs: python3 -B test/test_wio_tracker_l2_gps_power.py -v python3 -B test/test_lr1121_power_merge.py -v python3 -B test/test_lr2021_power.py -v + python3 -B test/test_lr2021_tx_prearm.py -v 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 diff --git a/docs/power_saving_diagnostics.md b/docs/power_saving_diagnostics.md index de2e6d71b..0bf6642e7 100644 --- a/docs/power_saving_diagnostics.md +++ b/docs/power_saving_diagnostics.md @@ -153,3 +153,22 @@ Nano G2 Ultra, ThinkNode M1/M8 and Keepteen LT1 retain their plain `NRF52Board` initializers for the same reason. The MeshTracker X1's LR2021 regulator likewise remains unchanged. Do not turn on a switching regulator without its required inductor/capacitor network. The MCU and LoRa regulator choices are independent. + +## Meshnology W12 transmit preparation + +The W12 adopts the GC1109 TX preparation from +[upstream PR #3579](https://github.com/meshcore-dev/MeshCore/pull/3579), but keeps +the PA shut down in ordinary standby rather than permanently selecting TX mode. +Before sub-GHz packet or Morse CW transmission, it temporarily enables the LF +PA in standby, waits 1 ms, starts TX, then restores the normal standby mask. +The TX mask stays unchanged during that restoration. Failure paths restore all +affected DIOs and request radio recovery if the PA state cannot be confirmed. +The 2.4 GHz path and other LR2021 boards do not use this LF prearm policy. + +LF/HF FEM rail changes now settle for 1 ms instead of 100 ms. Packet-wake deep +sleep retains only the selected FEM rail; terminal shutdown retains both rails +off. This is separate from the W12's existing circuit-verified DC/DC setting. + +Native fault-injection tests cover the mask transitions and power retention; +RF envelope and battery-current measurements on a physical W12 are still needed +to validate the TX improvement and quantify power use. diff --git a/src/helpers/ESP32GpioHold.h b/src/helpers/ESP32GpioHold.h index 678e3b8b2..93431e4db 100644 --- a/src/helpers/ESP32GpioHold.h +++ b/src/helpers/ESP32GpioHold.h @@ -70,8 +70,10 @@ inline esp_err_t configureHeldOutput(gpio_num_t pin, uint32_t level) { return gpio_config(&config); } -// Terminal shutdown only: explicitly select a known output before latching -// it. The IDF GPIO API selects the correct RTC/digital hold implementation. +// Explicitly select a known output before latching it. RTC pads can also be +// retained for packet-wake sleep (e.g. W12 FEM rails). Non-RTC/digital pads +// require the terminal-only commit below; never globally freeze live pads. +// The IDF GPIO API selects the correct RTC/digital hold implementation. inline esp_err_t holdOutputForDeepSleep(gpio_num_t pin, uint32_t level) { esp_err_t result = configureHeldOutput(pin, level); if (result != ESP_OK) return result; diff --git a/src/helpers/radiolib/CustomLR2021.h b/src/helpers/radiolib/CustomLR2021.h index 20b928cb5..552365627 100644 --- a/src/helpers/radiolib/CustomLR2021.h +++ b/src/helpers/radiolib/CustomLR2021.h @@ -15,6 +15,11 @@ #define LR2021_USE_DCDC 0 #endif +// Board-specific FEM warm-up. Do not leave a PA enabled in ordinary standby. +#ifndef LR2021_TX_PREARM_MS +#define LR2021_TX_PREARM_MS 0 +#endif + class CustomLR2021 : public LR2021 { uint32_t _preambleMillis = 66; uint32_t _maxPayloadMillis = 3934; @@ -24,6 +29,70 @@ class CustomLR2021 : public LR2021 { uint32_t _rf_switch_pins[Module::RFSWITCH_MAX_PINS] = {}; const Module::RfSwitchMode_t* _rf_switch_table = nullptr; +#if LR2021_TX_PREARM_MS > 0 + bool _tx_standby_armed = false; + + int16_t setTxStandbyMask(bool arm) { + if (!_rf_switch_table || (!arm && !_tx_standby_armed)) return RADIOLIB_ERR_NONE; + // Set the flag before writing: a failed SPI response may still have changed + // a DIO. Cleanup must attempt every affected pin, not stop at the first error. + _tx_standby_armed = true; + int16_t result = RADIOLIB_ERR_NONE; + for (size_t i = 0; i < Module::RFSWITCH_MAX_PINS; ++i) { + const uint32_t pin = _rf_switch_pins[i]; + if (pin == RADIOLIB_NC || !(pin & RFSWITCH_PIN_FLAG)) continue; + const uint8_t dio = RADIOLIB_LRXXXX_DIOx_VAL(pin); + if (dio >= 7) { + if (result == RADIOLIB_ERR_NONE) result = RADIOLIB_ERR_UNKNOWN; + continue; + } + uint8_t config = 0; + for (size_t j = 0; j < 8 && _rf_switch_table[j].mode != LR2021::MODE_END_OF_TABLE; ++j) { + const uint8_t mode = _rf_switch_table[j].mode; + if (mode > 0 && mode <= 8 + && _rf_switch_table[j].values[i] == this->mod->hal->GpioLevelHigh) + config |= 1UL << (mode - 1); + } + const uint8_t standby_bit = 1U << (LR2021::MODE_STBY - 1); + const uint8_t tx_bit = 1U << (LR2021::MODE_TX - 1); + if (!(config & tx_bit) || (config & standby_bit)) continue; + const int16_t status = setDioRfSwitchConfig(dio + 5, arm ? config | standby_bit : config); + if (result == RADIOLIB_ERR_NONE) result = status; + } + if (!arm && result == RADIOLIB_ERR_NONE) _tx_standby_armed = false; + return result; + } + + int16_t prepareTxRfSwitch() { + // stageMode(TX) does not enter standby. Stop RX before temporarily enabling + // the LF PA there; HF routing and boards without this opt-in are unchanged. + int16_t status = standby(); + if (status == RADIOLIB_ERR_NONE && !mesh::lr2021::isHighBand(freqMHz)) { + status = setTxStandbyMask(true); + // Reissue the mode after programming its mask: do not rely on a mask + // write changing an already-selected mode's outputs immediately. Bypass + // our cleanup override here so this one intentional warm-up stays armed. + if (status == RADIOLIB_ERR_NONE) status = LR2021::standby(); + if (status == RADIOLIB_ERR_NONE) this->mod->hal->delay(LR2021_TX_PREARM_MS); + } + return status; + } + + int16_t finishTxRfSwitch(int16_t status) { + // Only change masks while transmitting. SetDioFunction (used by the full + // reset-restoration helper) is restricted to Standby RC by the LR2021. + // TX bits remain HIGH: disarming standby does not interrupt the packet/CW. + const int16_t restored = setTxStandbyMask(false); + if (status != RADIOLIB_ERR_NONE || restored != RADIOLIB_ERR_NONE) { + const int16_t stopped = standby(); + // Request the wrapper's hard-reset recovery if the PA state is uncertain. + if (restored != RADIOLIB_ERR_NONE || stopped != RADIOLIB_ERR_NONE) + return RADIOLIB_ERR_SPI_CMD_TIMEOUT; + } + return status; + } +#endif + int16_t restoreRfSwitchTable() { if (!_rf_switch_table) return RADIOLIB_ERR_NONE; for (size_t i = 0; i < Module::RFSWITCH_MAX_PINS; ++i) { @@ -44,6 +113,9 @@ class CustomLR2021 : public LR2021 { if (status == RADIOLIB_ERR_NONE) status = setDioRfSwitchConfig(dio + 5, config); if (status != RADIOLIB_ERR_NONE) return status; } +#if LR2021_TX_PREARM_MS > 0 + _tx_standby_armed = false; +#endif return RADIOLIB_ERR_NONE; } @@ -58,6 +130,31 @@ class CustomLR2021 : public LR2021 { public: CustomLR2021(Module *mod) : LR2021(mod) { irqDioNum = LR2021_IRQ_DIO; } +#if LR2021_TX_PREARM_MS > 0 + using LR2021::standby; + int16_t standby() override { + const bool was_armed = _tx_standby_armed; + const int16_t status = LR2021::standby(); + const int16_t restored = setTxStandbyMask(false); + if (restored != RADIOLIB_ERR_NONE) return RADIOLIB_ERR_SPI_CMD_TIMEOUT; + // Also latch the normal mask after a dirty/prearmed-state cleanup. + if (was_armed && (status != RADIOLIB_ERR_NONE || LR2021::standby() != RADIOLIB_ERR_NONE)) { + // A mask write alone does not prove the PA turned off. Keep retrying + // cleanup (or hard reset) until a normal standby is confirmed. + _tx_standby_armed = true; + return RADIOLIB_ERR_SPI_CMD_TIMEOUT; + } + return status; + } + + using LR2021::sleep; + int16_t sleep() override { + const int16_t status = standby(); + if (status != RADIOLIB_ERR_NONE) return status; + return LR2021::sleep(); + } +#endif + int16_t setProfileProtocol(uint8_t syncWord) { int16_t status = LR2021::setSyncWord(syncWord); if (status == RADIOLIB_ERR_NONE) status = LR2021::setCRC(2); @@ -174,8 +271,17 @@ class CustomLR2021 : public LR2021 { int16_t transmitMorseCarrier() { // RadioLib's generic direct-TX path always selects MODE_TX. Use the // band-aware routing also used by our normal packet transmit path. + #if LR2021_TX_PREARM_MS > 0 + const int16_t prepared = prepareTxRfSwitch(); + if (prepared != RADIOLIB_ERR_NONE) return finishTxRfSwitch(prepared); + #endif this->mod->setRfSwitchState(rfSwitchTxMode()); - return this->setTxTestMode(RADIOLIB_LR2021_TX_TEST_MODE_CW); + const int16_t status = this->setTxTestMode(RADIOLIB_LR2021_TX_TEST_MODE_CW); + #if LR2021_TX_PREARM_MS > 0 + return finishTxRfSwitch(status); + #else + return status; + #endif } int16_t setRxBoostedGainMode(uint8_t level) { @@ -191,9 +297,23 @@ class CustomLR2021 : public LR2021 { const bool transmitting = this->stagedMode == RADIOLIB_RADIO_MODE_TX; int16_t state; if (this->stagedMode == RADIOLIB_RADIO_MODE_RX) { + #if LR2021_TX_PREARM_MS > 0 + state = setTxStandbyMask(false); + if (state != RADIOLIB_ERR_NONE) { + this->stagedMode = RADIOLIB_RADIO_MODE_NONE; + return RADIOLIB_ERR_SPI_CMD_TIMEOUT; + } + #endif this->mod->setRfSwitchState(rfSwitchRxMode()); state = this->setRx(this->rxTimeout); } else if (transmitting) { + #if LR2021_TX_PREARM_MS > 0 + state = prepareTxRfSwitch(); + if (state != RADIOLIB_ERR_NONE) { + this->stagedMode = RADIOLIB_RADIO_MODE_NONE; + return finishTxRfSwitch(state); + } + #endif this->mod->setRfSwitchState(rfSwitchTxMode()); state = this->setTx(RADIOLIB_LR2021_TX_TIMEOUT_NONE); } else { @@ -202,6 +322,9 @@ class CustomLR2021 : public LR2021 { if (state != RADIOLIB_ERR_NONE) { this->stagedMode = RADIOLIB_RADIO_MODE_NONE; + #if LR2021_TX_PREARM_MS > 0 + if (transmitting) return finishTxRfSwitch(state); + #endif return state; } @@ -211,12 +334,19 @@ class CustomLR2021 : public LR2021 { this->mod->hal->yield(); if (this->mod->hal->millis() - started >= LR2021_TX_BUSY_TIMEOUT_MS) { this->stagedMode = RADIOLIB_RADIO_MODE_NONE; + #if LR2021_TX_PREARM_MS > 0 + return finishTxRfSwitch(RADIOLIB_ERR_SPI_CMD_TIMEOUT); + #else return RADIOLIB_ERR_SPI_CMD_TIMEOUT; + #endif } } } this->stagedMode = RADIOLIB_RADIO_MODE_NONE; + #if LR2021_TX_PREARM_MS > 0 + if (transmitting) return finishTxRfSwitch(RADIOLIB_ERR_NONE); + #endif return RADIOLIB_ERR_NONE; } diff --git a/test/test_esp32_gpio_hold.py b/test/test_esp32_gpio_hold.py index 0ca84a755..1275b692a 100644 --- a/test/test_esp32_gpio_hold.py +++ b/test/test_esp32_gpio_hold.py @@ -398,6 +398,7 @@ int main(){ EnvironmentSensorManager sensors; prefix=begin[:begin.index(' prepareRadioFrequency(')]+'}\n' code=r''' #include +#include #define HIGH 1 #define LOW 0 #define OUTPUT 1 @@ -410,11 +411,15 @@ class ESP32Board {public:void begin(){mesh::esp32::resetTerminalDeepSleepHolds() class MeshnologyW12Board:public ESP32Board {public:void begin();}; @BEGIN@ int main(){ using namespace mesh::esp32; - holdOutputForDeepSleep(4,LOW);holdOutputForDeepSleep(3,LOW); - pads[4].output=false;pads[3].output=false;require_configured_release=true; - MeshnologyW12Board board;board.begin(); - assert(!pads[3].hold && !pads[4].hold && physical(3)==LOW && physical(4)==LOW); - assert(!deep_hold && !terminalDigitalHoldPending()); + for(int active : {-1,3,4}) { + // Wake safely from terminal shutdown or either band's packet-wake sleep. + holdOutputForDeepSleep(4,active==4?HIGH:LOW); + holdOutputForDeepSleep(3,active==3?HIGH:LOW); + pads[4].output=false;pads[3].output=false;require_configured_release=true; + MeshnologyW12Board board;board.begin(); + assert(!pads[3].hold && !pads[4].hold && physical(3)==LOW && physical(4)==LOW); + assert(!deep_hold && !terminalDigitalHoldPending()); + } } ''' self.compile_run(code.replace('@BEGIN@',prefix),s3=1) @@ -423,6 +428,99 @@ int main(){ using namespace mesh::esp32; 'releaseRtcGpioIfSupported((gpio_num_t)P_LORA_LF_PA_POWER)') self.compile_run(code.replace('@BEGIN@',broken),s3=1,helper_mutation=lambda text:text) + def test_w12_packet_wake_retains_only_selected_band_and_terminal_shutdown_disables_both(self): + source=(ROOT/'variants/meshnology_w12/MeshnologyW12Board.cpp').read_text() + methods='\n'.join(body(source,signature) for signature in ( + 'bool MeshnologyW12Board::prepareRadioFrequency(float frequency)', + 'void MeshnologyW12Board::enterDeepSleep(uint32_t secs, int pin_wake_btn)', + 'void MeshnologyW12Board::powerOff()')) + code=r''' +#include +#include +#include +#define HIGH 1 +#define LOW 0 +#define P_LORA_LF_PA_POWER 4 +#define P_LORA_HF_PA_POWER 3 +#define P_LORA_DIO_1 14 +#define P_LORA_NSS 8 +constexpr int ESP_PD_DOMAIN_RTC_PERIPH=1,ESP_PD_OPTION_ON=1; +constexpr int RTC_GPIO_MODE_INPUT_ONLY=0,ESP_EXT1_WAKEUP_ANY_HIGH=1; +static bool slept=false,shutdown=false; +static unsigned settles=0; +static uint64_t wake_mask=0,timer=0; +static std::vector writes; +void digitalWrite(int pin,int level){ + assert(!pads[pin].hold);pads[pin].output=true;gpio_set_level(pin,level); + writes.push_back(pin*2+level); + // Never power both FEMs, even transiently, while changing bands. + assert(physical(3)!=HIGH || physical(4)!=HIGH); +} +void delay(unsigned ms){assert(ms==1);++settles;} +void esp_sleep_pd_config(int domain,int option){assert(domain==1 && option==1);} +void rtc_gpio_set_direction(int pin,int mode){assert(pin==14 && mode==0);} +void rtc_gpio_pulldown_en(int pin){assert(pin==14);} +void esp_sleep_enable_ext1_wakeup(uint64_t mask,int mode){assert(mode==1);wake_mask=mask;} +void esp_sleep_enable_timer_wakeup(uint64_t value){timer=value;} +void sleep_pads(){ + // Unretained outputs may float once the CPU domain powers down. + for(int pin : {3,4}) if(!pads[pin].hold) pads[pin].output=false; +} +void esp_deep_sleep_start(){sleep_pads();slept=true;} +class ESP32Board {public:void powerOff(){ + assert(pads[3].hold && pads[4].hold && physical(3)==LOW && physical(4)==LOW); + sleep_pads();shutdown=true; +}}; +class MeshnologyW12Board:public ESP32Board { +public: + bool radio_band_initialized=false,radio_high_band=false; + bool prepareRadioFrequency(float frequency); + void enterDeepSleep(uint32_t secs,int pin_wake_btn); + void powerOff(); +}; +@METHODS@ +int main(){using namespace mesh::esp32; + for(bool high_band : {false,true}) { + resetTerminalDeepSleepHolds(); + releaseOutputAfterDeepSleep(3,LOW);releaseOutputAfterDeepSleep(4,LOW); + MeshnologyW12Board board; + const float frequency=high_band?2440.0f:909.5f; + const unsigned before=settles; + assert(board.prepareRadioFrequency(frequency) && settles==before+1); + assert(writes[writes.size()-3]==4*2 && writes[writes.size()-2]==3*2); + assert(physical(3)==high_band && physical(4)==!high_band); + assert(board.prepareRadioFrequency(frequency) && settles==before+1); + // A real LF/HF crossing disables the previous rail before enabling the next. + assert(board.prepareRadioFrequency(high_band?909.5f:2440.0f)); + assert(physical(3)==!high_band && physical(4)==high_band); + assert(board.prepareRadioFrequency(frequency) && settles==before+3); + for(int button : {-1,0}) { + slept=false;timer=0; + board.enterDeepSleep(button<0?5:0,button); + assert(slept && pads[3].hold && pads[4].hold); + assert(physical(3)==high_band && physical(4)==!high_band); + assert(wake_mask==((1ULL<<14)|(button<0?0:(1ULL< +#include +#include +#include +#include +#include +#include +@POLICY@ +#define LR2021_TX_BUSY_TIMEOUT_MS 4UL +#define LR2021_USE_DCDC 0 +#define RADIOLIB_ERR_NONE 0 +#define RADIOLIB_ERR_UNKNOWN -16 +#define RADIOLIB_ERR_UNSUPPORTED -99 +#define RADIOLIB_ERR_SPI_CMD_TIMEOUT -705 +#define RADIOLIB_NC 0xffffffffU +#define RFSWITCH_PIN_FLAG (1UL << 31) +#define RADIOLIB_LRXXXX_DIOx_VAL(pin) ((pin) & ~RFSWITCH_PIN_FLAG) +#define RADIOLIB_LR2021_DIO5 (RFSWITCH_PIN_FLAG | 0) +#define RADIOLIB_LR2021_DIO6 (RFSWITCH_PIN_FLAG | 1) +#define RADIOLIB_LR2021_DIO9 (RFSWITCH_PIN_FLAG | 4) +#define RADIOLIB_LR2021_DIO10 (RFSWITCH_PIN_FLAG | 5) +#define RADIOLIB_LR2021_DIO11 (RFSWITCH_PIN_FLAG | 6) +#define RADIOLIB_LR2021_DIO_SLEEP_PULL_UP 2 +#define RADIOLIB_LR2021_DIO_SLEEP_PULL_AUTO 3 +#define RADIOLIB_LR2021_DIO_FUNCTION_RF_SWITCH 0x20 +#define RADIOLIB_LR2021_TX_TIMEOUT_NONE 0 +#define RADIOLIB_LR2021_TX_TEST_MODE_CW 0 +#define RADIOLIB_LR2021_LORA_SYNC_WORD_PRIVATE 0x12 +#define RADIOLIB_LR2021_RX_TIMEOUT_INF 0xffffff +#define RADIOLIB_IRQ_RX_DEFAULT_FLAGS 0 +#define RADIOLIB_IRQ_RX_DEFAULT_MASK 0 +#define RADIOLIB_IRQ_PREAMBLE_DETECTED 5 +#define RADIOLIB_RADIO_MODE_NONE 0 +#define RADIOLIB_RADIO_MODE_RX 1 +#define RADIOLIB_RADIO_MODE_TX 2 +#define LOW 0 +#define HIGH 1 +#define END_OF_MODE_TABLE {0, {0, 0, 0, 0, 0}} +#define LORA_FREQ 909.5f +#define LORA_BW 500.0f +#define LORA_SF 5 +#define LORA_CR 5 +#define LORA_TX_POWER 2 +using RadioLibTime_t = uint32_t; +struct SPIClass {}; +struct Logger { void print(const char*) {} void println(int) {} }; +namespace mesh { Logger& usbLoggingPort() { static Logger logger;return logger; } } +struct Hal { + uint32_t GpioLevelHigh=HIGH; + uint32_t now=0; + bool busy=false; + int delays=0; + uint32_t millis() { return now; } + int digitalRead(uint32_t) { return busy; } + void yield() { ++now; } + void delay(uint32_t ms) { assert(ms==LR2021_TX_PREARM_MS);now+=ms;++delays; } +}; +struct Module { + static constexpr size_t RFSWITCH_MAX_PINS=5; + // These are RadioLib's actual Module::OpMode_t values, not synthetic modes. + enum OpMode_t { MODE_END_OF_TABLE=0, MODE_IDLE, MODE_RX, MODE_TX }; + struct RfSwitchMode_t { uint8_t mode;uint32_t values[RFSWITCH_MAX_PINS]; }; + Hal storage;Hal* hal=&storage; + uint8_t host_switch=0; + uint32_t getGpio() { return 13; } + // DIO-controlled RF switches are NOT host GPIOs. The chip mode selects them. + void setRfSwitchState(uint8_t mode) { host_switch=mode; } +}; +class LR2021 { +public: + // Exact RadioLib LR2021::OpMode_t declaration (aliases plus HF extensions). + enum OpMode_t { + MODE_END_OF_TABLE=Module::MODE_END_OF_TABLE, + MODE_STBY=Module::MODE_IDLE, + MODE_RX=Module::MODE_RX, + MODE_TX=Module::MODE_TX, + MODE_RX_HF, + MODE_TX_HF, + }; + Module* mod; + float freqMHz=909.5f; + uint8_t stagedMode=RADIOLIB_RADIO_MODE_NONE,chip_mode=MODE_STBY; + uint32_t rxTimeout=0xffffff; + uint8_t masks[7]={}; + bool levels[7]={},latch_masks_on_mode=false; + int fail_dio=0,fail_count=0,fail_standby_at=0; + bool fail_arm=false,fail_disarm=false,fail_tx=false,fail_standby=false; + bool busy_after_tx=false,failed_standby_not_applied=false; + int tx_calls=0,rx_calls=0,sleep_calls=0,standby_calls=0,function_calls=0; + int previous_tx_delays=0; + std::vector writes,clear_attempts; + explicit LR2021(Module* module):mod(module) {} + virtual ~LR2021()=default; + virtual int16_t launchMode() { return -99; } + virtual int16_t startReceive() { return -99; } + bool level(uint8_t dio) const { return levels[dio-5]; } + void applyMode(uint8_t mode) { + chip_mode=mode; + for(size_t i=0;i<7;++i) levels[i]=(masks[i] & (1U << (mode-1)))!=0; + } + bool offInStandby() const { + for(uint8_t dio:{9,10,11}) if(masks[dio-5]&1) return false; + return true; + } + virtual int16_t standby() { + ++standby_calls; + const bool failed=fail_standby || standby_calls==fail_standby_at; + if(!failed || !failed_standby_not_applied) applyMode(MODE_STBY); + return failed ? -71 : 0; + } + virtual int16_t sleep() { ++sleep_calls;applyMode(MODE_STBY);return 0; } + int16_t setTx(uint32_t timeout) { + assert(timeout==0);++tx_calls; +#if LR2021_TX_PREARM_MS > 0 + assert(chip_mode==MODE_STBY); + if(!mesh::lr2021::isHighBand(freqMHz)) { + assert(level(9) && level(10) && level(11)); + assert(mod->hal->delays==previous_tx_delays+1); + assert(mod->hal->now>=LR2021_TX_PREARM_MS); + } +#endif + previous_tx_delays=mod->hal->delays; + // A failed result does not prove that the TX command was not applied. + applyMode(mesh::lr2021::isHighBand(freqMHz) ? MODE_TX_HF : MODE_TX); + mod->hal->busy=busy_after_tx; + return fail_tx ? -72 : 0; + } + int16_t setTxTestMode(uint8_t mode) { assert(mode==0);return setTx(0); } + int16_t setRx(uint32_t timeout) { + assert(timeout==rxTimeout);++rx_calls; +#if LR2021_TX_PREARM_MS > 0 + assert(offInStandby()); +#endif + applyMode(mesh::lr2021::isHighBand(freqMHz) ? MODE_RX_HF : MODE_RX); + return 0; + } + int16_t setDioRfSwitchConfig(uint8_t dio,uint8_t mask) { + assert(dio>=5 && dio<=11);writes.push_back(dio); + const bool arming=(mask&1)!=0; + if(!arming && (dio>=9)) clear_attempts.push_back(dio); + masks[dio-5]=mask; + if(!latch_masks_on_mode) levels[dio-5]=(mask & (1U << (chip_mode-1)))!=0; + // Restoring STBY bits must never momentarily deassert an active TX FEM. + if(chip_mode==MODE_TX && dio>=9) assert(level(dio)); + const bool match=(arming && fail_arm) || (!arming && fail_disarm); + if(dio==fail_dio && match && fail_count) { + if(fail_count>0) --fail_count; + return -73; + } + return 0; + } + int16_t setDioFunction(uint8_t dio,uint8_t function,uint8_t pull) { + assert(chip_mode==MODE_STBY); // Semtech permits this in Standby RC only. + assert(dio>=5 && dio<=11 && function==0x20 && (pull==2 || pull==3)); + ++function_calls;return 0; + } + void setRfSwitchTable(const uint32_t (&pins)[5],const Module::RfSwitchMode_t* table) { + for(size_t i=0;i<5;++i) { + uint8_t mask=0; + for(size_t j=0;table[j].mode;++j) + if(table[j].values[i]==HIGH) mask|=1U<<(table[j].mode-1); + masks[RADIOLIB_LRXXXX_DIOx_VAL(pins[i])]=mask; + } + applyMode(chip_mode); + } + int16_t begin(float,float,uint8_t,uint8_t,uint8_t,int8_t,uint16_t,float) { + std::fill(masks,masks+7,0);applyMode(MODE_STBY);return 0; + } + int16_t setCRC(uint8_t) { return 0; } + int16_t explicitHeader() { return 0; } + int16_t clearRxFifo() { return 0; } + int16_t startReceive(uint32_t timeout,uint32_t,uint32_t,int) { + rxTimeout=timeout;stagedMode=RADIOLIB_RADIO_MODE_RX;return launchMode(); + } +}; +class CustomLR2021:public LR2021 { +public: + using LR2021::LR2021; + uint32_t _rf_switch_pins[Module::RFSWITCH_MAX_PINS]={}; + const Module::RfSwitchMode_t* _rf_switch_table=nullptr; +#if LR2021_TX_PREARM_MS > 0 + bool _tx_standby_armed=false; + @MASK@ + @PREPARE@ + @FINISH@ + @STANDBY@ + @SLEEP@ +#endif + @RESTORE@ + @TABLE@ + @INIT@ + @RX_MODE@ + @TX_MODE@ + @LAUNCH@ + @MORSE@ + @RECEIVE@ +}; +@BOARD_PINS@ +@BOARD_TABLE@ +static void configure(CustomLR2021& radio) { + radio.setRfSwitchTable(rfswitch_dios,rfswitch_table); + assert(radio.offInStandby()); + radio.writes.clear();radio.clear_attempts.clear(); +} +static void noBias(CustomLR2021& radio) { + assert(radio.offInStandby()); + if(radio.chip_mode==LR2021::MODE_STBY) + assert(!radio.level(9) && !radio.level(10) && !radio.level(11)); +#if LR2021_TX_PREARM_MS > 0 + assert(!radio._tx_standby_armed); +#endif +} +static int packet(CustomLR2021& radio) { + radio.stagedMode=RADIOLIB_RADIO_MODE_TX; + const int result=radio.launchMode(); + assert(radio.stagedMode==RADIOLIB_RADIO_MODE_NONE); + return result; +} +int main(int argc,char** argv) { + assert(argc==2);std::string test=argv[1]; + Module module;CustomLR2021 radio(&module);configure(radio); + if(test.rfind("latched_",0)==0) { + radio.latch_masks_on_mode=true;test=test.substr(8); + } + if(test=="packet" || test=="cw") { + radio.chip_mode=LR2021::MODE_RX; + assert((test=="packet" ? packet(radio) : radio.transmitMorseCarrier())==0); + assert(radio.tx_calls==1 && radio.chip_mode==LR2021::MODE_TX); + noBias(radio); +#if LR2021_TX_PREARM_MS > 0 + assert(radio.writes==std::vector({9,10,11,9,10,11})); + assert(module.hal->delays==1 && radio.standby_calls==2); +#else + assert(radio.writes.empty() && module.hal->delays==0 && radio.standby_calls==0); +#endif + assert(radio.function_calls==0); + assert(radio.standby()==0);noBias(radio); + assert(radio.startReceive()==0 && radio.chip_mode==LR2021::MODE_RX); + assert(!radio.level(9) && radio.level(11)); + } else if(test=="hf") { + radio.freqMHz=2440; + assert(packet(radio)==0 && radio.chip_mode==LR2021::MODE_TX_HF); + assert(radio.writes.empty() && module.hal->delays==0);noBias(radio); + assert(radio.mod->host_switch==LR2021::MODE_TX_HF); + assert(radio.level(5) && !radio.level(6) && !radio.level(9) && !radio.level(11)); + radio.stagedMode=RADIOLIB_RADIO_MODE_RX; + assert(radio.launchMode()==0 && radio.mod->host_switch==LR2021::MODE_RX_HF); + assert(!radio.level(5) && radio.level(6) && !radio.level(11)); + } +#if LR2021_TX_PREARM_MS > 0 + else if(test=="tx_fail" || test=="cw_fail" || test=="busy") { + radio.fail_tx=(test!="busy");radio.busy_after_tx=(test=="busy"); + assert((test=="cw_fail" ? radio.transmitMorseCarrier() : packet(radio))== + (test=="busy" ? RADIOLIB_ERR_SPI_CMD_TIMEOUT : -72)); + assert(radio.tx_calls==1 && radio.chip_mode==LR2021::MODE_STBY); + assert(radio.clear_attempts==std::vector({9,10,11}));noBias(radio); + } else if(test=="standby_fail" || test=="second_standby_fail") { + if(test=="standby_fail") radio.fail_standby=true; + else radio.fail_standby_at=2; + assert(packet(radio)==(radio.fail_standby ? RADIOLIB_ERR_SPI_CMD_TIMEOUT : -71)); + assert(radio.tx_calls==0 && module.hal->delays==0);noBias(radio); + if(radio.fail_standby) assert(radio.writes.empty()); + else assert(radio.clear_attempts==std::vector({9,10,11})); + } else if(test.rfind("arm_fail",0)==0 || test.rfind("disarm_fail",0)==0) { + const bool arm=test.rfind("arm_fail",0)==0; + radio.fail_dio=std::stoi(test.substr(arm?8:11)); + radio.fail_arm=arm;radio.fail_disarm=!arm;radio.fail_count=1; + assert(packet(radio)==(arm ? -73 : RADIOLIB_ERR_SPI_CMD_TIMEOUT)); + assert(radio.tx_calls==(arm?0:1));noBias(radio); + assert(radio.writes.size()>=(arm?6U:9U)); + // A failed response still requires every later pin to be restored. + assert(radio.clear_attempts[0]==9 && radio.clear_attempts[1]==10 && radio.clear_attempts[2]==11); + assert(radio.chip_mode==LR2021::MODE_STBY); + } else if(test=="persistent_cleanup") { + radio.fail_dio=10;radio.fail_disarm=true;radio.fail_count=-1; + assert(packet(radio)==RADIOLIB_ERR_SPI_CMD_TIMEOUT); + assert(radio._tx_standby_armed && radio.clear_attempts.size()==6); + assert(radio.clear_attempts==std::vector({9,10,11,9,10,11})); + radio.stagedMode=RADIOLIB_RADIO_MODE_RX; + assert(radio.launchMode()==RADIOLIB_ERR_SPI_CMD_TIMEOUT && radio.rx_calls==0); + assert(radio.stagedMode==RADIOLIB_RADIO_MODE_NONE && radio._tx_standby_armed); + assert(radio.sleep()==RADIOLIB_ERR_SPI_CMD_TIMEOUT && radio.sleep_calls==0); + radio.fail_count=0;assert(radio.sleep()==0 && radio.sleep_calls==1);noBias(radio); + } else if(test=="standby" || test=="sleep" || test=="receive" || test=="reset") { + assert(radio.prepareTxRfSwitch()==0 && radio._tx_standby_armed); + assert(!radio.offInStandby()); + if(test=="standby") assert(radio.standby()==0); + if(test=="sleep") assert(radio.sleep()==0 && radio.sleep_calls==1); + if(test=="receive") { + assert(radio.startReceive()==0 && radio.rx_calls==1); + assert(radio.chip_mode==LR2021::MODE_RX && !radio.level(9)); + } + if(test=="reset") assert(radio.std_init(nullptr)); + noBias(radio); + assert(radio.clear_attempts.size()>=3); + } else if(test=="dirty_first_standby_fail" || test=="dirty_final_standby_fail") { + // A mask can be written successfully while the current DIO output is + // unchanged until a mode command. A failed mode command may not execute. + radio.latch_masks_on_mode=true; + assert(radio.prepareTxRfSwitch()==0 && radio._tx_standby_armed); + assert(radio.standby_calls==2 && radio.level(9) && radio.level(10) && radio.level(11)); + radio.failed_standby_not_applied=true; + radio.fail_standby_at=(test=="dirty_first_standby_fail" ? 3 : 4); + assert(radio.standby()==RADIOLIB_ERR_SPI_CMD_TIMEOUT); + assert(radio._tx_standby_armed && radio.offInStandby()); + assert(radio.level(9) && radio.level(10) && radio.level(11)); + assert(radio.clear_attempts==std::vector({9,10,11})); + assert(radio.tx_calls==0 && radio.rx_calls==0 && radio.sleep_calls==0); + radio.fail_standby_at=0; + assert(radio.standby()==0);noBias(radio); + assert(radio.clear_attempts==std::vector({9,10,11,9,10,11})); + } else if(test=="repeat") { + for(float frequency:{909.5f,2440.0f,909.5f,2440.0f,909.5f}) { + radio.freqMHz=frequency; + for(bool cw:{false,true}) { + assert((cw ? radio.transmitMorseCarrier() : packet(radio))==0); + noBias(radio); + assert(radio.standby()==0);noBias(radio); + assert(radio.startReceive()==0);noBias(radio); + assert(radio.chip_mode==(frequency>1500 ? LR2021::MODE_RX_HF : LR2021::MODE_RX)); + } + } + assert(radio.tx_calls==10 && module.hal->delays==6); + assert(radio.function_calls==0); + } +#endif + else assert(false); +} +''' + + +class LR2021TxPrearmTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + if not compiler: + raise AssertionError("a host C++17 compiler is required") + cls.temp = tempfile.TemporaryDirectory(prefix="meshcore-lr2021-prearm-") + cls.addClassCleanup(cls.temp.cleanup) + cls.binaries = {} + driver = (ROOT / "src/helpers/radiolib/CustomLR2021.h").read_text() + board = (ROOT / "variants/meshnology_w12/target.cpp").read_text() + source = HARNESS + for marker, signature in { + "@MASK@": "int16_t setTxStandbyMask(", + "@PREPARE@": "int16_t prepareTxRfSwitch(", + "@FINISH@": "int16_t finishTxRfSwitch(", + "@STANDBY@": "int16_t standby()", + "@SLEEP@": "int16_t sleep()", + "@RESTORE@": "int16_t restoreRfSwitchTable(", + "@TABLE@": "void setRfSwitchTable(", + "@INIT@": "bool std_init(", + "@RX_MODE@": "uint8_t rfSwitchRxMode(", + "@TX_MODE@": "uint8_t rfSwitchTxMode(", + "@LAUNCH@": "int16_t launchMode(", + "@MORSE@": "int16_t transmitMorseCarrier(", + "@RECEIVE@": "int16_t startReceive()", + }.items(): + source = source.replace(marker, method(driver, signature)) + source = source.replace("@POLICY@", re.search( + r"#ifndef LR2021_TX_PREARM_MS\b.*?#endif", driver, re.S).group(0)) + for marker, pattern in { + "@BOARD_PINS@": r"const uint32_t rfswitch_dios\[\].*?\n};", + "@BOARD_TABLE@": r"static const Module::RfSwitchMode_t rfswitch_table\[\].*?\n};", + }.items(): + source = source.replace(marker, re.search(pattern, board, re.S).group(0)) + for name, flags in {"w12": ["-DLR2021_TX_PREARM_MS=1"], "default": [], + "disabled": ["-DLR2021_TX_PREARM_MS=0"]}.items(): + cpp = Path(cls.temp.name) / (name + ".cpp") + binary = Path(cls.temp.name) / (name + (".exe" if os.name == "nt" else "")) + cpp.write_text(source, encoding="utf-8") + sanitizers = [] if os.name == "nt" else ["-fsanitize=address,undefined", + "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"] + built = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-Wno-unused-parameter", *sanitizers, *flags, "-I", str(ROOT / "src"), + str(cpp), "-o", str(binary)], capture_output=True, text=True, timeout=40) + if built.returncode: + raise AssertionError(built.stdout + built.stderr) + cls.binaries[name] = binary + + def run_case(self, case, build="w12"): + ran = subprocess.run([str(self.binaries[build]), case], capture_output=True, + text=True, timeout=10) + self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr) + + def test_packet_settles_pa_without_retaining_standby_bias(self): + self.run_case("packet") + + def test_morse_cw_uses_same_preparation_and_cleanup(self): + self.run_case("cw") + + def test_high_band_does_not_arm_low_frequency_pa(self): + self.run_case("hf") + + def test_failed_packet_cw_and_busy_timeout_abort_and_disarm(self): + for case in ["tx_fail", "cw_fail", "busy", "standby_fail", "second_standby_fail"]: + with self.subTest(case=case): + self.run_case(case) + + def test_every_arm_and_cleanup_pin_failure_still_cleans_later_pins(self): + for phase in ["arm_fail", "disarm_fail"]: + for dio in [9, 10, 11]: + with self.subTest(phase=phase, dio=dio): + self.run_case(phase + str(dio)) + + def test_persistent_cleanup_error_blocks_receive_and_sleep_until_recovery(self): + self.run_case("persistent_cleanup") + + def test_standby_sleep_receive_and_actual_reset_restore_normal_table(self): + for case in ["standby", "sleep", "receive", "reset"]: + with self.subTest(case=case): + self.run_case(case) + + def test_default_and_explicit_optout_keep_existing_tx_behavior(self): + for build in ["default", "disabled"]: + for case in ["packet", "cw", "hf"]: + with self.subTest(build=build, case=case): + self.run_case(case, build) + + def test_prearm_and_cleanup_with_mode_latched_dio_outputs(self): + for case in ["packet", "cw", "tx_fail", "cw_fail", "busy", "second_standby_fail", + "persistent_cleanup", "standby", "sleep", "receive", "reset"]: + with self.subTest(case=case): + self.run_case("latched_" + case) + + def test_repeated_packet_and_cw_across_lf_hf_switches_on_same_instance(self): + for case in ["repeat", "latched_repeat"]: + with self.subTest(case=case): + self.run_case(case) + + def test_dirty_cleanup_keeps_retry_flag_until_physical_standby_is_confirmed(self): + for case in ["dirty_first_standby_fail", "dirty_final_standby_fail"]: + with self.subTest(case=case): + self.run_case(case) + + def test_only_w12_enables_prearm_and_normal_standby_remains_off(self): + opted_in = [] + for recipe in sorted((ROOT / "variants").glob("*/platformio.ini")): + if re.search(r"(?m)^\s*-D\s+LR2021_TX_PREARM_MS=[1-9]\d*\b", recipe.read_text()): + opted_in.append(recipe.parent.name) + self.assertEqual(opted_in, ["meshnology_w12"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/variants/meshnology_w12/MeshnologyW12Board.cpp b/variants/meshnology_w12/MeshnologyW12Board.cpp index 68f9b16ea..e40912524 100644 --- a/variants/meshnology_w12/MeshnologyW12Board.cpp +++ b/variants/meshnology_w12/MeshnologyW12Board.cpp @@ -47,7 +47,9 @@ void MeshnologyW12Board::begin() { digitalWrite(P_LORA_LF_PA_POWER, LOW); digitalWrite(P_LORA_HF_PA_POWER, LOW); digitalWrite(high_band ? P_LORA_HF_PA_POWER : P_LORA_LF_PA_POWER, HIGH); - delay(100); + // The FEM rail needs only 1 ms to settle. TX control-line prearming is + // handled separately immediately before transmit, not by idling the PA on. + delay(1); radio_high_band = high_band; radio_band_initialized = true; @@ -63,6 +65,12 @@ void MeshnologyW12Board::begin() { mesh::esp32::holdRtcGpioIfSupported((gpio_num_t)P_LORA_NSS); + // Packet-wake sleep leaves the radio receiving. Retain the selected FEM + // rail and the inactive rail's LOW level; both PA enable pins are RTC GPIOs + // on this ESP32-S3. Terminal powerOff() deliberately retains both LOW. + mesh::esp32::holdOutputForDeepSleep((gpio_num_t)P_LORA_LF_PA_POWER, radio_high_band ? LOW : HIGH); + mesh::esp32::holdOutputForDeepSleep((gpio_num_t)P_LORA_HF_PA_POWER, radio_high_band ? HIGH : LOW); + if (pin_wake_btn < 0) { esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet } else { diff --git a/variants/meshnology_w12/platformio.ini b/variants/meshnology_w12/platformio.ini index 3ff358928..7f7108b7f 100644 --- a/variants/meshnology_w12/platformio.ini +++ b/variants/meshnology_w12/platformio.ini @@ -11,6 +11,7 @@ build_flags = -D WRAPPER_CLASS=CustomLR2021Wrapper -D USE_LR2021 -D LR2021_USE_DCDC=1 ; verified W12 schematic: L5 2.2uH across LXA/LXB + -D LR2021_TX_PREARM_MS=1 ; settle GC1109 before LF TX, without an always-on standby PA -D NEOPIXEL_LED=46 -D NEOPIXEL_BRIGHTNESS=64 -D P_LORA_DIO_1=14 diff --git a/variants/meshnology_w12/target.cpp b/variants/meshnology_w12/target.cpp index cdf2f1171..1c0c47f02 100644 --- a/variants/meshnology_w12/target.cpp +++ b/variants/meshnology_w12/target.cpp @@ -39,7 +39,7 @@ const uint32_t rfswitch_dios[] = { static const Module::RfSwitchMode_t rfswitch_table[] = { // DIO5 DIO6 DIO9 DIO10 DIO11 { LR2021::MODE_STBY, { LOW, LOW, LOW, LOW, LOW } }, // everything off - { LR2021::MODE_RX, { LOW, LOW, LOW, LOW, HIGH } }, // rx_lf: GC1109 LNA + { LR2021::MODE_RX, { LOW, LOW, LOW, HIGH, HIGH } }, // rx_lf: GC1109 LNA (CPS is don't-care in RX) { LR2021::MODE_TX, { LOW, LOW, HIGH, HIGH, HIGH } }, // tx_lf: GC1109 full PA { LR2021::MODE_RX_HF, { LOW, HIGH, LOW, LOW, LOW } }, // rx_hf: 2G4 LNA, GC1109 off { LR2021::MODE_TX_HF, { HIGH, LOW, LOW, LOW, LOW } }, // tx_hf: 2G4 PA, GC1109 off