From 8eced48d345b8ed6948cbcaed822e28aa687869a Mon Sep 17 00:00:00 2001 From: mikecarper Date: Thu, 15 Oct 2026 22:30:37 -0700 Subject: [PATCH] Fix peripheral power shutdown and auto-detect Station G3 radio slots --- docs/cli_commands.md | 43 ++ docs/kiss_modem_protocol.md | 22 + docs/nrf52_power_management.md | 40 ++ examples/companion_radio/main.cpp | 5 +- examples/kiss_modem/KissModem.cpp | 53 +++ examples/kiss_modem/KissModem.h | 16 + examples/kiss_modem/main.cpp | 9 + examples/simple_repeater/main.cpp | 4 +- examples/simple_room_server/main.cpp | 4 +- examples/simple_sensor/main.cpp | 4 +- src/helpers/ESP32Board.cpp | 20 +- src/helpers/ESP32Board.h | 14 + src/helpers/ESP32GpioHold.h | 105 +++++ src/helpers/SensorManager.h | 6 + src/helpers/UserGpioPinPolicy.h | 10 +- src/helpers/esp32/TBeamBoard.cpp | 5 +- .../RAK13800/RAK13800EthernetInterface.cpp | 40 +- .../RAK13800/RAK13800EthernetInterface.h | 4 + src/helpers/nrf52/EthernetCLI.h | 82 +++- src/helpers/nrf52/EthernetCliRuntime.h | 34 ++ .../nrf52/TerminalPeripheralShutdown.h | 53 +++ src/helpers/radiolib/CustomLR2021.h | 18 + src/helpers/radiolib/CustomSX1262.h | 12 +- src/helpers/radiolib/RadioLibWrappers.h | 9 + .../sensors/EnvironmentSensorManager.cpp | 35 ++ .../sensors/EnvironmentSensorManager.h | 2 + .../sensors/WisBlockSharedPowerPolicy.h | 7 +- src/helpers/ui/DRV2605Vibration.cpp | 44 +- src/helpers/ui/DRV2605Vibration.h | 5 +- test/fixtures/hwcdc_kiss_setup.cpp | 56 ++- .../rak4631_ethernet_runtime/mocks/Arduino.h | 1 + .../rak4631_ethernet_runtime/test.cpp | 84 +++- test/fixtures/rak_gps_power.cpp | 30 ++ test/fixtures/rak_peripheral_power_no_gps.cpp | 8 + .../usb_diagnostic_logging/mocks/Arduino.h | 10 +- .../usb_diagnostic_logging/mocks/SPI.h | 1 + test/test_drv2605_power.py | 282 ++++++++++++ test/test_environment_no_gps_link.py | 3 + test/test_esp32_full_sleep.py | 3 +- test/test_esp32_gpio_hold.py | 428 ++++++++++++++++++ test/test_esp32_gps_discovery.py | 21 +- test/test_esp32_scanner_power_yield.py | 58 +++ test/test_ethernet_terminal_shutdown.py | 320 +++++++++++++ test/test_heltec_r8_external_power.py | 23 +- test/test_hwcdc_role_transport.py | 5 + test/test_kiss_radio_slot.py | 181 ++++++++ test/test_lr2021_power.py | 274 +++++++++++ test/test_meshtracker_x1_battery.py | 131 ++++++ test/test_rak4631_ethernet_runtime.py | 6 + test/test_rak_gps_power.py | 8 + test/test_rak_shutdown_power.py | 283 ++++++++++++ test/test_station_g3_boot_recovery.py | 288 ++++++++++++ test/test_station_g3_radio_pins.py | 237 ++++++++++ test/test_station_g3_slot_detection.py | 237 ++++++++++ test/test_station_g3_slot_settings.py | 388 ++++++++++++++++ test/test_tbeam_charge_target.py | 21 +- test/test_xiao_battery_safety.py | 164 +++++++ variants/heltec_e213/HeltecE213Board.cpp | 5 +- variants/heltec_e290/HeltecE290Board.cpp | 5 +- variants/heltec_rc32/HeltecRC32Board.cpp | 5 +- variants/heltec_t190/HeltecT190Board.cpp | 5 +- .../HeltecTrackerV2Board.cpp | 8 +- variants/heltec_tracker_v2/LoRaFEMControl.cpp | 11 +- variants/heltec_v2/HeltecV2Board.h | 5 +- variants/heltec_v3/HeltecV3Board.h | 5 +- variants/heltec_v4/HeltecV4Board.cpp | 8 +- variants/heltec_v4/LoRaFEMControl.cpp | 15 +- variants/heltec_v4_r8/HeltecV4R8Board.cpp | 8 +- variants/heltec_v4_r8/LoRaFEMControl.cpp | 13 +- .../ikoka_handheld_nrf/IkokaNrf52Board.cpp | 4 +- variants/ikoka_handheld_nrf/IkokaNrf52Board.h | 2 +- variants/ikoka_handheld_nrf/variant.cpp | 9 +- variants/ikoka_nano_nrf/IkokaNanoNRFBoard.cpp | 4 +- variants/ikoka_nano_nrf/IkokaNanoNRFBoard.h | 2 +- variants/ikoka_nano_nrf/variant.cpp | 9 +- .../ikoka_stick_nrf/IkokaStickNRFBoard.cpp | 4 +- variants/ikoka_stick_nrf/IkokaStickNRFBoard.h | 2 +- variants/ikoka_stick_nrf/variant.cpp | 9 +- variants/lilygo_tbeam_1w/TBeam1WBoard.cpp | 5 +- variants/lilygo_tdeck/TDeckBoard.cpp | 7 +- variants/meshadventurer/MeshadventurerBoard.h | 5 +- .../meshnology_w12/MeshnologyW12Board.cpp | 18 +- variants/meshnology_w12/platformio.ini | 1 + variants/meshtracker_x1/MeshTrackerX1Board.h | 9 +- variants/rak3112/RAK3112Board.h | 5 +- variants/rak3401/RAK3401Board.cpp | 61 ++- variants/rak3401/RAK3401Board.h | 1 + variants/rak3401/variant.h | 5 +- variants/rak4631/RAK4631Board.cpp | 46 +- variants/rak4631/RAK4631Board.h | 1 + variants/station_g2/StationG2Board.h | 5 +- variants/station_g3_esp32/LoRaFEMControl.cpp | 25 +- variants/station_g3_esp32/StationG3Board.cpp | 27 +- variants/station_g3_esp32/StationG3Board.h | 24 +- .../StationG3BootRecovery.cpp | 167 +++++++ .../station_g3_esp32/StationG3BootRecovery.h | 15 + .../station_g3_esp32/StationG3RadioSlot.cpp | 54 +++ .../station_g3_esp32/StationG3RadioSlot.h | 57 +++ variants/station_g3_esp32/platformio.ini | 12 +- variants/station_g3_esp32/target.cpp | 130 +++++- variants/wio-tracker-l2/WioTrackerL2Board.cpp | 5 +- variants/xiao_nrf52/XiaoNrf52Board.cpp | 6 +- variants/xiao_nrf52/variant.cpp | 9 +- 103 files changed, 4900 insertions(+), 194 deletions(-) create mode 100644 src/helpers/ESP32GpioHold.h create mode 100644 src/helpers/nrf52/TerminalPeripheralShutdown.h create mode 100644 test/test_drv2605_power.py create mode 100644 test/test_esp32_gpio_hold.py create mode 100644 test/test_esp32_scanner_power_yield.py create mode 100644 test/test_ethernet_terminal_shutdown.py create mode 100644 test/test_kiss_radio_slot.py create mode 100644 test/test_lr2021_power.py create mode 100644 test/test_meshtracker_x1_battery.py create mode 100644 test/test_rak_shutdown_power.py create mode 100644 test/test_station_g3_boot_recovery.py create mode 100644 test/test_station_g3_radio_pins.py create mode 100644 test/test_station_g3_slot_detection.py create mode 100644 test/test_station_g3_slot_settings.py create mode 100644 test/test_xiao_battery_safety.py create mode 100644 variants/station_g3_esp32/StationG3BootRecovery.cpp create mode 100644 variants/station_g3_esp32/StationG3BootRecovery.h create mode 100644 variants/station_g3_esp32/StationG3RadioSlot.cpp create mode 100644 variants/station_g3_esp32/StationG3RadioSlot.h diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 74cddf274..ec7cbbd54 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -1791,6 +1791,37 @@ Station G2/G3 targets default to `off`. --- +#### Select the Station G3 physical radio slot + +```text +get radio.slot +set radio.slot auto +set radio.slot 1 +set radio.slot 2 +reboot +``` + +Station G3 SX1262 builds support both physical RF sockets in the same image. +The default, `auto`, probes both sockets at boot without transmitting. If both +work, it prefers the last successfully used socket; on a fresh installation, +it prefers socket 1. A forced `1` or `2` never silently uses the other socket. + +`get radio.slot` reports the saved selection, active socket, detected sockets, +and whether a reboot is needed. Changes are saved immediately but applied only +after reboot, never during a transmission. The selector does not change keys, +node settings, or the time-shared `radio2` profile. The legacy `_r2` build name +means physical socket 2, not a motherboard revision; both build names now +support this selector. + +If no usable socket is found, startup waits with both LNAs disabled instead of +reboot-looping. A limited USB recovery console accepts `!g3 get radio.slot`, +`!g3 set radio.slot auto|1|2`, `!g3 reboot`, and `!g3 help`. The `!g3` prefix is +required in this recovery console; it is not the normal running CLI. KISS +firmware uses binary slot commands during normal operation, documented in the +[KISS protocol](kiss_modem_protocol.md). + +--- + ### System #### View or change this node's name @@ -2516,6 +2547,18 @@ inactive and no work is pending. Compiling those transports into Full does not itself prevent sleep. Turning off BLE advertising alone leaves its controller running and therefore retains idle behavior rather than manual light sleep. +An active radio2 requires continuous software scanning. With power saving on, +ESP32 Companion, Repeater, Room and Sensor builds do not insert the ordinary +1 ms idle delay while dual-radio scanning is active. This avoids delaying +sub-millisecond scan visits; it does not make active dual-radio scanning a sleep mode. + +The verified Meshnology W12 layout enables the LR2021 radio's DC-DC regulator +after LoRa and Morse receive initialization, including recovery. This setting +is independent of `powersaving` and does not remove radio features. Other +LR2021 layouts retain their existing regulator behavior unless their recipe +explicitly enables `LR2021_USE_DCDC` after confirming the required inductor; +the X1 is not opted in without that hardware confirmation. + --- #### View or set the reboot interval (Repeater and room server) diff --git a/docs/kiss_modem_protocol.md b/docs/kiss_modem_protocol.md index e5ea179b0..2fb95bca4 100644 --- a/docs/kiss_modem_protocol.md +++ b/docs/kiss_modem_protocol.md @@ -155,6 +155,8 @@ MeshCore-specific functionality uses the standard KISS SetHardware command. The | GetTempRadio2 | `0x1E` | - | | SetRadio2Ext | `0x1F` | Radio2 parameters + protocol (14) | | GetRadio2Ext | `0x20` | - | +| GetRadioSlot | `0x22` | - (Station G3 only) | +| SetRadioSlot | `0x23` | Mode (1): 0=auto, 1=slot 1, 2=slot 2 (Station G3 only) | ### Response Sub-commands (TNC to Host) @@ -187,6 +189,7 @@ Response codes use the high-bit convention: `response = command | 0x80`. Generic | Radio2 | `0x9C` | Saved radio2 parameters (13) | | TempRadio2 | `0x9E` | Active temporary radio2 + remaining minutes (15) | | Radio2Ext | `0xA0` | Saved radio2 parameters + protocol (14) | +| RadioSlot | `0xA2` | Saved mode, active slot, detected mask, reboot needed (4 bytes) | | OK | `0xF0` | - | | Error | `0xF1` | Error code (1) | | TxDone | `0xF8` | Result (1): 0x00=failed, 0x01=success | @@ -203,6 +206,25 @@ Response codes use the high-bit convention: `response = command | 0x80`. Generic | UnknownCmd | `0x05` | Unknown sub-command | | EncryptFailed | `0x06` | Encryption failed | | TxBusy | `0x07` | Radio TX busy, or host output queue full | +| SaveFailed | `0x08` | Setting could not be saved; running slot unchanged | + +### Station G3 physical radio slot + +`GetRadioSlot` takes no data and replies with four bytes: saved mode (`0` auto, +`1` or `2` forced socket), active socket (`0` unavailable, `1` or `2`), detected +socket mask (bit 0 = socket 1, bit 1 = socket 2), and reboot-needed flag (`0` or +`1`). This selects the physical SX1262 daughterboard socket, not the logical +`radio2` PHY profile. Other boards return `UnknownCmd`. + +`SetRadioSlot` requires exactly one mode byte (`0`–`2`). An `OK` means the mode +was saved; the running socket stays unchanged until a separate `Reboot` command +or power cycle. Radio settings and identity are untouched. A failed save returns +`SaveFailed`, with no change to the running socket. + +If no usable socket is found during boot, the normal KISS modem has not started. +Use the separate USB recovery console: `!g3 get radio.slot`, +`!g3 set radio.slot auto`, then `!g3 reboot`. Recovery accepts only these +prefixed management commands (and `!g3 help`), never KISS packet payloads. ### Unsolicited Events diff --git a/docs/nrf52_power_management.md b/docs/nrf52_power_management.md index d45fb9fb4..1cda91c13 100644 --- a/docs/nrf52_power_management.md +++ b/docs/nrf52_power_management.md @@ -71,6 +71,46 @@ Notes: ## Technical Details +### RAK peripheral shutdown + +Explicit power-off and timed deep sleep on RAK4631 and RAK3401 stop peripheral +owners before switching off the shared `3V3_S` supply. On RAK4631, Ethernet +must quiesce first; if its worker cannot stop within the bounded shutdown +interval, keep its rail and bus intact instead of removing power during an SPI +operation. Detected external-storage holds also retain the supply, including +after an unresolved flash-operation timeout. Before storage discovery and +configuration finish, rail removal is refused. Shutdown does not probe again, +cancel a staged update, or float a storage owner's select pin. +This does not change ordinary interrupt-idle sleep, boot/low-voltage +protection, or the configured WisBlock GPS/shared-rail policy during normal +operation. + +The [RAK19007 power schematic](https://images.docs.rakwireless.com/wisblock/rak19007/datasheet/power-schematic.png) +and [RAK13800 connector schematic](https://images.docs.rakwireless.com/wisblock/rak13800/datasheet/image-20211116120503808.png) +show that IO2 switches `3V3_S`, not the separate upstream PoE `VCC_IN` supply. +Merely compiling Ethernet support therefore does not require this rail to +stay on after terminal shutdown. The RAK4631 radio-enable GPIO controls the +RF-switch supply, not the SX1262 core supply. + +RAK3401's external RAK13302 radio and RAK13800 Ethernet use the same IO slot, +SPI signals, select pin and IO3 control. Their simultaneous operation is not +supported; Ethernet owner registration on RAK3401 is rejected rather than +allowing those electrically conflicting controls to run together. + +Software shutdown cannot remove every hardware leakage path. The baseboard +has I2C pull-ups to unswitched VDD. RAK13302 VD's boost enable follows IO2, but +the legacy VC default resistor configuration connects it directly to VBAT; +that boost cannot be switched off by firmware alone. + +### XIAO battery-divider safety + +XIAO and XIAO-based Ikoka startup preload `READ_BAT_ENABLE` LOW before enabling +its output. XIAO protective and user shutdown also keep it LOW. Seeed warns +that HIGH can overvoltage the ADC input during charging, so this is not a +safe pin to switch HIGH as a power optimization. The schematic's 1 MOhm + +510 kOhm divider draws about 3 microamps at 4.2 V, not 3 milliamps. Charging +current settings are unchanged. See [Seeed's battery guidance](https://wiki.seeedstudio.com/XIAO_BLE/#q3-what-are-the-considerations-when-using-xiao-nrf52840-sense-for-battery-charging). + ### Architecture The power management functionality is integrated into the `NRF52Board` base class in `src/helpers/NRF52Board.cpp`. Board variants provide hardware-specific configuration via a `PowerMgtConfig` struct and override `initiateShutdown(uint8_t reason)` to perform board-specific power-down work and conditionally enable voltage wake (LPCOMP + VBUS). diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index e560df6b7..801f67f34 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -4204,7 +4204,10 @@ void loop() { #endif } #if defined(ESP32_PLATFORM) - else if (!morse_timing && the_mesh.getNodePrefs()->powersaving_enabled) { + else if (!morse_timing && !the_mesh.isDualRadioActive() + && the_mesh.getNodePrefs()->powersaving_enabled) { + // The scanner has a zero-delay polling contract and sub-ms RX visits. + // Keep the idle yield for USB/logging/button holds, not live radio2. delay(1); // CPU idle remains available while USB/logging/button holds are active. } #endif diff --git a/examples/kiss_modem/KissModem.cpp b/examples/kiss_modem/KissModem.cpp index b4ba13374..8475de5d2 100644 --- a/examples/kiss_modem/KissModem.cpp +++ b/examples/kiss_modem/KissModem.cpp @@ -1,5 +1,8 @@ #include "KissModem.h" #include +#if defined(STATION_G3_ESP32) + #include "StationG3RadioSlot.h" +#endif namespace { @@ -467,6 +470,14 @@ void KissModem::handleHardwareCommand(uint8_t sub_cmd, const uint8_t* data, uint if (len != 0) writeHardwareError(HW_ERR_INVALID_LENGTH); else handleGetRadio2(true); break; +#if defined(STATION_G3_ESP32) + case HW_CMD_GET_RADIO_SLOT: + handleGetRadioSlot(len); + break; + case HW_CMD_SET_RADIO_SLOT: + handleSetRadioSlot(data, len); + break; +#endif #if defined(MESH_HIL_TX_FREQ_MHZ) case HW_CMD_GET_LAB_TX_GUARD: { if (len != 0) { @@ -489,6 +500,48 @@ void KissModem::handleHardwareCommand(uint8_t sub_cmd, const uint8_t* data, uint } } +#if defined(STATION_G3_ESP32) +void KissModem::handleGetRadioSlot(uint16_t len) { + if (len != 0) { + writeHardwareError(HW_ERR_INVALID_LENGTH); + return; + } + const auto& state = station_g3::slotState(); + const uint8_t status[KISS_RADIO_SLOT_STATUS_SIZE] = { + state.savedMode, state.active, state.detected, + static_cast(state.savedMode != state.bootMode)}; + writeHardwareFrame(HW_RESP(HW_CMD_GET_RADIO_SLOT), status, sizeof(status)); +} + +void KissModem::handleSetRadioSlot(const uint8_t* data, uint16_t len) { + if (len != 1) { + writeHardwareError(HW_ERR_INVALID_LENGTH); + return; + } + if (data[0] > 2) { + writeHardwareError(HW_ERR_INVALID_PARAM); + return; + } + static const char* const commands[] = { + "set radio.slot auto", "set radio.slot 1", "set radio.slot 2"}; + char reply[160] = {}; + if (!_board.handleCommand(commands[data[0]], 0, reply)) { + writeHardwareError(HW_ERR_NO_CALLBACK); + return; + } + // handleCommand's bool means "recognized", not "saved". The board is the + // single checked-persistence owner; never acknowledge its error as success. + if (strncmp(reply, "OK - ", 5) != 0 + || station_g3::slotState().savedMode != data[0]) { + writeHardwareError(HW_ERR_SAVE_FAILED); + return; + } + // This changes only next-boot selection. No radio retune, probe, RF packet, + // physical pin switch or reboot is performed by the KISS control command. + writeHardwareFrame(HW_RESP_OK, nullptr, 0); +} +#endif + void KissModem::expireTemporaryRadio2() { if (!_temporary_radio2_active || (int32_t)(millis() - _temporary_radio2_end_ms) < 0) return; diff --git a/examples/kiss_modem/KissModem.h b/examples/kiss_modem/KissModem.h index b6c7f8318..e539caf90 100644 --- a/examples/kiss_modem/KissModem.h +++ b/examples/kiss_modem/KissModem.h @@ -72,6 +72,8 @@ #define HW_CMD_SET_RADIO2_EXT 0x1F #define HW_CMD_GET_RADIO2_EXT 0x20 #define HW_CMD_GET_LAB_TX_GUARD 0x21 +#define HW_CMD_GET_RADIO_SLOT 0x22 +#define HW_CMD_SET_RADIO_SLOT 0x23 /* Response code = command code | 0x80. Generic / unsolicited use 0xF0+. */ #define HW_RESP(cmd) ((cmd) | 0x80) @@ -91,12 +93,22 @@ #define HW_ERR_UNKNOWN_CMD 0x05 #define HW_ERR_ENCRYPT_FAILED 0x06 #define HW_ERR_TX_BUSY 0x07 +#define HW_ERR_SAVE_FAILED 0x08 #define KISS_RADIO_PARAMS_SIZE 10 #define KISS_RADIO2_PARAMS_SIZE 13 #define KISS_RADIO2_EXT_PARAMS_SIZE 14 #define KISS_TEMPRADIO2_PARAMS_SIZE 15 #define KISS_MAX_TEMPRADIO2_MINUTES 10080 +#define KISS_RADIO_SLOT_STATUS_SIZE 4 + +/* Station G3 physical socket selection, not the logical radio2 PHY: + * GET_RADIO_SLOT: no payload; response 0xA2 payload is + * [saved (0=auto,1,2), active (0=none,1,2), detected (bit0=1,bit1=2), + * reboot_required (saved mode differs from the mode used at boot)]. + * SET_RADIO_SLOT: exactly one byte (0=auto,1,2); persistent, reboot-only. + * Success is generic OK, failure is framed ERROR (SAVE_FAILED for storage). + * Unsupported boards return UNKNOWN_CMD for both additive commands. */ /* Version 2 adds the logical secondary-radio KISS port and radio2 commands. * Extended radio2 commands are additive; old firmware returns UnknownCmd. */ @@ -224,6 +236,10 @@ class KissModem { void handleGetRadio2(bool extended = false); void handleSetTempRadio2(const uint8_t* data, uint16_t len); void handleGetTempRadio2(); +#if defined(STATION_G3_ESP32) + void handleGetRadioSlot(uint16_t len); + void handleSetRadioSlot(const uint8_t* data, uint16_t len); +#endif void handleGetTxPower(); void handleGetVersion(); void handleGetCurrentRssi(); diff --git a/examples/kiss_modem/main.cpp b/examples/kiss_modem/main.cpp index 2ca12fc01..ff8acd964 100644 --- a/examples/kiss_modem/main.cpp +++ b/examples/kiss_modem/main.cpp @@ -170,6 +170,13 @@ void setup() { // KISS owns the entire serial byte stream, including gaps between chunks. mesh::setUsbLoggingEnabled(false); mesh::setUsbDebugEnabled(false); +#endif +#if defined(STATION_G3_ESP32) + // G3 can wait in no-radio recovery before modem construction. Bring up + // USB now so its prefixed recovery console is reachable on an empty slot. + mesh::prepareUsbLoggingPort(); + Serial.begin(115200); + mesh::beginUsbLoggingPort(); #endif KISS_BOOT_STAGE("board.before"); board.begin(); @@ -239,6 +246,7 @@ void setup() { #endif modem = new KissModem(Serial1, identity, rng, radio_driver, board, sensors); #else +#if !defined(STATION_G3_ESP32) #if KISS_HWCDC_TRANSPORT // Allocate the USB queues before begin() enables the live driver ISR. mesh::prepareUsbLoggingPort(); @@ -247,6 +255,7 @@ void setup() { #if KISS_HWCDC_TRANSPORT mesh::beginUsbLoggingPort(); #endif +#endif // G3 already started USB before radio discovery, including UART builds. uint32_t start = millis(); while (!Serial && millis() - start < 3000) delay(10); delay(100); diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index ee85a80f9..9b8742c10 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -624,7 +624,9 @@ void loop() { } #if defined(ESP32_PLATFORM) - if (!morse_timing && !can_power_save && the_mesh.getNodePrefs()->powersaving_enabled) { + if (!morse_timing && !can_power_save && !the_mesh.isDualRadioActive() + && the_mesh.getNodePrefs()->powersaving_enabled) { + // Live radio2 needs the scanner's zero-delay polling cadence. delay(1); // Idle without suspending USB or the button wake interval. } #endif diff --git a/examples/simple_room_server/main.cpp b/examples/simple_room_server/main.cpp index de7fc2313..109be6084 100644 --- a/examples/simple_room_server/main.cpp +++ b/examples/simple_room_server/main.cpp @@ -375,7 +375,9 @@ void loop() { #endif } #if defined(ESP32_PLATFORM) - if (!morse_timing && !can_power_save && the_mesh.getNodePrefs()->powersaving_enabled) { + if (!morse_timing && !can_power_save && !the_mesh.isDualRadioActive() + && the_mesh.getNodePrefs()->powersaving_enabled) { + // Live radio2 needs the scanner's zero-delay polling cadence. delay(1); // Keep USB and the button wake interval serviced while idle. } #endif diff --git a/examples/simple_sensor/main.cpp b/examples/simple_sensor/main.cpp index 7ee29961c..b2f3d5390 100644 --- a/examples/simple_sensor/main.cpp +++ b/examples/simple_sensor/main.cpp @@ -375,8 +375,10 @@ void loop() { #endif } #if defined(ESP32_PLATFORM) - if (!morse_timing && !can_power_save && (the_mesh.getNodePrefs()->powersaving_enabled + if (!morse_timing && !can_power_save && !the_mesh.isDualRadioActive() + && (the_mesh.getNodePrefs()->powersaving_enabled || the_mesh.isTrackerModeEnabled())) { + // Live radio2 needs the scanner's zero-delay polling cadence. delay(1); // Keep USB and the button wake interval serviced while idle. } #endif diff --git a/src/helpers/ESP32Board.cpp b/src/helpers/ESP32Board.cpp index ae5081b32..40446149c 100644 --- a/src/helpers/ESP32Board.cpp +++ b/src/helpers/ESP32Board.cpp @@ -1,4 +1,5 @@ #ifdef ESP_PLATFORM +#include #include "ESP32Board.h" #include "WirelessControl.h" @@ -1285,20 +1286,22 @@ void ESP32Board::shutdownPeripherals() { // Power off LoRa radio_driver.powerOff(); - #ifdef P_LORA_NSS - // Keep LoRa inactive during deepsleep - digitalWrite(P_LORA_NSS, HIGH); -#if defined(CONFIG_IDF_TARGET_ESP32C3) || defined(CONFIG_IDF_TARGET_ESP32C6) - gpio_hold_en((gpio_num_t)P_LORA_NSS); -#else - rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS); -#endif + #if defined(STATION_G3_ESP32) + mesh::esp32::holdOutputForDeepSleep( + (gpio_num_t)station_g3::activePins().nss, HIGH); + #elif defined(P_LORA_NSS) + // Keep LoRa deselected even when NSS is not an RTC pad (classic GPIO18, + // Station G3 slot2 GPIO43). Configure OUTPUT even after partial radio init. + mesh::esp32::holdOutputForDeepSleep((gpio_num_t)P_LORA_NSS, HIGH); #endif // Power off GPS if any if (sensors.getLocationProvider() != NULL) { sensors.getLocationProvider()->stop(); } + // A stopped receiver may already have lost its rail. Avoid latching an + // idle-high UART output into it when digital deep-sleep auto-hold is armed. + sensors.shutdownGpsTransportForDeepSleep(); // Native USB must not wait for a host which has stopped reading. // Keep the original flush behavior for UART targets. @@ -1326,6 +1329,7 @@ void ESP32Board::enterDeepSleep(uint32_t secs) { } // Finally set ESP32 into deepsleep + mesh::esp32::commitTerminalDeepSleepHolds(); esp_deep_sleep_start(); // CPU halts here and never returns! } #endif diff --git a/src/helpers/ESP32Board.h b/src/helpers/ESP32Board.h index e4e63166b..6c07d8a6d 100644 --- a/src/helpers/ESP32Board.h +++ b/src/helpers/ESP32Board.h @@ -16,8 +16,12 @@ #include "esp_system.h" #include #include "ESP32TrueRandom.h" +#include "ESP32GpioHold.h" #include "UsbLogging.h" #include "UsbHostSleepPolicy.h" +#if defined(STATION_G3_ESP32) +#include +#endif #ifndef MESH_ESP32_USB_HOST_LOSS_SLEEP_GRACE_MS #define MESH_ESP32_USB_HOST_LOSS_SLEEP_GRACE_MS 120000UL @@ -53,6 +57,16 @@ protected: public: void begin() { + // Digital deep-sleep auto-hold survives wake. Clear it before runtime, + // restoring the dedicated radio select before releasing its per-pin hold. + mesh::esp32::resetTerminalDeepSleepHolds(); + #if defined(STATION_G3_ESP32) + mesh::esp32::releaseOutputAfterDeepSleep( + (gpio_num_t)station_g3::activePins().nss, HIGH); + #elif defined(P_LORA_NSS) + mesh::esp32::releaseOutputAfterDeepSleep((gpio_num_t)P_LORA_NSS, HIGH); + #endif + // Arduino's early init hook normally captured this before initVariant(). // Keep this idempotent fallback before this class touches ADC peripherals. mesh::initializeESP32TrueRandom(); diff --git a/src/helpers/ESP32GpioHold.h b/src/helpers/ESP32GpioHold.h new file mode 100644 index 000000000..678e3b8b2 --- /dev/null +++ b/src/helpers/ESP32GpioHold.h @@ -0,0 +1,105 @@ +#pragma once + +#include +#include + +namespace mesh { +namespace esp32 { + +// The RTC pad map differs by chip and board pin choice. NSS18 on classic +// ESP32 and NSS43/LNA42 on S3, for example, are ordinary digital GPIOs. +// These RTC-only wrappers are also used outside terminal shutdown. Do not +// replace them with digital holds: that would freeze live peripheral pins. +inline bool supportsRtcGpio(gpio_num_t pin) { +#if defined(SOC_RTCIO_INPUT_OUTPUT_SUPPORTED) && SOC_RTCIO_INPUT_OUTPUT_SUPPORTED + return static_cast(pin) >= 0 && rtc_gpio_is_valid_gpio(pin); +#else + (void)pin; + return false; +#endif +} + +inline esp_err_t holdRtcGpioIfSupported(gpio_num_t pin) { +#if defined(SOC_RTCIO_INPUT_OUTPUT_SUPPORTED) && SOC_RTCIO_INPUT_OUTPUT_SUPPORTED + if (supportsRtcGpio(pin)) return rtc_gpio_hold_en(pin); +#else + (void)pin; +#endif + return ESP_OK; +} + +inline esp_err_t releaseRtcGpioIfSupported(gpio_num_t pin) { +#if defined(SOC_RTCIO_INPUT_OUTPUT_SUPPORTED) && SOC_RTCIO_INPUT_OUTPUT_SUPPORTED + if (supportsRtcGpio(pin)) return rtc_gpio_hold_dis(pin); +#else + (void)pin; +#endif + return ESP_OK; +} + +inline esp_err_t deinitRtcGpioIfSupported(gpio_num_t pin) { +#if defined(SOC_RTCIO_INPUT_OUTPUT_SUPPORTED) && SOC_RTCIO_INPUT_OUTPUT_SUPPORTED + if (supportsRtcGpio(pin)) return rtc_gpio_deinit(pin); +#else + (void)pin; +#endif + return ESP_OK; +} + +// An inline function's local static is shared across translation units. A +// namespace-static flag here would silently give each board/FEM its own copy. +inline bool& terminalDigitalHoldPending() { + static bool pending = false; + return pending; +} + +inline esp_err_t configureHeldOutput(gpio_num_t pin, uint32_t level) { + if (static_cast(pin) < 0 || !GPIO_IS_VALID_OUTPUT_GPIO(pin)) { + return ESP_ERR_INVALID_ARG; + } + // Preload the latch before enabling the output. gpio_config also selects + // GPIO rather than a boot-time/peripheral IOMUX function after partial init. + esp_err_t result = gpio_set_level(pin, level ? 1 : 0); + if (result != ESP_OK) return result; + gpio_config_t config = {}; + config.pin_bit_mask = 1ULL << static_cast(pin); + config.mode = GPIO_MODE_OUTPUT; + config.pull_up_en = GPIO_PULLUP_DISABLE; + config.pull_down_en = GPIO_PULLDOWN_DISABLE; + config.intr_type = GPIO_INTR_DISABLE; + return gpio_config(&config); +} + +// Terminal shutdown only: explicitly select a known output before latching +// it. The IDF GPIO API selects the correct RTC/digital hold implementation. +inline esp_err_t holdOutputForDeepSleep(gpio_num_t pin, uint32_t level) { + esp_err_t result = configureHeldOutput(pin, level); + if (result != ESP_OK) return result; + result = gpio_hold_en(pin); + if (result == ESP_OK && !supportsRtcGpio(pin)) { + terminalDigitalHoldPending() = true; + } + return result; +} + +inline esp_err_t releaseOutputAfterDeepSleep(gpio_num_t pin, uint32_t level) { + // IDF requires mode/value to be restored BEFORE hold_dis to avoid a low + // glitch on wake. This also covers software resets while a hold was active. + esp_err_t result = configureHeldOutput(pin, level); + return result == ESP_OK ? gpio_hold_dis(pin) : result; +} + +inline void resetTerminalDeepSleepHolds() { + gpio_deep_sleep_hold_dis(); + terminalDigitalHoldPending() = false; +} + +inline void commitTerminalDeepSleepHolds() { + // IDF4.4's digital auto-hold affects all digital pads DURING deep sleep. + // Arm it only after every virtual shutdown hook and transport quiescence, + // immediately before terminal deep sleep; never in runtime or light sleep. + if (terminalDigitalHoldPending()) gpio_deep_sleep_hold_en(); +} + +} // namespace esp32 +} // namespace mesh diff --git a/src/helpers/SensorManager.h b/src/helpers/SensorManager.h index 75ce527d7..b525cd5d7 100644 --- a/src/helpers/SensorManager.h +++ b/src/helpers/SensorManager.h @@ -162,9 +162,15 @@ public: return false; } virtual LocationProvider* getLocationProvider() { return NULL; } + // Terminal deep sleep only. Providers may cut power without ending their + // UART; a concrete manager can release its own transport before pad hold. + virtual void shutdownGpsTransportForDeepSleep() { } // WisBlock slot power is a boot-time choice, separate from logical GPS on/off. virtual bool supportsSharedPeripheralMode() const { return false; } virtual void configureSharedPeripheralMode(bool shared) { (void)shared; } + // Terminal rail removal must not interrupt external storage or its retained + // recovery state. Unknown managers/discovery fail closed; no probing here. + virtual bool canRemoveSharedStoragePowerForDeepSleep() const { return false; } virtual bool isGpsPowered() const { LocationProvider* location = const_cast(this)->getLocationProvider(); return location != NULL && location->isEnabled(); diff --git a/src/helpers/UserGpioPinPolicy.h b/src/helpers/UserGpioPinPolicy.h index d08b2a7e2..e30d62d05 100644 --- a/src/helpers/UserGpioPinPolicy.h +++ b/src/helpers/UserGpioPinPolicy.h @@ -4,6 +4,9 @@ #include #include "sensors/EnvironmentI2CConfig.h" +#if defined(STATION_G3_ESP32) +#include +#endif // Simulator targets do not include RadioLib, but board pin macros can still // use its sentinel value. Keep the fallback local to this header. @@ -14,10 +17,15 @@ // This header is intentionally included only after target.h. The target's // build-time pin definitions are the authoritative list of GPIOs claimed by -// the firmware in that particular image. +// the firmware, except boot-selectable G3 slots which reserve both pin sets. namespace UserGpioPinPolicy { inline bool isFirmwareReserved(uint8_t pin) { +#if defined(STATION_G3_ESP32) + // A module may be present in the inactive slot too. Never offer its pins + // for user GPIO, even when the current boot selected the other slot. + if (station_g3::isRadioPin(pin)) return true; +#endif // RadioLib 7 represents its disconnected-pin sentinel as unsigned // 0xFFFFFFFF. Use a signed type wide enough for that value as well as the // board definitions that use -1, then reject both forms with the 0..63 diff --git a/src/helpers/esp32/TBeamBoard.cpp b/src/helpers/esp32/TBeamBoard.cpp index f8ad82a70..010b2ca32 100644 --- a/src/helpers/esp32/TBeamBoard.cpp +++ b/src/helpers/esp32/TBeamBoard.cpp @@ -1,4 +1,5 @@ #if defined(TBEAM_SUPREME_SX1262) || defined(TBEAM_SX1262) || defined(TBEAM_SX1276) +#include #include #include "TBeamBoard.h" @@ -50,8 +51,8 @@ void TBeamBoard::begin() { startup_reason = BD_STARTUP_RX_PACKET; } - rtc_gpio_hold_dis((gpio_num_t)P_LORA_NSS); - rtc_gpio_deinit((gpio_num_t)P_LORA_DIO_1); + mesh::esp32::releaseRtcGpioIfSupported((gpio_num_t)P_LORA_NSS); + mesh::esp32::deinitRtcGpioIfSupported((gpio_num_t)P_LORA_DIO_1); } } diff --git a/src/helpers/ethernet/RAK13800/RAK13800EthernetInterface.cpp b/src/helpers/ethernet/RAK13800/RAK13800EthernetInterface.cpp index 51455ecb6..f1b975af6 100644 --- a/src/helpers/ethernet/RAK13800/RAK13800EthernetInterface.cpp +++ b/src/helpers/ethernet/RAK13800/RAK13800EthernetInterface.cpp @@ -1,5 +1,7 @@ #include "RAK13800EthernetInterface.h" #include "../../nrf52/EthernetMac.h" +#include "../../nrf52/TerminalPeripheralShutdown.h" +#include "../../nrf52/EthernetCliTransmit.h" #include #include @@ -14,11 +16,10 @@ SPIClass ETHERNET_SPI_PORT(NRF_SPIM1, PIN_SPI1_MISO, PIN_SPI1_SCK, PIN_SPI1_MOSI #define PIN_ETHERNET_SS 26 bool RAK13800EthernetInterface::begin() { - - // WB_IO2 (power enable) is already driven HIGH by early constructor - // in RAK4631Board.cpp to support POE boot. - // Skip hardware reset — the W5100S comes out of power-on reset cleanly, - // and toggling reset kills the PHY link which breaks POE power. + if (_terminalStopping) return false; + if (!mesh::nrf52::registerTerminalEthernetShutdown(terminalShutdown, this)) return false; + // Preserve existing startup/link timing. PoE supplies separate 5V through + // the base's VCC_IN; WB_IO2 only supplies the W5100S's switched 3V3_S. #ifdef PIN_ETHERNET_RESET pinMode(PIN_ETHERNET_RESET, OUTPUT); digitalWrite(PIN_ETHERNET_RESET, HIGH); @@ -36,6 +37,7 @@ bool RAK13800EthernetInterface::begin() { mac[4], mac[5]); ETHERNET_SPI_PORT.begin(); + _spiStarted = true; Ethernet.init(ETHERNET_SPI_PORT, PIN_ETHERNET_SS); // Use static IP if build flags are defined, otherwise DHCP @@ -71,15 +73,39 @@ bool RAK13800EthernetInterface::begin() { return true; } +bool RAK13800EthernetInterface::terminalShutdown(void* context, uint32_t timeout_ms) { + return static_cast(context)->shutdownForPowerOff(timeout_ms); +} + +bool RAK13800EthernetInterface::shutdownForPowerOff(uint32_t timeout_ms) { + (void)timeout_ms; // All operations below are finite register transfers. + _terminalStopping = true; + // disable() cancels framed/session producers, but disconnectClient() must + // not enter the vendor socket-stop/ACK wait during terminal shutdown. + SerialEthernetInterface::disable(); + if (!_spiStarted) return true; + for (uint8_t socket = 0; socket < 4; ++socket) { + (void)mesh::nrf52::EthernetCliTransmit::close(socket); + } + client = EthernetClient(); + ETHERNET_SPI_PORT.end(); + _spiStarted = false; + mesh::nrf52::releaseRakEthernetBusPads(); + return true; +} + int RAK13800EthernetInterface::available() { + if (!_spiStarted || _terminalStopping) return 0; return client.available(); } int RAK13800EthernetInterface::read() { + if (!_spiStarted || _terminalStopping) return -1; return client.read(); } size_t RAK13800EthernetInterface::write(const uint8_t *buf, size_t size) { + if (!_spiStarted || _terminalStopping) return 0; return client.write(buf, size); } @@ -89,11 +115,11 @@ bool RAK13800EthernetInterface::isConnected() const { void RAK13800EthernetInterface::disconnectClient() { _isConnected = false; - client.stop(); + if (_spiStarted && !_terminalStopping) client.stop(); } void RAK13800EthernetInterface::loop() { - + if (!_spiStarted || _terminalStopping) return; Ethernet.maintain(); if (!isEnabled()) return; if (_isConnected && !client.connected()) { diff --git a/src/helpers/ethernet/RAK13800/RAK13800EthernetInterface.h b/src/helpers/ethernet/RAK13800/RAK13800EthernetInterface.h index 03288bb45..a66fed7d0 100644 --- a/src/helpers/ethernet/RAK13800/RAK13800EthernetInterface.h +++ b/src/helpers/ethernet/RAK13800/RAK13800EthernetInterface.h @@ -7,11 +7,15 @@ class RAK13800EthernetInterface : public SerialEthernetInterface { bool _isConnected; + bool _spiStarted = false; + bool _terminalStopping = false; EthernetServer server; EthernetClient client; protected: void disconnectClient() override; + bool shutdownForPowerOff(uint32_t timeout_ms); + static bool terminalShutdown(void* context, uint32_t timeout_ms); public: RAK13800EthernetInterface() : server(EthernetServer(ETHERNET_TCP_PORT)) { diff --git a/src/helpers/nrf52/EthernetCLI.h b/src/helpers/nrf52/EthernetCLI.h index 5115e5996..de82df631 100644 --- a/src/helpers/nrf52/EthernetCLI.h +++ b/src/helpers/nrf52/EthernetCLI.h @@ -7,9 +7,8 @@ #include #include #include -#if defined(RAK4631_COMBINED_ETHERNET) - #include -#endif +#include "TerminalPeripheralShutdown.h" +#include // Ethernet startup runs on a FreeRTOS task, and client acceptance runs in the // mesh loop. Never let either path wait on a CDC host which stopped reading. @@ -44,11 +43,7 @@ static EthernetServer ethernet_server(ETHERNET_TCP_PORT); #if !defined(RAK4631_COMBINED_ETHERNET) static EthernetClient ethernet_client; #endif -#if defined(RAK4631_COMBINED_ETHERNET) static std::atomic ethernet_running{false}; -#else -static volatile bool ethernet_running = false; -#endif #if defined(RAK4631_COMBINED_ETHERNET) static std::atomic ethernet_session_reset{false}; static bool ethernet_line_discarding = false; // Main-thread framing state. @@ -72,19 +67,58 @@ static void ethernet_check_client(); #include "EthernetCliRuntime.h" #else +static std::atomic ethernet_legacy_stop{false}; +static std::atomic ethernet_legacy_worker_active{false}; +static bool ethernet_legacy_spi_started = false; // Published by worker completion. +static TaskHandle_t ethernet_legacy_task_handle = nullptr; + +static void ethernet_legacy_finish_task() { + // No more hardware accesses after publication. This task deletes itself; + // terminal shutdown must never force-delete it inside a vendor SPI call. + taskENTER_CRITICAL(); + ethernet_legacy_worker_active.store(false); + ethernet_legacy_task_handle = nullptr; + taskEXIT_CRITICAL(); + vTaskDelete(nullptr); +} + +static bool ethernet_terminal_shutdown(void*, uint32_t timeout_ms) { + ethernet_legacy_stop.store(true); + ethernet_running = false; + taskENTER_CRITICAL(); + if (ethernet_legacy_task_handle) xTaskNotifyGive(ethernet_legacy_task_handle); + taskEXIT_CRITICAL(); + const uint32_t started = millis(); + while (ethernet_legacy_worker_active.load()) { + if (static_cast(millis() - started) >= timeout_ms) return false; + vTaskDelay(pdMS_TO_TICKS(1)); + } + ethernet_running = false; + // Main-loop client/maintain calls are now gated and the worker has no more + // hardware work. Do not call client.stop(): vendor ACK waits are unbounded. + ethernet_client = EthernetClient(); + ethernet_session_reset = true; + if (ethernet_legacy_spi_started) { + ETHERNET_SPI_PORT.end(); + ethernet_legacy_spi_started = false; + mesh::nrf52::releaseRakEthernetBusPads(); + } + return true; +} + // FreeRTOS task: handles hw init, DHCP, and retries in the background static void ethernet_task(void* param) { (void)param; + if (ethernet_legacy_stop.load()) { ethernet_legacy_finish_task(); return; } ETHERNET_CLI_LOG("Initializing hardware"); - // WB_IO2 (power enable) is already driven HIGH by early constructor - // in RAK4631Board.cpp to support POE boot. - // Skip hardware reset - the W5100S comes out of power-on reset cleanly, - // and toggling reset kills the PHY link which breaks POE power. + // WB_IO2 supplies the Ethernet controller, not PoE's separate 5V input. + // Retain the existing no-reset startup so the PHY link is not disturbed. pinMode(PIN_ETHERNET_RESET, OUTPUT); digitalWrite(PIN_ETHERNET_RESET, HIGH); ETHERNET_SPI_PORT.begin(); + ethernet_legacy_spi_started = true; Ethernet.init(ETHERNET_SPI_PORT, PIN_ETHERNET_SS); uint8_t mac[6]; @@ -93,12 +127,14 @@ static void ethernet_task(void* param) { mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); // Retry loop: keep trying until we get an IP - while (!ethernet_running) { + while (!ethernet_running && !ethernet_legacy_stop.load()) { ETHERNET_CLI_LOG("Attempting DHCP..."); - if (Ethernet.begin(mac, 10000, 2000) == 0) { + const bool connected = Ethernet.begin(mac, 10000, 2000) != 0; + if (ethernet_legacy_stop.load()) break; + if (!connected) { if (Ethernet.hardwareStatus() == EthernetNoHardware) { ETHERNET_CLI_LOG("Hardware not found, giving up"); - vTaskDelete(NULL); + ethernet_legacy_finish_task(); return; } if (Ethernet.linkStatus() == LinkOFF) { @@ -106,7 +142,7 @@ static void ethernet_task(void* param) { } else { ETHERNET_CLI_LOG("DHCP failed, will retry"); } - vTaskDelay(pdMS_TO_TICKS(ETHERNET_RETRY_INTERVAL_MS)); + (void)ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(ETHERNET_RETRY_INTERVAL_MS)); continue; } @@ -114,15 +150,25 @@ static void ethernet_task(void* param) { ETHERNET_CLI_LOG("IP: %u.%u.%u.%u", ip[0], ip[1], ip[2], ip[3]); ETHERNET_CLI_LOG("Listening on TCP port %d", ETHERNET_TCP_PORT); ethernet_server.begin(); - ethernet_running = true; + taskENTER_CRITICAL(); + if (!ethernet_legacy_stop.load()) ethernet_running = true; + taskEXIT_CRITICAL(); } // DHCP succeeded, task is done - vTaskDelete(NULL); + ethernet_legacy_finish_task(); } static void ethernet_start_task() { - xTaskCreate(ethernet_task, "eth_init", 1024, NULL, 1, NULL); + if (ethernet_legacy_worker_active.load() || ethernet_running || ethernet_legacy_stop.load()) return; + if (!mesh::nrf52::registerTerminalEthernetShutdown(ethernet_terminal_shutdown, nullptr)) return; + taskENTER_CRITICAL(); + ethernet_legacy_worker_active.store(true); + if (xTaskCreate(ethernet_task, "eth_init", 1024, nullptr, 1, ðernet_legacy_task_handle) != pdPASS) { + ethernet_legacy_worker_active.store(false); + ethernet_legacy_task_handle = nullptr; + } + taskEXIT_CRITICAL(); } #endif diff --git a/src/helpers/nrf52/EthernetCliRuntime.h b/src/helpers/nrf52/EthernetCliRuntime.h index d9dbf6de6..aa939f790 100644 --- a/src/helpers/nrf52/EthernetCliRuntime.h +++ b/src/helpers/nrf52/EthernetCliRuntime.h @@ -32,6 +32,7 @@ static TaskHandle_t ethernet_task_handle = nullptr; static bool ethernet_bus_reserved = false; static bool ethernet_spi_started = false; static bool ethernet_hardware_found = false; +static bool ethernet_terminal_pads_released = false; // Main-thread, per bus acquisition. static EthernetClient ethernet_raw_client; static std::atomic ethernet_client_attached{false}; static std::atomic ethernet_session_generation{0}; @@ -194,9 +195,11 @@ public: using Print::write; }; static EthernetCliClient ethernet_client; +static bool ethernet_terminal_shutdown(void*, uint32_t timeout_ms); static bool ethernet_configure(const EthernetCliHooks& hooks, bool saved_enabled = false) { if (ethernet_bus_reserved || ethernet_worker_active.load()) return false; + if (!mesh::nrf52::registerTerminalEthernetShutdown(ethernet_terminal_shutdown, nullptr)) return false; ethernet_hooks = hooks; ethernet_enabled.store(saved_enabled); ethernet_state.store(EthernetCliState::Off); @@ -253,6 +256,36 @@ static bool ethernet_complete_release() { return true; } +static bool ethernet_terminal_shutdown(void*, uint32_t timeout_ms) { + // This is a terminal lifecycle request, not a saved eth.off preference. + // A blocked DHCP/vendor call keeps its live bus and supply on until it + // cooperatively finishes; never delete that task or remap its pads early. + const bool had_bus = ethernet_bus_reserved || ethernet_spi_started || ethernet_worker_active.load(); + ethernet_enabled.store(false); + ethernet_running.store(false); + ethernet_state.store(EthernetCliState::Stopping); + ethernet_notify_worker(); + const uint32_t started = millis(); + for (;;) { + if (!ethernet_worker_active.load() || ethernet_worker_stopped.load()) { + if (had_bus && !ethernet_terminal_pads_released) { + // The stopped worker has ended its SPI controller. Release Ethernet + // pads before the OTA hook can reacquire those nets for storage. + mesh::nrf52::releaseRakEthernetBusPads(); + ethernet_terminal_pads_released = true; + } + // Includes synchronous task reclamation and the OTA-owner release hook + // (which can restore the radio). It must precede board common shutdown. + if (ethernet_complete_release()) { + ethernet_state.store(EthernetCliState::Off); + return true; + } + } + if (static_cast(millis() - started) >= timeout_ms) return false; + vTaskDelay(pdMS_TO_TICKS(1)); + } +} + static void ethernet_worker_take_access() { while (ethernet_spi_access.load() != EthernetSpiAccess::Worker) { EthernetSpiAccess expected = EthernetSpiAccess::Idle; @@ -462,6 +495,7 @@ static bool ethernet_request_start(char* reply, bool persist) { return false; } ethernet_bus_reserved = true; + ethernet_terminal_pads_released = false; ethernet_tx_queue = static_cast(malloc(ETHERNET_CLI_TX_BUFFER_BYTES)); if (!ethernet_tx_queue) { (void)ethernet_complete_release(); diff --git a/src/helpers/nrf52/TerminalPeripheralShutdown.h b/src/helpers/nrf52/TerminalPeripheralShutdown.h new file mode 100644 index 000000000..82716c28d --- /dev/null +++ b/src/helpers/nrf52/TerminalPeripheralShutdown.h @@ -0,0 +1,53 @@ +#pragma once + +#include + +namespace mesh { namespace nrf52 { + +// Registration and shutdown run on the application thread, never from an ISR. +// An inline function-local static is one shared object across translation +// units; a header-level static would silently separate the board and owner. +struct TerminalEthernetOwner { + bool (*stop)(void*, uint32_t) = nullptr; + void* context = nullptr; +}; + +inline TerminalEthernetOwner& terminalEthernetOwner() { + static TerminalEthernetOwner owner; + return owner; +} + +inline bool registerTerminalEthernetShutdown(bool (*stop)(void*, uint32_t), void* context) { +#if defined(RAK_3401) + // RAK13302 and RAK13800 occupy the same WisBlock IO slot and share SPI + // SCK/MISO/MOSI/CS; Ethernet RESET also aliases the radio FEM enable IO3. + // No runtime arbitration exists for this electrically conflicting pair. + (void)stop; + (void)context; + return false; +#else + if (!stop) return false; + auto& owner = terminalEthernetOwner(); + if (owner.stop && (owner.stop != stop || owner.context != context)) return false; + owner.stop = stop; + owner.context = context; + return true; +#endif +} + +inline bool terminalEthernetShutdown(uint32_t timeout_ms = 500) { + const auto& owner = terminalEthernetOwner(); + return !owner.stop || owner.stop(owner.context, timeout_ms); +} + +// Only after the actual SPI owner has stopped/ended its controller. These +// WisBlock nets otherwise retain high CS/reset/MOSI latches into 3V3_S loads. +inline void releaseRakEthernetBusPads() { + pinMode(3, INPUT); // SCK + pinMode(29, INPUT); // MISO + pinMode(30, INPUT); // MOSI + pinMode(26, INPUT); // CS + pinMode(21, INPUT); // W5100S reset (IO3) +} + +} } // namespace mesh::nrf52 diff --git a/src/helpers/radiolib/CustomLR2021.h b/src/helpers/radiolib/CustomLR2021.h index d0c9a2d3f..20b928cb5 100644 --- a/src/helpers/radiolib/CustomLR2021.h +++ b/src/helpers/radiolib/CustomLR2021.h @@ -9,6 +9,12 @@ #define LR2021_TX_BUSY_TIMEOUT_MS 1000UL #endif +// SIMO needs a fitted external inductor. Opt in only for verified board +// layouts; LR2021 also supports LDO-only designs without that circuitry. +#ifndef LR2021_USE_DCDC +#define LR2021_USE_DCDC 0 +#endif + class CustomLR2021 : public LR2021 { uint32_t _preambleMillis = 66; uint32_t _maxPayloadMillis = 3934; @@ -64,6 +70,11 @@ class CustomLR2021 : public LR2021 { const uint8_t gain = mesh::lr2021::isHighBand(frequency_mhz) ? gainModeHf : gainModeLf; int16_t status = LR2021::beginGFSK(frequency_mhz, mesh::MorseRx::ReceiverBitRateKbps, 0.6f, 4.8f, 2, 16, tcxoVoltage); + // Native modem initialization resets the regulator to LDO. Verified + // SIMO boards must restore DC/DC before admitting RX after any reset. + #if LR2021_USE_DCDC + if (status == RADIOLIB_ERR_NONE) status = setRegulatorDCDC(); + #endif if (status == RADIOLIB_ERR_NONE) status = restoreRfSwitchTable(); if (status == RADIOLIB_ERR_NONE) status = LR2021::setRxBoostedGainMode(gain); if (status == RADIOLIB_ERR_NONE) { @@ -115,6 +126,13 @@ class CustomLR2021 : public LR2021 { return false; // fail } + #if LR2021_USE_DCDC + // Also reached by radioDeepInit() when restoring LoRa after Morse RX. + // Check this before later setters can overwrite a regulator error. + status = setRegulatorDCDC(); + if (status != RADIOLIB_ERR_NONE) return false; + #endif + status = setCRC(2); if (status != RADIOLIB_ERR_NONE) return false; status = explicitHeader(); diff --git a/src/helpers/radiolib/CustomSX1262.h b/src/helpers/radiolib/CustomSX1262.h index 9c842a01d..c5a348b85 100644 --- a/src/helpers/radiolib/CustomSX1262.h +++ b/src/helpers/radiolib/CustomSX1262.h @@ -6,6 +6,9 @@ #include "RXPowerSaving.h" #include "SX1262ProfileSwitchState.h" #include "SX126xMorseRssi.h" +#if defined(STATION_G3_ESP32) +#include +#endif #ifndef MC_SX1262_FAST_PROFILE_SWITCH // CustomSX1262 targets use direct radio I/O unless a board opts out. Keep @@ -372,7 +375,14 @@ class CustomSX1262 : public SX1262 { MESH_DEBUG_PRINTLN("SX1262 regulator requested: %s", useRegulatorLDO ? "LDO" : "DC-DC"); - #if defined(P_LORA_SCLK) + #if defined(STATION_G3_ESP32) + // Reinitialization/watchdog recovery must keep the boot-selected slot; + // the build's historical slot macros are not the live SPI routing. + if (spi) { + const auto& pins = station_g3::activePins(); + spi->begin(pins.sck, pins.miso, pins.mosi); + } + #elif defined(P_LORA_SCLK) #ifdef NRF52_PLATFORM if (spi) { spi->setPins(P_LORA_MISO, P_LORA_SCLK, P_LORA_MOSI); spi->begin(); } #elif defined(RP2040_PLATFORM) diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 1bf5e1211..e3dd8e919 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -9,6 +9,9 @@ #include "RadioAirtime.h" #include "RXPowerSaving.h" #include "HilTxFrequencyGuard.h" +#if defined(STATION_G3_ESP32) +#include +#endif // Fallback RX powersaving timings, only used until setRxPowerSaving() is called // (begin() always applies the persisted values). The authoritative defaults live @@ -201,6 +204,11 @@ protected: return estimator.samplesRemaining() * (cycle_us / 1000000.0f); } + #if defined(STATION_G3_ESP32) + static bool hasDirectRadioResetPin() { + return station_g3::activePins().reset != static_cast(RADIOLIB_NC); + } + #else static constexpr bool hasDirectRadioResetPin() { #if defined(P_LORA_RESET) return P_LORA_RESET != RADIOLIB_NC; @@ -208,6 +216,7 @@ protected: return false; #endif } + #endif bool prepareRadioHardReset() { if (_board->supportsRadioHardReset()) return _board->resetRadio(); // A direct NRST is pulsed by the RadioLib begin() call that follows. diff --git a/src/helpers/sensors/EnvironmentSensorManager.cpp b/src/helpers/sensors/EnvironmentSensorManager.cpp index 6d5618554..5fcd3afa6 100644 --- a/src/helpers/sensors/EnvironmentSensorManager.cpp +++ b/src/helpers/sensors/EnvironmentSensorManager.cpp @@ -1222,6 +1222,12 @@ void EnvironmentSensorManager::setPowerSavingEnabled(bool enabled) { void EnvironmentSensorManager::finishBasicGpsDiscovery(bool found) { gps_discovery_pending = false; gps_detected = found; +#if defined(ESP32_PLATFORM) + // Relinquish the failed probe's UART while ownership is still known. Leaving + // its idle-high TX installed after the receiver rail is cut can back-power + // the receiver, especially when terminal deep sleep retains digital pads. + if (!found) shutdownGpsTransportForDeepSleep(); +#endif gps_serial_transport = found; if (found) { MESH_DEBUG_PRINTLN("GPS detected"); @@ -1388,6 +1394,19 @@ void EnvironmentSensorManager::configureSharedPeripheralMode(bool shared) { #endif } +bool EnvironmentSensorManager::canRemoveSharedStoragePowerForDeepSleep() const { +#if defined(RAK_BOARD) && !defined(RAK_WISMESH_TAG) + // Only the boot-frozen, read-only ownership result is relevant. Do not + // re-probe NOR, cancel a stage, reset a store, or assume unknown WIP ended. + // Radio and Ethernet are separately quiesced by the terminal board hook. + const uint8_t stopped_owners = mesh::gps::RadioPower | mesh::gps::EthernetPower; + return _wisblock_power.isConfigured() && !_wisblock_power.discoveryActive() + && (_wisblock_power.holds() & static_cast(~stopped_owners)) == 0; +#else + return false; +#endif +} + bool EnvironmentSensorManager::isGpsPowered() const { #ifdef RAK_WISBLOCK_GPS #if defined(PIN_GPS_EN) && PIN_GPS_EN >= 0 @@ -1669,6 +1688,22 @@ bool EnvironmentSensorManager::gpsUsesSerialUart(uint8_t uart) const { #endif } +void EnvironmentSensorManager::shutdownGpsTransportForDeepSleep() { +#if defined(ESP32_PLATFORM) && ENV_INCLUDE_GPS \ + && defined(PIN_GPS_TX) && PIN_GPS_TX >= 0 \ + && defined(PIN_GPS_RX) && PIN_GPS_RX >= 0 + // gpsUsesSerialUart stays true while a bridge temporarily owns the UART. + // Do not end that owner's transport or touch an I2C/shared peripheral bus. + if (gps_serial_transport && !gps_serial_transport_blocked) { + if (Serial1) Serial1.end(); + // The Arduino pin names are from the receiver's perspective on several + // boards. Release BOTH pads rather than assuming which one drives TX. + pinMode(PIN_GPS_TX, INPUT); + pinMode(PIN_GPS_RX, INPUT); + } +#endif +} + bool EnvironmentSensorManager::gpsSerialTransportMayConflict( uint8_t uart) const { #if ENV_INCLUDE_GPS diff --git a/src/helpers/sensors/EnvironmentSensorManager.h b/src/helpers/sensors/EnvironmentSensorManager.h index bfb94aa58..34119a22b 100644 --- a/src/helpers/sensors/EnvironmentSensorManager.h +++ b/src/helpers/sensors/EnvironmentSensorManager.h @@ -102,9 +102,11 @@ public: void setPowerSavingEnabled(bool enabled) override; bool supportsSharedPeripheralMode() const override; void configureSharedPeripheralMode(bool shared) override; + bool canRemoveSharedStoragePowerForDeepSleep() const override; bool isGpsPowered() const override; bool gpsUsesSerialUart(uint8_t uart) const override; 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; }; diff --git a/src/helpers/sensors/WisBlockSharedPowerPolicy.h b/src/helpers/sensors/WisBlockSharedPowerPolicy.h index b89b60f7a..29833ce3d 100644 --- a/src/helpers/sensors/WisBlockSharedPowerPolicy.h +++ b/src/helpers/sensors/WisBlockSharedPowerPolicy.h @@ -17,9 +17,10 @@ enum WisBlockRailHold : uint8_t { ExternalStorage = 1U << 2, }; -// These are hardware dependencies, not whether the corresponding service is -// enabled at runtime. In particular, removing Ethernet power can brown out a -// PoE-powered core even when its network service is disabled. +// Conservative live-service/bus ownership holds, not proof of physical module +// presence. Ethernet builds may still have an active worker or retained bus; +// keep their normal runtime policy until an explicit owner-aware handoff. +// RAK13800 PoE's separate 5V input does not depend on this switched 3V3_S rail. constexpr uint8_t wisBlockCompileRailHolds() { return 0 #if defined(RAK_3401) diff --git a/src/helpers/ui/DRV2605Vibration.cpp b/src/helpers/ui/DRV2605Vibration.cpp index 9a782faf2..d00e2c055 100644 --- a/src/helpers/ui/DRV2605Vibration.cpp +++ b/src/helpers/ui/DRV2605Vibration.cpp @@ -3,13 +3,26 @@ #include "DRV2605Vibration.h" #include +namespace { +// TI DRV2605L datasheet 8.4.1.4: software standby retains configuration and +// I2C access. Idle INTTRIG/RTP mode alone does not enter this low-power state. +constexpr uint8_t Standby = 0x40; +constexpr uint32_t EffectPollMillis = 20; +} + void DRV2605Vibration::begin() { + _ready = false; + _effect_active = _rtp_active = false; + _last_trigger = 0; #ifdef PIN_DRV_EN pinMode(PIN_DRV_EN, OUTPUT); digitalWrite(PIN_DRV_EN, HIGH); // power up the haptic driver delay(10); #endif if (!drv.begin(&Wire)) { +#ifdef PIN_DRV_EN + digitalWrite(PIN_DRV_EN, LOW); +#endif return; // no haptic driver found, stay silent } #ifdef DRV2605_USE_LRA @@ -17,7 +30,7 @@ void DRV2605Vibration::begin() { drv.writeRegister8(DRV2605_REG_FEEDBACK, 0xB6); #endif drv.selectLibrary(1); - drv.setMode(DRV2605_MODE_INTTRIG); + drv.setMode(Standby); _ready = true; } @@ -32,23 +45,46 @@ void DRV2605Vibration::trigger(bool force) { drv.setWaveform(2, DRV2605_EFFECT); drv.setWaveform(3, 0); // end of sequence drv.go(); + _effect_active = true; + _rtp_active = false; + _last_effect_check = now; } void DRV2605Vibration::loop() { + if (!_ready || !_effect_active) return; + const uint32_t now = millis(); + if (now - _last_effect_check < EffectPollMillis) return; + _last_effect_check = now; + // GO self-clears only after the complete waveform sequence. Do not infer + // completion from a fixed duration or poll GO in RTP mode (datasheet 8.4.3). + if ((drv.readRegister8(DRV2605_REG_GO) & 1) == 0) { + drv.setMode(Standby); + _effect_active = false; + } } bool DRV2605Vibration::isVibrating() { - return false; // effects are short, treat as instantaneous + return _effect_active || _rtp_active; } void DRV2605Vibration::stop() { - if (_ready) drv.stop(); + if (!_ready) return; + drv.stop(); + drv.setRealtimeValue(0); + drv.setMode(Standby); + _effect_active = _rtp_active = false; } void DRV2605Vibration::pulse(bool on) { if (!_ready) return; + if (!on) { + stop(); + return; + } drv.setMode(DRV2605_MODE_REALTIME); - drv.setRealtimeValue(on ? 100 : 0); + drv.setRealtimeValue(100); + _effect_active = false; + _rtp_active = true; } #endif // ifdef HAS_DRV2605 diff --git a/src/helpers/ui/DRV2605Vibration.h b/src/helpers/ui/DRV2605Vibration.h index 85e630700..f6b5ceecd 100644 --- a/src/helpers/ui/DRV2605Vibration.h +++ b/src/helpers/ui/DRV2605Vibration.h @@ -23,7 +23,7 @@ class DRV2605Vibration { public: void begin(); // power up and init the DRV2605 void trigger(bool force = false); // trigger vibration; force skips the cooldown - void loop(); // no-op, the DRV2605 plays effects autonomously + void loop(); // return to standby after autonomous effects complete bool isVibrating(); void stop(); // stop vibration immediately void pulse(bool on); // continuous RTP amplitude for scheduled ms pulses @@ -34,6 +34,9 @@ private: Adafruit_DRV2605 drv; bool _ready = false; bool _quiet = false; + bool _effect_active = false; + bool _rtp_active = false; + uint32_t _last_effect_check = 0; unsigned long _last_trigger = 0; }; diff --git a/test/fixtures/hwcdc_kiss_setup.cpp b/test/fixtures/hwcdc_kiss_setup.cpp index 2d80175ca..794e1d009 100644 --- a/test/fixtures/hwcdc_kiss_setup.cpp +++ b/test/fixtures/hwcdc_kiss_setup.cpp @@ -9,6 +9,8 @@ static std::vector 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 expected = { + "logging.gate", "debug.gate", "prepare", "serial.begin", "begin.logging", + "board.begin", "radio.begin", "modem.construct", "modem.begin", "boot.complete"}; +#else const std::vector 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 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 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 recovery = { + "logging.gate", "debug.gate", "prepare", "serial.begin", "begin.logging", + "board.begin", "radio.recovery"}; +#else + const std::vector recovery = { + "prepare", "serial.begin", "begin.logging", "board.begin", "radio.recovery"}; +#endif + assert(calls == recovery); +#endif } diff --git a/test/fixtures/rak4631_ethernet_runtime/mocks/Arduino.h b/test/fixtures/rak4631_ethernet_runtime/mocks/Arduino.h index 6db6536a8..d3440492a 100644 --- a/test/fixtures/rak4631_ethernet_runtime/mocks/Arduino.h +++ b/test/fixtures/rak4631_ethernet_runtime/mocks/Arduino.h @@ -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); diff --git a/test/fixtures/rak4631_ethernet_runtime/test.cpp b/test/fixtures/rak4631_ethernet_runtime/test.cpp index 96d2112f3..66046b88b 100644 --- a/test/fixtures/rak4631_ethernet_runtime/test.cpp +++ b/test/fixtures/rak4631_ethernet_runtime/test.cpp @@ -33,6 +33,7 @@ static std::atomic hardware_operations{0}; static std::atomic main_hardware_operations{0}; static std::atomic live_task_allocations{0}; static std::atomic task_deletions{0}; +static unsigned terminal_pad_releases = 0; static bool fail_task_creation = false; static std::atomic 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 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(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()); diff --git a/test/fixtures/rak_gps_power.cpp b/test/fixtures/rak_gps_power.cpp index c6e130848..4c9383c1b 100644 --- a/test/fixtures/rak_gps_power.cpp +++ b/test/fixtures/rak_gps_power.cpp @@ -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"); } diff --git a/test/fixtures/rak_peripheral_power_no_gps.cpp b/test/fixtures/rak_peripheral_power_no_gps.cpp index 5aa71bd29..61cdb2375 100644 --- a/test/fixtures/rak_peripheral_power_no_gps.cpp +++ b/test/fixtures/rak_peripheral_power_no_gps.cpp @@ -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")); diff --git a/test/fixtures/usb_diagnostic_logging/mocks/Arduino.h b/test/fixtures/usb_diagnostic_logging/mocks/Arduino.h index ca589a106..969953466 100644 --- a/test/fixtures/usb_diagnostic_logging/mocks/Arduino.h +++ b/test/fixtures/usb_diagnostic_logging/mocks/Arduino.h @@ -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; } diff --git a/test/fixtures/usb_diagnostic_logging/mocks/SPI.h b/test/fixtures/usb_diagnostic_logging/mocks/SPI.h index 2ac1f2953..c1675f467 100644 --- a/test/fixtures/usb_diagnostic_logging/mocks/SPI.h +++ b/test/fixtures/usb_diagnostic_logging/mocks/SPI.h @@ -4,4 +4,5 @@ class SPIClass { public: SPIClass(int, int, int, int) {} void begin() {} + void end() {} }; diff --git a/test/test_drv2605_power.py b/test/test_drv2605_power.py new file mode 100644 index 000000000..d6203d95c --- /dev/null +++ b/test/test_drv2605_power.py @@ -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 +#include +#include +#include +#define OUTPUT 1 +#define LOW 0 +#define HIGH 1 +struct Event { std::string name; unsigned value; }; +extern std::vector 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(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 +#include +#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((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 +std::vector 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 ') + 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() diff --git a/test/test_environment_no_gps_link.py b/test/test_environment_no_gps_link.py index 8a0e10b0b..7ba115778 100644 --- a/test/test_environment_no_gps_link.py +++ b/test/test_environment_no_gps_link.py @@ -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)); } diff --git a/test/test_esp32_full_sleep.py b/test/test_esp32_full_sleep.py index d943cb1f5..23ca06f5b 100644 --- a/test/test_esp32_full_sleep.py +++ b/test/test_esp32_full_sleep.py @@ -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; diff --git a/test/test_esp32_gpio_hold.py b/test/test_esp32_gpio_hold.py new file mode 100644 index 000000000..facd07c37 --- /dev/null +++ b/test/test_esp32_gpio_hold.py @@ -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 +#include +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<=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 +#include +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 +#include +#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 +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 +void femShutdown(); +int main(){ mesh::esp32::resetTerminalDeepSleepHolds(); femShutdown(); + assert(!deep_hold); mesh::esp32::commitTerminalDeepSleepHolds(); assert(deep_hold); +} +''',s3=1,extra_source=r''' +#include +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 +#include +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 +#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 +#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 \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 +#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 +#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() diff --git a/test/test_esp32_gps_discovery.py b/test/test_esp32_gps_discovery.py index 58d332430..d46a442cb 100644 --- a/test/test_esp32_gps_discovery.py +++ b/test/test_esp32_gps_discovery.py @@ -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) diff --git a/test/test_esp32_scanner_power_yield.py b/test/test_esp32_scanner_power_yield.py new file mode 100644 index 000000000..07a511dcc --- /dev/null +++ b/test/test_esp32_scanner_power_yield.py @@ -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 +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() diff --git a/test/test_ethernet_terminal_shutdown.py b/test/test_ethernet_terminal_shutdown.py new file mode 100644 index 000000000..cd4ff02e7 --- /dev/null +++ b/test/test_ethernet_terminal_shutdown.py @@ -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 +#include +#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 +#include +#include +#include +#include +#include +#include +#include +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 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 +#include +#include +#include +#include +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 +#include +#include +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 +#include +#include +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("", '"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 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() diff --git a/test/test_heltec_r8_external_power.py b/test/test_heltec_r8_external_power.py index 74ee3592a..640c965dd 100644 --- a/test/test_heltec_r8_external_power.py +++ b/test/test_heltec_r8_external_power.py @@ -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 +#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, } diff --git a/test/test_hwcdc_role_transport.py b/test/test_hwcdc_role_transport.py index 3bf151785..a9ee89516 100644 --- a/test/test_hwcdc_role_transport.py +++ b/test/test_hwcdc_role_transport.py @@ -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 []) diff --git a/test/test_kiss_radio_slot.py b/test/test_kiss_radio_slot.py new file mode 100644 index 000000000..2f57771ae --- /dev/null +++ b/test/test_kiss_radio_slot.py @@ -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 +#include +#include +#include +#include +#include + +struct Buffer : Stream { + std::vector input, output; + size_t at = 0, chunk = 4096; + int availableForWrite() override { return static_cast(chunk); } + int available() override { return static_cast(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(mode[0] - '0'); + std::strcpy(reply, "OK - radio.slot saved; reboot to apply"); + return true; + } +}; +struct Sensors : SensorManager {}; +static std::vector expected(uint8_t response, std::vector bytes = {}) { + std::vector 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 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{}, std::vector{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(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{}, std::vector{2}, std::vector{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() diff --git a/test/test_lr2021_power.py b/test/test_lr2021_power.py new file mode 100644 index 000000000..78d1179b5 --- /dev/null +++ b/test/test_lr2021_power.py @@ -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 +#include +#include +#include +#include +#include +@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 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({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({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() diff --git a/test/test_meshtracker_x1_battery.py b/test/test_meshtracker_x1_battery.py new file mode 100644 index 000000000..b8b2a45ba --- /dev/null +++ b/test/test_meshtracker_x1_battery.py @@ -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 +#include +#include +#include +#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 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(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((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() diff --git a/test/test_rak4631_ethernet_runtime.py b/test/test_rak4631_ethernet_runtime.py index 275ef0909..09816f831 100644 --- a/test/test_rak4631_ethernet_runtime.py +++ b/test/test_rak4631_ethernet_runtime.py @@ -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", ) diff --git a/test/test_rak_gps_power.py b/test/test_rak_gps_power.py index ba2100976..2a89c148e 100644 --- a/test/test_rak_gps_power.py +++ b/test/test_rak_gps_power.py @@ -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() diff --git a/test/test_rak_shutdown_power.py b/test/test_rak_shutdown_power.py new file mode 100644 index 000000000..f89930f03 --- /dev/null +++ b/test/test_rak_shutdown_power.py @@ -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 +#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 +#include +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 +class StationG3Board { + public: + bool handleCommand(const char*, uint32_t, char*); + void reboot(); +}; +''' + +HARNESS = r''' +#include "StationG3BootRecovery.h" +#include "StationG3Board.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +struct Port : Stream { + std::string input, output; + size_t at = 0, readCount = 0, writeCount = 0; + size_t txLimit = std::numeric_limits::max(); + bool transientReadFailure = false; + int available() override { return static_cast(input.size() - at); } + int read() override { + ++readCount; + if (transientReadFailure) { transientReadFailure = false; return -1; } + return at < input.size() ? static_cast(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(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 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(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() diff --git a/test/test_station_g3_radio_pins.py b/test/test_station_g3_radio_pins.py new file mode 100644 index 000000000..194915021 --- /dev/null +++ b/test/test_station_g3_radio_pins.py @@ -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 +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 +#include +#include +#if defined(STATION_G3_ESP32) +#include +#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 +#include +#include +#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 +#include +#define RADIOLIB_NC (-1) +#if defined(STATION_G3_ESP32) +#include +#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 +#include +#include +#if defined(STATION_G3_ESP32) +#include +#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 \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() diff --git a/test/test_station_g3_slot_detection.py b/test/test_station_g3_slot_detection.py new file mode 100644 index 000000000..6cc7eb73a --- /dev/null +++ b/test/test_station_g3_slot_detection.py @@ -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 +#include +#include +#include +#include +#include +#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 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] && "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 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() diff --git a/test/test_station_g3_slot_settings.py b/test/test_station_g3_slot_settings.py new file mode 100644 index 000000000..5f16fe07e --- /dev/null +++ b/test/test_station_g3_slot_settings.py @@ -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 +#include +#include +#include +#include +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 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 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 +#include +#include +#include +#include +#include +#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> releases; +inline std::vector 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 +#include +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({"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<= 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 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") diff --git a/test/test_xiao_battery_safety.py b/test/test_xiao_battery_safety.py new file mode 100644 index 000000000..91aee038c --- /dev/null +++ b/test/test_xiao_battery_safety.py @@ -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 +#include +#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 ", "#include \n#include ").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() diff --git a/variants/heltec_e213/HeltecE213Board.cpp b/variants/heltec_e213/HeltecE213Board.cpp index 88737c4d5..e336aaa42 100644 --- a/variants/heltec_e213/HeltecE213Board.cpp +++ b/variants/heltec_e213/HeltecE213Board.cpp @@ -1,4 +1,5 @@ #include "HeltecE213Board.h" +#include void HeltecE213Board::begin() { ESP32Board::begin(); @@ -15,8 +16,8 @@ void HeltecE213Board::begin() { startup_reason = BD_STARTUP_RX_PACKET; } - rtc_gpio_hold_dis((gpio_num_t)P_LORA_NSS); - rtc_gpio_deinit((gpio_num_t)P_LORA_DIO_1); + mesh::esp32::releaseRtcGpioIfSupported((gpio_num_t)P_LORA_NSS); + mesh::esp32::deinitRtcGpioIfSupported((gpio_num_t)P_LORA_DIO_1); } } diff --git a/variants/heltec_e290/HeltecE290Board.cpp b/variants/heltec_e290/HeltecE290Board.cpp index 96ec59c9b..2e491faf1 100644 --- a/variants/heltec_e290/HeltecE290Board.cpp +++ b/variants/heltec_e290/HeltecE290Board.cpp @@ -1,4 +1,5 @@ #include "HeltecE290Board.h" +#include void HeltecE290Board::begin() { ESP32Board::begin(); @@ -15,8 +16,8 @@ void HeltecE290Board::begin() { startup_reason = BD_STARTUP_RX_PACKET; } - rtc_gpio_hold_dis((gpio_num_t)P_LORA_NSS); - rtc_gpio_deinit((gpio_num_t)P_LORA_DIO_1); + mesh::esp32::releaseRtcGpioIfSupported((gpio_num_t)P_LORA_NSS); + mesh::esp32::deinitRtcGpioIfSupported((gpio_num_t)P_LORA_DIO_1); } } diff --git a/variants/heltec_rc32/HeltecRC32Board.cpp b/variants/heltec_rc32/HeltecRC32Board.cpp index ae8e8b662..1f3048b51 100644 --- a/variants/heltec_rc32/HeltecRC32Board.cpp +++ b/variants/heltec_rc32/HeltecRC32Board.cpp @@ -1,4 +1,5 @@ #include "HeltecRC32Board.h" +#include void HeltecRC32Board::begin() { ESP32Board::begin(); @@ -29,8 +30,8 @@ void HeltecRC32Board::begin() { startup_reason = BD_STARTUP_RX_PACKET; } - rtc_gpio_hold_dis((gpio_num_t)P_LORA_NSS); - rtc_gpio_deinit((gpio_num_t)P_LORA_DIO_1); + mesh::esp32::releaseRtcGpioIfSupported((gpio_num_t)P_LORA_NSS); + mesh::esp32::deinitRtcGpioIfSupported((gpio_num_t)P_LORA_DIO_1); } } diff --git a/variants/heltec_t190/HeltecT190Board.cpp b/variants/heltec_t190/HeltecT190Board.cpp index 0a16b52b8..9d8d46da7 100644 --- a/variants/heltec_t190/HeltecT190Board.cpp +++ b/variants/heltec_t190/HeltecT190Board.cpp @@ -1,4 +1,5 @@ #include "HeltecT190Board.h" +#include void HeltecT190Board::begin() { ESP32Board::begin(); @@ -15,8 +16,8 @@ void HeltecT190Board::begin() { startup_reason = BD_STARTUP_RX_PACKET; } - rtc_gpio_hold_dis((gpio_num_t)P_LORA_NSS); - rtc_gpio_deinit((gpio_num_t)P_LORA_DIO_1); + mesh::esp32::releaseRtcGpioIfSupported((gpio_num_t)P_LORA_NSS); + mesh::esp32::deinitRtcGpioIfSupported((gpio_num_t)P_LORA_DIO_1); } } diff --git a/variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp b/variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp index 7716af97b..51a037ea6 100644 --- a/variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp +++ b/variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp @@ -1,4 +1,5 @@ #include "HeltecTrackerV2Board.h" +#include void HeltecTrackerV2Board::begin() { ESP32Board::begin(); @@ -20,8 +21,8 @@ void HeltecTrackerV2Board::begin() { startup_reason = BD_STARTUP_RX_PACKET; } - rtc_gpio_hold_dis((gpio_num_t)P_LORA_NSS); - rtc_gpio_deinit((gpio_num_t)P_LORA_DIO_1); + mesh::esp32::releaseRtcGpioIfSupported((gpio_num_t)P_LORA_NSS); + mesh::esp32::deinitRtcGpioIfSupported((gpio_num_t)P_LORA_DIO_1); } } @@ -42,8 +43,7 @@ void HeltecTrackerV2Board::begin() { // only in variants/heltec_tracker_v2/platformio.ini). Same guard idiom // LoRaFEMControl.cpp already uses for this macro. #if defined(P_LORA_PA_POWER) - digitalWrite(P_LORA_PA_POWER, LOW); - rtc_gpio_hold_en((gpio_num_t)P_LORA_PA_POWER); + mesh::esp32::holdOutputForDeepSleep((gpio_num_t)P_LORA_PA_POWER, LOW); #endif } diff --git a/variants/heltec_tracker_v2/LoRaFEMControl.cpp b/variants/heltec_tracker_v2/LoRaFEMControl.cpp index 83f6ff035..df7159ef4 100644 --- a/variants/heltec_tracker_v2/LoRaFEMControl.cpp +++ b/variants/heltec_tracker_v2/LoRaFEMControl.cpp @@ -1,4 +1,5 @@ #include "LoRaFEMControl.h" +#include #include #include #include @@ -8,9 +9,9 @@ void LoRaFEMControl::init(void) #if defined(P_LORA_PA_POWER) && defined(P_LORA_KCT8103L_PA_CSD) && defined(P_LORA_KCT8103L_PA_CTX) pinMode(P_LORA_PA_POWER, OUTPUT); digitalWrite(P_LORA_PA_POWER, HIGH); - rtc_gpio_hold_dis((gpio_num_t)P_LORA_PA_POWER); - rtc_gpio_hold_dis((gpio_num_t)P_LORA_KCT8103L_PA_CSD); - rtc_gpio_hold_dis((gpio_num_t)P_LORA_KCT8103L_PA_CTX); + mesh::esp32::releaseRtcGpioIfSupported((gpio_num_t)P_LORA_PA_POWER); + mesh::esp32::releaseRtcGpioIfSupported((gpio_num_t)P_LORA_KCT8103L_PA_CSD); + mesh::esp32::releaseRtcGpioIfSupported((gpio_num_t)P_LORA_KCT8103L_PA_CTX); delay(1); pinMode(P_LORA_KCT8103L_PA_CSD, OUTPUT); digitalWrite(P_LORA_KCT8103L_PA_CSD, HIGH); @@ -52,13 +53,13 @@ void LoRaFEMControl::setRxModeEnableWhenMCUSleep(void) { #if defined(P_LORA_KCT8103L_PA_CSD) && defined(P_LORA_KCT8103L_PA_CTX) digitalWrite(P_LORA_KCT8103L_PA_CSD, HIGH); - rtc_gpio_hold_en((gpio_num_t)P_LORA_KCT8103L_PA_CSD); + mesh::esp32::holdRtcGpioIfSupported((gpio_num_t)P_LORA_KCT8103L_PA_CSD); if (lna_enabled) { digitalWrite(P_LORA_KCT8103L_PA_CTX, LOW); } else { digitalWrite(P_LORA_KCT8103L_PA_CTX, HIGH); } - rtc_gpio_hold_en((gpio_num_t)P_LORA_KCT8103L_PA_CTX); + mesh::esp32::holdRtcGpioIfSupported((gpio_num_t)P_LORA_KCT8103L_PA_CTX); #endif } diff --git a/variants/heltec_v2/HeltecV2Board.h b/variants/heltec_v2/HeltecV2Board.h index 9b08fe943..25e8d2f56 100644 --- a/variants/heltec_v2/HeltecV2Board.h +++ b/variants/heltec_v2/HeltecV2Board.h @@ -1,4 +1,5 @@ #pragma once +#include #include #include @@ -19,8 +20,8 @@ public: startup_reason = BD_STARTUP_RX_PACKET; } - rtc_gpio_hold_dis((gpio_num_t)P_LORA_NSS); - rtc_gpio_deinit((gpio_num_t)P_LORA_DIO_0); + mesh::esp32::releaseRtcGpioIfSupported((gpio_num_t)P_LORA_NSS); + mesh::esp32::deinitRtcGpioIfSupported((gpio_num_t)P_LORA_DIO_0); } } diff --git a/variants/heltec_v3/HeltecV3Board.h b/variants/heltec_v3/HeltecV3Board.h index 0b1569b90..17e5708d6 100644 --- a/variants/heltec_v3/HeltecV3Board.h +++ b/variants/heltec_v3/HeltecV3Board.h @@ -1,4 +1,5 @@ #pragma once +#include #include #include @@ -45,8 +46,8 @@ public: startup_reason = BD_STARTUP_RX_PACKET; } - rtc_gpio_hold_dis((gpio_num_t)P_LORA_NSS); - rtc_gpio_deinit((gpio_num_t)P_LORA_DIO_1); + mesh::esp32::releaseRtcGpioIfSupported((gpio_num_t)P_LORA_NSS); + mesh::esp32::deinitRtcGpioIfSupported((gpio_num_t)P_LORA_DIO_1); } } diff --git a/variants/heltec_v4/HeltecV4Board.cpp b/variants/heltec_v4/HeltecV4Board.cpp index 77300d8a6..24483c5e4 100644 --- a/variants/heltec_v4/HeltecV4Board.cpp +++ b/variants/heltec_v4/HeltecV4Board.cpp @@ -1,4 +1,5 @@ #include "HeltecV4Board.h" +#include void HeltecV4Board::begin() { ESP32Board::begin(); @@ -17,8 +18,8 @@ void HeltecV4Board::begin() { startup_reason = BD_STARTUP_RX_PACKET; } - rtc_gpio_hold_dis((gpio_num_t)P_LORA_NSS); - rtc_gpio_deinit((gpio_num_t)P_LORA_DIO_1); + mesh::esp32::releaseRtcGpioIfSupported((gpio_num_t)P_LORA_NSS); + mesh::esp32::deinitRtcGpioIfSupported((gpio_num_t)P_LORA_DIO_1); } } @@ -36,8 +37,7 @@ void HeltecV4Board::begin() { ESP32Board::shutdownPeripherals(); // Turn off PA - digitalWrite(P_LORA_PA_POWER, LOW); - rtc_gpio_hold_en((gpio_num_t)P_LORA_PA_POWER); + mesh::esp32::holdOutputForDeepSleep((gpio_num_t)P_LORA_PA_POWER, LOW); } uint16_t HeltecV4Board::getBattMilliVolts() { diff --git a/variants/heltec_v4/LoRaFEMControl.cpp b/variants/heltec_v4/LoRaFEMControl.cpp index 25fbc8b4e..f082306ce 100644 --- a/variants/heltec_v4/LoRaFEMControl.cpp +++ b/variants/heltec_v4/LoRaFEMControl.cpp @@ -1,4 +1,5 @@ #include "LoRaFEMControl.h" +#include #include #include #include @@ -9,7 +10,7 @@ void LoRaFEMControl::init(void) // atomic transition (no glitch on deep sleep wake). pinMode(P_LORA_PA_POWER, OUTPUT); digitalWrite(P_LORA_PA_POWER, HIGH); - rtc_gpio_hold_dis((gpio_num_t)P_LORA_PA_POWER); + mesh::esp32::releaseRtcGpioIfSupported((gpio_num_t)P_LORA_PA_POWER); esp_reset_reason_t reason = esp_reset_reason(); if (reason != ESP_RST_DEEPSLEEP) { @@ -19,7 +20,7 @@ void LoRaFEMControl::init(void) // Auto-detect FEM type via shared GPIO2 default pull level. // GC1109 CSD: internal pull-down -> reads LOW // KCT8103L CSD: internal pull-up -> reads HIGH - rtc_gpio_hold_dis((gpio_num_t)P_LORA_KCT8103L_PA_CSD); + mesh::esp32::releaseRtcGpioIfSupported((gpio_num_t)P_LORA_KCT8103L_PA_CSD); pinMode(P_LORA_KCT8103L_PA_CSD, INPUT); delay(1); if(digitalRead(P_LORA_KCT8103L_PA_CSD)==HIGH) { @@ -27,7 +28,7 @@ void LoRaFEMControl::init(void) fem_type= KCT8103L_PA; pinMode(P_LORA_KCT8103L_PA_CSD, OUTPUT); digitalWrite(P_LORA_KCT8103L_PA_CSD, HIGH); - rtc_gpio_hold_dis((gpio_num_t)P_LORA_KCT8103L_PA_CTX); + mesh::esp32::releaseRtcGpioIfSupported((gpio_num_t)P_LORA_KCT8103L_PA_CTX); pinMode(P_LORA_KCT8103L_PA_CTX, OUTPUT); digitalWrite(P_LORA_KCT8103L_PA_CTX, lna_enabled ? LOW : HIGH); setLnaCanControl(true); @@ -85,20 +86,20 @@ void LoRaFEMControl::setRxModeEnable(void) void LoRaFEMControl::setRxModeEnableWhenMCUSleep(void) { digitalWrite(P_LORA_PA_POWER, HIGH); - rtc_gpio_hold_en((gpio_num_t)P_LORA_PA_POWER); + mesh::esp32::holdRtcGpioIfSupported((gpio_num_t)P_LORA_PA_POWER); if(fem_type==GC1109_PA) { digitalWrite(P_LORA_GC1109_PA_EN, HIGH); - rtc_gpio_hold_en((gpio_num_t)P_LORA_GC1109_PA_EN); + mesh::esp32::holdRtcGpioIfSupported((gpio_num_t)P_LORA_GC1109_PA_EN); gpio_pulldown_en((gpio_num_t)P_LORA_GC1109_PA_TX_EN); } else if(fem_type==KCT8103L_PA) { digitalWrite(P_LORA_KCT8103L_PA_CSD, HIGH); - rtc_gpio_hold_en((gpio_num_t)P_LORA_KCT8103L_PA_CSD); + mesh::esp32::holdRtcGpioIfSupported((gpio_num_t)P_LORA_KCT8103L_PA_CSD); if(lna_enabled) { digitalWrite(P_LORA_KCT8103L_PA_CTX, LOW); // LNA on } else { digitalWrite(P_LORA_KCT8103L_PA_CTX, HIGH); // LNA bypass } - rtc_gpio_hold_en((gpio_num_t)P_LORA_KCT8103L_PA_CTX); + mesh::esp32::holdRtcGpioIfSupported((gpio_num_t)P_LORA_KCT8103L_PA_CTX); } } diff --git a/variants/heltec_v4_r8/HeltecV4R8Board.cpp b/variants/heltec_v4_r8/HeltecV4R8Board.cpp index 5ad27606a..f1ce286c4 100644 --- a/variants/heltec_v4_r8/HeltecV4R8Board.cpp +++ b/variants/heltec_v4_r8/HeltecV4R8Board.cpp @@ -1,4 +1,5 @@ #include "HeltecV4R8Board.h" +#include // Expansion Kit V2 control-pin notes (kept here beside the shared-reset // sequencing): display/touch I2C is SDA 17 / SCL 18; GPIO 21 is the shared @@ -30,8 +31,8 @@ void HeltecV4R8Board::begin() { startup_reason = BD_STARTUP_RX_PACKET; } - rtc_gpio_hold_dis((gpio_num_t)P_LORA_NSS); - rtc_gpio_deinit((gpio_num_t)P_LORA_DIO_1); + mesh::esp32::releaseRtcGpioIfSupported((gpio_num_t)P_LORA_NSS); + mesh::esp32::deinitRtcGpioIfSupported((gpio_num_t)P_LORA_DIO_1); } } @@ -52,8 +53,7 @@ void HeltecV4R8Board::powerOff() { void HeltecV4R8Board::shutdownPeripherals() { ESP32Board::shutdownPeripherals(); loRaFEMControl.setSleepModeEnable(); - digitalWrite(P_LORA_PA_POWER, LOW); - rtc_gpio_hold_en((gpio_num_t)P_LORA_PA_POWER); + mesh::esp32::holdOutputForDeepSleep((gpio_num_t)P_LORA_PA_POWER, LOW); periph_power.release(); // Drop the permanent antenna-boost/VEXT claim from begin(). } diff --git a/variants/heltec_v4_r8/LoRaFEMControl.cpp b/variants/heltec_v4_r8/LoRaFEMControl.cpp index 2d0feba64..a3cdbe075 100644 --- a/variants/heltec_v4_r8/LoRaFEMControl.cpp +++ b/variants/heltec_v4_r8/LoRaFEMControl.cpp @@ -1,4 +1,5 @@ #include "LoRaFEMControl.h" +#include #include #include @@ -7,15 +8,15 @@ void LoRaFEMControl::init(void) { pinMode(P_LORA_PA_POWER, OUTPUT); digitalWrite(P_LORA_PA_POWER, HIGH); - rtc_gpio_hold_dis((gpio_num_t)P_LORA_PA_POWER); + mesh::esp32::releaseRtcGpioIfSupported((gpio_num_t)P_LORA_PA_POWER); esp_reset_reason_t reason = esp_reset_reason(); if (reason != ESP_RST_DEEPSLEEP) { delay(1); } - rtc_gpio_hold_dis((gpio_num_t)P_LORA_KCT8103L_PA_CSD); - rtc_gpio_hold_dis((gpio_num_t)P_LORA_KCT8103L_PA_CTX); + mesh::esp32::releaseRtcGpioIfSupported((gpio_num_t)P_LORA_KCT8103L_PA_CSD); + mesh::esp32::releaseRtcGpioIfSupported((gpio_num_t)P_LORA_KCT8103L_PA_CTX); pinMode(P_LORA_KCT8103L_PA_CSD, OUTPUT); digitalWrite(P_LORA_KCT8103L_PA_CSD, HIGH); @@ -40,12 +41,12 @@ void LoRaFEMControl::setRxModeEnable(void) { void LoRaFEMControl::setRxModeEnableWhenMCUSleep(void) { digitalWrite(P_LORA_PA_POWER, HIGH); - rtc_gpio_hold_en((gpio_num_t)P_LORA_PA_POWER); + mesh::esp32::holdRtcGpioIfSupported((gpio_num_t)P_LORA_PA_POWER); digitalWrite(P_LORA_KCT8103L_PA_CSD, HIGH); - rtc_gpio_hold_en((gpio_num_t)P_LORA_KCT8103L_PA_CSD); + mesh::esp32::holdRtcGpioIfSupported((gpio_num_t)P_LORA_KCT8103L_PA_CSD); digitalWrite(P_LORA_KCT8103L_PA_CTX, lna_enabled ? LOW : HIGH); - rtc_gpio_hold_en((gpio_num_t)P_LORA_KCT8103L_PA_CTX); + mesh::esp32::holdRtcGpioIfSupported((gpio_num_t)P_LORA_KCT8103L_PA_CTX); } void LoRaFEMControl::setLNAEnable(bool enabled) { diff --git a/variants/ikoka_handheld_nrf/IkokaNrf52Board.cpp b/variants/ikoka_handheld_nrf/IkokaNrf52Board.cpp index f1d9f17d3..91decb1eb 100644 --- a/variants/ikoka_handheld_nrf/IkokaNrf52Board.cpp +++ b/variants/ikoka_handheld_nrf/IkokaNrf52Board.cpp @@ -17,8 +17,10 @@ void IkokaNrf52Board::begin() { #endif pinMode(PIN_VBAT, INPUT); + // Seeed's XIAO charging guidance requires READ_BAT_ENABLE to stay LOW. + // Preload the safe latch before enabling the output; do not pulse HIGH. + digitalWrite(VBAT_ENABLE, LOW); pinMode(VBAT_ENABLE, OUTPUT); - digitalWrite(VBAT_ENABLE, HIGH); // required button pullup is handled as part of button initilization // in target.cpp diff --git a/variants/ikoka_handheld_nrf/IkokaNrf52Board.h b/variants/ikoka_handheld_nrf/IkokaNrf52Board.h index 66752c0e4..954b34785 100644 --- a/variants/ikoka_handheld_nrf/IkokaNrf52Board.h +++ b/variants/ikoka_handheld_nrf/IkokaNrf52Board.h @@ -26,7 +26,7 @@ public: // We can't drive VBAT_ENABLE to HIGH as long // as we don't know whether we are charging or not ... - // this is a 3mA loss (4/1500) + // About 3 uA through the 1 MOhm + 510 kOhm divider, not 3 mA. digitalWrite(VBAT_ENABLE, LOW); int adcvalue = 0; analogReadResolution(12); diff --git a/variants/ikoka_handheld_nrf/variant.cpp b/variants/ikoka_handheld_nrf/variant.cpp index 38a94c895..be2de0ff2 100644 --- a/variants/ikoka_handheld_nrf/variant.cpp +++ b/variants/ikoka_handheld_nrf/variant.cpp @@ -56,14 +56,11 @@ const uint32_t g_ADigitalPinMap[] = { }; void initVariant() { - // Disable reading of the BAT voltage. + // Keep the battery divider enabled: HIGH can overvoltage the ADC while charging. // https://wiki.seeedstudio.com/XIAO_BLE#q3-what-are-the-considerations-when-using-xiao-nrf52840-sense-for-battery-charging - pinMode(VBAT_ENABLE, OUTPUT); - // digitalWrite(VBAT_ENABLE, HIGH); - // This was taken from Seeed github butis not coherent with the doc, - // VBAT_ENABLE should be kept to LOW to protect P0.14, (1500/500)*(4.2-3.3)+3.3 = 3.9V > 3.6V - // This induces a 3mA current in the resistors :( but it's better than burning the nrf + // The 1 MOhm + 510 kOhm divider draws about 3 uA, not 3 mA. digitalWrite(VBAT_ENABLE, LOW); + pinMode(VBAT_ENABLE, OUTPUT); // disable xiao charging current, the handheld uses a tp4056 to charge // instead of the onboard xiao charging circuit. This charges at a max of diff --git a/variants/ikoka_nano_nrf/IkokaNanoNRFBoard.cpp b/variants/ikoka_nano_nrf/IkokaNanoNRFBoard.cpp index b963b2f4b..7065ab256 100644 --- a/variants/ikoka_nano_nrf/IkokaNanoNRFBoard.cpp +++ b/variants/ikoka_nano_nrf/IkokaNanoNRFBoard.cpp @@ -9,8 +9,10 @@ void IkokaNanoNRFBoard::begin() { NRF52Board::begin(); pinMode(PIN_VBAT, INPUT); + // Seeed's XIAO charging guidance requires READ_BAT_ENABLE to stay LOW. + // Preload the safe latch before enabling the output; do not pulse HIGH. + digitalWrite(VBAT_ENABLE, LOW); pinMode(VBAT_ENABLE, OUTPUT); - digitalWrite(VBAT_ENABLE, HIGH); #ifdef PIN_USER_BTN pinMode(PIN_USER_BTN, INPUT_PULLUP); diff --git a/variants/ikoka_nano_nrf/IkokaNanoNRFBoard.h b/variants/ikoka_nano_nrf/IkokaNanoNRFBoard.h index 96b14dcaa..7d1b6ba00 100644 --- a/variants/ikoka_nano_nrf/IkokaNanoNRFBoard.h +++ b/variants/ikoka_nano_nrf/IkokaNanoNRFBoard.h @@ -34,7 +34,7 @@ public: // We can't drive VBAT_ENABLE to HIGH as long // as we don't know whether we are charging or not ... - // this is a 3mA loss (4/1500) + // About 3 uA through the 1 MOhm + 510 kOhm divider, not 3 mA. digitalWrite(VBAT_ENABLE, LOW); int adcvalue = 0; analogReadResolution(12); diff --git a/variants/ikoka_nano_nrf/variant.cpp b/variants/ikoka_nano_nrf/variant.cpp index 16542e273..28958077c 100644 --- a/variants/ikoka_nano_nrf/variant.cpp +++ b/variants/ikoka_nano_nrf/variant.cpp @@ -57,14 +57,11 @@ const uint32_t g_ADigitalPinMap[] = void initVariant() { - // Disable reading of the BAT voltage. + // Keep the battery divider enabled: HIGH can overvoltage the ADC while charging. // https://wiki.seeedstudio.com/XIAO_BLE#q3-what-are-the-considerations-when-using-xiao-nrf52840-sense-for-battery-charging - pinMode(VBAT_ENABLE, OUTPUT); - //digitalWrite(VBAT_ENABLE, HIGH); - // This was taken from Seeed github butis not coherent with the doc, - // VBAT_ENABLE should be kept to LOW to protect P0.14, (1500/500)*(4.2-3.3)+3.3 = 3.9V > 3.6V - // This induces a 3mA current in the resistors :( but it's better than burning the nrf + // The 1 MOhm + 510 kOhm divider draws about 3 uA, not 3 mA. digitalWrite(VBAT_ENABLE, LOW); + pinMode(VBAT_ENABLE, OUTPUT); // Low charging current (50mA) // https://wiki.seeedstudio.com/XIAO_BLE#battery-charging-current diff --git a/variants/ikoka_stick_nrf/IkokaStickNRFBoard.cpp b/variants/ikoka_stick_nrf/IkokaStickNRFBoard.cpp index 1c416039d..09f4c6308 100644 --- a/variants/ikoka_stick_nrf/IkokaStickNRFBoard.cpp +++ b/variants/ikoka_stick_nrf/IkokaStickNRFBoard.cpp @@ -9,8 +9,10 @@ void IkokaStickNRFBoard::begin() { NRF52Board::begin(); pinMode(PIN_VBAT, INPUT); + // Seeed's XIAO charging guidance requires READ_BAT_ENABLE to stay LOW. + // Preload the safe latch before enabling the output; do not pulse HIGH. + digitalWrite(VBAT_ENABLE, LOW); pinMode(VBAT_ENABLE, OUTPUT); - digitalWrite(VBAT_ENABLE, HIGH); #ifdef PIN_USER_BTN pinMode(PIN_USER_BTN, INPUT_PULLUP); diff --git a/variants/ikoka_stick_nrf/IkokaStickNRFBoard.h b/variants/ikoka_stick_nrf/IkokaStickNRFBoard.h index 8d089f166..6ab046c77 100644 --- a/variants/ikoka_stick_nrf/IkokaStickNRFBoard.h +++ b/variants/ikoka_stick_nrf/IkokaStickNRFBoard.h @@ -34,7 +34,7 @@ public: // We can't drive VBAT_ENABLE to HIGH as long // as we don't know whether we are charging or not ... - // this is a 3mA loss (4/1500) + // About 3 uA through the 1 MOhm + 510 kOhm divider, not 3 mA. digitalWrite(VBAT_ENABLE, LOW); int adcvalue = 0; analogReadResolution(12); diff --git a/variants/ikoka_stick_nrf/variant.cpp b/variants/ikoka_stick_nrf/variant.cpp index 16542e273..28958077c 100644 --- a/variants/ikoka_stick_nrf/variant.cpp +++ b/variants/ikoka_stick_nrf/variant.cpp @@ -57,14 +57,11 @@ const uint32_t g_ADigitalPinMap[] = void initVariant() { - // Disable reading of the BAT voltage. + // Keep the battery divider enabled: HIGH can overvoltage the ADC while charging. // https://wiki.seeedstudio.com/XIAO_BLE#q3-what-are-the-considerations-when-using-xiao-nrf52840-sense-for-battery-charging - pinMode(VBAT_ENABLE, OUTPUT); - //digitalWrite(VBAT_ENABLE, HIGH); - // This was taken from Seeed github butis not coherent with the doc, - // VBAT_ENABLE should be kept to LOW to protect P0.14, (1500/500)*(4.2-3.3)+3.3 = 3.9V > 3.6V - // This induces a 3mA current in the resistors :( but it's better than burning the nrf + // The 1 MOhm + 510 kOhm divider draws about 3 uA, not 3 mA. digitalWrite(VBAT_ENABLE, LOW); + pinMode(VBAT_ENABLE, OUTPUT); // Low charging current (50mA) // https://wiki.seeedstudio.com/XIAO_BLE#battery-charging-current diff --git a/variants/lilygo_tbeam_1w/TBeam1WBoard.cpp b/variants/lilygo_tbeam_1w/TBeam1WBoard.cpp index 8020617da..4efa9f4a2 100644 --- a/variants/lilygo_tbeam_1w/TBeam1WBoard.cpp +++ b/variants/lilygo_tbeam_1w/TBeam1WBoard.cpp @@ -1,4 +1,5 @@ #include "TBeam1WBoard.h" +#include #include #include #include @@ -15,8 +16,8 @@ void TBeam1WBoard::begin() { // can also hold the dedicated radio LDO off. These pad holds survive wake // and can survive a subsequent software reset, so release them on every // startup before RadioLib tries to select the SX1262. - esp_err_t nss_hold_result = rtc_gpio_hold_dis((gpio_num_t)P_LORA_NSS); - esp_err_t dio_result = rtc_gpio_deinit((gpio_num_t)P_LORA_DIO_1); + esp_err_t nss_hold_result = mesh::esp32::releaseRtcGpioIfSupported((gpio_num_t)P_LORA_NSS); + esp_err_t dio_result = mesh::esp32::deinitRtcGpioIfSupported((gpio_num_t)P_LORA_DIO_1); esp_err_t power_hold_result = gpio_hold_dis((gpio_num_t)SX126X_POWER_EN); MESH_DEBUG_PRINTLN("T-Beam 1W pad recovery: NSS=%d DIO1=%d LDO=%d", nss_hold_result, dio_result, power_hold_result); diff --git a/variants/lilygo_tdeck/TDeckBoard.cpp b/variants/lilygo_tdeck/TDeckBoard.cpp index ad1e435b9..7d8554044 100644 --- a/variants/lilygo_tdeck/TDeckBoard.cpp +++ b/variants/lilygo_tdeck/TDeckBoard.cpp @@ -1,4 +1,5 @@ #include +#include #include "TDeckBoard.h" uint32_t deviceOnline = 0x00; @@ -29,7 +30,7 @@ void TDeckBoard::begin() { startup_reason = BD_STARTUP_RX_PACKET; // received a LoRa packet (while in deep sleep) } - rtc_gpio_hold_dis((gpio_num_t)P_LORA_NSS); - rtc_gpio_deinit((gpio_num_t)P_LORA_DIO_1); + mesh::esp32::releaseRtcGpioIfSupported((gpio_num_t)P_LORA_NSS); + mesh::esp32::deinitRtcGpioIfSupported((gpio_num_t)P_LORA_DIO_1); } -} \ No newline at end of file +} diff --git a/variants/meshadventurer/MeshadventurerBoard.h b/variants/meshadventurer/MeshadventurerBoard.h index d9e88e1bc..a3a78d2fc 100644 --- a/variants/meshadventurer/MeshadventurerBoard.h +++ b/variants/meshadventurer/MeshadventurerBoard.h @@ -1,4 +1,5 @@ #pragma once +#include #include @@ -28,8 +29,8 @@ public: startup_reason = BD_STARTUP_RX_PACKET; } - rtc_gpio_hold_dis((gpio_num_t)P_LORA_NSS); - rtc_gpio_deinit((gpio_num_t)P_LORA_DIO_1); + mesh::esp32::releaseRtcGpioIfSupported((gpio_num_t)P_LORA_NSS); + mesh::esp32::deinitRtcGpioIfSupported((gpio_num_t)P_LORA_DIO_1); } } diff --git a/variants/meshnology_w12/MeshnologyW12Board.cpp b/variants/meshnology_w12/MeshnologyW12Board.cpp index 17b5189ae..68f9b16ea 100644 --- a/variants/meshnology_w12/MeshnologyW12Board.cpp +++ b/variants/meshnology_w12/MeshnologyW12Board.cpp @@ -1,10 +1,12 @@ #include "MeshnologyW12Board.h" +#include void MeshnologyW12Board::begin() { ESP32Board::begin(); - rtc_gpio_hold_dis((gpio_num_t)P_LORA_LF_PA_POWER); - rtc_gpio_hold_dis((gpio_num_t)P_LORA_HF_PA_POWER); + // Keep both FEM rails off while releasing a retained terminal shutdown. + mesh::esp32::releaseOutputAfterDeepSleep((gpio_num_t)P_LORA_LF_PA_POWER, LOW); + mesh::esp32::releaseOutputAfterDeepSleep((gpio_num_t)P_LORA_HF_PA_POWER, LOW); pinMode(PIN_ADC_CTRL, OUTPUT); digitalWrite(PIN_ADC_CTRL, LOW); // Disable battery sense until required @@ -22,8 +24,8 @@ void MeshnologyW12Board::begin() { startup_reason = BD_STARTUP_RX_PACKET; } - rtc_gpio_hold_dis((gpio_num_t)P_LORA_NSS); - rtc_gpio_deinit((gpio_num_t)P_LORA_DIO_1); + mesh::esp32::releaseRtcGpioIfSupported((gpio_num_t)P_LORA_NSS); + mesh::esp32::deinitRtcGpioIfSupported((gpio_num_t)P_LORA_DIO_1); } } @@ -59,7 +61,7 @@ void MeshnologyW12Board::begin() { rtc_gpio_set_direction((gpio_num_t)P_LORA_DIO_1, RTC_GPIO_MODE_INPUT_ONLY); rtc_gpio_pulldown_en((gpio_num_t)P_LORA_DIO_1); - rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS); + mesh::esp32::holdRtcGpioIfSupported((gpio_num_t)P_LORA_NSS); if (pin_wake_btn < 0) { esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet @@ -79,10 +81,8 @@ void MeshnologyW12Board::begin() { // A shutdown must not retain the packet-wake deep-sleep behavior above. // Disable both external FEM rails, then use the base shutdown path so the // display, GPS, radio, serial buffers, and stale wake sources are handled. - digitalWrite(P_LORA_LF_PA_POWER, LOW); - digitalWrite(P_LORA_HF_PA_POWER, LOW); - rtc_gpio_hold_en((gpio_num_t)P_LORA_LF_PA_POWER); - rtc_gpio_hold_en((gpio_num_t)P_LORA_HF_PA_POWER); + mesh::esp32::holdOutputForDeepSleep((gpio_num_t)P_LORA_LF_PA_POWER, LOW); + mesh::esp32::holdOutputForDeepSleep((gpio_num_t)P_LORA_HF_PA_POWER, LOW); ESP32Board::powerOff(); } diff --git a/variants/meshnology_w12/platformio.ini b/variants/meshnology_w12/platformio.ini index beea12c5b..3ff358928 100644 --- a/variants/meshnology_w12/platformio.ini +++ b/variants/meshnology_w12/platformio.ini @@ -10,6 +10,7 @@ build_flags = -D RADIO_CLASS=CustomLR2021 -D WRAPPER_CLASS=CustomLR2021Wrapper -D USE_LR2021 + -D LR2021_USE_DCDC=1 ; verified W12 schematic: L5 2.2uH across LXA/LXB -D NEOPIXEL_LED=46 -D NEOPIXEL_BRIGHTNESS=64 -D P_LORA_DIO_1=14 diff --git a/variants/meshtracker_x1/MeshTrackerX1Board.h b/variants/meshtracker_x1/MeshTrackerX1Board.h index e153dd7fb..02df7b683 100644 --- a/variants/meshtracker_x1/MeshTrackerX1Board.h +++ b/variants/meshtracker_x1/MeshTrackerX1Board.h @@ -20,11 +20,18 @@ public: analogReference(AR_INTERNAL_3_0); analogReadResolution(12); delay(10); - float volts = (analogRead(BATTERY_PIN) * ADC_MULTIPLIER * AREF_VOLTAGE) / 4096; + const int raw = analogRead(BATTERY_PIN); + #ifdef PIN_BAT_ADC_EN + // The divider is only needed while sampling; do not leave it powered + // between battery checks, including after an invalid ADC result. + digitalWrite(PIN_BAT_ADC_EN, LOW); + #endif analogReference(AR_DEFAULT); // put back to default analogReadResolution(10); + if (raw < 0 || raw > 4095) return 0; + float volts = (raw * ADC_MULTIPLIER * AREF_VOLTAGE) / 4096; return volts * 1000; #else return 0; diff --git a/variants/rak3112/RAK3112Board.h b/variants/rak3112/RAK3112Board.h index 704162b8c..c06aaad71 100644 --- a/variants/rak3112/RAK3112Board.h +++ b/variants/rak3112/RAK3112Board.h @@ -1,4 +1,5 @@ #pragma once +#include #include #include @@ -44,8 +45,8 @@ public: startup_reason = BD_STARTUP_RX_PACKET; } - rtc_gpio_hold_dis((gpio_num_t)P_LORA_NSS); - rtc_gpio_deinit((gpio_num_t)P_LORA_DIO_1); + mesh::esp32::releaseRtcGpioIfSupported((gpio_num_t)P_LORA_NSS); + mesh::esp32::deinitRtcGpioIfSupported((gpio_num_t)P_LORA_DIO_1); } } diff --git a/variants/rak3401/RAK3401Board.cpp b/variants/rak3401/RAK3401Board.cpp index 4da55eced..e2df74899 100644 --- a/variants/rak3401/RAK3401Board.cpp +++ b/variants/rak3401/RAK3401Board.cpp @@ -1,7 +1,10 @@ #include #include +#include #include "RAK3401Board.h" +#include "target.h" +#include "../../src/helpers/nrf52/TerminalPeripheralShutdown.h" #ifdef NRF52_POWER_MANAGEMENT // Static configuration for power management @@ -16,7 +19,8 @@ void RAK3401Board::initiateShutdown(uint8_t reason) { // Disable SKY66122 FEM (CSD+CPS LOW = shutdown, <1 uA) digitalWrite(SX126X_POWER_EN, LOW); - // Disable 3V3 switched peripherals and 5V boost + // Disable switched 3V3 peripherals and the RAK13302 VD boost (IO2 EN). + // Legacy VC hardware ties boost EN to VBAT unless its resistors are changed. digitalWrite(PIN_3V3_EN, LOW); if (reason == SHUTDOWN_REASON_LOW_VOLTAGE || @@ -28,6 +32,54 @@ void RAK3401Board::initiateShutdown(uint8_t reason) { } #endif +void RAK3401Board::shutdownPeripherals() { + // Runtime sleep/recovery must retain the RAK13302/shared rail. Only the + // terminal power-off/deep-sleep path reaches this virtual board hook. + const bool ethernet_stopped = mesh::nrf52::terminalEthernetShutdown(); + // Ethernet cannot register on this board (same IO slot, SPI and IO3). + // Still fail closed if an unexpected owner appears: even base radio sleep + // would remap that live SPI bus and lowering the FEM would reset W5100S. + if (!ethernet_stopped) return; + const bool storage_power_safe = sensors.canRemoveSharedStoragePowerForDeepSleep(); + NRF52Board::shutdownPeripherals(); +#if ENV_INCLUDE_GPS && defined(PIN_GPS_TX) && defined(PIN_GPS_RX) + if (storage_power_safe) { + if (Serial1) Serial1.end(); // never end an unstarted Adafruit UART + pinMode(PIN_GPS_TX, INPUT); + pinMode(PIN_GPS_RX, INPUT); + } +#endif + Wire.end(); + pinMode(PIN_WIRE_SDA, INPUT); + pinMode(PIN_WIRE_SCL, INPUT); + // The RAK13302 is an external, switched-supply radio. The base shutdown + // sleeps it but leaves SPI and NRESET/NSS driven; those signals can feed + // an unpowered module through its I/O protection. Release both controllers + // first (the base prepares SPI, while this radio uses SPI1). begin/end is + // deliberate: Adafruit SPI.end() asserts if its object has never begun. + if (storage_power_safe) { + SPI.end(); + SPI1.begin(); + SPI1.end(); + pinMode(P_LORA_NSS, INPUT); + pinMode(P_LORA_RESET, INPUT); + pinMode(P_LORA_SCLK, INPUT); + pinMode(P_LORA_MOSI, INPUT); + pinMode(P_LORA_MISO, INPUT); + } + // Sleeping the SX1262 alone does not disable the SKY66122's CSD input. + // A legacy RAK13302 VC boost can remain supplied independently of IO2. + digitalWrite(SX126X_POWER_EN, LOW); + pinMode(SX126X_POWER_EN, OUTPUT); + // PoE/external 5V enters the base separately from the switched 3V3_S rail. + // An external NOR can still be recovering an unknown-WIP operation. Keep + // its shared supply and driven CS/header pads until qualified teardown. + if (storage_power_safe) { + digitalWrite(PIN_3V3_EN, LOW); + pinMode(PIN_3V3_EN, OUTPUT); + } +} + void RAK3401Board::begin() { NRF52BoardDCDC::begin(); pinMode(PIN_VBAT_READ, INPUT); @@ -50,10 +102,9 @@ void RAK3401Board::begin() { checkBootVoltage(&power_config); #endif - // WB_IO2 controls the shared 3V3_S peripheral rail and the RAK13302 boost - // converter. Keep it enabled during normal operation. Cycling this rail as - // part of radio recovery also resets fitted GPS and sensor modules and does - // not provide a reliable reset of the SX1262 core. + // WB_IO2 controls shared 3V3_S and the RAK13302 VD boost (or a modified VC). + // Keep it enabled normally. Cycling it during radio recovery also resets + // fitted GPS/sensors and does not reliably reset the SX1262 core. pinMode(PIN_3V3_EN, OUTPUT); digitalWrite(PIN_3V3_EN, HIGH); diff --git a/variants/rak3401/RAK3401Board.h b/variants/rak3401/RAK3401Board.h index 9c2be9b5f..1bb4c65c8 100644 --- a/variants/rak3401/RAK3401Board.h +++ b/variants/rak3401/RAK3401Board.h @@ -19,6 +19,7 @@ protected: public: RAK3401Board() : NRF52Board("RAK3401_OTA") {} void begin(); + void shutdownPeripherals() override; bool recoverRadio(); void enableRadioFrontend(); bool supportsRadioHardReset() const override { return true; } diff --git a/variants/rak3401/variant.h b/variants/rak3401/variant.h index e3dcd98b8..b8f714c46 100644 --- a/variants/rak3401/variant.h +++ b/variants/rak3401/variant.h @@ -162,7 +162,10 @@ static const uint8_t AREF = PIN_AREF; // Setting IO3 HIGH enables the FEM (LNA for RX, PA path for TX). // CTX is connected to SX1262 DIO2 - the radio handles TX/RX switching // in hardware via SetDIO2AsRfSwitchCtrl (microsecond-accurate, no GPIO needed). -// The 5V boost for the PA is enabled by WB_IO2 (P0.34 = PIN_3V3_EN). +// WB_IO2 (P1.02 = PIN_3V3_EN) switches the module's 3V3_S supply. The +// RAK13302 VD boost defaults to EN=IO2 (R27 DNC, R28 fitted). Legacy VC +// defaults to EN=VBAT (R27 fitted, R28 DNC), so IO2 can disable its booster +// only after the resistor modification. External 5V is not software-gated. #define SX126X_POWER_EN (21) // P0.21 = IO3 -> SKY66122 CSD+CPS (FEM enable) // CTX is driven by SX1262 DIO2, not a GPIO diff --git a/variants/rak4631/RAK4631Board.cpp b/variants/rak4631/RAK4631Board.cpp index 4b1774228..237d923ab 100644 --- a/variants/rak4631/RAK4631Board.cpp +++ b/variants/rak4631/RAK4631Board.cpp @@ -5,13 +5,15 @@ #include "nrf_soc.h" #include "RAK4631Board.h" +#include "target.h" #include "../../src/helpers/nrf52/SoftDeviceState.h" +#include "../../src/helpers/nrf52/TerminalPeripheralShutdown.h" #ifdef ETHERNET_ENABLED // Drive WB_IO2 HIGH as early as possible using direct register access. // WB_IO2 (P1.02, Arduino pin 34) controls the WisBlock slot power switch. -// With POE through RAK13800, this must be enabled before the framework -// initializes or the board will brownout from insufficient power delivery. +// Preserve the existing early Ethernet slot-enable timing. RAK13800 PoE's +// 5V feeds VCC_IN separately; IO2 is not the core's upstream power gate. // Priority 102 runs just after SystemInit. static void __attribute__((constructor(102))) rak4631_early_poe_power() { nrf_gpio_cfg_output(NRF_GPIO_PIN_MAP(1, 2)); // WB_IO2 = P1.02 @@ -29,7 +31,7 @@ const PowerMgtConfig power_config = { }; void RAK4631Board::initiateShutdown(uint8_t reason) { - // Disable LoRa module power before shutdown + // Disable LoRa RF-switch power (SX1262 supply itself is not GPIO-gated). digitalWrite(SX126X_POWER_EN, LOW); if (reason == SHUTDOWN_REASON_LOW_VOLTAGE || @@ -41,6 +43,44 @@ void RAK4631Board::initiateShutdown(uint8_t reason) { } #endif // NRF52_POWER_MANAGEMENT +void RAK4631Board::shutdownPeripherals() { + // Explicit power-off/deep-sleep only, never ordinary event-driven sleep. + // Finish display/radio/GPS/crypto/USB shutdown before removing slot power. + // A combined owner's release hook may restore the radio. Finish it before + // common shutdown sleeps that radio. Slow DHCP may exceed the bounded wait; + // in that case do not remove power under a still-running SPI owner. + const bool ethernet_stopped = mesh::nrf52::terminalEthernetShutdown(); + const bool storage_power_safe = sensors.canRemoveSharedStoragePowerForDeepSleep(); + NRF52Board::shutdownPeripherals(); +#if ENV_INCLUDE_GPS && defined(PIN_GPS_TX) && defined(PIN_GPS_RX) + // Provider stop() does not end UARTE. An unstarted Adafruit Uart::end() + // can wait forever, so use its real begun-state operator before teardown. + if (storage_power_safe) { + if (Serial1) Serial1.end(); + pinMode(PIN_GPS_TX, INPUT); + pinMode(PIN_GPS_RX, INPUT); + } +#endif + // Wire.begin() leaves MCU pull-ups enabled. Stop the controller before + // removing sensor power, then release its pads without internal pulls. + // Base-board pull-ups to unswitched VDD are a separate hardware path. + Wire.end(); + pinMode(PIN_WIRE_SDA, INPUT); + pinMode(PIN_WIRE_SCL, INPUT); + digitalWrite(SX126X_POWER_EN, LOW); + pinMode(SX126X_POWER_EN, OUTPUT); + // RAK19007 Q7 switches only 3V3_S. RAK13800/RAK19018 PoE feeds the base + // separately through IO connector pin 9 (VCC_IN), not this switched rail. + // Ethernet capability therefore must not keep every fitted sensor awake + // after explicit power-off, even in combined companion builds. + // A detected NOR may retain a pending/unknown-WIP operation or driven CS31 + // and header pads. Keep it powered until a dedicated storage handoff exists. + if (ethernet_stopped && storage_power_safe) { + digitalWrite(WB_IO2, LOW); + pinMode(WB_IO2, OUTPUT); + } +} + void RAK4631Board::begin() { #ifdef RAK4631_COMPANION_FORCE_LDO // Some RAK4631 battery/board combinations have proved unstable with the diff --git a/variants/rak4631/RAK4631Board.h b/variants/rak4631/RAK4631Board.h index 6548f913d..a08ec4298 100644 --- a/variants/rak4631/RAK4631Board.h +++ b/variants/rak4631/RAK4631Board.h @@ -17,6 +17,7 @@ protected: public: RAK4631Board() : NRF52Board("RAK4631_OTA") {} void begin(); + void shutdownPeripherals() override; #define BATTERY_SAMPLES 8 diff --git a/variants/station_g2/StationG2Board.h b/variants/station_g2/StationG2Board.h index c98daa13f..d265bbdd0 100644 --- a/variants/station_g2/StationG2Board.h +++ b/variants/station_g2/StationG2Board.h @@ -1,4 +1,5 @@ #pragma once +#include #include #include @@ -15,8 +16,8 @@ public: startup_reason = BD_STARTUP_RX_PACKET; } - rtc_gpio_hold_dis((gpio_num_t)P_LORA_NSS); - rtc_gpio_deinit((gpio_num_t)P_LORA_DIO_1); + mesh::esp32::releaseRtcGpioIfSupported((gpio_num_t)P_LORA_NSS); + mesh::esp32::deinitRtcGpioIfSupported((gpio_num_t)P_LORA_DIO_1); } } diff --git a/variants/station_g3_esp32/LoRaFEMControl.cpp b/variants/station_g3_esp32/LoRaFEMControl.cpp index 8d4f90f6f..441209622 100644 --- a/variants/station_g3_esp32/LoRaFEMControl.cpp +++ b/variants/station_g3_esp32/LoRaFEMControl.cpp @@ -1,20 +1,21 @@ #include "LoRaFEMControl.h" +#include #include #include +#include "StationG3RadioSlot.h" void LoRaFEMControl::init() { #ifdef P_PA1_EN - rtc_gpio_hold_dis((gpio_num_t)P_PA1_EN); - pinMode(P_PA1_EN, OUTPUT); + mesh::esp32::releaseOutputAfterDeepSleep((gpio_num_t)P_PA1_EN, + pa_gain_enabled ? P_PA1_EN_ACTIVE : !P_PA1_EN_ACTIVE); applyPAGain(); #endif -#ifdef P_PRIMARY_LNA_EN - rtc_gpio_hold_dis((gpio_num_t)P_PRIMARY_LNA_EN); - pinMode(P_PRIMARY_LNA_EN, OUTPUT); - setRxModeEnable(); -#endif + // Neither socket's receive amplifier is enabled before radio detection. + for (uint8_t slot = 1; slot <= 2; ++slot) + mesh::esp32::releaseOutputAfterDeepSleep( + (gpio_num_t)station_g3::pinsForSlot(slot).lna, HIGH); } void LoRaFEMControl::setSleepModeEnable() { @@ -23,8 +24,9 @@ void LoRaFEMControl::setSleepModeEnable() { digitalWrite(P_PA1_EN, !P_PA1_EN_ACTIVE); #endif #ifdef P_PRIMARY_LNA_EN - // Preserve the existing Station G3 power-off state. - digitalWrite(P_PRIMARY_LNA_EN, P_PRIMARY_LNA_EN_ACTIVE); + // LNA P/S are active LOW. Bypass/disable the receive amplifier on shutdown, + // rather than retaining the same enabled state used during normal receive. + digitalWrite(station_g3::activePins().lna, !P_PRIMARY_LNA_EN_ACTIVE); #endif } @@ -34,13 +36,14 @@ void LoRaFEMControl::setTxModeEnable() { // the PA is still driven at full input power. applyPAGain(); #ifdef P_PRIMARY_LNA_EN - digitalWrite(P_PRIMARY_LNA_EN, !P_PRIMARY_LNA_EN_ACTIVE); + digitalWrite(station_g3::activePins().lna, !P_PRIMARY_LNA_EN_ACTIVE); #endif } void LoRaFEMControl::setRxModeEnable() { #ifdef P_PRIMARY_LNA_EN - digitalWrite(P_PRIMARY_LNA_EN, lna_enabled ? P_PRIMARY_LNA_EN_ACTIVE : !P_PRIMARY_LNA_EN_ACTIVE); + digitalWrite(station_g3::activePins().lna, + lna_enabled ? P_PRIMARY_LNA_EN_ACTIVE : !P_PRIMARY_LNA_EN_ACTIVE); #endif } diff --git a/variants/station_g3_esp32/StationG3Board.cpp b/variants/station_g3_esp32/StationG3Board.cpp index dae0f26b1..3e6dda887 100644 --- a/variants/station_g3_esp32/StationG3Board.cpp +++ b/variants/station_g3_esp32/StationG3Board.cpp @@ -1,15 +1,18 @@ #include "StationG3Board.h" +#include void StationG3Board::shutdownPeripherals() { ESP32Board::shutdownPeripherals(); loRaFEMControl.setSleepModeEnable(); #ifdef P_PA1_EN - rtc_gpio_hold_en((gpio_num_t)P_PA1_EN); + mesh::esp32::holdOutputForDeepSleep((gpio_num_t)P_PA1_EN, !P_PA1_EN_ACTIVE); #endif #ifdef P_PRIMARY_LNA_EN - rtc_gpio_hold_en((gpio_num_t)P_PRIMARY_LNA_EN); + for (uint8_t slot = 1; slot <= 2; ++slot) + mesh::esp32::holdOutputForDeepSleep( + (gpio_num_t)station_g3::pinsForSlot(slot).lna, !P_PRIMARY_LNA_EN_ACTIVE); #endif } @@ -68,6 +71,26 @@ bool StationG3Board::handleCommand(const char* command, uint32_t sender_timestam return false; } + if (strcmp(command, "get radio.slot") == 0) { + const auto& state = station_g3::slotState(); + snprintf(reply, 160, "> %s; active=%u; detected=%s%s%s", + station_g3::slotModeName(state.savedMode), state.active, + state.detected == 3 ? "1,2" : state.detected == 1 ? "1" : state.detected == 2 ? "2" : "none", + state.savedMode != state.bootMode ? "; reboot required" : "", + state.settingsHealthy ? "" : "; WARN settings unavailable/invalid"); + return true; + } + if (strncmp(command, "set radio.slot ", 15) == 0) { + const char* value = command + 15; + const int mode = strcmp(value, "auto") == 0 ? 0 : strcmp(value, "1") == 0 ? 1 : strcmp(value, "2") == 0 ? 2 : -1; + if (mode < 0) strcpy(reply, "Error: radio.slot must be auto, 1, or 2"); + else if (!station_g3::saveSlotMode(static_cast(mode))) + strcpy(reply, "Error: radio.slot could not be saved; running slot unchanged"); + else snprintf(reply, 160, "OK - radio.slot %s saved; reboot to apply (active=%u)", + station_g3::slotModeName(mode), station_g3::slotState().active); + return true; + } + if (strcmp(command, "get radio.fem.rxgain") == 0) { if (!loRaFEMControl.canControlLNA()) { strcpy(reply, "Error: unsupported"); diff --git a/variants/station_g3_esp32/StationG3Board.h b/variants/station_g3_esp32/StationG3Board.h index 84f978e2f..e84fc4756 100644 --- a/variants/station_g3_esp32/StationG3Board.h +++ b/variants/station_g3_esp32/StationG3Board.h @@ -1,9 +1,11 @@ #pragma once +#include #include #include #include #include "LoRaFEMControl.h" +#include "StationG3RadioSlot.h" class StationG3Board : public ESP32Board { KeyValueStore* _prefs = NULL; @@ -12,21 +14,37 @@ public: LoRaFEMControl loRaFEMControl; void begin() { + station_g3::loadSlotSettings(); + const auto& slotSettings = station_g3::slotState(); + station_g3::slotState().candidate = slotSettings.bootMode == 2 || + (slotSettings.bootMode == 0 && slotSettings.last == 2) ? 2 : 1; ESP32Board::begin(); + // Release holds from EITHER previously selected socket, before probing. + for (uint8_t slot = 1; slot <= 2; ++slot) { + const auto& pins = station_g3::pinsForSlot(slot); + mesh::esp32::releaseOutputAfterDeepSleep((gpio_num_t)pins.nss, HIGH); + mesh::esp32::releaseOutputAfterDeepSleep((gpio_num_t)pins.lna, HIGH); + } loRaFEMControl.init(); esp_reset_reason_t reason = esp_reset_reason(); if (reason == ESP_RST_DEEPSLEEP) { uint64_t wakeup_source = esp_sleep_get_ext1_wakeup_status(); - if (wakeup_source & (1ULL << P_LORA_DIO_1)) { + // A newly saved override may differ from the socket which woke us. + const uint64_t radio_wake_mask = (1ULL << station_g3::pinsForSlot(1).irq) | + (1ULL << station_g3::pinsForSlot(2).irq); + if (wakeup_source & radio_wake_mask) { startup_reason = BD_STARTUP_RX_PACKET; } - rtc_gpio_hold_dis((gpio_num_t)P_LORA_NSS); - rtc_gpio_deinit((gpio_num_t)P_LORA_DIO_1); + for (uint8_t slot = 1; slot <= 2; ++slot) + mesh::esp32::deinitRtcGpioIfSupported( + (gpio_num_t)station_g3::pinsForSlot(slot).irq); } } + uint32_t getIRQGpio() override { return station_g3::activePins().irq; } + void attachDynamicPrefs(KeyValueStore* prefs); bool handleCommand(const char* command, uint32_t sender_timestamp, char* reply) override; diff --git a/variants/station_g3_esp32/StationG3BootRecovery.cpp b/variants/station_g3_esp32/StationG3BootRecovery.cpp new file mode 100644 index 000000000..0ccd99155 --- /dev/null +++ b/variants/station_g3_esp32/StationG3BootRecovery.cpp @@ -0,0 +1,167 @@ +#include "StationG3BootRecovery.h" + +#include "StationG3Board.h" +#include + +#include + +namespace station_g3 { +namespace { + +constexpr size_t kRxBudget = 64; +constexpr size_t kLineCapacity = 64; +constexpr size_t kReplyCapacity = 256; +constexpr char kPrefix[] = "!g3 "; +constexpr char kNotice[] = + "\r\nStation G3 radio unavailable; USB recovery is active.\r\n" + "Press Enter, then send !g3 help (no erase required).\r\n"; +constexpr char kHelp[] = + "!g3 get radio.slot\r\n" + "!g3 set radio.slot auto|1|2\r\n" + "!g3 reboot\r\n" + "Slot changes are saved for the next boot. Recovery does not erase settings.\r\n"; + +struct RecoveryState { + char line[kLineCapacity] = {}; + size_t length = 0; + bool discardLine = false; + bool kissQuarantine = false; + bool immediatelyAfterFend = false; + char output[kReplyCapacity] = {}; + size_t outputLength = 0; + size_t outputOffset = 0; + + void clearInput() { + length = 0; + discardLine = false; + kissQuarantine = false; + immediatelyAfterFend = false; + } + + void queue(const char* text) { + size_t count = 0; + while (count + 1 < sizeof(output) && text[count]) ++count; + memcpy(output, text, count); + output[count] = 0; + outputLength = count; + outputOffset = 0; + } + + bool flushOnce(Stream& port) { + if (outputOffset < outputLength) { + const size_t remaining = outputLength - outputOffset; + const size_t sent = port.write( + reinterpret_cast(output + outputOffset), remaining); + outputOffset += sent < remaining ? sent : remaining; + } + return outputOffset == outputLength; + } +}; + +RecoveryState state; + +void dispatch(StationG3Board& board) { + state.line[state.length] = 0; + if (strncmp(state.line, kPrefix, sizeof(kPrefix) - 1) != 0) return; + const char* command = state.line + sizeof(kPrefix) - 1; + + if (strcmp(command, "help") == 0) { + state.queue(kHelp); + return; + } + if (strcmp(command, "reboot") == 0) { + board.reboot(); + return; + } + const bool allowed = strcmp(command, "get radio.slot") == 0 + || strcmp(command, "set radio.slot auto") == 0 + || strcmp(command, "set radio.slot 1") == 0 + || strcmp(command, "set radio.slot 2") == 0; + if (!allowed) { + state.queue("Error: recovery accepts only radio.slot, help and reboot.\r\n"); + return; + } + + char reply[kReplyCapacity] = {}; + if (!board.handleCommand(command, 0, reply)) { + state.queue("Error: radio.slot recovery unavailable.\r\n"); + return; + } + // The board owns persistence and its failure reply. Never turn a failed save + // into success or reboot automatically before the operator has seen it. + reply[sizeof(reply) - 3] = 0; + const size_t length = strlen(reply); + memcpy(reply + length, "\r\n", 3); + state.queue(reply); +} + +} // namespace + +void serviceBootRecovery(StationG3Board& board, bool firstNotice) { + if (firstNotice) { + state.clearInput(); + state.queue(kNotice); + } + + mesh::serviceUsbLoggingPort(); + mesh::serviceUsbTerminalPort(); + if (mesh::takeUsbTerminalSessionReset()) { + // An unfinished command must not be completed by a different USB session. + state.clearInput(); + state.queue(kNotice); + } + if (!mesh::tryCompleteUsbTerminalSessionReset()) return; + + Stream& port = mesh::usbConsolePort(); + if (!state.flushOnce(port)) return; + + for (size_t count = 0; count < kRxBudget && port.available() > 0; ++count) { + const int input = port.read(); + if (input < 0) break; + const uint8_t byte = static_cast(input); + + if (byte == 0xc0) { + // KISS can contain arbitrary newlines and even recovery-looking text. + // Ignore everything through a FEND followed directly by the operator's + // Enter. This also tolerates adjacent frames sharing one FEND delimiter. + state.length = 0; + state.discardLine = false; + state.kissQuarantine = true; + state.immediatelyAfterFend = true; + continue; + } + if (state.kissQuarantine) { + if ((byte == '\r' || byte == '\n') && state.immediatelyAfterFend) { + state.clearInput(); + } else { + state.immediatelyAfterFend = false; + } + continue; + } + if (byte == '\r' || byte == '\n') { + const bool complete = !state.discardLine && state.length != 0; + if (complete) dispatch(board); + state.length = 0; + state.discardLine = false; + if (complete) { + (void)state.flushOnce(port); + return; // At most one command per call, including rejected commands. + } + continue; + } + if (byte < 0x20 || byte > 0x7e) { + state.discardLine = true; + state.length = 0; + continue; + } + if (state.discardLine) continue; + if (state.length + 1 >= sizeof(state.line)) { + state.discardLine = true; + state.length = 0; + continue; + } + state.line[state.length++] = static_cast(byte); + } +} + +} // namespace station_g3 diff --git a/variants/station_g3_esp32/StationG3BootRecovery.h b/variants/station_g3_esp32/StationG3BootRecovery.h new file mode 100644 index 000000000..37710776d --- /dev/null +++ b/variants/station_g3_esp32/StationG3BootRecovery.h @@ -0,0 +1,15 @@ +#pragma once + +class StationG3Board; + +namespace station_g3 { + +// Call only while radio initialization has failed, with primary USB already +// started. Every command needs the literal "!g3 " prefix. This is deliberately +// not a replacement for the working role's CLI or binary KISS protocol. +// firstNotice starts a new recovery interval and forgets any partial input. +// Each call performs bounded, zero-wait USB work; the retry loop still owns +// watchdog servicing, yield/delay and the next attempt to initialize the radio. +void serviceBootRecovery(StationG3Board& board, bool firstNotice); + +} // namespace station_g3 diff --git a/variants/station_g3_esp32/StationG3RadioSlot.cpp b/variants/station_g3_esp32/StationG3RadioSlot.cpp new file mode 100644 index 000000000..f06877dde --- /dev/null +++ b/variants/station_g3_esp32/StationG3RadioSlot.cpp @@ -0,0 +1,54 @@ +#include "StationG3RadioSlot.h" +#include + +namespace station_g3 { +namespace { +constexpr const char* SettingsNamespace = "g3-radio"; + +bool saveByte(const char* key, uint8_t value) { + nvs_handle_t handle; + if (nvs_open(SettingsNamespace, NVS_READWRITE, &handle) != ESP_OK) return false; + const bool ok = nvs_set_u8(handle, key, value) == ESP_OK && nvs_commit(handle) == ESP_OK; + nvs_close(handle); + return ok; +} +} // namespace + +void loadSlotSettings() { + SlotState& state = slotState(); + if (state.loaded) return; + state.loaded = true; + nvs_handle_t handle; + const esp_err_t opened = nvs_open(SettingsNamespace, NVS_READONLY, &handle); + if (opened == ESP_ERR_NVS_NOT_FOUND) return; // new installation: auto + if (opened != ESP_OK) { state.settingsHealthy = false; return; } + uint8_t value = 0; + esp_err_t result = nvs_get_u8(handle, "mode", &value); + if (result == ESP_OK && value <= 2) state.bootMode = state.savedMode = value; + else if (result != ESP_ERR_NVS_NOT_FOUND) state.settingsHealthy = false; + value = 0; + result = nvs_get_u8(handle, "last", &value); + if (result == ESP_OK && value <= 2) state.last = value; + else if (result != ESP_ERR_NVS_NOT_FOUND) state.settingsHealthy = false; + nvs_close(handle); +} + +bool saveSlotMode(uint8_t mode) { + if (mode > 2 || !saveByte("mode", mode)) return false; + slotState().savedMode = mode; + // A mode write does not repair an invalid/unreadable "last" key. Preserve + // any boot warning until the next boot revalidates both stored settings. + return true; +} + +void rememberActiveSlot(uint8_t slot) { + SlotState& state = slotState(); + if (slot < 1 || slot > 2 || slot == state.last) return; + if (saveByte("last", slot)) state.last = slot; + else state.settingsHealthy = false; +} + +const char* slotModeName(uint8_t mode) { + return mode == 1 ? "1" : mode == 2 ? "2" : "auto"; +} +} // namespace station_g3 diff --git a/variants/station_g3_esp32/StationG3RadioSlot.h b/variants/station_g3_esp32/StationG3RadioSlot.h new file mode 100644 index 000000000..28885d382 --- /dev/null +++ b/variants/station_g3_esp32/StationG3RadioSlot.h @@ -0,0 +1,57 @@ +#pragma once + +#include + +namespace station_g3 { + +// Physical daughterboard sockets. This is unrelated to MeshCore's radio2 PHY. +struct RadioPins { + uint8_t nss, irq, reset, busy, sck, miso, mosi, lna; +}; + +inline const RadioPins& pinsForSlot(uint8_t slot) { + static const RadioPins primary = {11, 48, 21, 47, 12, 14, 13, 10}; + static const RadioPins secondary = {43, 2, 44, 1, 39, 41, 40, 42}; + return slot == 2 ? secondary : primary; +} + +struct SlotState { + uint8_t bootMode = 0; // 0=auto, 1/2=explicit; frozen for this boot + uint8_t savedMode = 0; // next-boot setting, changes only after checked commit + uint8_t last = 0; // last successfully used socket + uint8_t candidate = 1; // pin binding during a no-transmit probe + uint8_t active = 0; + uint8_t detected = 0; // bit 0=primary, bit 1=secondary + bool loaded = false; + bool settingsHealthy = true; + + uint8_t choose(uint8_t mask) const { + if (bootMode > 2) return 0; + if (bootMode != 0) return mask & (1U << (bootMode - 1)) ? bootMode : 0; + if (last >= 1 && last <= 2 && (mask & (1U << (last - 1)))) return last; + return mask & 1U ? 1 : (mask & 2U ? 2 : 0); + } +}; + +// Shared across translation units: board, driver, FEM and reset/wake logic +// must observe one binding, never separate header-static copies. +inline SlotState& slotState() { + static SlotState state; + return state; +} +inline const RadioPins& activePins() { return pinsForSlot(slotState().candidate); } +inline bool isRadioPin(uint8_t pin) { + for (uint8_t slot = 1; slot <= 2; ++slot) { + const RadioPins& p = pinsForSlot(slot); + 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; +} + +void loadSlotSettings(); +bool saveSlotMode(uint8_t mode); +void rememberActiveSlot(uint8_t slot); +const char* slotModeName(uint8_t mode); + +} // namespace station_g3 diff --git a/variants/station_g3_esp32/platformio.ini b/variants/station_g3_esp32/platformio.ini index b34965819..750d3765d 100644 --- a/variants/station_g3_esp32/platformio.ini +++ b/variants/station_g3_esp32/platformio.ini @@ -1,6 +1,10 @@ -; The motherboard has two RF daughterboard slots on separate GPIOs, mirrored by its -; "LNA P" / "LNA S" jumpers. Slot 1 is [Station_G3_ESP32], slot 2 is [Station_G3_ESP32_r2]; -; the slot pins sit in their own sections so neither set of -D flags shadows the other. +; Both Station_G3_ESP32 and its legacy _r2 recipes now detect the populated SX1262 +; daughterboard slot at boot. "r2" historically meant physical slot 2, not revision 2. +; Keep both environment names/identities for explicit builds and existing tooling; +; future bulk releases need only one image per role/profile. The pin flags below +; provide constructor defaults only; runtime discovery binds every radio GPIO. +; CLI: get/set radio.slot auto|1|2 (saved, reboot required). This physical selector +; is unrelated to the time-shared radio2 PHY profile. [Station_G3_ESP32_common] extends = esp32_base board = station-g3-esp32 @@ -55,7 +59,7 @@ build_flags = -D P_PRIMARY_LNA_EN=10 ; Primary Slot LNA Mode: LOW/open is LNA on, HIGH/short is LNA off. -D P_PRIMARY_LNA_EN_ACTIVE=LOW -; Slot 2 ("LNA S"). P_PRIMARY_LNA_EN keeps its name because LoRaFEMControl knows only one LNA pin. +; Legacy slot-2 constructor defaults ("LNA S"); FEM control uses runtime slot pins. [station_g3_esp32_r2_pins] build_flags = -D P_LORA_DIO_1=2 diff --git a/variants/station_g3_esp32/target.cpp b/variants/station_g3_esp32/target.cpp index c0c026379..58b31f537 100644 --- a/variants/station_g3_esp32/target.cpp +++ b/variants/station_g3_esp32/target.cpp @@ -1,5 +1,7 @@ #include #include "target.h" +#include "StationG3BootRecovery.h" +#include // The normal Station G3 roles retain the established RadioLib Arduino HAL. // The KISS modem can opt into buffered SPI for its on-board dual-profile RX @@ -10,6 +12,12 @@ StationG3Board board; +// Release qualification must prove the selector is in the linked application, +// not merely infer it from a board/environment name. +extern const char meshcore_capability_station_g3_auto_slots[]; +const char meshcore_capability_station_g3_auto_slots[] __attribute__((used)) = + "meshcore.capability.station_g3.auto_slots.v1"; + #if defined(P_LORA_SCLK) static SPIClass spi; #if defined(MC_SX1262_BUFFERED_RADIO_HAL) && MC_SX1262_BUFFERED_RADIO_HAL @@ -40,16 +48,128 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock); MomentaryButton user_btn(PIN_USER_BTN, 1000, true); #endif +namespace { +bool waitForProbeReady(const station_g3::RadioPins& pins) { + const uint32_t started = millis(); + while (digitalRead(pins.busy)) { + if (static_cast(millis() - started) >= 100) return false; + delay(1); + } + return true; +} + +// Identification only: no SetTx, CAD, packet send, or saved profile changes. +// Use a small bounded transaction rather than RadioLib's 10 reset attempts +// (each may wait a second) on every empty socket. +bool probeRadioSlot(uint8_t slot) { + const auto& pins = station_g3::pinsForSlot(slot); + spi.end(); + station_g3::slotState().candidate = slot; + digitalWrite(pins.nss, HIGH); + pinMode(pins.nss, OUTPUT); + digitalWrite(pins.lna, HIGH); + pinMode(pins.lna, OUTPUT); + pinMode(pins.irq, INPUT); + pinMode(pins.busy, INPUT_PULLDOWN); + digitalWrite(pins.reset, HIGH); + pinMode(pins.reset, OUTPUT); + digitalWrite(pins.reset, LOW); + delay(1); + digitalWrite(pins.reset, HIGH); + delay(10); + spi.begin(pins.sck, pins.miso, pins.mosi); + bool found = false; + for (unsigned attempt = 0; attempt < 3 && !found; ++attempt) { + if (!waitForProbeReady(pins)) break; + spi.beginTransaction(SPISettings(2000000, MSBFIRST, SPI_MODE0)); + digitalWrite(pins.nss, LOW); + spi.transfer(RADIOLIB_SX126X_CMD_SET_STANDBY); + spi.transfer(RADIOLIB_SX126X_STANDBY_RC); + digitalWrite(pins.nss, HIGH); + spi.endTransaction(); + // SX126x BUSY can assert just after NSS rises (RadioLib uses the same guard). + delayMicroseconds(1); + if (!waitForProbeReady(pins)) break; + char version[16] = {}; + spi.beginTransaction(SPISettings(2000000, MSBFIRST, SPI_MODE0)); + digitalWrite(pins.nss, LOW); + spi.transfer(RADIOLIB_SX126X_CMD_READ_REGISTER); + spi.transfer(RADIOLIB_SX126X_REG_VERSION_STRING >> 8); + spi.transfer(RADIOLIB_SX126X_REG_VERSION_STRING & 0xff); + spi.transfer(0); // status/dummy byte before register data + for (unsigned i = 0; i < sizeof(version); ++i) version[i] = spi.transfer(0); + digitalWrite(pins.nss, HIGH); + spi.endTransaction(); + // SX1262 intentionally uses RadioLib's SX1261 silicon version signature. + found = memcmp(version, RADIOLIB_SX1262_CHIP_TYPE, 6) == 0; + if (!found) delay(10); + } + // Leave the unselected socket in reset, not receiving or driving an IRQ. + // The chosen socket's std_init will perform its own verified reset. + digitalWrite(pins.reset, LOW); + spi.end(); + return found; +} + +bool initializeSlot(uint8_t slot) { + spi.end(); + station_g3::slotState().candidate = slot; + const auto& pins = station_g3::activePins(); + Module* module = radio.getMod(); + // The pinned RadioLib build already opts into GODMODE. Keep one stable + // Module/radio/wrapper address, and bind only before std_init/ISR startup. + module->csPin = pins.nss; + module->irqPin = pins.irq; + module->rstPin = pins.reset; + module->gpioPin = pins.busy; + spi.begin(pins.sck, pins.miso, pins.mosi); + if (!radio.std_init(&spi)) { + digitalWrite(pins.reset, LOW); + spi.end(); + return false; + } + station_g3::slotState().active = slot; + station_g3::rememberActiveSlot(slot); + board.loRaFEMControl.setRxModeEnable(); + return true; +} +} // namespace + bool radio_init() { + volatile const char* capability = meshcore_capability_station_g3_auto_slots; + (void)*capability; + auto& state = station_g3::slotState(); + // Runtime recovery must never change physical sockets under live wrapper + // state/interrupts. Only the first boot discovery is allowed to select one. + if (state.active != 0) return radio.std_init(&spi); + fallback_clock.begin(); rtc_clock.begin(Wire); + bool firstNotice = true; + for (;;) { + state.detected = 0; + for (uint8_t slot = 1; slot <= 2; ++slot) + if (probeRadioSlot(slot)) state.detected |= 1U << (slot - 1); + const uint8_t preferred = state.choose(state.detected); + if (preferred && initializeSlot(preferred)) return true; + // Auto may recover using another detected, functional socket. Explicit + // overrides never silently fall back to an unintended antenna/PA. + if (state.bootMode == 0 && preferred && state.detected == 3 && + initializeSlot(preferred == 1 ? 2 : 1)) return true; -#if defined(P_LORA_SCLK) - spi.begin(P_LORA_SCLK, P_LORA_MISO, P_LORA_MOSI); - return radio.std_init(&spi); -#else - return radio.std_init(); + // No radio is not permission to generate a new identity, transmit, or + // reboot-loop before the operator can correct a saved hardware setting. + const uint32_t started = millis(); + do { +#if MESH_USB_CONSOLE_COOPERATIVE + mesh::serviceUsbLoggingPort(); + mesh::serviceUsbTerminalPort(); #endif + station_g3::serviceBootRecovery(board, firstNotice); + firstNotice = false; + delay(2); + } while (static_cast(millis() - started) < 10000); + } } mesh::LocalIdentity radio_new_identity() { diff --git a/variants/wio-tracker-l2/WioTrackerL2Board.cpp b/variants/wio-tracker-l2/WioTrackerL2Board.cpp index 7dc2cda03..c68a5e1de 100644 --- a/variants/wio-tracker-l2/WioTrackerL2Board.cpp +++ b/variants/wio-tracker-l2/WioTrackerL2Board.cpp @@ -1,4 +1,5 @@ #include "WioTrackerL2Board.h" +#include // TCA9535 register map static const uint8_t TCA_REG_OUTPUT0 = 0x02; @@ -136,8 +137,8 @@ void WioTrackerL2Board::begin() { if (wakeup_source & (1 << P_LORA_DIO_1)) { startup_reason = BD_STARTUP_RX_PACKET; // LoRa packet woke us from deep sleep } - rtc_gpio_hold_dis((gpio_num_t)P_LORA_NSS); - rtc_gpio_deinit((gpio_num_t)P_LORA_DIO_1); + mesh::esp32::releaseRtcGpioIfSupported((gpio_num_t)P_LORA_NSS); + mesh::esp32::deinitRtcGpioIfSupported((gpio_num_t)P_LORA_DIO_1); } } diff --git a/variants/xiao_nrf52/XiaoNrf52Board.cpp b/variants/xiao_nrf52/XiaoNrf52Board.cpp index 96c000606..5812d6fa2 100644 --- a/variants/xiao_nrf52/XiaoNrf52Board.cpp +++ b/variants/xiao_nrf52/XiaoNrf52Board.cpp @@ -18,8 +18,10 @@ void XiaoNrf52Board::initiateShutdown(uint8_t reason) { bool enable_lpcomp = (reason == SHUTDOWN_REASON_LOW_VOLTAGE || reason == SHUTDOWN_REASON_BOOT_PROTECT); + // Keep the divider's lower leg grounded even for user shutdown: driving + // READ_BAT_ENABLE HIGH can overvoltage the ADC input during charging. + digitalWrite(VBAT_ENABLE, LOW); pinMode(VBAT_ENABLE, OUTPUT); - digitalWrite(VBAT_ENABLE, enable_lpcomp ? LOW : HIGH); if (enable_lpcomp) { configureVoltageWake(power_config.lpcomp_ain_channel, power_config.lpcomp_refsel); @@ -34,8 +36,8 @@ void XiaoNrf52Board::begin() { // Configure battery voltage ADC pinMode(PIN_VBAT, INPUT); - pinMode(VBAT_ENABLE, OUTPUT); digitalWrite(VBAT_ENABLE, LOW); // Enable VBAT divider for reading + pinMode(VBAT_ENABLE, OUTPUT); analogReadResolution(12); analogReference(AR_INTERNAL_3_0); delay(50); // Allow ADC to settle diff --git a/variants/xiao_nrf52/variant.cpp b/variants/xiao_nrf52/variant.cpp index 04ef3a924..962f871ba 100644 --- a/variants/xiao_nrf52/variant.cpp +++ b/variants/xiao_nrf52/variant.cpp @@ -56,14 +56,11 @@ const uint32_t g_ADigitalPinMap[] = { }; void initVariant() { - // Disable reading of the BAT voltage. + // Keep the battery divider enabled: HIGH can overvoltage the ADC while charging. // https://wiki.seeedstudio.com/XIAO_BLE#q3-what-are-the-considerations-when-using-xiao-nrf52840-sense-for-battery-charging - pinMode(VBAT_ENABLE, OUTPUT); - // digitalWrite(VBAT_ENABLE, HIGH); - // This was taken from Seeed github butis not coherent with the doc, - // VBAT_ENABLE should be kept to LOW to protect P0.14, (1500/500)*(4.2-3.3)+3.3 = 3.9V > 3.6V - // This induces a 3mA current in the resistors :( but it's better than burning the nrf + // The 1 MOhm + 510 kOhm divider draws about 3 uA, not 3 mA. digitalWrite(VBAT_ENABLE, LOW); + pinMode(VBAT_ENABLE, OUTPUT); // Low charging current (50mA) // https://wiki.seeedstudio.com/XIAO_BLE#battery-charging-current