Fix W12 TX prearming without standby PA drain

This commit is contained in:
mikecarper committed 2026-10-17 13:36:38 -07:00
1 parent f1d281a766
commit 8874cf62ca
9 files changed
+747 -10

No files matched your search

+5
View File
@@ -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
+19
View File
@@ -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.
+4 -2
View File
@@ -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;
+131 -1
View File
@@ -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;
}
+103 -5
View File
@@ -398,6 +398,7 @@ int main(){ EnvironmentSensorManager sensors;
prefix=begin[:begin.index(' prepareRadioFrequency(')]+'}\n'
code=r'''
#include <helpers/ESP32GpioHold.h>
#include <initializer_list>
#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 <helpers/ESP32GpioHold.h>
#include <helpers/radiolib/LR2021Band.h>
#include <vector>
#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<int> 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<<button))));
assert(timer==(button<0?5000000:0));
assert(!deep_hold && !terminalDigitalHoldPending());
// Deep sleep never returns: model begin() releasing the retained rails,
// then its normal band selection, before the next runtime operation.
releaseOutputAfterDeepSleep(3,LOW);releaseOutputAfterDeepSleep(4,LOW);
board.radio_band_initialized=false;
assert(board.prepareRadioFrequency(frequency));
}
// Explicit runtime shutdown must disable whichever band was powered.
board.powerOff();assert(shutdown && physical(3)==LOW && physical(4)==LOW);
}
}
'''
self.compile_run(code.replace('@METHODS@',methods),s3=1)
# Detect either active-rail loss or a floating inactive rail on sleep.
for rail in ('P_LORA_LF_PA_POWER','P_LORA_HF_PA_POWER'):
broken=re.sub(r'(?m)^\s*mesh::esp32::holdOutputForDeepSleep\(\(gpio_num_t\)'+rail+
r', radio_high_band[^;]*;', '', methods)
self.assertNotEqual(broken,methods)
self.compile_run(code.replace('@METHODS@',broken),s3=1,helper_mutation=lambda text:text)
def test_all_production_hold_release_deinit_sites_use_capability_wrapper(self):
# No board or FEM may keep using unsupported RTC calls behind the fix.
for folder in (ROOT/"src",ROOT/"variants"):
+474
View File
@@ -0,0 +1,474 @@
"""Exercise the W12 TX warm-up using real driver methods and board routing.
The native radio mock models the distinction between host RF-switch GPIOs and
LR2021 DIO masks. No PlatformIO or RF hardware is needed. Failed SPI responses
may still apply a mask, so cleanup must not assume an error left a pin unchanged.
"""
from pathlib import Path
import os
import re
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
def method(source, signature):
start = source.index(signature)
end = source.index("{", start) + 1
depth = 1
while depth:
depth += (source[end] == "{") - (source[end] == "}")
end += 1
return source[start:end].replace(" override", "")
HARNESS = r'''
#include <algorithm>
#include <cassert>
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
#include <helpers/radiolib/LR2021Band.h>
@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<int> 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<int>({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<int>({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<int>({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<int>({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<int>({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<int>({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()
@@ -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 {
+1
View File
@@ -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
+1 -1
View File
@@ -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