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