Fix peripheral power shutdown and auto-detect Station G3 radio slots

This commit is contained in:
mikecarper committed 2026-10-15 22:30:37 -07:00
1 parent c6e43c5530
commit 8eced48d34
103 files changed
+4900 -194

No files matched your search

+53 -3
View File
@@ -9,6 +9,8 @@ static std::vector<std::string> calls;
static bool usb_logging = true, usb_debug = true;
static bool serial_live = false;
static bool reset_event = false, cleanup_ready = false;
static bool simulate_no_radio = false;
static unsigned identity_loads = 0;
struct TestSerial : Stream {
int rx = -1, tx = -1;
unsigned baud = 0;
@@ -76,8 +78,20 @@ struct Sensors {
void begin() {}
void loop() { calls.push_back("sensors.loop"); }
} sensors;
static bool radio_init() { return true; }
static void loadOrCreateIdentity() {}
struct RecoveryReached {};
static bool radio_init() {
#if defined(STATION_G3_ESP32)
assert(serial_live && "empty-slot recovery must have USB before radio init");
if (simulate_no_radio) {
calls.push_back("radio.recovery");
throw RecoveryReached();
}
#endif
return true;
}
static void loadOrCreateIdentity() { ++identity_loads; }
struct Filesystem { bool begin(bool format_if_empty) { assert(format_if_empty);return true; } } SPIFFS;
static void halt() { assert(false); }
static void onSetRadio() {}
static void onSetTxPower() {}
static void onGetCurrentRssi() {}
@@ -102,6 +116,7 @@ public:
};
static KissModem* modem = nullptr;
#define MESH_DEBUG_PRINTLN(...) ((void)0)
#define KISS_BOOT_STAGE(stage) ((void)0)
@TRANSPORT_SELECTION@
@LOOP_STATE@
@SETUP@
@@ -112,16 +127,30 @@ int main() {
assert(modem != nullptr);
#if EXPECT_HWCDC
assert(modem->port == &guarded_port);
#if defined(STATION_G3_ESP32)
const std::vector<std::string> expected = {
"logging.gate", "debug.gate", "prepare", "serial.begin", "begin.logging",
"board.begin", "radio.begin", "modem.construct", "modem.begin", "boot.complete"};
#else
const std::vector<std::string> expected = {
"logging.gate", "debug.gate", "board.begin", "radio.begin", "prepare",
"serial.begin", "begin.logging", "modem.construct", "modem.begin", "boot.complete"};
#endif
assert(calls == expected);
#elif EXPECT_UART
assert(modem->port == &Serial1);
assert(Serial1.rx == KISS_UART_RX && Serial1.tx == KISS_UART_TX);
assert(Serial1.baud == 115200 && !serial_live);
assert(Serial1.baud == 115200);
#if defined(STATION_G3_ESP32)
assert(serial_live);
const std::vector<std::string> expected = {
"prepare", "serial.begin", "begin.logging", "board.begin", "radio.begin",
"uart.begin", "modem.construct", "modem.begin", "boot.complete"};
#else
assert(!serial_live);
const std::vector<std::string> expected = {
"board.begin", "radio.begin", "uart.begin", "modem.construct", "modem.begin", "boot.complete"};
#endif
assert(calls == expected);
#else
assert(modem->port == &Serial);
@@ -166,4 +195,25 @@ int main() {
assert(calls == normal);
#endif
delete modem;
#if defined(STATION_G3_ESP32)
// Execute the same production setup with radio discovery suspended at its
// recovery loop. Neither a working modem nor identity creation is required
// to make the primary USB console reachable, even for UART-hosted KISS.
modem = nullptr;
const unsigned old_identity_loads = identity_loads;
serial_live = false;
calls.clear();
simulate_no_radio = true;
try { setup(); assert(false); } catch (const RecoveryReached&) {}
assert(serial_live && modem == nullptr && identity_loads == old_identity_loads);
#if EXPECT_HWCDC
const std::vector<std::string> recovery = {
"logging.gate", "debug.gate", "prepare", "serial.begin", "begin.logging",
"board.begin", "radio.recovery"};
#else
const std::vector<std::string> recovery = {
"prepare", "serial.begin", "begin.logging", "board.begin", "radio.recovery"};
#endif
assert(calls == recovery);
#endif
}
@@ -52,6 +52,7 @@ extern MockFicr mock_ficr;
#define NRF_SPIM1 1
#define WB_IO2 34
#define OUTPUT 1
#define INPUT 0
#define HIGH 1
#define LOW 0
void pinMode(int pin, int mode);
+83 -1
View File
@@ -33,6 +33,7 @@ static std::atomic<int> hardware_operations{0};
static std::atomic<int> main_hardware_operations{0};
static std::atomic<int> live_task_allocations{0};
static std::atomic<int> task_deletions{0};
static unsigned terminal_pad_releases = 0;
static bool fail_task_creation = false;
static std::atomic<bool> defer_task_start{false};
static std::mutex library_mutex;
@@ -80,7 +81,13 @@ void mock_spi_end() {
assert(spi_active.exchange(false));
++hardware_operations;
}
void pinMode(int pin, int mode) { assert(pin == 21 && mode == OUTPUT); }
void pinMode(int pin, int mode) {
if (mode == INPUT) {
assert((pin==3||pin==29||pin==30||pin==26||pin==21)&&!spi_active.load());
assert(!ethernet_worker_active.load()||ethernet_worker_stopped.load());
++terminal_pad_releases;
} else assert(pin == 21 && mode == OUTPUT);
}
void digitalWrite(int pin, int value) {
assert((pin == 21 || pin == 26) && value == HIGH);
}
@@ -129,6 +136,11 @@ uint32_t ulTaskNotifyTake(int clear_on_exit, uint32_t ticks) {
return notifications;
}
void vTaskDelay(uint32_t ticks) {
if (current_task == nullptr) {
mock_millis.fetch_add(ticks);
std::this_thread::sleep_for(std::chrono::milliseconds(ticks));
return;
}
assert(current_task != nullptr);
if (ticks == portMAX_DELAY) {
std::unique_lock<std::mutex> lock(current_task->mutex);
@@ -735,6 +747,76 @@ int main(int argc, char** argv) {
assert(output().find("old-record-result") == std::string::npos);
break;
}
case 29: { // Registered but never-started Ethernet must not touch storage's pads.
assert(mesh::nrf52::terminalEthernetShutdown(0));
assert(hardware_operations.load()==0&&terminal_pad_releases==0&&context.saves==0);
break;
}
case 30: { // A blocked DHCP owner times out without task deletion or pad remapping.
configure(context,true);
Ethernet.begin_blocked.store(true);
assert(command("eth on").find("starting")!=std::string::npos);
wait_until([] { return Ethernet.begin_count.load()==1; });
const unsigned saves=context.saves;
const uint32_t started=millis();
assert(!mesh::nrf52::terminalEthernetShutdown(20));
assert(static_cast<uint32_t>(millis()-started)>=20);
assert(spi_active.load()&&bus_owner.load()&&context.releases==0);
assert(task_deletions.load()==0&&terminal_pad_releases==0&&context.saves==saves);
unblock(Ethernet.begin_blocked);
wait_until([] { return ethernet_worker_stopped.load(); });
assert(mesh::nrf52::terminalEthernetShutdown(500));
assert(task_deletions.load()==1&&terminal_pad_releases==5&&context.saves==saves);
break;
}
case 31: { // Online terminal stop completes all owner hooks without changing preferences.
configure(context,true);
assert(command("eth on").find("starting")!=std::string::npos);
wait_until([] { return ethernet_is_running(); });
const unsigned saves=context.saves;
assert(mesh::nrf52::terminalEthernetShutdown(500));
assert(!ethernet_running.load()&&!ethernet_enabled.load());
assert(context.releases==1&&context.saves==saves&&terminal_pad_releases==5);
assert(task_deletions.load()==1&&!spi_active.load()&&!bus_owner.load());
// Repeated shutdown is harmless and does not end an unstarted SPI object.
const int operations=hardware_operations.load();
assert(mesh::nrf52::terminalEthernetShutdown(0));
assert(hardware_operations.load()==operations&&context.releases==1);
break;
}
case 32: { // An OTA release refusal cannot be mistaken for permission to cut the rail.
start_online();context.release_allowed=false;
assert(!mesh::nrf52::terminalEthernetShutdown(50));
assert(!spi_active.load()&&live_task_allocations.load()==0&&bus_owner.load());
assert(context.releases>0&&terminal_pad_releases==5);
context.release_allowed=true;
assert(mesh::nrf52::terminalEthernetShutdown(500));
assert(!bus_owner.load()&&context.saves==0&&terminal_pad_releases==5);
break;
}
case 33: { // Slow renewal cannot be force-deleted or lose its supply.
start_online();Ethernet.maintain_blocked.store(true);
mock_millis.fetch_add(1000);ethernet_loop_maintain();
wait_until([] { return Ethernet.maintain_count.load()==1; });
assert(!mesh::nrf52::terminalEthernetShutdown(20));
assert(spi_active.load()&&task_deletions.load()==0&&terminal_pad_releases==0);
unblock(Ethernet.maintain_blocked);
wait_until([] { return ethernet_worker_stopped.load(); });
assert(mesh::nrf52::terminalEthernetShutdown(500));
break;
}
case 34: { // Stop deadline remains bounded across millis() rollover.
Ethernet.begin_blocked.store(true);
assert(command("eth on").find("starting")!=std::string::npos);
wait_until([] { return Ethernet.begin_count.load()==1; });
mock_millis.store(UINT32_MAX-9);
assert(!mesh::nrf52::terminalEthernetShutdown(20));
assert(millis()<100&&spi_active.load()&&terminal_pad_releases==0);
unblock(Ethernet.begin_blocked);
wait_until([] { return ethernet_worker_stopped.load(); });
assert(mesh::nrf52::terminalEthernetShutdown(500));
break;
}
default: assert(false);
}
assert(live_task_allocations.load() == 0 && !spi_active.load() && !bus_owner.load());
+30
View File
@@ -291,6 +291,7 @@ static void storage() {
assert(sensors.begin()); assert(!sensors.isGPSDetected()&&Serial1.begins==0);
sensors.configureSharedPeripheralMode(false);
assert(sensors._wisblock_power.holds()&mesh::gps::ExternalStorage);
assert(!sensors.canRemoveSharedStoragePowerForDeepSleep());
assert(digitalRead(WB_IO2)==HIGH); requireNoLow();
sensors.configureSharedPeripheralMode(true);
assert(digitalRead(WB_IO2)==HIGH); requireNoLow();
@@ -298,6 +299,7 @@ static void storage() {
static void fixedStorage() {
fresh(); TestLocation location; TestEnvironment sensors(location); bootUart(sensors,location);
assert(sensors._wisblock_power.holds()&mesh::gps::ExternalStorage);
assert(!sensors.canRemoveSharedStoragePowerForDeepSleep());
assert(sensors.isGpsPowered()&&digitalRead(WB_IO2)==HIGH);
assert(!sensors.active()&&!Serial1.installed); requireNoLow();
assert(mesh::ota::OtaStoreQspiNrf52::probes==0);
@@ -319,6 +321,7 @@ static void storageIds() {
sensors.configureSharedPeripheralMode(false);
const bool storage_held=selected!=0||(id!=0&&id!=0xffffffU);
assert(bool(sensors._wisblock_power.holds()&mesh::gps::ExternalStorage)==storage_held);
assert(sensors.canRemoveSharedStoragePowerForDeepSleep()==!storage_held);
assert(digitalRead(WB_IO2)==int(storage_held||compile_held));
assert(mesh::ota::OtaStoreQspiNrf52::probes>0);
}
@@ -418,6 +421,32 @@ static void dedicated() {
#endif
}
static void terminalStorage() {
SensorManager unknown;
assert(!unknown.canRemoveSharedStoragePowerForDeepSleep());
for(uint8_t holds:{uint8_t(0),uint8_t(mesh::gps::RadioPower),uint8_t(mesh::gps::EthernetPower),
uint8_t(mesh::gps::ExternalStorage),uint8_t(0x80)}) {
for(bool shared:{false,true}) {
fresh();TestLocation location;TestEnvironment sensors(location);
sensors._wisblock_power=mesh::gps::WisBlockSharedPowerPolicy(holds);
assert(!sensors.canRemoveSharedStoragePowerForDeepSleep());
assert(sensors._wisblock_power.configure(shared?mesh::gps::WisBlockPeripheralMode::Shared
:mesh::gps::WisBlockPeripheralMode::GpsOnly));
assert(!sensors.canRemoveSharedStoragePowerForDeepSleep());
sensors._wisblock_power.setDiscoveryActive(false);
const size_t events=hardware_events.size();
const unsigned probes=mesh::ota::OtaStoreQspiNrf52::probes;
const bool allowed=(holds&(mesh::gps::ExternalStorage|0x80))==0;
assert(sensors.canRemoveSharedStoragePowerForDeepSleep()==allowed);
sensors._wisblock_power.setGpsActive(true);
assert(sensors.canRemoveSharedStoragePowerForDeepSleep()==allowed);
assert(hardware_events.size()==events&&mesh::ota::OtaStoreQspiNrf52::probes==probes);
sensors._wisblock_power.setDiscoveryActive(true);
assert(!sensors.canRemoveSharedStoragePowerForDeepSleep());
}
}
}
int main(int argc,char** argv) {
assert(argc==2);
if(!std::strcmp(argv[1],"uart")) uart(false);
@@ -440,5 +469,6 @@ int main(int argc,char** argv) {
else if(!std::strcmp(argv[1],"wake_bus_failure")) i2c(false,4);
else if(!std::strcmp(argv[1],"initial_config_failure")) initialConfigFailure();
else if(!std::strcmp(argv[1],"dedicated")) dedicated();
else if(!std::strcmp(argv[1],"terminal_storage")) terminalStorage();
else assert(false&&"unknown scenario");
}
+8
View File
@@ -46,15 +46,18 @@ int main(int argc,char** argv) {
mesh::ota::OtaStoreQspiNrf52::raw_id=id;
mesh::ota::OtaStoreQspiNrf52::probes=0;
TestEnvironment sensors;
assert(!sensors.canRemoveSharedStoragePowerForDeepSleep());
if(prefs_first) {
sensors.configureSharedPeripheralMode(shared);
assert(sensors._wisblock_power.discoveryActive());
assert(!sensors.canRemoveSharedStoragePowerForDeepSleep());
assert(digitalRead(WB_IO2)==HIGH);
}
assert(sensors.begin());
if(!prefs_first) {
// Even a no-GPS image must wait for validated mode after discovery.
assert(digitalRead(WB_IO2)==HIGH);
assert(!sensors.canRemoveSharedStoragePowerForDeepSleep());
sensors.configureSharedPeripheralMode(shared);
}
assert(sensors._wisblock_power.isConfigured()&&!sensors._wisblock_power.discoveryActive());
@@ -62,6 +65,11 @@ int main(int argc,char** argv) {
const bool held=shared||storage||mesh::gps::wisBlockCompileRailHolds()!=0;
assert(digitalRead(WB_IO2)==int(held));
assert(bool(sensors._wisblock_power.holds()&mesh::gps::ExternalStorage)==storage);
const unsigned probes=mesh::ota::OtaStoreQspiNrf52::probes;
const size_t events=hardware_events.size();
assert(sensors.canRemoveSharedStoragePowerForDeepSleep()==!storage);
assert(sensors.canRemoveSharedStoragePowerForDeepSleep()==!storage);
assert(mesh::ota::OtaStoreQspiNrf52::probes==probes&&hardware_events.size()==events);
assert(mesh::ota::OtaStoreQspiNrf52::probes>0);
assert(!sensors.isGPSDetected()&&!sensors.isGpsPowered());
assert(!sensors.setSettingValue("gps","1"));
+9 -1
View File
@@ -47,6 +47,7 @@ extern MockFicr mock_ficr;
#define NRF_SPIM1 1
#define WB_IO2 2
#define OUTPUT 1
#define INPUT 0
#define HIGH 1
#define INPUT_PULLUP 2
inline void pinMode(int, int) {}
@@ -55,4 +56,11 @@ inline int digitalRead(int) { return HIGH; }
inline void vTaskDelete(void*) {}
inline void vTaskDelay(uint32_t value) { delay(value); }
inline uint32_t pdMS_TO_TICKS(uint32_t value) { return value; }
inline void xTaskCreate(void (*)(void*), const char*, int, void*, int, void*) {}
using TaskHandle_t = void*;
#define pdPASS 1
#define pdTRUE 1
#define taskENTER_CRITICAL() do {} while(0)
#define taskEXIT_CRITICAL() do {} while(0)
inline void xTaskNotifyGive(TaskHandle_t) {}
inline uint32_t ulTaskNotifyTake(int, uint32_t value) { delay(value);return 0; }
inline int xTaskCreate(void (*)(void*), const char*, int, void*, int, TaskHandle_t*) { return pdPASS; }
+1
View File
@@ -4,4 +4,5 @@ class SPIClass {
public:
SPIClass(int, int, int, int) {}
void begin() {}
void end() {}
};
+282
View File
@@ -0,0 +1,282 @@
"""Exercise the real haptic driver lifecycle with inert I2C/register mocks.
No PlatformIO, device access or board-current measurement. Negative controls
prove the checks reject idle-awake and premature-playback-stop regressions.
"""
from pathlib import Path
import os
import shutil
import subprocess
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
DRIVER = ROOT / "src/helpers/ui/DRV2605Vibration.cpp"
ARDUINO = r'''
#pragma once
#include <cassert>
#include <cstdint>
#include <string>
#include <vector>
#define OUTPUT 1
#define LOW 0
#define HIGH 1
struct Event { std::string name; unsigned value; };
extern std::vector<Event> events;
extern uint32_t now_ms;
extern int enable_level;
inline uint32_t millis() { return now_ms; }
inline void pinMode(int pin,int mode) {
assert(pin==37 && mode==OUTPUT);events.push_back({"enable-mode",1});
}
inline void digitalWrite(int pin,int value) {
assert(pin==37);enable_level=value;
events.push_back({"enable",static_cast<unsigned>(value)});
}
inline void delay(unsigned ms) {
assert(ms==10);events.push_back({"delay",ms});
}
'''
WIRE = "#pragma once\nstruct TwoWire {};\nextern TwoWire Wire;\n"
ADAFRUIT = r'''
#pragma once
#include <Arduino.h>
#include <Wire.h>
#define DRV2605_MODE_INTTRIG 0x00
#define DRV2605_MODE_REALTIME 0x05
#define DRV2605_REG_MODE 0x01
#define DRV2605_REG_FEEDBACK 0x1a
#define DRV2605_REG_GO 0x0c
struct DriverState {
bool found=true;
unsigned begins=0,reads=0;
uint8_t registers[256]={};
};
extern DriverState hardware;
class Adafruit_DRV2605 {
public:
bool begin(TwoWire* wire) {
assert(wire==&Wire);
#ifdef PIN_DRV_EN
assert(enable_level==HIGH);
#endif
++hardware.begins;events.push_back({"begin",0});
if(!hardware.found) return false;
// The dependency takes the chip out of standby during initialization.
writeRegister8(DRV2605_REG_MODE,0);
return true;
}
void writeRegister8(uint8_t reg,uint8_t value) {
hardware.registers[reg]=value;
events.push_back({"write",static_cast<unsigned>((reg<<8)|value)});
}
uint8_t readRegister8(uint8_t reg) {
assert(reg==DRV2605_REG_GO);++hardware.reads;
events.push_back({"read",reg});return hardware.registers[reg];
}
void selectLibrary(uint8_t library) { writeRegister8(3,library); }
void setMode(uint8_t mode) { writeRegister8(DRV2605_REG_MODE,mode); }
void setWaveform(uint8_t slot,uint8_t effect) {
assert(slot<8);writeRegister8(4+slot,effect);
}
void setRealtimeValue(uint8_t value) { writeRegister8(2,value); }
void go() {
assert(hardware.registers[DRV2605_REG_MODE]==DRV2605_MODE_INTTRIG);
writeRegister8(DRV2605_REG_GO,1);
}
void stop() { writeRegister8(DRV2605_REG_GO,0); }
};
'''
HARNESS = r'''
#include <helpers/ui/DRV2605Vibration.h>
std::vector<Event> events;
uint32_t now_ms=100;
int enable_level=LOW;
TwoWire Wire;
DriverState hardware;
bool standby() { return hardware.registers[DRV2605_REG_MODE]==0x40; }
void idleLoop(DRV2605Vibration& vibration) {
const auto count=events.size();
const auto reads=hardware.reads;
for(unsigned i=0;i<10;++i) { now_ms+=1000;vibration.loop(); }
assert(events.size()==count && hardware.reads==reads);
}
void checkSettings() {
assert(hardware.begins==1); // wake is not a reset/reinitialization
assert(hardware.registers[3]==1 && hardware.registers[0x16]==0x55);
#ifdef DRV2605_USE_LRA
assert(hardware.registers[DRV2605_REG_FEEDBACK]==0xb6);
#else
assert(hardware.registers[DRV2605_REG_FEEDBACK]==0);
#endif
}
void checkEffect(DRV2605Vibration& vibration) {
assert(vibration.isVibrating() && !standby());
assert(hardware.registers[DRV2605_REG_MODE]==DRV2605_MODE_INTTRIG);
assert(hardware.registers[DRV2605_REG_GO]==1);
assert(hardware.registers[4]==DRV2605_EFFECT && hardware.registers[5]==0);
assert(hardware.registers[6]==DRV2605_EFFECT && hardware.registers[7]==0);
checkSettings();
}
int main() {
DRV2605Vibration vibration;
// Inert before initialization, including stop/off callbacks.
vibration.loop();vibration.stop();vibration.pulse(false);
vibration.pulse(true);vibration.trigger(true);
assert(events.empty() && !vibration.isVibrating());
vibration.begin();
assert(standby() && !vibration.isVibrating());
#ifdef PIN_DRV_EN
assert(enable_level==HIGH);
#endif
hardware.registers[0x16]=0x55; // representative retained actuator setting
checkSettings();idleLoop(vibration);
vibration.trigger();checkEffect(vibration);
const auto reads=hardware.reads;
now_ms+=19;vibration.loop();assert(hardware.reads==reads);
++now_ms;vibration.loop();
assert(hardware.reads==reads+1);checkEffect(vibration);
// Still active after a long effect: do not guess a fixed playback duration.
now_ms+=10000;vibration.loop();checkEffect(vibration);
hardware.registers[DRV2605_REG_GO]=0; // hardware finishes the entire sequence
now_ms+=20;vibration.loop();
assert(standby() && !vibration.isVibrating());
checkSettings();idleLoop(vibration);
vibration.trigger(true);checkEffect(vibration);
auto count=events.size();
vibration.trigger();assert(events.size()==count); // cooldown suppresses wake
vibration.quiet(true);
vibration.trigger(true);assert(events.size()==count);
vibration.stop();assert(standby() && !vibration.isVibrating());
assert(hardware.registers[DRV2605_REG_GO]==0 && hardware.registers[2]==0);
vibration.quiet(false);checkSettings();idleLoop(vibration);
vibration.pulse(true);
assert(vibration.isVibrating());
assert(hardware.registers[DRV2605_REG_MODE]==DRV2605_MODE_REALTIME);
assert(hardware.registers[2]==100 && hardware.registers[DRV2605_REG_GO]==0);
// GO does not reflect RTP playback. Never let the effect poll stop a pulse.
idleLoop(vibration);assert(vibration.isVibrating() && !standby());
vibration.pulse(false);
assert(standby() && !vibration.isVibrating() && hardware.registers[2]==0);
idleLoop(vibration);checkSettings();
// Switching between RTP and an effect must replace tracked state, not let an
// old effect completion turn off an active pulse.
vibration.trigger(true);checkEffect(vibration);
vibration.pulse(true);hardware.registers[DRV2605_REG_GO]=0;
idleLoop(vibration);assert(vibration.isVibrating() && !standby());
vibration.trigger(true);checkEffect(vibration);
hardware.registers[DRV2605_REG_GO]=0;now_ms+=20;vibration.loop();
assert(standby() && !vibration.isVibrating());
// Poll scheduling must survive the nRF millis() rollover.
now_ms=UINT32_MAX-10;vibration.trigger(true);checkEffect(vibration);
count=hardware.reads;
now_ms+=19;vibration.loop();assert(hardware.reads==count);
++now_ms;vibration.loop();assert(hardware.reads==count+1);
checkEffect(vibration);
hardware.registers[DRV2605_REG_GO]=0;now_ms+=20;vibration.loop();
assert(standby() && !vibration.isVibrating());
checkSettings();idleLoop(vibration);
// No rail toggling during successful standby/wake or register resets.
#ifdef PIN_DRV_EN
unsigned enables=0;
for(const auto& event:events) if(event.name=="enable") {
assert(event.value==HIGH);++enables;
}
assert(enables==1 && enable_level==HIGH);
#endif
// Reinitialization failure must discard active state and remain inert.
vibration.pulse(true);hardware.found=false;vibration.begin();
assert(!vibration.isVibrating());
#ifdef PIN_DRV_EN
assert(enable_level==LOW);
#endif
count=events.size();
vibration.loop();vibration.trigger(true);vibration.stop();
vibration.pulse(true);vibration.pulse(false);
assert(events.size()==count && !vibration.isVibrating());
}
'''
class Drv2605PowerTests(unittest.TestCase):
def run_driver(self, *, enable=True, lra=True, mutation=None, expected_success=True):
source = DRIVER.read_text()
if mutation == "no-initial-standby":
source = source.replace("drv.setMode(Standby);", "// negative control", 1)
elif mutation == "no-completion-standby":
original = extract_braced(source, "void DRV2605Vibration::loop()")
source = source.replace(original, "void DRV2605Vibration::loop() {}")
elif mutation == "premature-stop":
source = source.replace("(drv.readRegister8(DRV2605_REG_GO) & 1) == 0",
"(drv.readRegister8(DRV2605_REG_GO) & 1) != 0")
elif mutation == "no-pulse-off":
source = source.replace("if (!on) {", "if (false && !on) {")
elif mutation == "poll-idle":
source = source.replace("if (!_ready || !_effect_active) return;",
"if (!_ready) return;")
elif mutation == "failed-begin-rail-on":
source = source.replace("digitalWrite(PIN_DRV_EN, LOW);", "// negative control")
# Compile the actual implementation and header, replacing dependencies
# only. The include path still resolves the production driver header.
source = source.replace('#include "DRV2605Vibration.h"',
'#include <helpers/ui/DRV2605Vibration.h>')
compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++")
self.assertIsNotNone(compiler, "a host C++11 compiler is required")
with tempfile.TemporaryDirectory(prefix="meshcore-drv2605-power-") as folder:
work = Path(folder)
(work/"Arduino.h").write_text(ARDUINO, encoding="utf-8")
(work/"Wire.h").write_text(WIRE, encoding="utf-8")
(work/"Adafruit_DRV2605.h").write_text(ADAFRUIT, encoding="utf-8")
cpp = work/"case.cpp"
cpp.write_text(source+"\n"+HARNESS, encoding="utf-8")
binary = work/("case.exe" if os.name=="nt" else "case")
flags = ["-DHAS_DRV2605=1"]
if enable:
flags.append("-DPIN_DRV_EN=37")
if lra:
flags.append("-DDRV2605_USE_LRA=1")
sanitize = [] if os.name=="nt" else ["-fsanitize=address,undefined",
"-fno-sanitize-recover=all", "-fno-pie", "-no-pie"]
built = subprocess.run([compiler, "-std=c++11", "-Wall", "-Wextra", "-Werror",
*sanitize, *flags, "-I", str(work), "-I", str(ROOT/"src"),
str(cpp), "-o", str(binary)], capture_output=True, text=True, timeout=30)
self.assertEqual(built.returncode, 0, built.stdout+built.stderr)
run = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10)
if expected_success:
self.assertEqual(run.returncode, 0, run.stdout+run.stderr)
else:
self.assertNotEqual(run.returncode, 0, "negative control unexpectedly passed")
def test_lra_effect_completion_rtp_retention_cooldown_and_failed_begin(self):
self.run_driver()
def test_no_optional_enable_pin_or_lra_mode(self):
self.run_driver(enable=False, lra=False)
def test_initial_idle_awake_negative_control(self):
self.run_driver(mutation="no-initial-standby", expected_success=False)
def test_effect_completion_awake_negative_control(self):
self.run_driver(mutation="no-completion-standby", expected_success=False)
def test_stop_before_go_finishes_negative_control(self):
self.run_driver(mutation="premature-stop", expected_success=False)
def test_pulse_off_awake_negative_control(self):
self.run_driver(mutation="no-pulse-off", expected_success=False)
def test_idle_i2c_polling_negative_control(self):
self.run_driver(mutation="poll-idle", expected_success=False)
def test_failed_begin_enable_leak_negative_control(self):
self.run_driver(mutation="failed-begin-rail-on", expected_success=False)
if __name__ == "__main__":
unittest.main()
+3
View File
@@ -29,11 +29,13 @@ SIGNATURES = (
"void EnvironmentSensorManager::setPowerSavingEnabled(",
"bool EnvironmentSensorManager::supportsSharedPeripheralMode()",
"void EnvironmentSensorManager::configureSharedPeripheralMode(",
"bool EnvironmentSensorManager::canRemoveSharedStoragePowerForDeepSleep() const",
"bool EnvironmentSensorManager::isGpsPowered()",
"bool EnvironmentSensorManager::gpsUsesSerialUart(",
"bool EnvironmentSensorManager::gpsSerialTransportMayConflict(",
"bool EnvironmentSensorManager::gpsSerialTransportCanYield(",
"bool EnvironmentSensorManager::setGpsSerialTransportBlocked(",
"void EnvironmentSensorManager::shutdownGpsTransportForDeepSleep()",
)
GENERIC = r'''
@@ -63,6 +65,7 @@ int main() {
assert(!hooks.supportsSharedPeripheralMode());
hooks.configureSharedPeripheralMode(true);
hooks.configureSharedPeripheralMode(false);
assert(!hooks.canRemoveSharedStoragePowerForDeepSleep());
assert(!hooks.isGpsPowered() && !hooks.isGPSDetected());
assert(!hooks.gpsUsesSerialUart(1) && !hooks.setGpsSerialTransportBlocked(1, true));
}
+2 -1
View File
@@ -136,9 +136,10 @@ struct TestRadio : mesh::Radio {
bool isCarrierWaveActive() const override { return cw; }
} timing_radio;
struct Mesh {
bool pending = false;
bool pending = false, dual = false;
Prefs* getNodePrefs() { return &prefs; }
bool hasPendingWork() const { return pending; }
bool isDualRadioActive() const { return dual; }
mesh::Radio* getProfileRadio() { return &timing_radio; }
} the_mesh;
struct Interfaces { bool pending = false; bool hasPendingIO() const { return pending; } } interface_manager;
+428
View File
@@ -0,0 +1,428 @@
"""Exercise RTC guards and real terminal GPIO retention on ESP32 families."""
from pathlib import Path
import re
import shutil
import subprocess
import tempfile
import unittest
from cpp_source import body
ROOT = Path(__file__).resolve().parents[1]
HEADER = ROOT / "src/helpers/ESP32GpioHold.h"
SDK = r'''
#pragma once
#include <cassert>
#include <cstdint>
using gpio_num_t=int;
using esp_err_t=int;
constexpr int ESP_OK=0;
constexpr int ESP_ERR_INVALID_ARG=258;
constexpr int GPIO_MODE_OUTPUT=2, GPIO_PULLUP_DISABLE=0;
constexpr int GPIO_PULLDOWN_DISABLE=0, GPIO_INTR_DISABLE=0;
struct gpio_config_t { uint64_t pin_bit_mask; int mode,pull_up_en,pull_down_en,intr_type; };
inline unsigned held=0,released=0,deinitialized=0,digital_held=0;
inline int api_result=0;
struct Pad { int latch=0; bool output=false, hold=false; int retained=-1; };
inline Pad pads[64];
inline bool deep_hold=false;
inline bool require_configured_release=false;
inline unsigned deep_hold_en_calls=0, deep_hold_dis_calls=0;
inline bool valid_output(int pin) {
#if CHIP_S3
return pin>=0 && pin<=48 && pin!=46;
#else
return pin>=0 && pin<=33;
#endif
}
#define GPIO_IS_VALID_OUTPUT_GPIO(pin) valid_output(pin)
inline int physical(int pin) { return pads[pin].hold ? pads[pin].retained : (pads[pin].output ? pads[pin].latch : -1); }
inline int gpio_set_level(int pin,unsigned level) { assert(valid_output(pin)); pads[pin].latch=level; return api_result; }
inline int gpio_config(const gpio_config_t* config) {
assert(config->mode==GPIO_MODE_OUTPUT && config->pull_up_en==0 && config->pull_down_en==0 && config->intr_type==0);
if (api_result) return api_result;
for(int pin=0;pin<64;++pin) if(config->pin_bit_mask&(1ULL<<pin)) {
assert(valid_output(pin)); pads[pin].output=true;
}
return ESP_OK;
}
#if SOC_RTCIO_INPUT_OUTPUT_SUPPORTED
inline bool rtc_gpio_is_valid_gpio(int pin) {
#if CHIP_S3
return pin>=0 && pin<=21;
#else
return pin==0 || pin==2 || pin==4 || (pin>=12 && pin<=15)
|| (pin>=25 && pin<=27) || (pin>=32 && pin<=39);
#endif
}
inline int rtc_gpio_hold_en(int pin) { assert(pin>=0 && rtc_gpio_is_valid_gpio(pin)); ++held; if(!api_result) { pads[pin].retained=physical(pin); pads[pin].hold=true; } return api_result; }
inline int rtc_gpio_hold_dis(int pin) { assert(pin>=0 && rtc_gpio_is_valid_gpio(pin)); assert(!require_configured_release || !pads[pin].hold || pads[pin].output); ++released; if(!api_result) pads[pin].hold=false; return api_result; }
inline int rtc_gpio_deinit(int pin) { assert(pin>=0 && rtc_gpio_is_valid_gpio(pin)); ++deinitialized; return api_result; }
#endif
inline int gpio_hold_en(int pin) {
assert(valid_output(pin));
#if SOC_RTCIO_INPUT_OUTPUT_SUPPORTED
if(rtc_gpio_is_valid_gpio(pin)) return rtc_gpio_hold_en(pin);
#endif
++digital_held;
if(!api_result) { pads[pin].retained=physical(pin); pads[pin].hold=true; }
return api_result;
}
inline int gpio_hold_dis(int pin) {
assert(valid_output(pin));
assert(pads[pin].output); // Mode MUST be restored before release.
#if SOC_RTCIO_INPUT_OUTPUT_SUPPORTED
if(rtc_gpio_is_valid_gpio(pin)) return rtc_gpio_hold_dis(pin);
#endif
if(!api_result) pads[pin].hold=false;
return api_result;
}
inline void gpio_deep_sleep_hold_en() { deep_hold=true; ++deep_hold_en_calls; }
inline void gpio_deep_sleep_hold_dis() { deep_hold=false; ++deep_hold_dis_calls; }
inline void simulate_deep_sleep() {
for(int pin=0;pin<64;++pin) {
bool rtc=false;
#if SOC_RTCIO_INPUT_OUTPUT_SUPPORTED
rtc=rtc_gpio_is_valid_gpio(pin);
#endif
if(!rtc && !deep_hold) { pads[pin].output=false; pads[pin].retained=-1; }
}
}
'''
HARNESS = r'''
#include <helpers/ESP32GpioHold.h>
#include <initializer_list>
int main() {
for(int pin : {-1,0,2,4,7,8,15,18,33,42,43,47,48,64}) {
bool valid=false;
#if SOC_RTCIO_INPUT_OUTPUT_SUPPORTED
valid=pin>=0 && rtc_gpio_is_valid_gpio(pin);
#endif
const unsigned before=held, release_before=released, deinit_before=deinitialized;
assert(mesh::esp32::supportsRtcGpio(pin)==valid);
assert(mesh::esp32::holdRtcGpioIfSupported(pin)==ESP_OK);
assert(mesh::esp32::releaseRtcGpioIfSupported(pin)==ESP_OK);
assert(mesh::esp32::deinitRtcGpioIfSupported(pin)==ESP_OK);
assert(held==before+valid && released==release_before+valid && deinitialized==deinit_before+valid);
}
assert(digital_held==0); // No broad digital retention behavior was invented.
#if SOC_RTCIO_INPUT_OUTPUT_SUPPORTED
api_result=99;
assert(mesh::esp32::holdRtcGpioIfSupported(4)==99);
assert(mesh::esp32::releaseRtcGpioIfSupported(4)==99);
assert(mesh::esp32::deinitRtcGpioIfSupported(4)==99);
#endif
}
'''
class GpioHoldTests(unittest.TestCase):
def compile_run(self, source, *, caps=1, s3=0, defines=(), broken=False, legacy_guard=False, helper_mutation=None, extra_source=None, files=None):
compiler=shutil.which("g++") or shutil.which("clang++")
self.assertIsNotNone(compiler)
with tempfile.TemporaryDirectory(prefix="esp32-rtc-guard-") as directory:
work=Path(directory)
(work/"driver").mkdir()
(work/"driver/rtc_io.h").write_text(SDK)
(work/"driver/gpio.h").write_text('#include "rtc_io.h"\n')
for name,contents in (files or {}).items():
path=work/name
path.parent.mkdir(parents=True,exist_ok=True)
path.write_text(contents)
if broken or legacy_guard or helper_mutation:
(work/"helpers").mkdir()
helper = HEADER.read_text()
if broken:
helper = helper.replace("if (supportsRtcGpio(pin))", "if (true)")
if legacy_guard:
guard = "#if defined(SOC_RTCIO_INPUT_OUTPUT_SUPPORTED) && SOC_RTCIO_INPUT_OUTPUT_SUPPORTED"
self.assertIn(guard, helper)
helper = helper.replace(guard, "#if !defined(SOC_RTCIO_INPUT_OUTPUT_SUPPORTED) || SOC_RTCIO_INPUT_OUTPUT_SUPPORTED")
if helper_mutation:
helper = helper_mutation(helper)
(work/"helpers/ESP32GpioHold.h").write_text(helper)
cpp,binary=work/"test.cpp",work/"test"
cpp.write_text(source)
extra=[]
if extra_source:
other=work/"other.cpp"
other.write_text(extra_source)
extra=[str(other)]
result=subprocess.run([compiler,"-std=c++17","-Wall","-Wextra","-Werror", "-I",str(work),"-I",str(ROOT/"src"),
*([] if caps is None else [f"-DSOC_RTCIO_INPUT_OUTPUT_SUPPORTED={caps}"]),
f"-DCHIP_S3={s3}",*("-D"+flag for flag in defines),str(cpp),*extra,"-o",str(binary)],capture_output=True,text=True)
if legacy_guard:
self.assertNotEqual(result.returncode, 0, "old undefined-capability guard compiled without RTC APIs")
self.assertIn("not declared", result.stderr)
return
self.assertEqual(result.returncode,0,result.stderr)
ran=subprocess.run([str(binary)],capture_output=True,text=True)
if broken or helper_mutation:
self.assertNotEqual(ran.returncode,0,"negative control concealed unsupported RTC call")
else:
self.assertEqual(ran.returncode,0,ran.stderr)
def test_chip_specific_and_absent_rtc_apis_no_global_hold(self):
for caps,s3 in ((1,0),(1,1),(0,0),(None,0)):
with self.subTest(caps=caps,s3=s3):
self.compile_run(HARNESS,caps=caps,s3=s3)
self.compile_run(HARNESS,broken=True)
# Installed ESP-IDF4.4C3 leaves the macro UNDEFINED and omits RTC API
# declarations entirely, rather than defining capability0.
self.compile_run(HARNESS,caps=None,legacy_guard=True)
def test_real_shutdown_latches_known_output_without_arming_global_hold(self):
shutdown=body((ROOT/"src/helpers/ESP32Board.cpp").read_text(),"void ESP32Board::shutdownPeripherals()")
code=r'''
#include <helpers/ESP32GpioHold.h>
#include <cstddef>
#define HIGH 1
static bool powered_off=false,gps_off=false,flushed=false;
void delay(unsigned ms) { assert(ms==100); }
struct Radio { void powerOff(){powered_off=true;} } radio_driver;
struct GPS { void stop(){gps_off=true;} } gps;
struct Sensors { GPS* getLocationProvider(){return &gps;} void shutdownGpsTransportForDeepSleep(){assert(gps_off);} } sensors;
struct SerialPort { void flush(){flushed=true;} } Serial;
class ESP32Board { public: void shutdownPeripherals(); };
@SHUTDOWN@
int main(){ ESP32Board board; board.shutdownPeripherals();
assert(powered_off && gps_off && flushed);
const bool rtc=mesh::esp32::supportsRtcGpio(P_LORA_NSS);
assert(held==rtc && digital_held==!rtc);
assert(pads[P_LORA_NSS].output && physical(P_LORA_NSS)==HIGH);
assert(!deep_hold && deep_hold_en_calls==0);
}
'''.replace("@SHUTDOWN@",shutdown)
for caps,s3,definitions in ((1,0,("P_LORA_NSS=18",)),(1,1,("P_LORA_NSS=8",)),
(1,1,("P_LORA_NSS=43",)),(0,0,("P_LORA_NSS=18","CONFIG_IDF_TARGET_ESP32C3=1")),
(None,0,("P_LORA_NSS=18","CONFIG_IDF_TARGET_ESP32C3=1")),
(0,0,("P_LORA_NSS=18","CONFIG_IDF_TARGET_ESP32C6=1"))):
with self.subTest(definitions=definitions):
self.compile_run(code,caps=caps,s3=s3,defines=definitions)
def test_terminal_hold_retains_output_and_boot_release_preloads_before_unhold(self):
code=r'''
#include <helpers/ESP32GpioHold.h>
int main() {
using namespace mesh::esp32;
resetTerminalDeepSleepHolds();
assert(!deep_hold && !terminalDigitalHoldPending());
assert(holdOutputForDeepSleep(-1,1)==ESP_ERR_INVALID_ARG);
assert(holdOutputForDeepSleep(64,1)==ESP_ERR_INVALID_ARG);
assert(holdOutputForDeepSleep(34,1)==@PIN34_RESULT@);
resetTerminalDeepSleepHolds();
assert(holdOutputForDeepSleep(P_LORA_NSS,1)==ESP_OK);
assert(physical(P_LORA_NSS)==1 && pads[P_LORA_NSS].output);
assert(!deep_hold); // No global hold in runtime/light sleep.
commitTerminalDeepSleepHolds();
assert(deep_hold==!supportsRtcGpio(P_LORA_NSS));
simulate_deep_sleep();
assert(physical(P_LORA_NSS)==1); // Negative control detects lost non-RTC retention.
pads[P_LORA_NSS].latch=0; pads[P_LORA_NSS].output=false; // CPU reset.
resetTerminalDeepSleepHolds();
assert(!deep_hold);
assert(releaseOutputAfterDeepSleep(P_LORA_NSS,1)==ESP_OK);
assert(!pads[P_LORA_NSS].hold && physical(P_LORA_NSS)==1);
// Neither an invalid pin nor failed setup is allowed to request auto-hold.
resetTerminalDeepSleepHolds(); api_result=77;
assert(holdOutputForDeepSleep(P_LORA_NSS,0)==77);
commitTerminalDeepSleepHolds(); assert(!deep_hold);
}
'''
for caps,s3,pin in ((1,0,18),(1,1,43),(1,1,8),(None,0,18),(0,0,18)):
with self.subTest(caps=caps,s3=s3,pin=pin):
self.compile_run(code.replace('@PIN34_RESULT@','ESP_OK' if s3 else 'ESP_ERR_INVALID_ARG'),
caps=caps,s3=s3,defines=(f'P_LORA_NSS={pin}',))
# gpio_hold_en alone is NOT a physical non-RTC deep-sleep fix.
self.compile_run(code.replace('@PIN34_RESULT@','ESP_ERR_INVALID_ARG'),defines=('P_LORA_NSS=18',),
helper_mutation=lambda text: text.replace('if (terminalDigitalHoldPending()) gpio_deep_sleep_hold_en();',''))
def test_pending_terminal_hold_is_shared_between_board_and_fem_translation_units(self):
self.compile_run(r'''
#include <helpers/ESP32GpioHold.h>
void femShutdown();
int main(){ mesh::esp32::resetTerminalDeepSleepHolds(); femShutdown();
assert(!deep_hold); mesh::esp32::commitTerminalDeepSleepHolds(); assert(deep_hold);
}
''',s3=1,extra_source=r'''
#include <helpers/ESP32GpioHold.h>
void femShutdown(){ assert(mesh::esp32::holdOutputForDeepSleep(42,1)==ESP_OK); }
''')
def test_only_terminal_sleep_commits_after_virtual_shutdown_and_wake_setup(self):
production=(ROOT/'src/helpers/ESP32Board.cpp').read_text()
sleep=body(production,'void ESP32Board::enterDeepSleep(uint32_t secs)')
self.compile_run(r'''
#include <helpers/ESP32GpioHold.h>
#include <cstdint>
constexpr int ESP_SLEEP_WAKEUP_ALL=99;
static bool shut=false,wake_cleared=false,timer_set=false,slept=false;
void esp_sleep_disable_wakeup_source(int value){assert(shut && !deep_hold && value==99); wake_cleared=true;}
void esp_sleep_enable_timer_wakeup(uint64_t value){assert(wake_cleared && !deep_hold && value==5000000); timer_set=true;}
void esp_deep_sleep_start(){assert(shut && timer_set && deep_hold); slept=true;}
class ESP32Board { public: virtual void shutdownPeripherals(){}; void enterDeepSleep(uint32_t secs); };
class FEMBoard:public ESP32Board { void shutdownPeripherals() override {
assert(!deep_hold); mesh::esp32::holdOutputForDeepSleep(43,1);
mesh::esp32::holdOutputForDeepSleep(42,1); shut=true;
} };
@SLEEP@
int main(){FEMBoard board; board.enterDeepSleep(5); assert(slept);}
'''.replace('@SLEEP@',sleep),s3=1)
header=(ROOT/'src/helpers/ESP32Board.h').read_text()
light=body(header,'void sleep(uint32_t secs) override')
self.assertNotIn('commitTerminalDeepSleepHolds',light)
for folder in (ROOT/'src',ROOT/'variants'):
for path in folder.rglob('*'):
if path.suffix not in ('.cpp','.h') or path in (HEADER,ROOT/'src/helpers/ESP32Board.cpp'):
continue
self.assertNotIn('commitTerminalDeepSleepHolds(',path.read_text(),str(path))
def test_actual_boot_restores_radio_output_before_runtime_initialization(self):
header=(ROOT/'src/helpers/ESP32Board.h').read_text()
boot=body(header,'void begin()')
prefix=boot[:boot.index('// Arduino\'s early init hook')]
prefix=prefix[prefix.index('{')+1:]
self.compile_run(r'''
#include <helpers/ESP32GpioHold.h>
#define HIGH 1
class Board{public:void begin(){ @BOOT@ }};
int main(){using namespace mesh::esp32;
holdOutputForDeepSleep(P_LORA_NSS,1); commitTerminalDeepSleepHolds();
pads[P_LORA_NSS].latch=0; pads[P_LORA_NSS].output=false;
Board board; board.begin(); assert(!deep_hold && !pads[P_LORA_NSS].hold && physical(P_LORA_NSS)==1);
}
'''.replace('@BOOT@',prefix),s3=1,defines=('P_LORA_NSS=43',))
def test_g3_fem_real_startup_and_terminal_lna_disable_both_slots(self):
variant=ROOT/'variants/station_g3_esp32'
shutdown=body((variant/'StationG3Board.cpp').read_text(),'void StationG3Board::shutdownPeripherals()')
code=r'''
#include <helpers/ESP32GpioHold.h>
#include "LoRaFEMControl.h"
#include "StationG3RadioSlot.h"
static bool radio_off=false;
class ESP32Board{public:void shutdownPeripherals(){radio_off=true;mesh::esp32::holdOutputForDeepSleep(P_LORA_NSS,HIGH);}};
class StationG3Board:public ESP32Board{public:LoRaFEMControl loRaFEMControl;void shutdownPeripherals();};
@SHUTDOWN@
int main(){ using namespace mesh::esp32;
station_g3::slotState().candidate=@SLOT@;
const int active_lna=station_g3::activePins().lna;
const int inactive_lna=station_g3::pinsForSlot(@SLOT@==1 ? 2:1).lna;
StationG3Board board; resetTerminalDeepSleepHolds();
releaseOutputAfterDeepSleep(P_LORA_NSS,HIGH); board.loRaFEMControl.init();
assert(physical(P_PA1_EN)==LOW && physical(active_lna)==HIGH && physical(inactive_lna)==HIGH);
// The selector enables RX only after a physical radio has been found.
board.loRaFEMControl.setRxModeEnable();
assert(physical(active_lna)==LOW && physical(inactive_lna)==HIGH);
board.loRaFEMControl.setPAGainEnable(true); board.loRaFEMControl.setTxModeEnable();
assert(physical(P_PA1_EN)==HIGH && physical(active_lna)==HIGH && physical(inactive_lna)==HIGH);
board.loRaFEMControl.setRxModeEnable(); assert(physical(active_lna)==LOW && physical(inactive_lna)==HIGH);
board.shutdownPeripherals(); assert(radio_off && !deep_hold);
assert(pads[active_lna].output && pads[active_lna].hold);
assert(pads[inactive_lna].output && pads[inactive_lna].hold);
assert(physical(P_PA1_EN)==LOW && physical(active_lna)==HIGH && physical(inactive_lna)==HIGH);
commitTerminalDeepSleepHolds(); simulate_deep_sleep();
assert(physical(active_lna)==HIGH && physical(inactive_lna)==HIGH && physical(P_LORA_NSS)==HIGH);
// Reset has changed backing latches/modes; startup must configure before release.
pads[P_PA1_EN].output=false; pads[active_lna].output=false; pads[inactive_lna].output=false;
resetTerminalDeepSleepHolds(); releaseOutputAfterDeepSleep(P_LORA_NSS,HIGH);
board.loRaFEMControl.init();
assert(!pads[P_PA1_EN].hold && !pads[active_lna].hold && !pads[inactive_lna].hold);
assert(physical(P_PA1_EN)==HIGH && physical(active_lna)==HIGH && physical(inactive_lna)==HIGH && !deep_hold);
board.loRaFEMControl.setRxModeEnable();
assert(physical(active_lna)==LOW && physical(inactive_lna)==HIGH);
}
'''.replace('@SHUTDOWN@',shutdown)
files={'LoRaFEMControl.h':(variant/'LoRaFEMControl.h').read_text(),
'StationG3RadioSlot.h':(variant/'StationG3RadioSlot.h').read_text(),
'Arduino.h':'#pragma once\n#include <driver/rtc_io.h>\ninline void digitalWrite(int pin,int level){ gpio_set_level(pin,level); }\n'}
# Both historical build-macro configurations must drive either runtime
# slot, not merely the one which happened to be compiled in.
for nss,lna in ((11,10),(43,42)):
for slot in (1,2):
self.compile_run(code.replace('@SLOT@',str(slot)),s3=1,
defines=(f'P_LORA_NSS={nss}',f'P_PRIMARY_LNA_EN={lna}',
'P_PA1_EN=9','P_PA1_EN_ACTIVE=1','P_PRIMARY_LNA_EN_ACTIVE=0','HIGH=1','LOW=0'),
extra_source=(variant/'LoRaFEMControl.cpp').read_text(),files=files)
def test_terminal_gps_uart_release_respects_owner_and_leaves_i2c_alone(self):
hook=body((ROOT/'src/helpers/sensors/EnvironmentSensorManager.cpp').read_text(),
'void EnvironmentSensorManager::shutdownGpsTransportForDeepSleep()')
code=r'''
#include <helpers/ESP32GpioHold.h>
#define INPUT 0
static unsigned released_pads=0;
struct Port { bool installed=false;unsigned ends=0;explicit operator bool()const{return installed;}
void end(){assert(installed);installed=false;++ends;} } Serial1;
void pinMode(int pin,int mode){assert(!Serial1.installed && mode==INPUT);++released_pads;pads[pin].output=false;}
class EnvironmentSensorManager {public:bool gps_serial_transport=false,gps_serial_transport_blocked=false;
void shutdownGpsTransportForDeepSleep();};
@HOOK@
int main(){ EnvironmentSensorManager sensors;
// I2C/no-owned GPS: an unrelated serial user is untouched.
Serial1.installed=true;sensors.shutdownGpsTransportForDeepSleep();assert(Serial1.installed && !Serial1.ends && !released_pads);
sensors.gps_serial_transport=true;sensors.gps_serial_transport_blocked=true;
sensors.shutdownGpsTransportForDeepSleep();assert(Serial1.installed && !Serial1.ends && !released_pads);
sensors.gps_serial_transport_blocked=false;sensors.shutdownGpsTransportForDeepSleep();
#if EXPECT_RELEASE
assert(!Serial1.installed && Serial1.ends==1 && released_pads==2);
sensors.shutdownGpsTransportForDeepSleep();assert(Serial1.ends==1 && released_pads==4);
#else
assert(Serial1.installed && !Serial1.ends && !released_pads);
#endif
}
'''.replace('@HOOK@',hook)
for definitions in (
('ESP32_PLATFORM=1','ENV_INCLUDE_GPS=1','PIN_GPS_TX=18','PIN_GPS_RX=17','EXPECT_RELEASE=1'),
('ESP32_PLATFORM=1','ENV_INCLUDE_GPS=0','PIN_GPS_TX=18','PIN_GPS_RX=17','EXPECT_RELEASE=0'),
('NRF52_PLATFORM=1','ENV_INCLUDE_GPS=1','PIN_GPS_TX=18','PIN_GPS_RX=17','EXPECT_RELEASE=0'),
('ESP32_PLATFORM=1','ENV_INCLUDE_GPS=1','PIN_GPS_TX=-1','PIN_GPS_RX=17','EXPECT_RELEASE=0'),
):
self.compile_run(code,defines=definitions)
def test_w12_fem_rails_restore_known_output_before_releasing_terminal_hold(self):
source=(ROOT/'variants/meshnology_w12/MeshnologyW12Board.cpp').read_text()
begin=body(source,'void MeshnologyW12Board::begin()')
prefix=begin[:begin.index(' prepareRadioFrequency(')]+'}\n'
code=r'''
#include <helpers/ESP32GpioHold.h>
#define HIGH 1
#define LOW 0
#define OUTPUT 1
#define P_LORA_LF_PA_POWER 4
#define P_LORA_HF_PA_POWER 3
#define PIN_ADC_CTRL 2
void pinMode(int pin,int mode){assert(mode==OUTPUT);pads[pin].output=true;}
void digitalWrite(int pin,int level){gpio_set_level(pin,level);}
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());
}
'''
self.compile_run(code.replace('@BEGIN@',prefix),s3=1)
# The prior release-before-pinMode sequence must be caught.
broken=prefix.replace('releaseOutputAfterDeepSleep((gpio_num_t)P_LORA_LF_PA_POWER, LOW)',
'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_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"):
for path in folder.rglob("*"):
if path.suffix not in (".h",".cpp") or path==HEADER:
continue
self.assertNotRegex(path.read_text(),r"\brtc_gpio_(?:hold_en|hold_dis|deinit)\s*\(",str(path))
pins=(ROOT/"variants/station_g3_esp32/platformio.ini").read_text()
self.assertRegex(pins,r"P_LORA_NSS=43\b")
self.assertRegex(pins,r"P_PRIMARY_LNA_EN=42\b")
self.assertRegex((ROOT/"variants/lilygo_tbeam_SX1262/platformio.ini").read_text(),r"P_LORA_NSS=18\b")
if __name__ == "__main__":
unittest.main()
+19 -2
View File
@@ -36,8 +36,9 @@ struct SerialMock {
unsigned begins=0, ends=0, polls=0, baud=0;
int first_pin=-1, second_pin=-1, bytes=0;
bool installed=false;
explicit operator bool() const { return installed; }
void setPins(int first, int second) { first_pin=first; second_pin=second; }
void begin(unsigned speed) { installed=true; baud=speed; ++begins; }
void begin(unsigned speed) { installed=true; baud=speed; ++begins; gpio_modes[first_pin]=INPUT; gpio_modes[second_pin]=OUTPUT; }
void end() { installed=false; ++ends; }
int available() { ++polls; return bytes; }
} Serial1;
@@ -82,6 +83,7 @@ struct EnvironmentSensorManager : SensorManager {
bool gpsSerialTransportMayConflict(uint8_t uart) const override;
bool gpsSerialTransportCanYield(uint8_t uart) const override;
bool setGpsSerialTransportBlocked(uint8_t uart, bool blocked) override;
void shutdownGpsTransportForDeepSleep() override;
};
@METHODS@
@@ -113,6 +115,12 @@ static void absent() {
now_ms=5100; sensors.loop();
assert(!sensors.gps_discovery_pending && !sensors.gps_detected && !sensors.gps_active);
assert(!location.powered && location.stops==1 && !sensors.gpsUsesSerialUart(1));
#ifdef ESP32_PLATFORM
assert(!Serial1.installed && Serial1.ends==1);
assert(gpio_modes[PIN_GPS_TX]==INPUT && gpio_modes[PIN_GPS_RX]==INPUT);
#else
assert(Serial1.installed && Serial1.ends==0); // nRF lifecycle is unchanged.
#endif
const unsigned polls=Serial1.polls;
Serial1.bytes=8; now_ms=6100; sensors.loop();
assert(Serial1.polls==polls && !sensors.gps_detected && location.stops==1);
@@ -277,6 +285,7 @@ def harness(*, negative=None):
"bool EnvironmentSensorManager::gpsSerialTransportMayConflict(",
"bool EnvironmentSensorManager::gpsSerialTransportCanYield(",
"bool EnvironmentSensorManager::setGpsSerialTransportBlocked(",
"void EnvironmentSensorManager::shutdownGpsTransportForDeepSleep()",
"void EnvironmentSensorManager::loop()",
)
# These two methods live under the production platform/provider guard.
@@ -297,6 +306,8 @@ def harness(*, negative=None):
" // Negative control: leave the pending receiver powered."),
"preference_loss": (" gps_discovery_preference_known = true;",
" // Negative control: forget that an explicit preference was loaded."),
"failed_uart": (" if (!found) shutdownGpsTransportForDeepSleep();",
" // Negative control: abandon the failed probe with TX driven."),
}
if negative:
before, after = mutations[negative]
@@ -317,7 +328,9 @@ class CooperativeGpsDiscoveryTests(unittest.TestCase):
with tempfile.TemporaryDirectory(prefix="meshcore-esp32-gps-") as directory:
work = Path(directory)
for name, text in {
"Arduino.h": ARDUINO, "Mesh.h": MESH, "CayenneLPP.h": CAYENNE,
"Arduino.h": ARDUINO.replace("inline void pinMode(int,int){}",
"#define INPUT 0\ninline int gpio_modes[64]={};\ninline void pinMode(int pin,int mode){gpio_modes[pin]=mode;}"),
"Mesh.h": MESH, "CayenneLPP.h": CAYENNE,
"Wire.h": "#pragma once\nclass TwoWire {};\n",
}.items():
(work / name).write_text(text, encoding="utf-8")
@@ -412,6 +425,10 @@ class CooperativeGpsDiscoveryTests(unittest.TestCase):
self.compile_run(cases, negative=negative, defines=defines,
platform="NRF52_PLATFORM")
def test_failed_esp32_probe_releases_its_uart_before_ownership_is_dropped(self):
self.compile_run(("absent",))
self.compile_run(("absent",), negative="failed_uart")
if __name__ == "__main__":
unittest.main(verbosity=2)
+58
View File
@@ -0,0 +1,58 @@
"""Execute all four production ESP32 idle-yield gates with actual conditions."""
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
from cpp_source import body
ROOT = Path(__file__).resolve().parents[1]
class ScannerPowerYieldTests(unittest.TestCase):
def test_each_role_keeps_idle_yield_but_not_live_dual_profile_delay(self):
compiler = shutil.which("g++") or shutil.which("clang++")
self.assertIsNotNone(compiler)
for role in ("companion_radio", "simple_repeater", "simple_room_server", "simple_sensor"):
with self.subTest(role=role), tempfile.TemporaryDirectory(prefix="scanner-power-yield-") as directory:
source = (ROOT / "examples" / role / "main.cpp").read_text()
marker = "else if (!morse_timing && !the_mesh.isDualRadioActive()" if role == "companion_radio" else "if (!morse_timing && !can_power_save && !the_mesh.isDualRadioActive()"
gate = body(source, marker).removeprefix("else ")
harness = r'''
#include <cassert>
static unsigned yields=0;
void delay(unsigned ms) { assert(ms==1); ++yields; }
struct Prefs { bool powersaving_enabled=true; } prefs;
struct Mesh {
bool dual=false, tracker=false;
Prefs* getNodePrefs() { return &prefs; }
bool isDualRadioActive() const { return dual; }
bool isTrackerModeEnabled() const { return tracker; }
} the_mesh;
void run(bool morse_timing=false, bool can_power_save=false) { (void)can_power_save; @GATE@ }
int main() {
run(); assert(yields==1); // USB/logging/button holds keep CPU idle available.
the_mesh.dual=true; run(); assert(yields==1); // Radio2 scanner: no1ms delay.
run(true); assert(yields==1); // No precise Morse timing delay either.
the_mesh.dual=false; run(true); assert(yields==1);
prefs.powersaving_enabled=false; run(); assert(yields==1);
@TRACKER@
prefs.powersaving_enabled=true; the_mesh.tracker=false; the_mesh.dual=false; run(); assert(yields==2);
}
'''.replace("@GATE@", gate).replace("@TRACKER@", "the_mesh.tracker=true; the_mesh.dual=true; run(); assert(yields==1);" if role == "simple_sensor" else "")
work = Path(directory)
cpp, binary = work / "test.cpp", work / "test"
for broken in (False, True):
cpp.write_text(harness.replace("!the_mesh.isDualRadioActive()", "true") if broken else harness)
compiled = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", str(cpp), "-o", str(binary)], capture_output=True, text=True)
self.assertEqual(compiled.returncode, 0, compiled.stderr)
ran = subprocess.run([str(binary)], capture_output=True, text=True)
if broken:
self.assertNotEqual(ran.returncode, 0, "negative control concealed delayed scanner")
else:
self.assertEqual(ran.returncode, 0, ran.stderr)
if __name__ == "__main__":
unittest.main()
+320
View File
@@ -0,0 +1,320 @@
"""Host-only Ethernet power-off ownership, shared registry and legacy lifecycle."""
from pathlib import Path
import os
import subprocess
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
ARDUINO = """#pragma once
#include <cstddef>
#include <cstdint>
#define INPUT 0
#define OUTPUT 1
#define HIGH 1
void pinMode(int,int);
"""
FLAGS = ["-std=c++17", "-Wall", "-Wextra", "-Werror", "-pthread",
"-fsanitize=address,undefined", "-fno-pie", "-no-pie"]
def run_sources(test, sources, cases=("0",), negative=False, extra_files=None):
with tempfile.TemporaryDirectory(prefix="ethernet-terminal-") as folder:
work = Path(folder)
(work / "Arduino.h").write_text(ARDUINO)
for name, content in (extra_files or {}).items():
(work / name).write_text(content)
paths = []
for index, source in enumerate(sources):
path = work / f"part{index}.cpp"
path.write_text(source)
paths.append(str(path))
binary = work / "case"
built = subprocess.run([os.environ.get("CXX", "g++"), *FLAGS,
"-I"+str(work), "-I"+str(ROOT/"src"),
*paths, "-o", str(binary)], text=True,
capture_output=True, timeout=60)
test.assertEqual(built.returncode, 0, built.stderr)
for case in cases:
with test.subTest(case=case):
ran = subprocess.run([str(binary), case], text=True, capture_output=True, timeout=10)
if negative:
test.assertNotEqual(ran.returncode, 0)
else:
test.assertEqual(ran.returncode, 0, ran.stdout+ran.stderr)
LEGACY = r'''
#include <Arduino.h>
#include <atomic>
#include <cassert>
#include <cstdlib>
#include <cstring>
#include <cstdio>
#include <vector>
#include <helpers/nrf52/TerminalPeripheralShutdown.h>
static uint32_t now=0;
uint32_t millis(){return now;}
using TaskHandle_t=int*;
static int task=0,create_result=1,ends=0,pads=0,deletes=0,notifications=0,waits=0;
static bool spi=false,run_on_delay=false,stop_in_server=false;
static void(*scheduled)(void*)=nullptr;
struct Done{};
#define pdPASS 1
#define pdTRUE 1
#define taskENTER_CRITICAL() do {} while(0)
#define taskEXIT_CRITICAL() do {} while(0)
#define pdMS_TO_TICKS(ms) (ms)
#define ETHERNET_RETRY_INTERVAL_MS 30000
#define PIN_ETHERNET_RESET 21
#define PIN_ETHERNET_SS 26
#define ETHERNET_TCP_PORT 23
#define ETHERNET_CLI_LOG(...) do { if(false) std::printf(__VA_ARGS__); } while(0)
static std::atomic<bool> ethernet_running{false};
static bool ethernet_session_reset=false;
struct Spi { void begin(){assert(!spi);spi=true;} void end(){assert(spi);spi=false;++ends;} } ETHERNET_SPI_PORT;
struct EthernetClient {};
static EthernetClient ethernet_client;
struct IPAddress { unsigned operator[](unsigned) const{return 1;} };
enum {EthernetNoHardware=0,LinkOFF=0};
struct {
int result=1,hardware=1;
void init(Spi&,int){assert(spi);}
int begin(uint8_t*,int,int){assert(spi);return result;}
int hardwareStatus(){return hardware;}
int linkStatus(){return LinkOFF;}
IPAddress localIP(){return {};}
} Ethernet;
void serverBegin();
struct {void begin(){serverBegin();}} ethernet_server;
void generateEthernetMac(uint8_t* p){std::memset(p,0,6);}
void pinMode(int pin,int mode){
if(mode==INPUT){assert(!spi);assert(pin==3||pin==29||pin==30||pin==26||pin==21);++pads;}
else assert(pin==21&&mode==OUTPUT);
}
void digitalWrite(int pin,int value){assert(pin==21&&value==HIGH);}
int xTaskCreate(void(*fn)(void*),const char*,int,void*,int,TaskHandle_t* handle){
if(create_result!=pdPASS)return create_result;
scheduled=fn;*handle=&task;return pdPASS;
}
void xTaskNotifyGive(TaskHandle_t handle){assert(handle==&task);++notifications;}
void vTaskDelete(TaskHandle_t handle){assert(!handle);++deletes;throw Done{};}
void vTaskDelay(uint32_t ticks);
uint32_t ulTaskNotifyTake(int,uint32_t ticks);
@LEGACY@
void serverBegin(){assert(spi);if(stop_in_server)ethernet_legacy_stop.store(true);}
void runWorker(){assert(scheduled);try{scheduled(nullptr);}catch(const Done&){} scheduled=nullptr;}
void vTaskDelay(uint32_t ticks){now+=ticks;if(run_on_delay){run_on_delay=false;runWorker();}}
uint32_t ulTaskNotifyTake(int clear,uint32_t ticks){
assert(clear==pdTRUE&&ticks==30000);++waits;
// Model the stop notification arriving during a retry wait, not a 30s delay.
ethernet_legacy_stop.store(true);return 1;
}
int main(int argc,char** argv){
assert(argc==2);int which=std::atoi(argv[1]);
ethernet_start_task();
assert(ethernet_legacy_worker_active.load());
if(which==0){ // Cancellation before the worker ever acquires hardware.
run_on_delay=true;assert(mesh::nrf52::terminalEthernetShutdown(20));
assert(!spi&&ends==0&&pads==0&&deletes==1);
}else if(which==1){ // Active vendor call: bounded, no deletion/end/pad changes.
spi=true;ethernet_legacy_spi_started=true;
now=UINT32_MAX-9;assert(!mesh::nrf52::terminalEthernetShutdown(20));
assert(now<100&&spi&&ends==0&&pads==0&&deletes==0);
runWorker();assert(mesh::nrf52::terminalEthernetShutdown(20));
assert(!spi&&ends==1&&pads==5&&deletes==1);
}else if(which==2){ // Normal DHCP completion publishes no further hardware work.
runWorker();assert(ethernet_running&&!ethernet_legacy_worker_active.load());
assert(mesh::nrf52::terminalEthernetShutdown(0));
assert(!spi&&ends==1&&pads==5&&deletes==1);
}else if(which==3){ // Legacy retry uses an interruptible notification wait.
Ethernet.result=0;runWorker();assert(waits==1&&now==0);
assert(mesh::nrf52::terminalEthernetShutdown(0));
assert(!spi&&ends==1&&pads==5&&deletes==1);
}else if(which==4){ // Stop between DHCP completion and online publication.
stop_in_server=true;runWorker();assert(!ethernet_running);
assert(mesh::nrf52::terminalEthernetShutdown(0));
assert(!ethernet_running&&!spi&&ends==1&&pads==5);
}else assert(false);
const int previous_ends=ends;
assert(mesh::nrf52::terminalEthernetShutdown(0));assert(ends==previous_ends);
assert(!ethernet_legacy_worker_active.load()&&ethernet_legacy_task_handle==nullptr);
ethernet_start_task();assert(!ethernet_legacy_worker_active.load());
}
'''
SYNC = r'''
#include <Arduino.h>
#include <cassert>
#include <cstdlib>
#include <cstdint>
#include <helpers/nrf52/TerminalPeripheralShutdown.h>
static bool spi=false;
static int begins=0,ends=0,pads=0,cancels=0,closes=0,reads=0;
void pinMode(int,int mode){if(mode==INPUT){assert(!spi);++pads;}}
void digitalWrite(int,int){}
#define ETHERNET_TCP_PORT 5000
#define PIN_ETHERNET_RESET 21
#define PIN_ETHERNET_SS 26
#define ETHERNET_DEBUG_PRINTLN(...) do {} while(0)
#define ETHERNET_DEBUG_PRINT_IP(...) do {} while(0)
struct SPIClass {void begin(){assert(!spi);spi=true;++begins;} void end(){assert(spi);spi=false;++ends;}} ETHERNET_SPI_PORT;
struct IPAddress {uint8_t operator[](int) const{return 1;} operator uint32_t() const{return 1;}};
struct EthernetClient {
explicit operator bool() const{return false;}
bool connected() const{assert(spi);return false;}
void stop(){assert(false&&"vendor socket stop must not block terminal shutdown");}
int available(){assert(spi);++reads;return 0;}
int read(){assert(spi);++reads;return -1;}
size_t write(const uint8_t*,size_t){assert(spi);++reads;return 0;}
IPAddress remoteIP(){return {};}
uint16_t remotePort(){return 1;}
};
struct EthernetServer {explicit EthernetServer(int){} void begin(){} EthernetClient accept(){return {};}};
enum {EthernetNoHardware=0,LinkOFF=0};
struct {
int result=1;
void init(SPIClass&,int){assert(spi);}
int begin(uint8_t*){return result;}
int hardwareStatus(){return EthernetNoHardware;}
int linkStatus(){return LinkOFF;}
IPAddress localIP(){return {};}
void maintain(){assert(spi);++reads;}
} Ethernet;
void generateEthernetMac(uint8_t*){}
namespace mesh {namespace nrf52 {struct EthernetCliTransmit {
static bool close(uint8_t){assert(spi);++closes;return false;}
};}}
struct SerialEthernetInterface {
virtual ~SerialEthernetInterface()=default;
virtual void disconnectClient()=0;
void disable(){disconnectClient();++cancels;}
void onClientDisconnected(){}
void onClientConnected(uint32_t){}
bool isEnabled() const{return true;}
virtual void loop()=0;
virtual bool isConnected() const=0;
virtual int available()=0;
virtual int read()=0;
virtual size_t write(const uint8_t*,size_t)=0;
};
@CLASS@
@METHODS@
bool otherOwner(void*,uint32_t){return false;}
int main(int argc,char** argv){
assert(argc==2);int which=std::atoi(argv[1]);RAK13800EthernetInterface owner;
if(which==0){assert(mesh::nrf52::terminalEthernetShutdown(0));assert(begins==0&&ends==0&&pads==0);}
else if(which==3){
assert(mesh::nrf52::registerTerminalEthernetShutdown(otherOwner,nullptr));
assert(!owner.begin());assert(begins==0&&pads==0);
}else{
if(which==2)Ethernet.result=0;
assert(owner.begin()==(which==1));
assert(mesh::nrf52::terminalEthernetShutdown(0));
assert(begins==1&&ends==1&&!spi&&pads==5&&closes==4&&cancels==1);
assert(owner.available()==0&&owner.read()==-1&&owner.write(nullptr,0)==0);
owner.loop();assert(reads==0);
assert(mesh::nrf52::terminalEthernetShutdown(0));assert(ends==1&&pads==5);
assert(!owner.begin());
}
}
'''
class EthernetTerminalShutdownTests(unittest.TestCase):
def test_registry_is_shared_across_translation_units_and_rejects_conflicts(self):
app = r'''
#include <Arduino.h>
#include <cassert>
#include <helpers/nrf52/TerminalPeripheralShutdown.h>
static int calls=0;
bool stopped(void* context,uint32_t budget){assert(context==&calls&&budget==17);++calls;return false;}
bool other(void*,uint32_t){return true;}
void registerApp(){
assert(!mesh::nrf52::registerTerminalEthernetShutdown(nullptr,nullptr));
assert(mesh::nrf52::registerTerminalEthernetShutdown(stopped,&calls));
assert(mesh::nrf52::registerTerminalEthernetShutdown(stopped,&calls));
assert(!mesh::nrf52::registerTerminalEthernetShutdown(other,&calls));
assert(!mesh::nrf52::registerTerminalEthernetShutdown(stopped,nullptr));
}
void* appOwner(){return &mesh::nrf52::terminalEthernetOwner();}
int callCount(){return calls;}
'''
board = r'''
#include <Arduino.h>
#include <cassert>
#include <helpers/nrf52/TerminalPeripheralShutdown.h>
void pinMode(int,int){}
void registerApp();void* appOwner();int callCount();
int main(){
assert(mesh::nrf52::terminalEthernetShutdown());registerApp();
assert(appOwner()==&mesh::nrf52::terminalEthernetOwner());
assert(!mesh::nrf52::terminalEthernetShutdown(17)&&callCount()==1);
}
'''
run_sources(self, (app, board))
# Prove this specifically detects a mistakenly TU-local registry.
header = (ROOT/"src/helpers/nrf52/TerminalPeripheralShutdown.h").read_text()
broken = header.replace("inline TerminalEthernetOwner& terminalEthernetOwner()",
"static TerminalEthernetOwner& terminalEthernetOwner()")
self.assertNotEqual(header,broken)
local = tuple(text.replace("<helpers/nrf52/TerminalPeripheralShutdown.h>", '"Registry.h"')
for text in (app,board))
run_sources(self,local,negative=True,extra_files={"Registry.h":broken})
def test_actual_legacy_worker_cancellation_timeout_and_retry_wait(self):
source = (ROOT/"src/helpers/nrf52/EthernetCLI.h").read_text()
start = source.index("static std::atomic<bool> ethernet_legacy_stop")
end = source.index("\n#endif", source.index("static void ethernet_start_task()", start))
run_sources(self, (LEGACY.replace("@LEGACY@", source[start:end]),), ("0","1","2","3","4"))
def test_rak3401_rejects_electrically_conflicting_ethernet_before_hardware(self):
source = SYNC.split("int main(",1)[0]
stem = ROOT/"src/helpers/ethernet/RAK13800/RAK13800EthernetInterface"
header = stem.with_suffix(".h").read_text()
cpp = stem.with_suffix(".cpp").read_text()
cls = extract_braced(header,"class RAK13800EthernetInterface")+";"
# Only the actual begin() and virtual methods are needed; the complete
# implementation is already exercised above. Reuse its method builder.
methods = "\n".join(extract_braced(cpp,sig) for sig in (
"bool RAK13800EthernetInterface::begin()",
"bool RAK13800EthernetInterface::terminalShutdown(",
"bool RAK13800EthernetInterface::shutdownForPowerOff(",
"void RAK13800EthernetInterface::disconnectClient()",
"bool RAK13800EthernetInterface::isConnected() const",
"int RAK13800EthernetInterface::available()",
"int RAK13800EthernetInterface::read()",
"size_t RAK13800EthernetInterface::write(",
"void RAK13800EthernetInterface::loop()",
)).replace("uint16_t remotePort = newClient.remotePort();", "(void)newClient.remotePort();")
source = "#define RAK_3401 1\n"+source.replace("@CLASS@",cls).replace("@METHODS@",methods)
source += "int main(){RAK13800EthernetInterface owner;assert(!owner.begin());assert(!spi&&begins==0&&pads==0);}\n"
run_sources(self,(source,))
def test_actual_synchronous_companion_shutdown_is_bounded_and_initialized_only(self):
stem = ROOT/"src/helpers/ethernet/RAK13800/RAK13800EthernetInterface"
header = stem.with_suffix(".h").read_text()
cpp = stem.with_suffix(".cpp").read_text()
cls = extract_braced(header,"class RAK13800EthernetInterface")+";"
signatures = (
"bool RAK13800EthernetInterface::begin()",
"bool RAK13800EthernetInterface::terminalShutdown(",
"bool RAK13800EthernetInterface::shutdownForPowerOff(",
"void RAK13800EthernetInterface::disconnectClient()",
"bool RAK13800EthernetInterface::isConnected() const",
"int RAK13800EthernetInterface::available()",
"int RAK13800EthernetInterface::read()",
"size_t RAK13800EthernetInterface::write(",
"void RAK13800EthernetInterface::loop()",
)
methods = "\n".join(extract_braced(cpp,sig) for sig in signatures)
# Disabled debug logging intentionally discards remotePort.
methods = methods.replace("uint16_t remotePort = newClient.remotePort();", "(void)newClient.remotePort();")
run_sources(self,(SYNC.replace("@CLASS@",cls).replace("@METHODS@",methods),), ("0","1","2","3"))
if __name__ == "__main__":
unittest.main()
+20 -3
View File
@@ -182,13 +182,30 @@ public:
""",
"driver/rtc_io.h": """#pragma once
using gpio_num_t = int;
using esp_err_t = int;
constexpr int ESP_OK = 0;
using esp_reset_reason_t = int;
constexpr int ESP_RST_DEEPSLEEP = 1;
inline int esp_reset_reason() { return 0; }
inline long esp_sleep_get_ext1_wakeup_status() { return 0; }
inline void rtc_gpio_hold_dis(int) {}
inline void rtc_gpio_hold_en(int) {}
inline void rtc_gpio_deinit(int) {}
inline bool rtc_gpio_is_valid_gpio(int pin) { return pin >= 0 && pin <= 21; }
inline int rtc_gpio_hold_dis(int) { return 0; }
inline int rtc_gpio_hold_en(int) { return 0; }
inline int rtc_gpio_deinit(int) { return 0; }
""",
"driver/gpio.h": """#pragma once
#include <cstdint>
#include "rtc_io.h"
constexpr int ESP_ERR_INVALID_ARG=258;
constexpr int GPIO_MODE_OUTPUT=2,GPIO_PULLUP_DISABLE=0,GPIO_PULLDOWN_DISABLE=0,GPIO_INTR_DISABLE=0;
#define GPIO_IS_VALID_OUTPUT_GPIO(pin) ((pin)>=0 && (pin)<49)
struct gpio_config_t { uint64_t pin_bit_mask;int mode,pull_up_en,pull_down_en,intr_type; };
inline int gpio_set_level(int,int) { return 0; }
inline int gpio_config(const gpio_config_t*) { return 0; }
inline int gpio_hold_en(int) { return 0; }
inline int gpio_hold_dis(int) { return 0; }
inline void gpio_deep_sleep_hold_en() {}
inline void gpio_deep_sleep_hold_dis() {}
""",
"test.cpp": TEST,
}
+5
View File
@@ -149,6 +149,11 @@ class HwcdcRoleTransportTest(unittest.TestCase):
"KISS_UART_RX=43", "KISS_UART_TX=44", "EXPECT_UART=1"],
"tinyusb": ["ARDUINO_USB_MODE=0", "ARDUINO_USB_CDC_ON_BOOT=1"],
"classic-uart": ["ARDUINO_USB_MODE=1", "ARDUINO_USB_CDC_ON_BOOT=0"],
"g3-hwcdc": ["STATION_G3_ESP32=1", "ARDUINO_USB_MODE=1",
"ARDUINO_USB_CDC_ON_BOOT=1", "EXPECT_HWCDC=1"],
"g3-uart-hwcdc": ["STATION_G3_ESP32=1", "ARDUINO_USB_MODE=1",
"ARDUINO_USB_CDC_ON_BOOT=1", "KISS_UART_RX=16",
"KISS_UART_TX=17", "EXPECT_UART=1"],
}
sanitizers = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all",
"-fno-pie", "-no-pie"] if os.name != "nt" else [])
+181
View File
@@ -0,0 +1,181 @@
"""Actual KISS framing/dispatch for persistent G3 physical-slot selection."""
from pathlib import Path
import os
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
HARNESS = r'''
#include "KissModem.h"
#include "StationG3RadioSlot.h"
#include <algorithm>
#include <cassert>
#include <cstring>
#include <iostream>
#include <string>
#include <vector>
struct Buffer : Stream {
std::vector<uint8_t> input, output;
size_t at = 0, chunk = 4096;
int availableForWrite() override { return static_cast<int>(chunk); }
int available() override { return static_cast<int>(input.size() - at); }
int read() override { return at < input.size() ? input[at++] : -1; }
size_t write(uint8_t value) override { return write(&value, 1); }
size_t write(const uint8_t* data, size_t size) override {
const size_t count = std::min(size, chunk);
output.insert(output.end(), data, data + count);
return count;
}
};
struct Radio : mesh::Radio {
unsigned sends = 0, tunes = 0;
bool startSendRaw(const uint8_t*, uint16_t) override { ++sends; return false; }
mesh::RadioParamApplyResult trySetPrimaryParams(const mesh::RadioProfileParams&,
bool, const uint32_t* = nullptr) override { ++tunes; return mesh::RadioParamApplyResult::FAILED; }
};
struct Rng : mesh::RNG {
void random(uint8_t* dest, size_t count) override { std::memset(dest, 0, count); }
};
struct Board : mesh::MainBoard {
bool fail = false, recognized = true, lie = false;
unsigned calls = 0, reboots = 0;
std::string last;
void reboot() override { ++reboots; }
bool handleCommand(const char* command, uint32_t timestamp, char* reply) override {
++calls; last = command; assert(timestamp == 0);
if (!recognized) return false;
if (fail) { std::strcpy(reply, "Error: radio.slot could not be saved; running slot unchanged"); return true; }
assert(std::strncmp(command, "set radio.slot ", 15) == 0);
const char* mode = command + 15;
if (!lie) station_g3::slotState().savedMode =
std::strcmp(mode, "auto") == 0 ? 0 : static_cast<uint8_t>(mode[0] - '0');
std::strcpy(reply, "OK - radio.slot saved; reboot to apply");
return true;
}
};
struct Sensors : SensorManager {};
static std::vector<uint8_t> expected(uint8_t response, std::vector<uint8_t> bytes = {}) {
std::vector<uint8_t> result{KISS_FEND, KISS_CMD_SETHARDWARE, response};
result.insert(result.end(), bytes.begin(), bytes.end());
result.push_back(KISS_FEND);
return result;
}
int main() {
Buffer serial; Radio radio; Rng rng; Board board; Sensors sensors;
mesh::LocalIdentity identity;
KissModem modem(serial, identity, rng, radio, board, sensors); modem.begin();
auto& state = station_g3::slotState();
state.bootMode = 0; state.savedMode = 0; state.active = 2;
state.detected = 3; state.candidate = 2; state.last = 2;
// Any unintended pin operation is observable, including unchanged levels.
for (unsigned pin = 0; pin < 64; ++pin) {
g_mock_pin_modes[pin] = 77; g_mock_pin_levels[pin] = 88;
}
auto request = [&](uint8_t command, std::vector<uint8_t> bytes = {}) {
serial.output.clear();
serial.input.insert(serial.input.end(), {KISS_FEND, KISS_CMD_SETHARDWARE, command});
for (uint8_t byte : bytes) {
if (byte == KISS_FEND) serial.input.insert(serial.input.end(), {KISS_FESC, KISS_TFEND});
else if (byte == KISS_FESC) serial.input.insert(serial.input.end(), {KISS_FESC, KISS_TFESC});
else serial.input.push_back(byte);
}
serial.input.push_back(KISS_FEND);
for (unsigned i = 0; i < 20; ++i) modem.loop();
};
#if defined(STATION_G3_ESP32)
request(HW_CMD_GET_RADIO_SLOT);
assert(serial.output == expected(0xa2, {0, 2, 3, 0}));
request(HW_CMD_GET_RADIO_SLOT, {0});
assert(serial.output == expected(HW_RESP_ERROR, {HW_ERR_INVALID_LENGTH}));
for (auto bytes : {std::vector<uint8_t>{}, std::vector<uint8_t>{1, 2}}) {
request(HW_CMD_SET_RADIO_SLOT, bytes);
assert(serial.output == expected(HW_RESP_ERROR, {HW_ERR_INVALID_LENGTH}));
}
for (uint8_t mode : {3, 255, int(KISS_FEND), int(KISS_FESC)}) {
request(HW_CMD_SET_RADIO_SLOT, {mode});
assert(serial.output == expected(HW_RESP_ERROR, {HW_ERR_INVALID_PARAM}));
}
assert(board.calls == 0 && state.savedMode == 0);
for (uint8_t mode : {1, 2, 0}) {
request(HW_CMD_SET_RADIO_SLOT, {mode});
assert(serial.output == expected(HW_RESP_OK));
assert(state.savedMode == mode);
assert(board.last == (mode == 0 ? "set radio.slot auto" : mode == 1 ? "set radio.slot 1" : "set radio.slot 2"));
request(HW_CMD_GET_RADIO_SLOT);
assert(serial.output == expected(0xa2, {mode, 2, 3, static_cast<uint8_t>(mode != 0)}));
}
board.fail = true;
request(HW_CMD_SET_RADIO_SLOT, {1});
assert(serial.output == expected(HW_RESP_ERROR, {HW_ERR_SAVE_FAILED}));
assert(state.savedMode == 0);
board.fail = false; board.recognized = false;
request(HW_CMD_SET_RADIO_SLOT, {1});
assert(serial.output == expected(HW_RESP_ERROR, {HW_ERR_NO_CALLBACK}));
board.recognized = true; board.lie = true;
request(HW_CMD_SET_RADIO_SLOT, {1});
assert(serial.output == expected(HW_RESP_ERROR, {HW_ERR_SAVE_FAILED}));
// Status stays fixed-length even when no radio is present.
state.active = 0; state.detected = 0;
serial.chunk = 1;
request(HW_CMD_GET_RADIO_SLOT);
assert(serial.output == expected(0xa2, {0, 0, 0, 0}));
#else
for (uint8_t command : {HW_CMD_GET_RADIO_SLOT, HW_CMD_SET_RADIO_SLOT}) {
for (auto bytes : {std::vector<uint8_t>{}, std::vector<uint8_t>{2}, std::vector<uint8_t>{1, 2}}) {
request(command, bytes);
assert(serial.output == expected(HW_RESP_ERROR, {HW_ERR_UNKNOWN_CMD}));
}
}
assert(board.calls == 0 && state.savedMode == 0);
#endif
assert(radio.sends == 0 && radio.tunes == 0 && board.reboots == 0);
assert(state.bootMode == 0 && state.candidate == 2 && state.last == 2);
for (unsigned pin = 0; pin < 64; ++pin) {
assert(g_mock_pin_modes[pin] == 77 && g_mock_pin_levels[pin] == 88);
}
std::cout << "PASS: KISS physical radio slots\n";
}
'''
class KissRadioSlotTests(unittest.TestCase):
def test_actual_kiss_dispatch_framing_and_board_gating(self):
compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++")
if not compiler:
self.skipTest("a host C++17 compiler is required")
with tempfile.TemporaryDirectory(prefix="kiss-radio-slot-") as folder:
work = Path(folder)
mesh = (ROOT / "test/mocks/Mesh.h").read_text(encoding="utf-8")
# Extend this test's isolated mock with the existing production board API.
mesh = mesh.replace("virtual void reboot() {}", "virtual void reboot() {}\n"
" virtual bool handleCommand(const char*, uint32_t, char*) { return false; }")
(work / "Mesh.h").write_text(mesh, encoding="utf-8")
(work / "test.cpp").write_text(HARNESS, encoding="utf-8")
for g3 in (False, True):
with self.subTest(station_g3=g3):
binary = work / "test.exe"
built = subprocess.run(
[compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-Wno-unused-parameter", "-Wno-sign-compare",
*(["-DSTATION_G3_ESP32=1"] if g3 else []),
"-I" + str(work), "-I" + str(ROOT / "test/mocks"),
"-I" + str(ROOT / "src"),
"-I" + str(ROOT / "examples/kiss_modem"),
"-I" + str(ROOT / "variants/station_g3_esp32"),
str(ROOT / "examples/kiss_modem/KissModem.cpp"),
str(work / "test.cpp"), "-o", str(binary)],
capture_output=True, text=True, timeout=60,
)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
checked = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10)
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
self.assertIn("PASS: KISS physical radio slots", checked.stdout)
if __name__ == "__main__":
unittest.main()
+274
View File
@@ -0,0 +1,274 @@
"""Execute LR2021 regulator setup and reset/restoration with inert native calls.
No hardware or PlatformIO. These tests execute extracted production methods,
including their real DIO routing, and inject failures before RX admission.
"""
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]
HARNESS = r'''
#include <cassert>
#include <cstddef>
#include <cstdint>
#include <vector>
#include <algorithm>
#include <helpers/radiolib/LR2021Band.h>
@POLICY@
constexpr bool ExpectDcdc = @EXPECT_DCDC@;
static_assert((LR2021_USE_DCDC != 0)==ExpectDcdc,"unexpected board regulator default");
#define RADIOLIB_ERR_NONE 0
#define RADIOLIB_ERR_UNKNOWN -16
#define RADIOLIB_NC 0xffffffffU
#define RFSWITCH_PIN_FLAG 0x80U
#define RADIOLIB_LRXXXX_DIOx_VAL(pin) ((pin)&0x7fU)
#define RADIOLIB_LR2021_DIO_SLEEP_PULL_UP 1
#define RADIOLIB_LR2021_DIO_SLEEP_PULL_AUTO 0
#define RADIOLIB_LR2021_DIO_FUNCTION_RF_SWITCH 6
#define RADIOLIB_LR2021_LORA_SYNC_WORD_PRIVATE 0x12
#define LORA_FREQ 909.5f
#define LORA_BW 500.0f
#define LORA_SF 5
#define LORA_CR 5
#define LORA_TX_POWER 2
#define P_LORA_SCLK 11
#define P_LORA_MISO 40
#define P_LORA_MOSI 41
struct SPIClass {
bool started=false,pins_set=false;
void setPins(int miso,int sclk,int mosi) {
assert(miso==40 && sclk==11 && mosi==41);pins_set=true;
}
void begin() { assert(pins_set);started=true; }
void begin(int sclk,int miso,int mosi) {
assert(sclk==11 && miso==40 && mosi==41);pins_set=started=true;
}
};
struct Logger { void print(const char*) {} void println(int) {} };
namespace mesh {
Logger& usbLoggingPort() { static Logger log;return log; }
struct MorseRx { static constexpr float ReceiverBitRateKbps=4.8f; };
}
struct Hal { int GpioLevelHigh=1; };
struct Module {
static constexpr size_t RFSWITCH_MAX_PINS=3;
struct RfSwitchMode_t { uint8_t mode;int values[RFSWITCH_MAX_PINS]; };
Hal storage;Hal* hal=&storage;
};
class LR2021 {
public:
enum { MODE_RX=3,MODE_TX=4,MODE_RX_HF=6,MODE_TX_HF=7,MODE_END_OF_TABLE=0 };
Module* mod;
int fail=0,regulator_calls=0;
bool regulator_on=false;
float tcxoVoltage=1.6f,frequency=0;
uint8_t gainModeLf=1,gainModeHf=3,preambleDetLength=4,gain=0;
uint8_t routing[7]={},pulls[7]={};
std::vector<int> calls;
explicit LR2021(Module* module):mod(module) {}
int16_t step(int value) { calls.push_back(value);return value==fail ? -700-value : 0; }
void resetNativeState() {
regulator_on=false;std::fill(routing,routing+7,0);preambleDetLength=4;
}
int16_t begin(float freq,float bw,uint8_t sf,uint8_t cr,uint8_t sync,int8_t power,
uint16_t preamble,float tcxo) {
assert(freq==LORA_FREQ && bw==500 && sf==5 && cr==5 && sync==0x12);
assert(power==2 && preamble==16);
resetNativeState();frequency=freq;tcxoVoltage=tcxo;return step(1);
}
int16_t beginGFSK(float freq,float rate,float deviation,float bw,int8_t power,
uint16_t preamble,float tcxo) {
assert(rate==4.8f && deviation==0.6f && bw==4.8f && power==2 && preamble==16);
assert(tcxo==tcxoVoltage);
resetNativeState();frequency=freq;return step(1);
}
int16_t setRegulatorDCDC() {
++regulator_calls;const int16_t result=step(2);if(!result)regulator_on=true;return result;
}
int16_t setCRC(uint8_t value) { assert(value==0 || value==2);return step(3); }
int16_t explicitHeader() { return step(4); }
int16_t setRxBoostedGainMode(uint8_t value) { gain=value;return step(5); }
void setRfSwitchTable(const uint32_t (&)[Module::RFSWITCH_MAX_PINS],const Module::RfSwitchMode_t*) {}
int16_t setDioFunction(uint8_t dio,uint8_t function,uint8_t pull) {
assert(dio>=5 && dio<12 && function==6 && (pull==0 || pull==1));
pulls[dio-5]=pull;return step(6);
}
int16_t setDioRfSwitchConfig(uint8_t dio,uint8_t mask) {
assert(dio>=5 && dio<12);routing[dio-5]=mask;return step(7);
}
int16_t setSyncWord(uint8_t*,size_t length) { assert(length==0);return step(8); }
};
class CustomLR2021:public LR2021 {
uint32_t _rf_switch_pins[Module::RFSWITCH_MAX_PINS]={};
const Module::RfSwitchMode_t* _rf_switch_table=nullptr;
@ROUTING@
public:
using LR2021::LR2021;
@TABLE@
@INIT@
@MORSE@
};
struct Board {
int prepare_calls=0,frequency_calls=0,finish_calls=0;
bool prepare_ok=true,frequency_ok=true,finish_ok=true;
bool prepareRadioFrequency(float frequency) { ++frequency_calls;assert(frequency==909.5f);return frequency_ok; }
bool finishRadioHardReset() { ++finish_calls;return finish_ok; }
};
class Wrapper {
public:
CustomLR2021* _radio;
Board* _board;
bool _morse_rx_fsk_mode=false;
bool prepareRadioHardReset() { ++_board->prepare_calls;return _board->prepare_ok; }
bool restoreAfterDeepInit() { return radioDeepInit(); }
@DEEP@
@RESTORE@
};
static const uint32_t pins[]={0x80,0x81,7};
static const Module::RfSwitchMode_t table[]={
{LR2021::MODE_RX,{1,0,0}}, {LR2021::MODE_TX,{0,1,0}},
{LR2021::MODE_RX_HF,{1,0,0}}, {LR2021::MODE_TX_HF,{0,1,0}},
{LR2021::MODE_END_OF_TABLE,{0,0,0}}};
static void configure(CustomLR2021& radio) { radio.setRfSwitchTable(pins,table); }
static void routed(const CustomLR2021& radio) {
assert(radio.routing[0]==((1U<<2)|(1U<<5)));
assert(radio.routing[1]==((1U<<3)|(1U<<6)));
assert(radio.pulls[0]==1 && radio.pulls[1]==0);
}
int main() {
// Only verified/opted-in layouts reapply DC/DC after a native reset.
Module module;CustomLR2021 radio(&module);configure(radio);SPIClass spi;
assert(radio.std_init(&spi) && spi.started && radio.regulator_on==ExpectDcdc);
assert(radio.regulator_calls==(ExpectDcdc?1:0));
assert(radio.calls[0]==1 && radio.calls[1]==(ExpectDcdc?2:3));
#ifdef LR2021_TCXO_VOLTAGE
assert(radio.tcxoVoltage==LR2021_TCXO_VOLTAGE);
#else
assert(radio.tcxoVoltage==1.6f);
#endif
routed(radio);
for(int fault:{1,2,3,4,6,7}) {
CustomLR2021 failed(&module);configure(failed);failed.fail=fault;
if(fault==2 && !ExpectDcdc) {
assert(failed.std_init() && !failed.regulator_on && failed.regulator_calls==0);
routed(failed);continue;
}
assert(!failed.std_init());
assert(failed.calls.back()==fault);
assert(failed.regulator_calls==(ExpectDcdc && fault!=1 ? 1 : 0));
if(fault==2) assert(failed.calls==std::vector<int>({1,2}) && !failed.regulator_on);
}
// GFSK initialization resets native regulator state too, for both RF bands.
for(float frequency:{902.1f,2440.0f}) for(int fault:{0,1,2,3,5,6,7,8}) {
CustomLR2021 received(&module);configure(received);received.fail=fault;
const int16_t result=received.beginMorseReceive(frequency);
const int expected_fault=(fault==2 && !ExpectDcdc)?0:fault;
assert(result==(expected_fault ? -700-expected_fault : 0));
assert(received.regulator_calls==(ExpectDcdc && fault!=1 ? 1 : 0));
if(expected_fault) assert(received.calls.back()==expected_fault);
if(fault==2 && ExpectDcdc)
assert(received.calls==std::vector<int>({1,2}) && !received.regulator_on);
if(!expected_fault) {
assert(received.regulator_on==ExpectDcdc && received.preambleDetLength==0);
assert(received.gain==(frequency>1900 ? 3 : 1));routed(received);
}
}
// Execute the actual wrapper restoration: regulator failure keeps recovery pending.
assert(radio.beginMorseReceive(902.1f)==0 && radio.regulator_calls==(ExpectDcdc?2:0));
Board board;Wrapper wrapper{&radio,&board};wrapper._morse_rx_fsk_mode=true;
radio.fail=ExpectDcdc?2:3;
assert(!wrapper.restoreCarrierWaveModem() && wrapper._morse_rx_fsk_mode);
assert(board.prepare_calls==1 && board.finish_calls==0);
radio.fail=0;
assert(wrapper.restoreCarrierWaveModem() && !wrapper._morse_rx_fsk_mode);
assert(board.prepare_calls==2 && board.finish_calls==1 && radio.regulator_on==ExpectDcdc);
assert(radio.frequency==909.5f && radio.regulator_calls==(ExpectDcdc?4:0));routed(radio);
// Board reset/frequency failures do not proceed into the radio/regulator.
const int calls=radio.regulator_calls;
const size_t native_calls=radio.calls.size();
board.prepare_ok=false;assert(!wrapper.radioDeepInit());
board.prepare_ok=true;board.frequency_ok=false;assert(!wrapper.radioDeepInit());
assert(radio.regulator_calls==calls && radio.calls.size()==native_calls);
}
'''
class LR2021PowerTest(unittest.TestCase):
def run_methods(self, *flags):
driver = (ROOT / "src/helpers/radiolib/CustomLR2021.h").read_text()
wrapper = (ROOT / "src/helpers/radiolib/CustomLR2021Wrapper.h").read_text()
restore = method(wrapper, "bool restoreCarrierWaveModem(").replace(" override", "")
# The non-FSK branch is unchanged and already covered by CW/PA tests;
# keep only the real FSK reset/restoration branch in this focused mock.
branch = method(restore, "if (_morse_rx_fsk_mode)")
restore = "bool restoreCarrierWaveModem() { " + branch + " return true; }"
source = HARNESS
policy = re.search(r"#ifndef LR2021_USE_DCDC\b.*?#endif", driver, re.S)
self.assertIsNotNone(policy, "LR2021 board policy must remain explicit and default-safe")
for marker, body in {
"@POLICY@": policy.group(0),
"@EXPECT_DCDC@": "true" if "-DLR2021_USE_DCDC=1" in flags else "false",
"@ROUTING@": method(driver, "int16_t restoreRfSwitchTable("),
"@TABLE@": method(driver, "void setRfSwitchTable("),
"@INIT@": method(driver, "bool std_init("),
"@MORSE@": method(driver, "int16_t beginMorseReceive("),
"@DEEP@": method(wrapper, "bool radioDeepInit(").replace(" override", ""),
"@RESTORE@": restore,
}.items():
source = source.replace(marker, body)
compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++")
self.assertIsNotNone(compiler, "a host C++17 compiler is required")
with tempfile.TemporaryDirectory(prefix="meshcore-lr2021-power-") as directory:
cpp = Path(directory) / "test.cpp"
binary = Path(directory) / ("test.exe" if os.name == "nt" else "test")
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",
*sanitizers, *flags, "-I", str(ROOT / "src"), str(cpp), "-o", str(binary)],
capture_output=True, text=True, timeout=30)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
ran = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10)
self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr)
def test_xtal_esp32_init_fsk_routing_failures_and_lora_restore(self):
self.run_methods("-DLR2021_TCXO_VOLTAGE=0.0f", "-DLR2021_USE_DCDC=1")
def test_tcxo_nrf52_init_fsk_routing_failures_and_lora_restore(self):
self.run_methods("-DNRF52_PLATFORM=1", "-DLR2021_TCXO_VOLTAGE=1.6f",
"-DLR2021_RX_BOOSTED_GAIN=1", "-DLR2021_USE_DCDC=1")
def test_default_tcxo_keeps_existing_regulator_and_routing_policy(self):
self.run_methods()
def test_explicit_ldo_remains_off_across_lora_gfsk_and_recovery(self):
self.run_methods("-DNRF52_PLATFORM=1", "-DLR2021_USE_DCDC=0")
def test_only_schematic_verified_w12_opts_in(self):
opted_in = []
for recipe in sorted((ROOT / "variants").glob("*/platformio.ini")):
if re.search(r"(?m)^\s*-D\s+LR2021_USE_DCDC=1\b", recipe.read_text()):
opted_in.append(recipe.parent.name)
self.assertEqual(opted_in, ["meshnology_w12"])
if __name__ == "__main__":
unittest.main()
+131
View File
@@ -0,0 +1,131 @@
"""Execute the X1 battery sampling method without hardware or PlatformIO.
The existing board contract drives its dedicated divider gate HIGH to sample
and LOW at shutdown. Verify balanced sampling without changing that polarity,
the existing 10 ms settling interval, or the global ADC defaults.
"""
from pathlib import Path
import os
import shutil
import subprocess
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
HEADER = ROOT / "variants/meshtracker_x1/MeshTrackerX1Board.h"
def sampling_source(mutation=None):
method = extract_braced(HEADER.read_text(), "uint16_t getBattMilliVolts() override")
if mutation == "gate-left-powered":
method = method.replace("digitalWrite(PIN_BAT_ADC_EN, LOW);", "// negative control")
if mutation == "invalid-return-before-cleanup":
method = method.replace("const int raw = analogRead(BATTERY_PIN);",
"const int raw = analogRead(BATTERY_PIN);\n"
" if (raw < 0 || raw > 4095) return 0;")
method = method.replace(" override", "")
return r'''
#include <cassert>
#include <cstdint>
#include <string>
#include <vector>
#define LOW 0
#define HIGH 1
#define AR_DEFAULT 10
#define AR_INTERNAL_3_0 30
#define ADC_MULTIPLIER 2.0f
#define AREF_VOLTAGE 3.0f
struct Event { std::string name; int value; };
static std::vector<Event> events;
static int level=LOW,reference=AR_DEFAULT,resolution=10,raw=0;
void digitalWrite(int pin,int value) {
assert(pin==38);level=value;events.push_back({"gate",value});
}
void analogReference(int value) {
reference=value;events.push_back({"reference",value});
}
void analogReadResolution(int value) {
resolution=value;events.push_back({"resolution",value});
}
void delay(unsigned ms) {
assert(ms==10);events.push_back({"settle",static_cast<int>(ms)});
}
int analogRead(int pin) {
assert(pin==2 && reference==AR_INTERNAL_3_0 && resolution==12);
#ifdef PIN_BAT_ADC_EN
assert(level==HIGH);
#endif
assert(!events.empty() && events.back().name=="settle");
events.push_back({"sample",raw});return raw;
}
class Board { public: @METHOD@ };
int main() {
Board board;
#ifdef BATTERY_PIN
for(int value:{0,1,2048,2731,3000,4095,-1,4096,65535}) {
events.clear();raw=value;level=LOW;reference=AR_DEFAULT;resolution=10;
const uint16_t result=board.getBattMilliVolts();
assert(reference==AR_DEFAULT && resolution==10);
const uint16_t expected=(value<0 || value>4095)?0:
static_cast<uint16_t>((value*ADC_MULTIPLIER*AREF_VOLTAGE)/4096*1000);
assert(result==expected);
#ifdef PIN_BAT_ADC_EN
assert(level==LOW && events.size()==8);
assert(events[0].name=="gate" && events[0].value==HIGH);
assert(events[5].name=="gate" && events[5].value==LOW);
#else
assert(events.size()==6);
for(const Event& event:events) assert(event.name!="gate");
#endif
assert(events[events.size()-2].name=="reference");
assert(events.back().name=="resolution");
}
#else
assert(board.getBattMilliVolts()==0 && events.empty());
#endif
}
'''.replace("@METHOD@", method)
class MeshTrackerX1BatteryTests(unittest.TestCase):
def run_case(self, defines, mutation=None, expected_success=True):
compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++")
self.assertIsNotNone(compiler, "a host C++11 compiler is required")
with tempfile.TemporaryDirectory(prefix="meshtracker-x1-battery-") as directory:
cpp = Path(directory) / "test.cpp"
binary = Path(directory) / ("test.exe" if os.name == "nt" else "test")
cpp.write_text(sampling_source(mutation), encoding="utf-8")
sanitizer = [] if os.name == "nt" else ["-fsanitize=address,undefined",
"-fno-sanitize-recover=all", "-fno-pie", "-no-pie"]
build = subprocess.run([compiler, "-std=c++11", "-Wall", "-Wextra", "-Werror",
*sanitizer, *["-D"+flag for flag in defines], str(cpp), "-o", str(binary)],
text=True, capture_output=True, timeout=30)
self.assertEqual(build.returncode, 0, build.stdout+build.stderr)
run = subprocess.run([str(binary)], text=True, capture_output=True, timeout=10)
if expected_success:
self.assertEqual(run.returncode, 0, run.stdout+run.stderr)
else:
self.assertNotEqual(run.returncode, 0, "negative control unexpectedly passed")
def test_balanced_gate_valid_invalid_repeated_samples_and_adc_defaults(self):
self.run_case(("BATTERY_PIN=2", "PIN_BAT_ADC_EN=38"))
def test_optional_gate_absent_still_preserves_adc_defaults(self):
self.run_case(("BATTERY_PIN=2",))
def test_no_battery_pin_has_no_adc_or_gpio_side_effects(self):
self.run_case(())
def test_old_always_powered_gate_negative_control(self):
self.run_case(("BATTERY_PIN=2", "PIN_BAT_ADC_EN=38"),
mutation="gate-left-powered", expected_success=False)
def test_invalid_adc_early_return_negative_control(self):
self.run_case(("BATTERY_PIN=2", "PIN_BAT_ADC_EN=38"),
mutation="invalid-return-before-cleanup", expected_success=False)
if __name__ == "__main__":
unittest.main()
+6
View File
@@ -43,6 +43,12 @@ SCENARIOS = (
"new peer cannot receive previous peer synchronous or retained async output",
"partial record from a replaced peer cannot concatenate with new input",
"concurrent worker replacement cannot steal a record's response owner",
"never-started terminal shutdown leaves storage bus pads untouched",
"terminal DHCP timeout retains live SPI without task deletion or preference changes",
"online terminal shutdown completes owner handoff and is idempotent",
"terminal release refusal remains fail-closed until OTA owner permits handoff",
"terminal renewal timeout retains active worker and SPI until cooperative completion",
"terminal stop deadline survives millis rollover",
)
+8
View File
@@ -67,6 +67,7 @@ def lifecycle_harness(gps=True, negative=None):
"void EnvironmentSensorManager::applyWisBlockRailPower()",
"uint8_t EnvironmentSensorManager::detectWisBlockStorageHolds()",
"void EnvironmentSensorManager::configureSharedPeripheralMode(",
"bool EnvironmentSensorManager::canRemoveSharedStoragePowerForDeepSleep() const",
"bool EnvironmentSensorManager::isGpsPowered()",
"void EnvironmentSensorManager::rakGPSInit()",
"bool EnvironmentSensorManager::gpsIsAwake(",
@@ -77,6 +78,7 @@ def lifecycle_harness(gps=True, negative=None):
"bool EnvironmentSensorManager::gpsSerialTransportMayConflict(",
"bool EnvironmentSensorManager::gpsSerialTransportCanYield(",
"bool EnvironmentSensorManager::setGpsSerialTransportBlocked(",
"void EnvironmentSensorManager::shutdownGpsTransportForDeepSleep()",
"void EnvironmentSensorManager::loop()",
)
if not gps:
@@ -199,6 +201,12 @@ class RakGpsPowerTests(unittest.TestCase):
with self.subTest(store=store):
compile_lifecycle(self, ("fixed_storage",), ("-DRAK_4631", "-D" + store), auto_store=False)
def test_terminal_storage_query_is_frozen_read_only_and_fail_closed(self):
for defines in (("-DRAK_4631",), ("-DRAK_3401",),
("-DRAK_4631", "-DETHERNET_ENABLED=1")):
with self.subTest(defines=defines):
compile_lifecycle(self, ("terminal_storage",), defines)
if __name__ == "__main__":
unittest.main()
+283
View File
@@ -0,0 +1,283 @@
"""Run actual RAK terminal shutdown hooks; no RF/current measurement or PIO."""
from pathlib import Path
import unittest
from test_nrf52_shutdown_leds import compile_source, lifecycle_source
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
BOARDS = (("rak4631", "RAK4631Board", 37), ("rak3401", "RAK3401Board", 21))
POE_MACROS = ("ETHERNET_ENABLED", "ETHERNET_USE_RAK13800", "RAK4631_COMBINED_ETHERNET")
def source_for(variant, board, enable_pin, *, mutation=None):
cpp = (ROOT / f"variants/{variant}/{board}.cpp").read_text()
header = (ROOT / f"variants/{variant}/{board}.h").read_text()
if "void shutdownPeripherals() override;" not in header:
raise AssertionError("RAK must retain the inherited power-off dispatch hook")
shutdown = extract_braced(cpp, f"void {board}::shutdownPeripherals()")
if mutation == "no-board-override":
shutdown = f"void {board}::shutdownPeripherals() {{ NRF52Board::shutdownPeripherals(); }}"
elif mutation == "ethernet-keeps-rail-on":
rail = "PIN_3V3_EN" if variant == "rak3401" else "WB_IO2"
shutdown = shutdown.replace(f"digitalWrite({rail}, LOW);",
f"#ifndef ETHERNET_ENABLED\n digitalWrite({rail}, LOW);\n#endif")
elif mutation == "wire-left-active":
shutdown = shutdown.replace("Wire.end();", "// negative control")
elif mutation == "spi1-ended-unstarted":
shutdown = shutdown.replace("SPI1.begin();", "// negative control")
elif mutation == "external-radio-pad-left-driven":
shutdown = shutdown.replace("pinMode(P_LORA_NSS, INPUT);", "// negative control")
elif mutation == "ignore-storage-hold":
shutdown = shutdown.replace("sensors.canRemoveSharedStoragePowerForDeepSleep()", "true")
protective = extract_braced(cpp, f"void {board}::initiateShutdown(uint8_t reason)")
# Reuse the real base shutdown, deferred-LED-owner model and Arduino stub.
# Remove its main only; the RAK cases exercise inherited virtual dispatch.
source = lifecycle_source().split("int main() {", 1)[0]
if variant == "rak3401":
# The real external-radio nets alias RAK13800 SPI/CS/reset. Synthetic
# private-radio pins would conceal unsafe timeout fallback operations.
for name, old, actual in (("RESET",40,4),("BUSY",41,9),("NSS",42,26),
("DIO_1",43,10),("SCLK",44,3),("MISO",45,29),("MOSI",46,30)):
source = source.replace(f"#define P_LORA_{name} {old}", f"#define P_LORA_{name} {actual}")
source = source.replace("#define PIN_3V3_EN 17", "#define PIN_3V3_EN 34")
source = source.replace(
"struct SensorsMock { LocationMock* getLocationProvider() { return &location; } } sensors;",
"""struct SensorsMock {
bool storage_safe=true;
bool canRemoveSharedStoragePowerForDeepSleep() const { return storage_safe; }
LocationMock* getLocationProvider() { return &location; }
} sensors;""")
source = source.replace(
'struct SpiMock { void setPins(int,int,int) {} void begin() { event("spi"); } } SPI;',
'''struct SpiMock {
bool begun=false;
void setPins(int,int,int) {}
void begin() { event("spi");begun=true; }
void end() { assert(begun);event("spi-end");begun=false; }
} SPI;''')
source += f"\n#define SX126X_POWER_EN {enable_pin}\n#define WB_IO2 PIN_3V3_EN\n"
if variant == "rak3401":
source += "#define RAK_EXTERNAL_RADIO 1\n"
source += r'''
#include <helpers/nrf52/TerminalPeripheralShutdown.h>
#define PIN_GPS_TX 15
#define PIN_GPS_RX 16
#define PIN_WIRE_SDA 13
#define PIN_WIRE_SCL 14
#define SHUTDOWN_REASON_LOW_VOLTAGE 0x4c
#define SHUTDOWN_REASON_BOOT_PROTECT 0x42
struct { uint8_t lpcomp_ain_channel=3,lpcomp_refsel=4; } power_config;
struct UartMock {
bool begun=false;
explicit operator bool() const { return begun; }
void end() {
assert(begun); // the real Adafruit end() is unsafe before begin()
event("gps-uart-end");begun=false;
}
} Serial1;
struct {
bool begun=false;
void begin() { event("spi1-begin");begun=true; }
void end() { assert(begun);event("spi1-end");begun=false; }
} SPI1;
struct WireMock { void end() { event("wire-end"); } } Wire;
bool ethernet_can_stop=true;
bool stopEthernet(void*,uint32_t timeout) {
assert(timeout==500);event("ethernet-stop");return ethernet_can_stop;
}
'''
source += f'''
class {board} : public NRF52Board {{
public:
void shutdownPeripherals() override;
void initiateShutdown(uint8_t reason);
void configureVoltageWake(uint8_t,uint8_t) {{ event("voltage-wake"); }}
}};
'''
source += shutdown + "\n" + protective
source += r'''
size_t lastMode(int pin) {
size_t found=events.size();
for(size_t i=0;i<events.size();++i)
if(events[i].name=="mode"&&events[i].pin==pin) found=i;
return found;
}
static void verifyRakShutdown(bool uart_started,const char* exit_event) {
assert(first("ethernet-stop")<first("radio-off"));
assert(first("radio-off")<first("gps-off"));
assert(first("gps-off")<lastWrite(SX126X_POWER_EN));
assert(levels[SX126X_POWER_EN]==LOW);
assert(events[lastWrite(SX126X_POWER_EN)].value==LOW);
assert(events[lastMode(SX126X_POWER_EN)].value==OUTPUT);
#if ENV_INCLUDE_GPS
assert((first("gps-uart-end")<events.size())==uart_started);
if(uart_started) assert(first("gps-off")<first("gps-uart-end"));
for(int pin:{PIN_GPS_TX,PIN_GPS_RX}) {
assert(lastMode(pin)<events.size());
assert(events[lastMode(pin)].value==INPUT);
assert(first("gps-off")<lastMode(pin));
}
#else
(void)uart_started;
assert(first("gps-uart-end")==events.size());
assert(lastMode(PIN_GPS_TX)==events.size());
assert(lastMode(PIN_GPS_RX)==events.size());
#endif
assert(first("gps-off")<first("wire-end"));
for(int pin:{PIN_WIRE_SDA,PIN_WIRE_SCL}) {
assert(first("wire-end")<lastMode(pin));
assert(events[lastMode(pin)].value==INPUT);
assert(lastMode(pin)<lastWrite(WB_IO2));
}
#ifdef RAK_EXTERNAL_RADIO
assert(!SPI.begun&&!SPI1.begun);
assert(first("radio-off")<first("spi-end"));
assert(first("spi1-begin")<first("spi1-end"));
for(int pin:{P_LORA_NSS,P_LORA_RESET,P_LORA_SCLK,P_LORA_MOSI,P_LORA_MISO}) {
assert(first("spi-end")<lastMode(pin));
assert(first("spi1-end")<lastMode(pin));
assert(events[lastMode(pin)].value==INPUT);
assert(lastMode(pin)<lastWrite(WB_IO2));
}
#else
assert(first("spi-end")==events.size());
assert(first("spi1-begin")==events.size());
#endif
// RAK19007/RAK13800 schematics: PoE 5V enters VCC_IN independently of Q7's
// 3V3_S switch. A compiled Ethernet feature must not hold that switch on.
assert(levels[WB_IO2]==LOW);
assert(first("gps-off")<lastWrite(WB_IO2));
assert(lastWrite(SX126X_POWER_EN)<lastWrite(WB_IO2));
#if ENV_INCLUDE_GPS
assert(lastMode(PIN_GPS_TX)<lastWrite(WB_IO2));
assert(lastMode(PIN_GPS_RX)<lastWrite(WB_IO2));
#endif
verify(false,true,exit_event);
}
int main() {
@BOARD@ board;
assert(mesh::nrf52::registerTerminalEthernetShutdown(stopEthernet,nullptr));
for(bool ble_started:{false,true}) for(bool connected:{false,true})
for(bool softdevice:{false,true}) for(bool uart_started:{false,true}) {
for(unsigned mode=0;mode<4;++mode) {
prepare(ble_started,connected,softdevice);
// Start with both power gates asserted, matching a fully running RAK.
levels[WB_IO2]=levels[SX126X_POWER_EN]=HIGH;
Serial1.begun=uart_started;SPI.begun=false;SPI1.begun=false;
try {
if(mode==0) board.shutdownPeripherals();
if(mode==1) board.powerOff();
if(mode==2) board.enterDeepSleep(0);
if(mode==3) board.enterDeepSleep(10);
} catch(const Exit&) {}
const char* exit_event=mode==0?nullptr:(mode==3?"timed-sleep":"systemoff");
verifyRakShutdown(uart_started,exit_event);
// Repeated terminal shutdown must not end an already stopped UART.
if(mode==0) { board.shutdownPeripherals();verifyRakShutdown(uart_started,nullptr); }
}
}
// A vendor DHCP call may outlive the bounded Ethernet stop. Do not remove
// the still-live bus supply (or remap RAK3401's shared SPI pins) underneath it.
prepare(false,false,true);Serial1.begun=false;
levels[WB_IO2]=HIGH;ethernet_can_stop=false;
board.shutdownPeripherals();
assert(levels[WB_IO2]==HIGH&&lastWrite(WB_IO2)==events.size());
assert(first("ethernet-stop")<first("radio-off"));
assert(first("spi-end")==events.size()&&first("spi1-begin")==events.size());
#ifdef RAK_EXTERNAL_RADIO
assert(first("spi")==events.size()&&first("radio-off")==events.size());
for(int pin:{3,29,30,26,21,4}) {
assert(lastMode(pin)==events.size()&&lastWrite(pin)==events.size());
}
#endif
ethernet_can_stop=true;
// Frozen storage/unknown discovery retains power and the NOR's driven
// CS31/header15/16/17 pads; do not reinterpret those pads as a GPS UART.
prepare(false,false,true);Serial1.begun=true;sensors.storage_safe=false;
levels[WB_IO2]=levels[31]=levels[15]=levels[16]=levels[17]=HIGH;
board.shutdownPeripherals();
assert(levels[WB_IO2]==HIGH&&lastWrite(WB_IO2)==events.size());
assert(first("gps-uart-end")==events.size()&&Serial1.begun);
for(int pin:{15,16,17,31}) {
assert(levels[pin]==HIGH&&lastMode(pin)==events.size()&&lastWrite(pin)==events.size());
}
assert(first("spi-end")==events.size()&&first("spi1-begin")==events.size());
sensors.storage_safe=true;
// Boot protection intentionally bypasses normal SPI/GPS shutdown: the
// application peripherals may not have begun. Preserve that established path.
for(uint8_t reason:{SHUTDOWN_REASON_LOW_VOLTAGE,SHUTDOWN_REASON_BOOT_PROTECT}) {
prepare(false,false,true);Serial1.begun=false;
try { board.initiateShutdown(reason); } catch(const Exit&) {}
assert(first("spi")==events.size()&&first("radio-off")==events.size());
assert(first("gps-uart-end")==events.size());
assert(first("voltage-wake")<first("systemoff"));
assert(levels[SX126X_POWER_EN]==LOW);
}
}
'''.replace("@BOARD@", board)
return source
class RakShutdownPowerTests(unittest.TestCase):
def test_explicit_poweroff_and_deep_sleep_stop_rails_after_common_shutdown(self):
for variant, board, enable_pin in BOARDS:
with self.subTest(board=board):
compile_source(self, source_for(variant, board, enable_pin), ("ENV_INCLUDE_GPS=1",))
def test_ethernet_build_aliases_do_not_hold_the_switched_rail_on(self):
for variant, board, enable_pin in BOARDS:
for macro in POE_MACROS:
with self.subTest(board=board, macro=macro):
compile_source(self, source_for(variant, board, enable_pin),
("ENV_INCLUDE_GPS=1", macro+"=1"))
def test_no_gps_builds_do_not_touch_a_possible_serial1_owner(self):
for variant, board, enable_pin in BOARDS:
with self.subTest(board=board):
compile_source(self, source_for(variant, board, enable_pin), ("ENV_INCLUDE_GPS=0",))
def test_original_inherited_shutdown_negative_control_keeps_power_on(self):
for variant, board, enable_pin in BOARDS:
with self.subTest(board=board):
compile_source(self, source_for(variant, board, enable_pin, mutation="no-board-override"),
("ENV_INCLUDE_GPS=1",), expect_success=False)
def test_ethernet_compile_guard_negative_control_leaves_sensors_powered(self):
for variant, board, enable_pin in BOARDS:
with self.subTest(board=board):
compile_source(self, source_for(variant, board, enable_pin, mutation="ethernet-keeps-rail-on"),
("ENV_INCLUDE_GPS=1", "ETHERNET_ENABLED=1"), expect_success=False)
def test_i2c_shutdown_negative_control_leaves_internal_pulls_and_controller(self):
for variant, board, enable_pin in BOARDS:
with self.subTest(board=board):
compile_source(self, source_for(variant, board, enable_pin, mutation="wire-left-active"),
("ENV_INCLUDE_GPS=1",), expect_success=False)
def test_external_radio_shutdown_must_release_spi_owner_and_chip_select(self):
variant, board, enable_pin = BOARDS[1]
for mutation in ("spi1-ended-unstarted", "external-radio-pad-left-driven"):
with self.subTest(mutation=mutation):
compile_source(self, source_for(variant, board, enable_pin, mutation=mutation),
("ENV_INCLUDE_GPS=1",), expect_success=False)
def test_storage_hold_negative_control_cuts_recovery_power_and_header_pads(self):
for variant, board, enable_pin in BOARDS:
with self.subTest(board=board):
compile_source(self, source_for(variant, board, enable_pin, mutation="ignore-storage-hold"),
("ENV_INCLUDE_GPS=1",), expect_success=False)
def test_no_ordinary_sleep_or_runtime_shared_power_policy_override(self):
for variant, board, _ in BOARDS:
header = (ROOT/f"variants/{variant}/{board}.h").read_text()
self.assertNotIn("void sleep(", header)
self.assertNotIn("void enterDeepSleep(", header)
source = (ROOT/f"variants/{variant}/{board}.cpp").read_text()
protective = extract_braced(source, f"void {board}::initiateShutdown(uint8_t reason)")
self.assertNotIn("shutdownPeripherals", protective)
self.assertNotIn("Serial1", protective)
if __name__ == "__main__":
unittest.main()
+288
View File
@@ -0,0 +1,288 @@
"""Host-test the actual bounded Station G3 pre-radio USB recovery implementation."""
from pathlib import Path
import os
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
SOURCE = ROOT / "variants/station_g3_esp32"
USB_HEADER = r'''#pragma once
#include <cstddef>
#include <cstdint>
class Stream {
public:
virtual int available() = 0;
virtual int read() = 0;
virtual size_t write(const uint8_t*, size_t) = 0;
virtual ~Stream() = default;
};
namespace mesh {
void serviceUsbLoggingPort();
void serviceUsbTerminalPort();
bool takeUsbTerminalSessionReset();
bool tryCompleteUsbTerminalSessionReset();
Stream& usbConsolePort();
}
'''
BOARD_HEADER = r'''#pragma once
#include <cstdint>
class StationG3Board {
public:
bool handleCommand(const char*, uint32_t, char*);
void reboot();
};
'''
HARNESS = r'''
#include "StationG3BootRecovery.h"
#include "StationG3Board.h"
#include <helpers/UsbLogging.h>
#include <algorithm>
#include <cassert>
#include <cstdlib>
#include <cstring>
#include <iostream>
#include <limits>
#include <string>
#include <vector>
struct Port : Stream {
std::string input, output;
size_t at = 0, readCount = 0, writeCount = 0;
size_t txLimit = std::numeric_limits<size_t>::max();
bool transientReadFailure = false;
int available() override { return static_cast<int>(input.size() - at); }
int read() override {
++readCount;
if (transientReadFailure) { transientReadFailure = false; return -1; }
return at < input.size() ? static_cast<unsigned char>(input[at++]) : -1;
}
size_t write(const uint8_t* bytes, size_t size) override {
++writeCount;
const size_t count = std::min(size, txLimit);
output.append(reinterpret_cast<const char*>(bytes), count);
return count;
}
void feed(const std::string& bytes) { input += bytes; }
} port;
static bool reset = false, resetReady = true, saveFails = false, handled = true;
static unsigned logs = 0, terminals = 0, reboots = 0;
static std::vector<std::string> commands;
static std::string saved = "auto";
namespace mesh {
void serviceUsbLoggingPort() { ++logs; }
void serviceUsbTerminalPort() { ++terminals; }
bool takeUsbTerminalSessionReset() { const bool result = reset; reset = false; return result; }
bool tryCompleteUsbTerminalSessionReset() { return resetReady; }
Stream& usbConsolePort() { return port; }
}
bool StationG3Board::handleCommand(const char* command, uint32_t stamp, char* reply) {
assert(stamp == 0);
commands.emplace_back(command);
if (!handled) return false;
if (std::strncmp(command, "set radio.slot ", 15) == 0) {
if (saveFails) { std::strcpy(reply, "Error: slot could not be saved"); return true; }
saved = command + 15;
std::strcpy(reply, "OK - saved; reboot required");
} else std::strcpy(reply, "saved=auto active=none");
return true;
}
void StationG3Board::reboot() { ++reboots; }
static StationG3Board board;
void tick(bool first = false) { station_g3::serviceBootRecovery(board, first); }
void drain(unsigned ticks = 50) { while (ticks--) tick(); }
void fresh() { tick(true); port.output.clear(); port.writeCount = port.readCount = 0; }
int main(int argc, char** argv) {
assert(argc == 2);
const int scenario = std::atoi(argv[1]);
fresh();
if (scenario == 0) {
port.feed("!g3 get radio.slot\r\n!g3 set radio.slot 2\n");
tick();
assert(commands.size() == 1 && commands[0] == "get radio.slot");
assert(port.output == "saved=auto active=none\r\n");
tick();
assert(commands.size() == 2 && saved == "2");
assert(reboots == 0); // Changing slot must not reboot behind the operator.
assert(port.output.find("OK - saved; reboot required\r\n") != std::string::npos);
for (const auto& choice : {"1", "auto"}) {
port.feed(std::string("!g3 set radio.slot ") + choice + "\n"); tick();
assert(saved == choice);
}
port.feed("!g3 reboot\n"); tick(); assert(reboots == 1);
} else if (scenario == 1) {
for (const auto& text : {"get radio.slot", "set radio.slot 2", "reboot", "help",
" !g3 reboot", "!g3reboot", "!G3 reboot"}) {
port.feed(std::string(text) + "\n"); tick();
}
assert(commands.empty() && reboots == 0 && port.output.empty());
for (const auto& text : {"!g3 set radio.slot 0", "!g3 set radio.slot 3",
"!g3 set radio.slot 2 extra", "!g3 set radio.slot 01",
"!g3 set tx 22", "!g3 erase", "!g3 reboot now"}) {
port.feed(std::string(text) + "\n"); tick();
}
assert(commands.empty() && reboots == 0 && saved == "auto");
assert(port.output.find("Error: recovery accepts only") != std::string::npos);
} else if (scenario == 2) {
port.feed("!g3 set radio."); tick(); assert(commands.empty());
port.feed("slot 2"); tick(); assert(commands.empty());
port.feed("\n"); tick(); assert(commands.size() == 1 && saved == "2");
port.feed(std::string(1000, 'x') + "!g3 reboot\n!g3 get radio.slot\n");
const size_t before = port.readCount; tick();
assert(port.readCount - before == 64 && commands.size() == 1);
drain();
assert(reboots == 0 && commands.size() == 2 && commands.back() == "get radio.slot");
} else if (scenario == 3) {
for (int byte : {0, 1, 8, 9, 27, 127, 128, 255}) {
port.feed(std::string("!g3 ") + static_cast<char>(byte) + "reboot\n"); tick();
}
assert(commands.empty() && reboots == 0);
port.feed(std::string(1, char(0xc0)) + char(0) + "\n!g3 reboot\n!g3 set radio.slot 2\n"
+ char(0xc0) + char(0) + "\n!g3 reboot\n" + char(0xc0));
drain();
assert(commands.empty() && reboots == 0 && saved == "auto");
// An explicit Enter following the closing FEND leaves quarantine.
port.feed("\r\n!g3 get radio.slot\n"); drain();
assert(commands.size() == 1 && commands[0] == "get radio.slot");
} else if (scenario == 4) {
port.feed("!g3 set radio.slot "); tick();
reset = true; resetReady = false;
port.feed("2\n"); tick();
assert(commands.empty());
const size_t before = port.readCount;
tick(); assert(port.readCount == before);
resetReady = true; drain();
assert(commands.empty() && saved == "auto");
port.feed("!g3 set radio.slot 1\n"); tick(); assert(saved == "1");
assert(port.output.find("USB recovery is active") != std::string::npos);
} else if (scenario == 5) {
port.txLimit = 0;
tick(true); port.feed("!g3 set radio.slot 2\n");
for (unsigned i = 0; i < 10; ++i) tick();
assert(port.readCount == 0 && commands.empty());
port.txLimit = 3;
drain(150);
assert(commands.size() == 1 && saved == "2");
assert(port.output.find("OK - saved; reboot required\r\n") != std::string::npos);
assert(port.output.find("OK - saved;") == port.output.rfind("OK - saved;"));
} else if (scenario == 6) {
saveFails = true;
port.feed("!g3 set radio.slot 2\n"); tick();
assert(saved == "auto" && reboots == 0);
assert(port.output == "Error: slot could not be saved\r\n");
handled = false; port.output.clear();
port.feed("!g3 get radio.slot\n"); tick();
assert(port.output == "Error: radio.slot recovery unavailable.\r\n");
} else if (scenario == 7) {
port.feed("!g3 help\n"); tick();
assert(commands.empty() && reboots == 0);
for (const auto& text : {"!g3 get radio.slot", "!g3 set radio.slot auto|1|2", "!g3 reboot"})
assert(port.output.find(text) != std::string::npos);
port.feed("!g3 reboot\n"); port.transientReadFailure = true;
const size_t before = port.readCount; tick();
assert(port.readCount == before + 1 && reboots == 0);
tick(); assert(reboots == 1);
} else if (scenario == 8) {
port.feed("!g3 set radio.slot "); tick();
tick(true); port.feed("2\n"); tick();
assert(commands.empty() && saved == "auto");
port.feed("!g3 get radio.slot\n"); tick(); assert(commands.size() == 1);
} else assert(false);
assert(logs != 0 && terminals != 0);
std::cout << "PASS: Station G3 recovery " << scenario << '\n';
}
'''
class StationG3BootRecoveryTests(unittest.TestCase):
def test_all_role_usb_startup_precedes_board_and_radio(self):
compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++")
if compiler is None:
self.skipTest("a host C++ preprocessor is required")
role_paths = ("simple_repeater", "simple_room_server", "simple_sensor",
"companion_radio", "kiss_modem")
for role in role_paths:
with self.subTest(role=role):
source = (ROOT / "examples" / role / "main.cpp").read_text(encoding="utf-8")
setup = source.split("void setup() {", 1)[1].split("void loop() {", 1)[0]
self.assertLess(setup.index("board.begin()"), setup.index("radio_init()"))
before_radio = setup[:setup.index("radio_init()")]
self.assertNotIn("Serial.end(", before_radio)
# Preprocess actual startup: textual index checks alone can
# mistake an excluded #if branch for a functioning USB path.
before_board = setup[:setup.index("board.begin()")]
flags = ["ESP32=1", "STATION_G3_ESP32=1", "ARDUINO_USB_MODE=1",
"ARDUINO_USB_CDC_ON_BOOT=1", "MESH_USB_CONSOLE_COOPERATIVE=1",
"MESH_ESP32_HWCDC_SESSION_GUARD=1"]
for uart in ((False, True) if role == "kiss_modem" else (False,)):
active_flags = flags + (["KISS_UART_RX=16", "KISS_UART_TX=17",
"KISS_HWCDC_TRANSPORT=0"] if uart else [])
if not uart:
active_flags.append("KISS_HWCDC_TRANSPORT=1")
checked = subprocess.run(
[compiler, "-E", "-P", "-x", "c++",
*["-D" + flag for flag in active_flags], "-"],
input=before_board, capture_output=True, text=True, timeout=10,
)
self.assertEqual(checked.returncode, 0, checked.stderr)
compiled = checked.stdout
self.assertEqual(compiled.count("Serial.begin(115200)"), 1)
self.assertEqual(compiled.count("mesh::prepareUsbLoggingPort()"), 1)
self.assertEqual(compiled.count("mesh::beginUsbLoggingPort()"), 1)
self.assertLess(compiled.index("mesh::prepareUsbLoggingPort()"),
compiled.index("Serial.begin(115200)"))
self.assertLess(compiled.index("Serial.begin(115200)"),
compiled.index("mesh::beginUsbLoggingPort()"))
self.assertNotIn("serviceBootRecovery", setup)
self.assertNotIn("serviceBootRecovery", source.split("void loop() {", 1)[1])
def test_actual_parser_and_usb_state_machine(self):
compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++")
if compiler is None:
self.skipTest("a host C++17 compiler is required")
with tempfile.TemporaryDirectory(prefix="g3-boot-recovery-") as folder:
work = Path(folder)
(work / "helpers").mkdir()
for name in ("StationG3BootRecovery.h", "StationG3BootRecovery.cpp"):
# Compile the whole production translation unit, not a copied parser.
shutil.copyfile(SOURCE / name, work / name)
(work / "StationG3Board.h").write_text(BOARD_HEADER, encoding="utf-8")
(work / "helpers/UsbLogging.h").write_text(USB_HEADER, encoding="utf-8")
(work / "test.cpp").write_text(HARNESS, encoding="utf-8")
binary = work / "test.exe"
built = subprocess.run(
[compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-I" + str(work), str(work / "StationG3BootRecovery.cpp"),
str(work / "test.cpp"), "-o", str(binary)],
capture_output=True, text=True, timeout=60,
)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
for scenario in range(9):
with self.subTest(scenario=scenario):
checked = subprocess.run([str(binary), str(scenario)],
capture_output=True, text=True, timeout=10)
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
self.assertIn("PASS: Station G3 recovery", checked.stdout)
def test_recovery_has_no_raw_or_blocking_transport(self):
source = (SOURCE / "StationG3BootRecovery.cpp").read_text(encoding="utf-8")
self.assertNotIn("Serial.", source)
self.assertNotIn("delay(", source)
self.assertNotIn("readString", source)
self.assertNotIn("readBytes", source)
self.assertNotIn(".flush(", source)
self.assertNotIn("new ", source)
self.assertIn("mesh::usbConsolePort()", source)
self.assertIn("mesh::tryCompleteUsbTerminalSessionReset()", source)
self.assertIn("kRxBudget = 64", source)
if __name__ == "__main__":
unittest.main()
+237
View File
@@ -0,0 +1,237 @@
"""Execute generic G3 radio-pin consumers with both boot-selected slots.
The slot selector itself has separate target tests. This fixture deliberately
keeps historical slot-1 build macros while selecting slot 2 at runtime, so a
later SPI reinit or shutdown cannot silently restore the build-time wiring.
"""
from pathlib import Path
import os
import subprocess
import tempfile
import unittest
from cpp_source import body
import test_esp32_gpio_hold as gpio_compiler
ROOT = Path(__file__).resolve().parents[1]
SLOT_API = r'''
#pragma once
#include <cstdint>
namespace station_g3 {
struct RadioPins { uint8_t nss,irq,reset,busy,sck,miso,mosi,lna; };
inline RadioPins slots[]={{11,48,21,47,12,14,13,10},{43,2,44,1,39,41,40,42}};
inline unsigned selected=0;
inline const RadioPins& activePins(){return slots[selected];}
inline bool isRadioPin(uint8_t pin){
for(const auto& p:slots)
if(pin==p.nss || pin==p.irq || pin==p.reset || pin==p.busy
|| pin==p.sck || pin==p.miso || pin==p.mosi || pin==p.lna) return true;
return false;
}
}
'''
PINS = ("P_LORA_NSS=11", "P_LORA_DIO_1=48", "P_LORA_RESET=21",
"P_LORA_BUSY=47", "P_LORA_SCLK=12", "P_LORA_MISO=14",
"P_LORA_MOSI=13", "P_PRIMARY_LNA_EN=10", "P_PA1_EN=9")
class StationG3RadioPinsTests(unittest.TestCase):
def compile_run(self, source, *, station=True, fail=False, files=None,
definitions=()):
with tempfile.TemporaryDirectory(prefix="g3-radio-pins-") as folder:
work = Path(folder)
for name, content in (files or {}).items():
destination = work / name
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_text(content)
# The header is deliberately absent on other-board builds, so
# an accidentally unconditional target include fails compilation.
if station:
(work / "StationG3RadioSlot.h").write_text(SLOT_API)
cpp, binary = work / "test.cpp", work / "test.exe"
cpp.write_text(source)
flags = (*PINS, *definitions, *(("STATION_G3_ESP32=1",) if station else ()))
built = subprocess.run([
os.environ.get("CXX", "g++"), "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-I", str(work), "-I", str(ROOT / "src"),
*("-D" + flag for flag in flags), str(cpp), "-o", str(binary),
], capture_output=True, text=True)
self.assertEqual(built.returncode, 0, built.stderr)
ran = subprocess.run([str(binary)], capture_output=True, text=True)
if fail:
self.assertNotEqual(ran.returncode, 0, "static-pin negative control escaped")
else:
self.assertEqual(ran.returncode, 0, ran.stderr)
def test_sx1262_init_and_recovery_use_selected_spi_without_static_overwrite(self):
source = (ROOT / "src/helpers/radiolib/CustomSX1262.h").read_text()
extracted = body(source, "bool std_init(SPIClass* spi = NULL)")
init = "bool std_init(SPIClass* spi = nullptr) " + extracted[extracted.index("{"):]
harness = r'''
#include <cassert>
#include <cstdint>
#include <cstddef>
#if defined(STATION_G3_ESP32)
#include <StationG3RadioSlot.h>
#endif
#define MESH_DEBUG_PRINTLN(...) ((void)0)
#define LORA_FREQ 909.5
#define LORA_BW 500
#define LORA_SF 5
#define LORA_TX_POWER 2
#define RADIOLIB_ERR_NONE 0
#define RADIOLIB_SX126X_SYNC_WORD_PRIVATE 0x12
struct SPIClass {
unsigned calls=0; int sck=-1,miso=-1,mosi=-1;
void begin(int c,int i,int o){++calls;sck=c;miso=i;mosi=o;}
};
struct Port { void print(const char*){} void println(int){} };
namespace mesh { inline Port& usbLoggingPort(){static Port port;return port;} }
struct Radio {
uint32_t tcxoDelay=1600,_rxDutyCycleTransitionUs=0;
int begin(float,float,int,uint8_t,int,int,int,float,bool){return 0;}
void setCRC(int){}
@INIT@
};
int main(){Radio radio;SPIClass spi;
#if defined(STATION_G3_ESP32)
for(unsigned slot=0;slot<2;++slot){
station_g3::selected=slot;
const auto& pins=station_g3::activePins();
// Initial setup and each watchdog/recovery init must use the same slot.
for(int attempt=0;attempt<3;++attempt){
const unsigned before=spi.calls;
assert(radio.std_init(&spi));assert(spi.calls==before+1);
assert(spi.sck==pins.sck && spi.miso==pins.miso && spi.mosi==pins.mosi);
}
}
#else
assert(radio.std_init(&spi));
assert(spi.calls==1 && spi.sck==12 && spi.miso==14 && spi.mosi==13);
#endif
assert(radio._rxDutyCycleTransitionUs==2600);
const unsigned before=spi.calls;
assert(radio.std_init(nullptr) && spi.calls==before);
}
'''.replace("@INIT@", init)
self.compile_run(harness)
self.compile_run(harness, station=False)
self.assertIn("spi->begin(pins.sck, pins.miso, pins.mosi)", harness)
self.compile_run(harness.replace(
"spi->begin(pins.sck, pins.miso, pins.mosi)",
"(void)pins; spi->begin(P_LORA_SCLK, P_LORA_MISO, P_LORA_MOSI)"), fail=True)
def test_base_boot_and_terminal_shutdown_keep_selected_nss(self):
header = (ROOT / "src/helpers/ESP32Board.h").read_text()
boot = body(header, "void begin()")
boot = boot[boot.index("{") + 1:boot.index("// Arduino's early init hook")]
shutdown = body((ROOT / "src/helpers/ESP32Board.cpp").read_text(),
"void ESP32Board::shutdownPeripherals()")
code = r'''
#include <helpers/ESP32GpioHold.h>
#include <StationG3RadioSlot.h>
#include <cstddef>
#define HIGH 1
static bool powered_off=false;
void delay(unsigned value){assert(value==100);}
struct Radio {void powerOff(){powered_off=true;}} radio_driver;
struct GPS {void stop(){}};
struct Sensors {GPS* getLocationProvider(){return nullptr;} void shutdownGpsTransportForDeepSleep(){}} sensors;
struct Port {void flush(){}} Serial;
class ESP32Board {public:void begin(){@BOOT@} void shutdownPeripherals();};
@SHUTDOWN@
int main(){
for(unsigned slot=0;slot<2;++slot){
station_g3::selected=slot;
const int nss=station_g3::activePins().nss;
ESP32Board board;
// A prior deep-sleep hold must be released with OUTPUT/HIGH restored.
mesh::esp32::holdOutputForDeepSleep(nss,HIGH);
pads[nss].latch=0;pads[nss].output=false;
board.begin();assert(!pads[nss].hold && physical(nss)==HIGH);
powered_off=false;board.shutdownPeripherals();
assert(powered_off && pads[nss].hold && physical(nss)==HIGH);
assert(!deep_hold); // Only final terminal sleep may arm global retention.
}
}
'''.replace("@BOOT@", boot).replace("@SHUTDOWN@", shutdown)
options = dict(s3=1, defines=(*PINS, "STATION_G3_ESP32=1"),
files={"StationG3RadioSlot.h": SLOT_API})
gpio_compiler.GpioHoldTests.compile_run(self, code, **options)
# This negative control models either old NSS branch surviving an
# otherwise successful runtime slot-2 selection.
gpio_compiler.GpioHoldTests.compile_run(self, code.replace(
"station_g3::activePins().nss", "P_LORA_NSS", 2),
helper_mutation=lambda text: text, **options)
def test_runtime_reset_capability_and_other_board_constexpr_are_preserved(self):
source = (ROOT / "src/helpers/radiolib/RadioLibWrappers.h").read_text()
start = source.index("#if defined(STATION_G3_ESP32)\n static bool hasDirectRadioResetPin()")
block = source[start:source.index(" bool prepareRadioHardReset()", start)]
harness = r'''
#include <cassert>
#include <cstdint>
#define RADIOLIB_NC (-1)
#if defined(STATION_G3_ESP32)
#include <StationG3RadioSlot.h>
#endif
struct Wrapper {
@BLOCK@
};
int main(){
#if defined(STATION_G3_ESP32)
for(unsigned slot=0;slot<2;++slot){
station_g3::selected=slot;
assert(Wrapper::hasDirectRadioResetPin());
const auto saved=station_g3::slots[slot].reset;
station_g3::slots[slot].reset=255;
assert(!Wrapper::hasDirectRadioResetPin());
station_g3::slots[slot].reset=saved;
}
#else
static_assert(Wrapper::hasDirectRadioResetPin(),"other boards keep compile-time capability");
#endif
}
'''.replace("@BLOCK@", block)
self.compile_run(harness)
self.compile_run(harness, station=False)
def test_user_gpio_reserves_both_slots_independent_of_selected_slot(self):
harness = r'''
#include <helpers/UserGpioPinPolicy.h>
#include <cassert>
#include <cstdint>
#if defined(STATION_G3_ESP32)
#include <StationG3RadioSlot.h>
#endif
int main(){
#if defined(STATION_G3_ESP32)
const uint8_t pins[]={11,48,21,47,12,14,13,10,43,2,44,1,39,41,40,42};
for(unsigned slot=0;slot<2;++slot){
station_g3::selected=slot;
for(uint8_t pin:pins) assert(UserGpioPinPolicy::isFirmwareReserved(pin));
for(uint8_t pin:{uint8_t(22),uint8_t(23),uint8_t(24),uint8_t(25)})
assert(!UserGpioPinPolicy::isFirmwareReserved(pin));
}
#else
assert(UserGpioPinPolicy::isFirmwareReserved(11));
assert(!UserGpioPinPolicy::isFirmwareReserved(43));
#endif
assert(UserGpioPinPolicy::isFirmwareReserved(9)); // Shared PA remains reserved.
}
'''
harness = "#include <initializer_list>\n" + harness
self.compile_run(harness)
self.compile_run(harness, station=False)
policy = (ROOT / "src/helpers/UserGpioPinPolicy.h").read_text()
self.assertIn("if (station_g3::isRadioPin(pin)) return true;", policy)
self.compile_run(harness, fail=True, files={
"helpers/UserGpioPinPolicy.h": policy.replace(
"if (station_g3::isRadioPin(pin)) return true;", ""),
"helpers/sensors/EnvironmentI2CConfig.h":
(ROOT / "src/helpers/sensors/EnvironmentI2CConfig.h").read_text(),
})
if __name__ == "__main__":
unittest.main()
+237
View File
@@ -0,0 +1,237 @@
"""Run real G3 probe/boot selection with inert SPI; never contacts hardware."""
from pathlib import Path
import os
import shutil
import subprocess
import tempfile
import unittest
from cpp_source import body
ROOT = Path(__file__).resolve().parents[1]
VARIANT = ROOT / "variants/station_g3_esp32"
def harness(mutation=None):
source = (VARIANT / "target.cpp").read_text()
methods = "\n".join(body(source, signature) for signature in (
"bool waitForProbeReady(", "bool probeRadioSlot(",
"bool initializeSlot(", "bool radio_init()"))
if mutation == "accept-empty":
methods = methods.replace("memcmp(version, RADIOLIB_SX1262_CHIP_TYPE, 6) == 0", "true")
if mutation == "reboot-loop":
methods = methods.replace("station_g3::serviceBootRecovery(board, firstNotice);", "return false;")
if mutation == "no-busy-settle":
guard = "delayMicroseconds(1);"
assert guard in methods, "production post-command BUSY guard missing"
methods = methods.replace(guard, "", 1)
return r'''
#include <cassert>
#include <cstdio>
#include <cstdlib>
#include <cstdint>
#include <cstring>
#include <vector>
#include "StationG3RadioSlot.h"
// Negative controls intentionally fail. Do not let Windows' CRT abort dialog
// turn a useful assertion into a hanging GUI process / locked temp executable.
#undef assert
#define assert(condition) do { if(!(condition)) { \
std::fprintf(stderr,"fixture assertion line %u: %s\n",unsigned(__LINE__),#condition); \
std::exit(91); } } while(false)
#define LOW 0
#define HIGH 1
#define OUTPUT 1
#define INPUT 2
#define INPUT_PULLDOWN 3
#define MSBFIRST 1
#define SPI_MODE0 0
#define MESH_USB_CONSOLE_COOPERATIVE 1
#define RADIOLIB_SX126X_CMD_SET_STANDBY 0x80
#define RADIOLIB_SX126X_STANDBY_RC 0
#define RADIOLIB_SX126X_CMD_READ_REGISTER 0x1d
#define RADIOLIB_SX126X_REG_VERSION_STRING 0x320
#define RADIOLIB_SX1262_CHIP_TYPE "SX1261"
static unsigned now_ms=0,present=0,working=0,stuck=0,reads=0,recoveries=0,rx=0,remembered=0;
static uint64_t now_us=0,busy_asserts_at[3]={},busy_clears_at[3]={};
static bool busy_scheduled[3]={};
static unsigned settle_guards=0,busy_high_samples=0;
static bool returned=false;
static int levels[64];
static std::vector<unsigned> initializations;
uint32_t millis() { return now_ms; }
void delay(unsigned ms) { now_us+=uint64_t(ms)*1000;now_ms=now_us/1000; }
void delayMicroseconds(unsigned us) { assert(us==1);++settle_guards;now_us+=us;now_ms=now_us/1000; }
void pinMode(int,int) {}
void digitalWrite(int pin,int level) {
assert(pin>=0 && pin<64);levels[pin]=level;
for(unsigned slot=1;slot<=2;++slot)
if(pin==station_g3::pinsForSlot(slot).reset && level==LOW) busy_scheduled[slot]=false;
}
int digitalRead(int pin) {
for(unsigned slot=1;slot<=2;++slot) {
if(pin!=station_g3::pinsForSlot(slot).busy) continue;
if(stuck&(1<<(slot-1))) return HIGH;
// Immediately after NSS rises BUSY still reads LOW. It asserts only
// after the guard interval, then remains HIGH during command handling.
if(busy_scheduled[slot] && now_us>=busy_asserts_at[slot] && now_us<busy_clears_at[slot]) {
++busy_high_samples;return HIGH;
}
return LOW;
}
assert(false);return 0;
}
struct SPISettings { SPISettings(unsigned hz,int order,int mode) {assert(hz==2000000 && order==MSBFIRST && mode==SPI_MODE0);} };
struct SPI {
unsigned slot=0,index=0,command=0;
bool transaction=false;
void end() { assert(!transaction);slot=0; }
void begin(int sck,int miso,int mosi) {
slot=sck==12?1:2;
const auto& p=station_g3::pinsForSlot(slot);
assert(sck==p.sck && miso==p.miso && mosi==p.mosi);
}
void beginTransaction(SPISettings) {
assert(slot && !transaction);transaction=true;index=0;command=0;
assert(levels[11]==HIGH && levels[43]==HIGH);
}
uint8_t transfer(uint8_t value) {
assert(transaction && levels[station_g3::pinsForSlot(slot).nss]==LOW);
if(index==0) {
command=value;
assert(command==0x80 || command==0x1d); // No SetTx or packet writes!
// Seeing a transient LOW before delayed BUSY assertion is not proof
// that the previous command has completed. Reject an early read even
// when the caller just sampled that pre-assertion LOW.
if(command==0x1d && busy_scheduled[slot])
assert(now_us>=busy_clears_at[slot] && "ReadRegister before delayed BUSY completed");
}
const unsigned offset=index++;
if(command==0x80) { assert(offset==0 || value==0);return 0x22; }
if(offset==1) assert(value==3);
if(offset==2) assert(value==0x20);
if(offset>=3) assert(value==0);
if(offset>=4) {
++reads;
if(!(present&(1<<(slot-1)))) return 0xff;
return offset-4<6?"SX1261"[offset-4]:0;
}
return 0x22;
}
void endTransaction() {
assert(transaction && levels[station_g3::pinsForSlot(slot).nss]==HIGH);
assert(index==(command==0x80?2:20));transaction=false;
if(command==0x80 && (present&(1<<(slot-1)))) {
busy_scheduled[slot]=true;
busy_asserts_at[slot]=now_us+1;
busy_clears_at[slot]=now_us+51;
}
}
} spi;
struct Module { unsigned csPin=0,irqPin=0,rstPin=0,gpioPin=0; } module;
struct Radio {
Module* getMod() { return &module; }
bool std_init(SPI* bus) {
assert(bus==&spi && spi.slot==station_g3::slotState().candidate);
const auto& p=station_g3::activePins();
assert(module.csPin==p.nss && module.irqPin==p.irq && module.rstPin==p.reset && module.gpioPin==p.busy);
initializations.push_back(spi.slot);levels[p.reset]=HIGH;
return working&(1<<(spi.slot-1));
}
} radio;
struct Fem { void setRxModeEnable() { ++rx;assert(station_g3::slotState().active); } };
struct StationG3Board { Fem loRaFEMControl; } board;
struct Clock { void begin() {} template<typename T> void begin(T&) {} } fallback_clock,rtc_clock;
int Wire=0;
const char meshcore_capability_station_g3_auto_slots[]="meshcore.capability.station_g3.auto_slots.v1";
struct Recovery {};
namespace station_g3 {
void rememberActiveSlot(uint8_t slot) { remembered=slot;slotState().last=slot; }
void serviceBootRecovery(StationG3Board&,bool first) { assert(first);++recoveries;throw Recovery(); }
}
namespace mesh { void serviceUsbLoggingPort() {} void serviceUsbTerminalPort() {} }
@METHODS@
void reset(unsigned mask,unsigned successful,unsigned mode,unsigned last,unsigned busy=0) {
station_g3::slotState()=station_g3::SlotState();
station_g3::slotState().bootMode=station_g3::slotState().savedMode=mode;
station_g3::slotState().last=last;
spi.end();initializations.clear();
now_ms=reads=recoveries=rx=remembered=0;present=mask;working=successful;stuck=busy;
now_us=settle_guards=busy_high_samples=0;
for(unsigned slot=1;slot<=2;++slot) {
busy_scheduled[slot]=false;busy_asserts_at[slot]=busy_clears_at[slot]=0;
}
for(int& value:levels) value=HIGH;
returned=false;
}
int main() {
for(unsigned mask=0;mask<4;++mask) for(unsigned ok=0;ok<4;++ok)
for(unsigned mode=0;mode<3;++mode) for(unsigned last=0;last<3;++last) {
reset(mask,ok,mode,last);
const auto before=station_g3::slotState();
unsigned expected=before.choose(mask);
if(expected && !(ok&(1<<(expected-1)))) {
if(mode==0 && mask==3 && (ok&(1<<((expected==1?2:1)-1)))) expected=expected==1?2:1;
else expected=0;
}
try { returned=radio_init(); } catch(const Recovery&) {}
assert(station_g3::slotState().detected==mask);
assert(station_g3::slotState().bootMode==mode && station_g3::slotState().savedMode==mode);
assert(station_g3::slotState().active==expected);
if(!expected) { assert(!returned && recoveries==1 && rx==0 && remembered==0); }
else {
assert(returned && !recoveries && rx==1 && remembered==expected);
const unsigned oldreads=reads;
assert(radio_init()); // runtime recovery stays on selected socket
assert(reads==oldreads && initializations.back()==expected);
assert(levels[station_g3::pinsForSlot(expected==1?2:1).reset]==LOW);
}
assert(now_ms<500);
}
reset(3,3,0,0,1);assert(radio_init());
assert(station_g3::slotState().detected==2 && station_g3::slotState().active==2);
assert(now_ms<500);
// A present device exercises both the 1-us post-NSS guard and the ensuing
// real BUSY wait. Neither a no-op delay shim nor an early LOW is enough.
reset(1,1,0,0);assert(probeRadioSlot(1));
assert(settle_guards==1 && busy_high_samples==1 && reads==16 && now_ms>=12);
assert(!spi.transaction && levels[station_g3::pinsForSlot(1).reset]==LOW);
// Corrupt policy cannot shift outside its mask or silently become auto.
station_g3::slotState().bootMode=255;
assert(station_g3::slotState().choose(3)==0);
}
'''.replace("@METHODS@", methods)
class StationG3DetectionTests(unittest.TestCase):
def run_case(self, mutation=None):
compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++")
self.assertIsNotNone(compiler)
with tempfile.TemporaryDirectory(prefix="g3-slot-detection-") as directory:
cpp=Path(directory)/"test.cpp"
binary=Path(directory)/("test.exe" if os.name=="nt" else "test")
cpp.write_text(harness(mutation),encoding="utf-8")
sanitizer=[] if os.name=="nt" else ["-fsanitize=address,undefined","-fno-pie","-no-pie"]
build=subprocess.run([compiler,"-std=c++17","-Wall","-Wextra",*sanitizer,
"-I",str(VARIANT),str(cpp),"-o",str(binary)],capture_output=True,text=True,timeout=30)
self.assertEqual(build.returncode,0,build.stderr)
run=subprocess.run([str(binary)],capture_output=True,text=True,timeout=10)
if mutation:
self.assertNotEqual(run.returncode,0,"negative control unexpectedly passed")
if mutation=="no-busy-settle":
self.assertIn("ReadRegister before delayed BUSY completed",run.stderr)
else:self.assertEqual(run.returncode,0,run.stdout+run.stderr)
def test_real_probe_selection_matrix_recovery_and_busy_deadlines(self):
self.run_case()
def test_empty_bus_cannot_count_as_radio_negative_control(self):
self.run_case("accept-empty")
def test_no_radio_must_stay_in_usb_recovery_negative_control(self):
self.run_case("reboot-loop")
def test_delayed_busy_requires_post_command_guard_negative_control(self):
self.run_case("no-busy-settle")
if __name__=="__main__":unittest.main()
+388
View File
@@ -0,0 +1,388 @@
"""Run the real G3 NVS adapter and board CLI/startup against a strict SDK fake."""
from pathlib import Path
import os
import subprocess
import tempfile
import unittest
from cpp_source import body
ROOT = Path(__file__).resolve().parents[1]
VARIANT = ROOT / "variants/station_g3_esp32"
NVS = r'''
#pragma once
#include <cassert>
#include <cstdint>
#include <map>
#include <string>
#include <vector>
using esp_err_t=int;
using nvs_handle_t=unsigned;
constexpr int ESP_OK=0, ESP_ERR_NVS_NOT_FOUND=1, ESP_ERR_NVS_TYPE_MISMATCH=2;
constexpr int NVS_READONLY=0, NVS_READWRITE=1;
namespace fake {
struct Entry { int value; bool u8=true; };
inline bool exists=false,eagerWrites=false;
inline std::map<std::string,Entry> disk,pending;
inline unsigned opens=0,closes=0,gets=0,sets=0,commits=0;
inline int openError=0,setError=0,commitError=0,modeReadError=0,lastReadError=0;
inline int openedMode=-1;
inline std::vector<std::string> written;
inline void reset(){
exists=eagerWrites=false;disk.clear();pending.clear();written.clear();
opens=closes=gets=sets=commits=0;
openError=setError=commitError=modeReadError=lastReadError=0;openedMode=-1;
}
inline void put(const char* key,int value,bool u8=true){exists=true;disk[key]={value,u8};}
}
inline int nvs_open(const char* ns,int mode,nvs_handle_t* handle){
assert(std::string(ns)=="g3-radio"); ++fake::opens;
if(fake::openError) return fake::openError;
if(mode==NVS_READONLY && !fake::exists) return ESP_ERR_NVS_NOT_FOUND;
assert(mode==NVS_READONLY || mode==NVS_READWRITE);
fake::openedMode=mode;fake::pending.clear();*handle=7;return ESP_OK;
}
inline int nvs_get_u8(nvs_handle_t handle,const char* key,uint8_t* value){
assert(handle==7); ++fake::gets;
const std::string name(key);assert(name=="mode" || name=="last");
const int injected=name=="mode" ? fake::modeReadError:fake::lastReadError;
if(injected) return injected;
const auto found=fake::disk.find(name);
if(found==fake::disk.end()) return ESP_ERR_NVS_NOT_FOUND;
if(!found->second.u8) return ESP_ERR_NVS_TYPE_MISMATCH;
*value=found->second.value;return ESP_OK;
}
inline int nvs_set_u8(nvs_handle_t handle,const char* key,uint8_t value){
assert(handle==7 && fake::openedMode==NVS_READWRITE);++fake::sets;
const std::string name(key);assert(name=="mode" || name=="last");
fake::written.push_back(name);
if(fake::setError) return fake::setError;
fake::pending[name]={value,true};
// The NVS contract permits storage updates before commit. A commit error
// therefore proves no live binding change, not physical flash rollback.
if(fake::eagerWrites){fake::disk[name]={value,true};fake::exists=true;}
return ESP_OK;
}
inline int nvs_commit(nvs_handle_t handle){
assert(handle==7 && fake::openedMode==NVS_READWRITE);++fake::commits;
if(fake::commitError) return fake::commitError;
for(const auto& item:fake::pending) fake::disk[item.first]=item.second;
fake::pending.clear();fake::exists=true;return ESP_OK;
}
inline void nvs_close(nvs_handle_t handle){
assert(handle==7);++fake::closes;fake::pending.clear();fake::openedMode=-1;
}
'''
BOARD = r'''
#pragma once
#include <cassert>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <utility>
#include <vector>
#include "StationG3RadioSlot.h"
using gpio_num_t=int;
using esp_reset_reason_t=int;
constexpr int HIGH=1,ESP_RST_DEEPSLEEP=5;
constexpr int BD_STARTUP_NORMAL=0,BD_STARTUP_RX_PACKET=1;
namespace fake {
inline int resetReason=0;
inline uint64_t wakeMask=0;
inline unsigned femInit=0,femMutations=0,baseBegins=0;
inline std::vector<std::pair<int,int>> releases;
inline std::vector<int> deinitialized;
inline void resetBoard(){resetReason=0;wakeMask=0;femInit=femMutations=baseBegins=0;releases.clear();deinitialized.clear();}
}
inline int esp_reset_reason(){return fake::resetReason;}
inline uint64_t esp_sleep_get_ext1_wakeup_status(){return fake::wakeMask;}
namespace mesh { namespace esp32 {
inline void releaseOutputAfterDeepSleep(int pin,int level){fake::releases.emplace_back(pin,level);}
inline void deinitRtcGpioIfSupported(int pin){fake::deinitialized.push_back(pin);}
inline void holdOutputForDeepSleep(int,int){}
}}
class KeyValueStore {public:
virtual bool setByKey(const char*,const char*){assert(false);return false;}
virtual bool getByKey(const char*,char*,size_t){assert(false);return false;}
};
class LoRaFEMControl {bool lna=false,pa=false;public:
void init(){
// Both previously held sockets are safe before any FEM startup occurs.
for(int pin:{11,43,10,42}){
bool found=false;for(const auto& item:fake::releases) if(item.first==pin && item.second==HIGH) found=true;
assert(found);
}
++fake::femInit;
}
void setSleepModeEnable(){}
bool canControlLNA()const{return true;} bool canControlPAGain()const{return true;}
void setLNAEnable(bool value){lna=value;++fake::femMutations;}
void setPAGainEnable(bool value){pa=value;++fake::femMutations;}
bool isLNAEnabled()const{return lna;} bool isPAGainEnabled()const{return pa;}
};
class ESP32Board {public:
uint8_t startup_reason=BD_STARTUP_NORMAL;
void begin(){startup_reason=BD_STARTUP_NORMAL;++fake::baseBegins;}
void shutdownPeripherals(){}
virtual uint32_t getIRQGpio(){return uint32_t(-1);}
};
class StationG3Board:public ESP32Board {
KeyValueStore* _prefs=nullptr;
public:
LoRaFEMControl loRaFEMControl;
@BOOT@
@IRQ@
void attachDynamicPrefs(KeyValueStore*);
bool handleCommand(const char*,uint32_t,char*);
bool setLoRaFemLnaEnabled(bool);bool canControlLoRaFemLna()const;bool isLoRaFemLnaEnabled()const;
bool setLoRaFemPaGainEnabled(bool);bool canControlLoRaFemPaGain()const;bool isLoRaFemPaGainEnabled()const;
void shutdownPeripherals();
};
'''
HARNESS = r'''
#include "nvs.h"
#include "StationG3Board.h"
#include <algorithm>
#include <string>
using namespace station_g3;
void reset(){fake::reset();fake::resetBoard();slotState()=SlotState{};}
void unchangedForeign(){
// The strict NVS fake rejects access to ANY namespace except g3-radio and
// ANY key except mode/last. The separate KeyValueStore also rejects calls.
assert(fake::femMutations==0);
for(const auto& key:fake::written) assert(key=="mode" || key=="last");
}
void fresh(){
reset();loadSlotSettings();const auto& s=slotState();
assert(s.loaded && s.settingsHealthy && s.bootMode==0 && s.savedMode==0 && s.last==0);
assert(s.active==0 && s.detected==0 && s.candidate==1);
assert(fake::opens==1 && fake::closes==0 && fake::sets==0 && fake::commits==0);
loadSlotSettings();assert(fake::opens==1);unchangedForeign();
reset();fake::exists=true;loadSlotSettings();assert(slotState().settingsHealthy && fake::closes==1);
}
void valid(){
for(uint8_t mode=0;mode<=2;++mode) for(uint8_t last=0;last<=2;++last){
reset();fake::put("mode",mode);fake::put("last",last);loadSlotSettings();
const auto& s=slotState();assert(s.bootMode==mode && s.savedMode==mode && s.last==last);
assert(s.loaded && s.settingsHealthy && fake::opens==1 && fake::closes==1);
assert(fake::gets==2 && fake::sets==0 && fake::commits==0);unchangedForeign();
}
}
void readErrors(){
reset();fake::openError=77;loadSlotSettings();
assert(slotState().loaded && !slotState().settingsHealthy && slotState().bootMode==0);
assert(fake::closes==0);loadSlotSettings();assert(fake::opens==1);
for(int error:{ESP_ERR_NVS_TYPE_MISMATCH,78}){
reset();fake::put("mode",2);fake::put("last",2);fake::modeReadError=error;
loadSlotSettings();assert(!slotState().settingsHealthy && slotState().bootMode==0 && slotState().savedMode==0 && slotState().last==2);
reset();fake::put("mode",1);fake::put("last",2);fake::lastReadError=error;
loadSlotSettings();assert(!slotState().settingsHealthy && slotState().bootMode==1 && slotState().last==0);
}
reset();fake::put("mode",255);fake::put("last",2);loadSlotSettings();
assert(!slotState().settingsHealthy && slotState().bootMode==0 && slotState().last==2);
reset();fake::put("mode",1);fake::put("last",3);loadSlotSettings();
assert(!slotState().settingsHealthy && slotState().bootMode==1 && slotState().last==0);
reset();fake::put("mode",2,false);loadSlotSettings();assert(!slotState().settingsHealthy && slotState().bootMode==0);
unchangedForeign();
}
void failedSaves(){
for(int stage=0;stage<4;++stage){
reset();fake::put("mode",1);fake::put("last",1);loadSlotSettings();
auto& s=slotState();s.active=s.candidate=1;s.detected=3;
if(stage==0) fake::openError=77;
if(stage==1) fake::setError=78;
if(stage>=2) fake::commitError=79;
if(stage==3) fake::eagerWrites=true;
const unsigned closes=fake::closes,sets=fake::sets,commits=fake::commits;
StationG3Board board;char reply[160]={};
assert(board.handleCommand("set radio.slot 2",0,reply));
assert(std::string(reply).find("could not be saved")!=std::string::npos);
assert(s.bootMode==1 && s.savedMode==1 && s.last==1 && s.active==1 && s.candidate==1 && s.detected==3);
assert(fake::disk.at("mode").value==(stage==3 ? 2:1) && fake::disk.at("last").value==1);
assert(fake::closes==closes+(stage!=0));assert(fake::sets==sets+(stage!=0));
assert(fake::commits==commits+(stage>=2));unchangedForeign();
if(stage==3){
slotState()=SlotState{};loadSlotSettings();
assert(slotState().bootMode==2); // A future boot must trust actual NVS.
}
}
reset();assert(!saveSlotMode(3) && fake::opens==0);
}
void pendingCLI(){
reset();fake::put("mode",1);fake::put("last",1);loadSlotSettings();
auto& s=slotState();s.active=s.candidate=1;s.detected=3;
StationG3Board board;char reply[160]={};
assert(board.handleCommand("set radio.slot 2",123,reply));
assert(std::string(reply)=="OK - radio.slot 2 saved; reboot to apply (active=1)");
assert(s.savedMode==2 && s.bootMode==1 && s.active==1 && s.candidate==1 && s.last==1);
assert(s.choose(3)==1); // Saved setting cannot move the running radio.
assert(board.handleCommand("get radio.slot",0,reply));
assert(std::string(reply)=="> 2; active=1; detected=1,2; reboot required");
unchangedForeign();s=SlotState{};loadSlotSettings();assert(s.bootMode==2 && s.savedMode==2);
assert(s.choose(3)==2);
for(const char* value:{"auto","1","2"}){
const std::string command=std::string("set radio.slot ")+value;
assert(board.handleCommand(command.c_str(),0,reply));assert(std::string(reply).find("OK -")==0);
}
unchangedForeign();
}
void invalidCLI(){
reset();loadSlotSettings();StationG3Board board;char reply[160]={};
for(const char* value:{"","0","3","-1","255","AUTO","Auto","01","2 "," 2","1,2","1\n","2foo"}){
const std::string command=std::string("set radio.slot ")+value;
assert(board.handleCommand(command.c_str(),0,reply));
assert(std::string(reply)=="Error: radio.slot must be auto, 1, or 2");
}
for(const char* command:{"set radio.slot","set radio.slotx 2","get radio.slotx","get radio2","get radio.slot "})
assert(!board.handleCommand(command,0,reply));
assert(!board.handleCommand(nullptr,0,reply) && !board.handleCommand("get radio.slot",0,nullptr));
assert(fake::sets==0 && fake::commits==0 && slotState().savedMode==0);
slotState().settingsHealthy=false;assert(board.handleCommand("get radio.slot",0,reply));
assert(std::string(reply).find("WARN settings unavailable/invalid")!=std::string::npos);
unchangedForeign();
}
void invalidLastWarning(){
for(bool wrongType:{false,true}){
reset();fake::put("mode",1);fake::put("last",wrongType ? 2:255,!wrongType);
loadSlotSettings();assert(!slotState().settingsHealthy);
StationG3Board board;char reply[160]={};
assert(board.handleCommand("set radio.slot 2",0,reply));
assert(std::string(reply).find("OK -")==0);
assert(slotState().savedMode==2 && slotState().bootMode==1 && !slotState().settingsHealthy);
assert(board.handleCommand("get radio.slot",0,reply));
assert(std::string(reply).find("WARN settings unavailable/invalid")!=std::string::npos);
assert(fake::disk.at("mode").value==2);
assert(fake::disk.at("last").value==(wrongType ? 2:255));
assert(fake::disk.at("last").u8==!wrongType);
// Successful independent repair still does not erase the historical boot
// warning. Only a subsequent load can establish both keys are now valid.
rememberActiveSlot(1);assert(!slotState().settingsHealthy);
slotState()=SlotState{};loadSlotSettings();
assert(slotState().settingsHealthy && slotState().bootMode==2 && slotState().last==1);
unchangedForeign();
}
}
void remember(){
reset();fake::put("last",1);loadSlotSettings();
const unsigned opens=fake::opens;
rememberActiveSlot(0);rememberActiveSlot(3);rememberActiveSlot(1);
assert(fake::opens==opens && fake::sets==0 && fake::commits==0);
rememberActiveSlot(2);assert(slotState().last==2 && fake::disk.at("last").value==2);
assert(fake::sets==1 && fake::commits==1);
rememberActiveSlot(2);assert(fake::sets==1 && fake::commits==1);
fake::commitError=79;rememberActiveSlot(1);
assert(slotState().last==2 && fake::disk.at("last").value==2 && !slotState().settingsHealthy);
assert(fake::written==std::vector<std::string>({"last","last"}));unchangedForeign();
}
void choose(){
reset();auto& s=slotState();
for(uint8_t last=0;last<=2;++last){s.last=last;
assert(s.choose(0)==0 && s.choose(1)==1 && s.choose(2)==2);
assert(s.choose(3)==(last==2 ? 2:1));
}
for(uint8_t forced=1;forced<=2;++forced){s.bootMode=forced;
assert(s.choose(0)==0 && s.choose(3)==forced);
assert(s.choose(1U<<(forced-1))==forced);
assert(s.choose(1U<<(2-forced))==0);
}
s.bootMode=3;assert(s.choose(3)==0);unchangedForeign();
}
void boot(){
// Wake sources belong to the PREVIOUS boot's selected socket. A newly saved
// next-boot slot must not hide a genuine wake from the other physical IRQ.
for(uint8_t mode=0;mode<=2;++mode) for(uint8_t last=0;last<=2;++last)
for(int wake:{-1,2,48}){
reset();fake::put("mode",mode);fake::put("last",last);
fake::resetReason=ESP_RST_DEEPSLEEP;
fake::wakeMask=wake<0 ? 0:1ULL<<wake;
StationG3Board board;board.begin();
const uint8_t expected=mode==2 || (mode==0 && last==2) ? 2:1;
assert(slotState().candidate==expected && slotState().active==0 && slotState().detected==0);
assert(board.getIRQGpio()==pinsForSlot(expected).irq);
assert(board.startup_reason==(wake<0 ? BD_STARTUP_NORMAL:BD_STARTUP_RX_PACKET));
assert(fake::femInit==1 && fake::baseBegins==1 && fake::sets==0);
unchangedForeign();
}
}
int main(int argc,char** argv){
assert(argc==2);const std::string which=argv[1];
if(which=="fresh") fresh();else if(which=="valid") valid();
else if(which=="read-errors") readErrors();else if(which=="failed-saves") failedSaves();
else if(which=="pending-cli") pendingCLI();else if(which=="invalid-cli") invalidCLI();
else if(which=="invalid-last-warning") invalidLastWarning();
else if(which=="remember") remember();else if(which=="choose") choose();
else if(which=="boot") boot();else assert(false);
}
'''
class StationG3SlotSettingsTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.temporary = tempfile.TemporaryDirectory(prefix="g3-slot-settings-")
work = Path(cls.temporary.name)
header = (VARIANT / "StationG3Board.h").read_text()
board = BOARD.replace("@BOOT@", body(header, "void begin()"))
board = board.replace("@IRQ@", body(header, "uint32_t getIRQGpio()"))
(work / "StationG3Board.h").write_text(board)
(work / "StationG3RadioSlot.h").write_text((VARIANT / "StationG3RadioSlot.h").read_text())
(work / "nvs.h").write_text(NVS)
(work / "helpers").mkdir()
(work / "helpers/ESP32GpioHold.h").write_text("#pragma once\n")
(work / "settings.cpp").write_text((VARIANT / "StationG3RadioSlot.cpp").read_text())
(work / "board.cpp").write_text((VARIANT / "StationG3Board.cpp").read_text())
(work / "test.cpp").write_text(HARNESS)
cls.binary = work / "test.exe"
built = subprocess.run([
os.environ.get("CXX", "g++"), "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-I", str(work), str(work / "settings.cpp"), str(work / "board.cpp"),
str(work / "test.cpp"), "-o", str(cls.binary),
], capture_output=True, text=True)
if built.returncode:
cls.temporary.cleanup()
raise AssertionError(built.stderr)
@classmethod
def tearDownClass(cls):
cls.temporary.cleanup()
def run_case(self, name):
result = subprocess.run([str(self.binary), name], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
def test_fresh_and_empty_namespace_default_auto_without_writes(self):
self.run_case("fresh")
def test_checked_values_load_once_without_touching_radio_or_identity(self):
self.run_case("valid")
def test_read_errors_invalid_modes_and_wrong_types_remain_safe_and_visible(self):
self.run_case("read-errors")
def test_save_failures_preserve_live_state_without_claiming_flash_rollback(self):
self.run_case("failed-saves")
def test_cli_saved_pending_mode_stays_boot_frozen_until_reload(self):
self.run_case("pending-cli")
def test_invalid_cli_values_never_write_and_status_reports_invalid_storage(self):
self.run_case("invalid-cli")
def test_mode_save_preserves_invalid_last_warning_until_reboot_revalidation(self):
self.run_case("invalid-last-warning")
def test_last_successful_slot_only_persists_when_changed(self):
self.run_case("remember")
def test_choice_respects_forced_slot_and_last_successful_auto_preference(self):
self.run_case("choose")
def test_both_physical_wake_irqs_are_recognized_before_current_boot_probing(self):
self.run_case("boot")
if __name__ == "__main__":
unittest.main()
+18 -3
View File
@@ -244,8 +244,13 @@ extern uint64_t wakeup_mask;
inline int esp_reset_reason() { return reset_reason; }
inline int esp_sleep_get_wakeup_cause() { return 0; }
inline uint64_t esp_sleep_get_ext1_wakeup_status() { return wakeup_mask; }
inline void rtc_gpio_hold_dis(int) {}
inline void rtc_gpio_deinit(int) {}
inline bool rtc_gpio_is_valid_gpio(int pin) {
return pin == 0 || pin == 2 || pin == 4 || (pin >= 12 && pin <= 15)
|| (pin >= 25 && pin <= 27) || (pin >= 32 && pin <= 39);
}
inline int rtc_gpio_hold_dis(int) { return 0; }
inline int rtc_gpio_hold_en(int) { return 0; }
inline int rtc_gpio_deinit(int) { return 0; }
inline void pinMode(int, int) {}
inline void digitalWrite(int, int) {}
inline void attachInterrupt(int, void(*)(), int) {}
@@ -341,6 +346,16 @@ struct XPowersAXP2101 : XPowersLibInterface {
using esp_err_t = int;
using nvs_handle_t = uint32_t;
constexpr int ESP_OK = 0, ESP_FAIL = 1, ESP_ERR_NVS_NOT_FOUND = 2;
constexpr int ESP_ERR_INVALID_ARG=258;
constexpr int GPIO_MODE_OUTPUT=2,GPIO_PULLUP_DISABLE=0,GPIO_PULLDOWN_DISABLE=0,GPIO_INTR_DISABLE=0;
#define GPIO_IS_VALID_OUTPUT_GPIO(pin) ((pin)>=0 && (pin)<49)
struct gpio_config_t { uint64_t pin_bit_mask;int mode,pull_up_en,pull_down_en,intr_type; };
inline int gpio_set_level(int,int) { return 0; }
inline int gpio_config(const gpio_config_t*) { return 0; }
inline int gpio_hold_en(int) { return 0; }
inline int gpio_hold_dis(int) { return 0; }
inline void gpio_deep_sleep_hold_en() {}
inline void gpio_deep_sleep_hold_dis() {}
constexpr int NVS_READONLY = 0, NVS_READWRITE = 1;
extern std::map<std::string,uint16_t> saved;
extern std::string opened_namespace;
@@ -566,7 +581,7 @@ class TBeamChargeTargetTest(unittest.TestCase):
directory = Path(temporary)
(directory / "MockSdk.h").write_text(MOCK_SDK, encoding="ascii")
for name in ("Arduino.h", "Wire.h", "XPowersLib.h", "nvs.h",
"helpers/ESP32Board.h"):
"helpers/ESP32Board.h", "driver/rtc_io.h", "driver/gpio.h"):
stub = directory / name
stub.parent.mkdir(parents=True, exist_ok=True)
stub.write_text('#include "MockSdk.h"\n', encoding="ascii")
+164
View File
@@ -0,0 +1,164 @@
"""Run the real XIAO/Ikoka startup and shutdown GPIO sequences.
Seeed warns against READ_BAT_ENABLE HIGH while charging. Start with an unsafe
latched level so enabling the output before preloading LOW is observable too.
The battery divider is about 3 uA, not a reason to overvoltage the ADC input.
"""
from pathlib import Path
import os
import shutil
import subprocess
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
BOARDS = (
("xiao_nrf52", "XiaoNrf52Board"),
("ikoka_stick_nrf", "IkokaStickNRFBoard"),
("ikoka_nano_nrf", "IkokaNanoNRFBoard"),
("ikoka_handheld_nrf", "IkokaNrf52Board"),
)
def source(mutation=None):
methods = []
calls = []
for variant, board in BOARDS:
directory = ROOT / "variants" / variant
init = extract_braced((directory / "variant.cpp").read_text(), "void initVariant()")
init = init.replace("initVariant()", "init_" + board + "()", 1)
begin = extract_braced((directory / (board + ".cpp")).read_text(),
"void " + board + "::begin()")
if mutation == "old-begin-high" and variant != "xiao_nrf52":
begin = begin.replace("digitalWrite(VBAT_ENABLE, LOW);",
"digitalWrite(VBAT_ENABLE, HIGH);")
if mutation == "output-before-safe-latch":
init = init.replace("digitalWrite(VBAT_ENABLE, LOW);\n", "")
init = init.replace("pinMode(VBAT_ENABLE, OUTPUT);",
"pinMode(VBAT_ENABLE, OUTPUT);\n"
"digitalWrite(VBAT_ENABLE, LOW);")
methods += [init, "class " + board + " { public: void begin(); };", begin]
calls += ["reset(); init_" + board + "(); check();",
"reset(); " + board + "().begin(); check();",
"reset(); init_" + board + "(); " + board + "().begin(); check();"]
shutdown = extract_braced((ROOT / "variants/xiao_nrf52/XiaoNrf52Board.cpp").read_text(),
"void XiaoNrf52Board::initiateShutdown(uint8_t reason)")
shutdown = shutdown.replace("XiaoNrf52Board::initiateShutdown", "shutdown")
if mutation == "old-user-shutdown-high":
shutdown = shutdown.replace("digitalWrite(VBAT_ENABLE, LOW);",
"digitalWrite(VBAT_ENABLE, enable_lpcomp ? LOW : HIGH);")
return r'''
#include <cassert>
#include <cstdint>
#define LOW 0
#define HIGH 1
#define INPUT 0
#define OUTPUT 1
#define INPUT_PULLUP 2
#define VBAT_ENABLE 14
#define PIN_VBAT 31
#define PIN_CHARGING_CURRENT 13
#define PIN_QSPI_CS 25
#define LED_RED 26
#define LED_GREEN 27
#define LED_BLUE 28
#define AR_INTERNAL_3_0 30
#define SHUTDOWN_REASON_LOW_VOLTAGE 1
#define SHUTDOWN_REASON_BOOT_PROTECT 2
#define SHUTDOWN_REASON_USER 3
#ifdef OPTIONAL_PINS
#define PIN_USER_BTN 3
#define PIN_WIRE_SDA 4
#define PIN_WIRE_SCL 5
#define P_LORA_TX_LED 6
#define DISPLAY_CLASS FakeDisplay
#define DISPLAY_ROTATION 0
#endif
static int latch=HIGH, mode=INPUT, writes=0, wakes=0, reason_seen=0;
void digitalWrite(int pin, int value) {
if(pin==VBAT_ENABLE) { assert(value==LOW); latch=value; ++writes; }
}
void pinMode(int pin,int value) {
if(pin==VBAT_ENABLE) {
assert(value==OUTPUT && latch==LOW); mode=value;
}
}
void delay(unsigned) {}
void analogReadResolution(int) {}
void analogReference(int) {}
struct FakeWire { void setPins(int,int) {} void begin() {} } Wire;
struct NRF52Board { static void begin() {} };
struct NRF52BoardDCDC { static void begin() {} };
struct Config { int lpcomp_ain_channel; int lpcomp_refsel; };
static const Config power_config={7,3};
void checkBootVoltage(const Config*) {}
void configureVoltageWake(int channel,int reference) {
assert(channel==7 && reference==3 && latch==LOW && mode==OUTPUT); ++wakes;
}
void enterSystemOff(uint8_t reason) {
assert(latch==LOW && mode==OUTPUT); reason_seen=reason;
}
void reset() { latch=HIGH; mode=INPUT; writes=wakes=reason_seen=0; }
void check() { assert(writes>0 && latch==LOW && mode==OUTPUT); }
@METHODS@
@SHUTDOWN@
int main() {
// Repeated startup, optional peripherals and USB/charging status must not
// create a HIGH pulse; the production code deliberately does not infer USB.
for(int repeat=0;repeat<3;++repeat) { @CALLS@ }
for(uint8_t reason : {SHUTDOWN_REASON_LOW_VOLTAGE, SHUTDOWN_REASON_BOOT_PROTECT,
SHUTDOWN_REASON_USER, 0}) {
reset(); shutdown(reason); check();
assert(reason_seen==reason);
assert(wakes==(reason==SHUTDOWN_REASON_LOW_VOLTAGE ||
reason==SHUTDOWN_REASON_BOOT_PROTECT ? 1 : 0));
}
}
'''.replace("#include <cstdint>", "#include <cstdint>\n#include <initializer_list>").replace(
"@METHODS@", "\n".join(methods)).replace("@SHUTDOWN@", shutdown).replace(
"@CALLS@", "\n".join(calls))
class XiaoBatterySafetyTests(unittest.TestCase):
def run_case(self, optional=False, mutation=None):
compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++")
self.assertIsNotNone(compiler, "a host C++11 compiler is required")
with tempfile.TemporaryDirectory(prefix="xiao-battery-safety-") as directory:
cpp = Path(directory) / "test.cpp"
binary = Path(directory) / ("test.exe" if os.name == "nt" else "test")
cpp.write_text(source(mutation), encoding="utf-8")
sanitizer = [] if os.name == "nt" else ["-fsanitize=address,undefined",
"-fno-sanitize-recover=all", "-fno-pie", "-no-pie"]
flags = ["-DNRF52_POWER_MANAGEMENT"]
if optional:
flags.append("-DOPTIONAL_PINS")
build = subprocess.run([compiler, "-std=c++11", "-Wall", "-Wextra", "-Werror",
*sanitizer, *flags, str(cpp), "-o", str(binary)],
text=True, capture_output=True, timeout=30)
self.assertEqual(build.returncode, 0, build.stdout + build.stderr)
run = subprocess.run([str(binary)], text=True, capture_output=True, timeout=10)
if mutation:
self.assertNotEqual(run.returncode, 0, "negative control unexpectedly passed")
else:
self.assertEqual(run.returncode, 0, run.stdout + run.stderr)
def test_startup_and_all_shutdown_reasons(self):
self.run_case()
def test_optional_button_i2c_display_led_startup(self):
self.run_case(optional=True)
def test_old_ikoka_high_startup_is_rejected(self):
self.run_case(mutation="old-begin-high")
def test_output_before_safe_latch_is_rejected(self):
self.run_case(mutation="output-before-safe-latch")
def test_old_user_shutdown_high_is_rejected(self):
self.run_case(mutation="old-user-shutdown-high")
if __name__ == "__main__":
unittest.main()