Fix peripheral power shutdown and auto-detect Station G3 radio slots
This commit is contained in:
1 parent
c6e43c5530
commit
8eced48d34
103 files changed
+4900
-194
No files matched your search
Vendored
+53
-3
@@ -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
@@ -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());
|
||||
|
||||
Vendored
+30
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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; }
|
||||
@@ -4,4 +4,5 @@ class SPIClass {
|
||||
public:
|
||||
SPIClass(int, int, int, int) {}
|
||||
void begin() {}
|
||||
void end() {}
|
||||
};
|
||||
@@ -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()
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()&ðernet_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()
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 [])
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user