Add an hourly emergency battery sleep schedule for infrastructure nodes
This commit is contained in:
1 parent
744692fb93
commit
0c7dec9f19
30 files changed
+2020
-33
No files matched your search
@@ -595,6 +595,10 @@ jobs:
|
||||
python3 -B test/test_regulator_recovery.py -v
|
||||
python3 -B test/test_regulator_settings.py -v
|
||||
python3 -B test/test_battery_forwarding.py -v
|
||||
python3 -B test/test_emergency_power.py -v
|
||||
python3 -B test/test_emergency_power_runtime.py -v
|
||||
python3 -B test/test_emergency_power_ota.py -v
|
||||
python3 -B test/test_emergency_sleep_backends.py -v
|
||||
python3 -B test/test_battery_calibration.py -v
|
||||
python3 -B test/test_battery_calibration_store.py -v
|
||||
python3 -B test/test_power_saving_diagnostics.py -v
|
||||
|
||||
+55
-8
@@ -3024,8 +3024,8 @@ send text.flood checking ridge link
|
||||
- `set battery.alert.critical <0-99>`
|
||||
|
||||
**Defaults:**
|
||||
- `battery.alert.low`: `20`
|
||||
- `battery.alert.critical`: `10`
|
||||
- `battery.alert.low`: `25`
|
||||
- `battery.alert.critical`: `15`
|
||||
|
||||
**Note:** The low threshold must be greater than the critical threshold. Alerts at or below the critical threshold use `CRITICAL BATTERY` in the message; both severities use the same 12-hour resend cooldown.
|
||||
|
||||
@@ -3052,12 +3052,17 @@ readings remain available.
|
||||
- `set flood.battery <off|on|1-100>`
|
||||
- `get battery.forward`
|
||||
|
||||
`set repeat.battery on` enables the saved repeat threshold, initially **10%**.
|
||||
`set flood.battery on` enables the saved flood threshold, initially **20%**.
|
||||
`set repeat.battery on` enables the saved repeat threshold, initially **15%**.
|
||||
`set flood.battery on` enables the saved flood threshold, initially **25%**.
|
||||
A number both enables the control and sets its threshold. `off` disables it
|
||||
while retaining that threshold for the next `on`. Changes are saved and take
|
||||
effect without a reboot.
|
||||
|
||||
An older forwarding record with exactly the former 10% repeat / 20% flood
|
||||
defaults adopts 15% / 25% on upgrade. Its enabled controls and voltage endpoints
|
||||
stay unchanged. Other saved thresholds are retained; newly selected custom
|
||||
10% / 20% settings also remain available.
|
||||
|
||||
Below the repeat threshold, the node stops LoRa retransmissions of received
|
||||
traffic. Below the flood threshold, it stops LoRa retransmissions of received
|
||||
flood traffic but may still
|
||||
@@ -3071,8 +3076,8 @@ limits. Exporting radio reception to WiFi, ESP-NOW, or RS232 is unchanged.
|
||||
|
||||
Forwarding resumes automatically after the measured percentage reaches two
|
||||
percentage points above the applicable threshold, capped at 100%. With the
|
||||
initial thresholds, flood forwarding pauses below 20% and resumes at 22%; all
|
||||
relay forwarding pauses below 10% and resumes at 12%. This recovery margin
|
||||
initial thresholds, flood forwarding pauses below 25% and resumes at 27%; all
|
||||
relay forwarding pauses below 15% and resumes at 17%. This recovery margin
|
||||
avoids repeatedly switching forwarding on and off near a threshold.
|
||||
|
||||
Battery voltage is sampled every 30 seconds while either control is enabled,
|
||||
@@ -3093,12 +3098,54 @@ get battery.forward
|
||||
To choose different thresholds and later disable the feature:
|
||||
|
||||
```text
|
||||
set repeat.battery 15
|
||||
set flood.battery 25
|
||||
set repeat.battery 12
|
||||
set flood.battery 22
|
||||
set repeat.battery off
|
||||
set flood.battery off
|
||||
```
|
||||
|
||||
#### Emergency battery schedule
|
||||
|
||||
Supported ESP32 and nRF52 Repeater, Room Server and Sensor images enter the
|
||||
final battery-saving stage at **5% or less**, independently of the optional
|
||||
forwarding gates. Automatic mode returns to normal at **7% or more**. An
|
||||
invalid battery reading cannot activate it, or falsely clear an existing low
|
||||
battery state. Percentage uses the same configured voltage endpoints as
|
||||
`battery.forward`; it does not prove that a physical battery is connected.
|
||||
|
||||
In emergency mode, normal work runs during the first **15 minutes of every
|
||||
UTC hour** (`:00:00` through `:14:59`). For the remaining 45 minutes the LoRa
|
||||
radio sleeps and normal mesh, sensor and screen work is suspended. The CPU
|
||||
uses timed sleep with brief watchdog, clock, voltage-protection and command
|
||||
maintenance wakes. Active firmware updates and unfinished critical operations
|
||||
defer sleep. Existing hard low-voltage protection remains in force.
|
||||
|
||||
```text
|
||||
get power.emergency
|
||||
set power.emergency on
|
||||
set power.awake 30
|
||||
set power.awake 0
|
||||
set power.emergency auto
|
||||
```
|
||||
|
||||
`on` saves manual emergency mode, including across reboots. `auto` restores
|
||||
battery-controlled entry and is the default. The initial transition allows
|
||||
five seconds for its reply. `power.awake` keeps the node up for the next given
|
||||
number of minutes (0 cancels; maximum 10080), even outside the hourly window.
|
||||
That override is held in RAM and ends on reboot; it never disables hard
|
||||
low-voltage shutdown. Use it before maintenance that needs continuous uptime.
|
||||
|
||||
`get power.emergency` reports the mode, battery estimate, desired `window`,
|
||||
actual radio ownership, seconds until the next awake window, and remaining
|
||||
hold. With an unset clock it reports `clock=uptime` and follows the same
|
||||
15/45-minute cycle from boot until a valid UTC clock is available. Queued
|
||||
future traffic does not keep a critically low node awake indefinitely.
|
||||
|
||||
This schedule uses resumable timed sleep, preserving identity and settings.
|
||||
WiFi/ESP-NOW services are temporarily stopped and their previous live subset
|
||||
is restored when the node wakes. Companion builds and flash-constrained
|
||||
profiles that omit battery-forwarding controls do not add this feature.
|
||||
|
||||
#### Set the battery percentage voltage range
|
||||
|
||||
The forwarding thresholds use a voltage-derived percentage. On infrastructure
|
||||
|
||||
@@ -246,6 +246,27 @@ measurement or entry into ship mode. Read the corresponding status afterward.
|
||||
See the [bench acceptance report](test-results/solarxiao_power_2026-10-21.md)
|
||||
for exact firmware identity, measured readouts and test limits.
|
||||
|
||||
## Emergency battery schedule
|
||||
|
||||
The [emergency schedule](cli_commands.md#emergency-battery-schedule) keeps the
|
||||
radio and normal work awake for the first 15 minutes of each UTC hour, with
|
||||
timed sleep for the remaining 45 minutes. It starts automatically at 5% and
|
||||
recovers at 7%. Forwarding thresholds now default to 25% for floods and 15%
|
||||
for all relayed traffic; those forwarding gates remain opt-in.
|
||||
|
||||
```text
|
||||
get power.emergency
|
||||
set power.emergency on
|
||||
set power.awake 30
|
||||
set power.emergency auto
|
||||
```
|
||||
|
||||
The percentage follows the configured self battery voltage endpoints used by
|
||||
`battery.forward`. On MPPT carriers this is the XIAO ADC, not an estimate of
|
||||
the separate carrier battery's charge from INA260/LTC4162. Check the actual
|
||||
voltage source before relying on automatic entry. Manual mode and timed wake
|
||||
work independently of the charger. Hard low-voltage protection still applies.
|
||||
|
||||
## Conversion defaults and carrier revisions
|
||||
|
||||
The supplied [Standard schematic](https://github.com/wehooper4/Meshtastic-Hardware/blob/main/XaioSeries/SolarXaio/MPPT/Standard/Schematic_SolarXaio_MPPT_2025-10-23.pdf)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <Mesh.h>
|
||||
#include <helpers/PacketFileLoggingConfig.h>
|
||||
#include <helpers/PowerSavingDiagnostics.h>
|
||||
#include <helpers/EmergencyOtaWork.h>
|
||||
#include <helpers/FloodGroupPlainText.h>
|
||||
#include <helpers/FloodRuleExtensions.h>
|
||||
#include <helpers/GuestLoginSettings.h>
|
||||
@@ -1277,6 +1278,21 @@ public:
|
||||
NodePrefs* getNodePrefs() {
|
||||
return &_prefs;
|
||||
}
|
||||
#if MESH_ENABLE_EMERGENCY_POWER
|
||||
mesh::power::EmergencyOtaWork _emergency_ota_work;
|
||||
CommonCLI& getPowerCLI() { return _cli; }
|
||||
bool emergencyPowerBusy() const {
|
||||
#if defined(ENABLE_OTA)
|
||||
if (_emergency_ota_work.busy(mesh::ota::ota_context_if_active(), millis())) return true;
|
||||
#endif
|
||||
#if MESH_ENABLE_DIRECT_OTA_ADMIN
|
||||
if (direct_ota_admin.awaitingPeer()) return true;
|
||||
#endif
|
||||
return hasPendingOtaApply() || _cli.hasPendingRadioSettings()
|
||||
|| _cli.hasActiveUserGpioTimer() || dirty_contacts_expiry != 0
|
||||
|| (saved_radio_apply_pending && !temp_radio_applied) || _ota_update_at != 0;
|
||||
}
|
||||
#endif
|
||||
bool isHiddenNode() const {
|
||||
return _prefs.advert_interval == 0 && _prefs.flood_advert_interval == 0;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
#include <helpers/HostKiss.h>
|
||||
#include <helpers/MorseLoopPolicy.h>
|
||||
#include <helpers/PowerManagementUtils.h>
|
||||
#include <helpers/EmergencyPowerRuntime.h>
|
||||
#include <helpers/HilStartupTrace.h>
|
||||
#if MESH_PACKET_LOGGING
|
||||
#include <helpers/SerialPacketLog.h>
|
||||
@@ -727,6 +728,23 @@ void loop() {
|
||||
#endif
|
||||
serviceCommandInterfaces();
|
||||
|
||||
#if MESH_ENABLE_EMERGENCY_POWER
|
||||
static mesh::power::EmergencyPowerRuntime emergency_power;
|
||||
#if defined(ESP32_PLATFORM)
|
||||
board.servicePowerTestControls();
|
||||
#endif
|
||||
rtc_clock.tick();
|
||||
#ifdef HAS_EXTERNAL_WATCHDOG
|
||||
external_watchdog.loop();
|
||||
#endif
|
||||
bool emergency_busy = command[0];
|
||||
#if MESH_USB_CONSOLE_COOPERATIVE
|
||||
emergency_busy = emergency_busy || the_mesh.hasPendingSerialOutput();
|
||||
#endif
|
||||
if (emergency_power.service(the_mesh, radio_driver, board,
|
||||
the_mesh.getPowerCLI(), emergency_busy, the_mesh.emergencyPowerBusy())) return;
|
||||
#endif
|
||||
|
||||
#if defined(PIN_USER_BTN) && defined(_SEEED_SENSECAP_SOLAR_H_) && !defined(DISPLAY_CLASS)
|
||||
// Hold the user button to power off the SenseCAP Solar repeater.
|
||||
int btnState = digitalRead(PIN_USER_BTN);
|
||||
@@ -778,7 +796,9 @@ void loop() {
|
||||
// Report the guard that really stopped this iteration, without probing
|
||||
// later guards or changing their short-circuit hardware access.
|
||||
#if defined(ESP32_PLATFORM)
|
||||
#if !MESH_ENABLE_EMERGENCY_POWER
|
||||
board.servicePowerTestControls();
|
||||
#endif
|
||||
mesh::esp32::serviceCpuFrequencyPolicy(board, the_mesh.getNodePrefs()->cpu_freq_mhz,
|
||||
the_mesh.getNodePrefs()->powersaving_enabled != 0, millis(), false, the_mesh.isDualRadioActive());
|
||||
#endif
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
#include <Arduino.h> // needed for PlatformIO
|
||||
#include <Mesh.h>
|
||||
#include <helpers/PowerSavingDiagnostics.h>
|
||||
#include <helpers/EmergencyOtaWork.h>
|
||||
#if MESH_ENABLE_EMERGENCY_POWER && defined(ENABLE_OTA)
|
||||
#include <helpers/ota/OtaContext.h>
|
||||
#endif
|
||||
#include "RoomServerFeatures.h"
|
||||
|
||||
#if defined(NRF52_PLATFORM)
|
||||
@@ -553,6 +557,18 @@ public:
|
||||
NodePrefs* getNodePrefs() {
|
||||
return &_prefs;
|
||||
}
|
||||
#if MESH_ENABLE_EMERGENCY_POWER
|
||||
mesh::power::EmergencyOtaWork _emergency_ota_work;
|
||||
CommonCLI& getPowerCLI() { return _cli; }
|
||||
bool emergencyPowerBusy() const {
|
||||
#if defined(ENABLE_OTA)
|
||||
if (_emergency_ota_work.busy(mesh::ota::ota_context_if_active(), millis())) return true;
|
||||
#endif
|
||||
return hasPendingOtaApply() || _cli.hasPendingRadioSettings()
|
||||
|| _cli.hasActiveUserGpioTimer() || dirty_contacts_expiry != 0
|
||||
|| (saved_radio_apply_pending && !temp_radio_applied) || _ota_update_at != 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef DISPLAY_ACTIVITY_DASHBOARD
|
||||
RadioActivityWindow* getActivityWindow() { return &_activity; }
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
#include <helpers/HostKiss.h>
|
||||
#include <helpers/MorseLoopPolicy.h>
|
||||
#include <helpers/PowerManagementUtils.h>
|
||||
#include <helpers/EmergencyPowerRuntime.h>
|
||||
#if MESH_PACKET_LOGGING
|
||||
#include <helpers/SerialPacketLog.h>
|
||||
#endif
|
||||
@@ -452,6 +453,28 @@ void loop() {
|
||||
}
|
||||
#endif
|
||||
|
||||
mesh::power::serviceRegulatorSettings();
|
||||
#if defined(MESH_SOLARXIAO_POWER) && MESH_SOLARXIAO_POWER
|
||||
mesh::solar::service();
|
||||
#endif
|
||||
board.loop();
|
||||
|
||||
#if MESH_ENABLE_EMERGENCY_POWER
|
||||
static mesh::power::EmergencyPowerRuntime emergency_power;
|
||||
#if defined(ESP32_PLATFORM)
|
||||
board.servicePowerTestControls();
|
||||
#endif
|
||||
rtc_clock.tick();
|
||||
#ifdef HAS_EXTERNAL_WATCHDOG
|
||||
external_watchdog.loop();
|
||||
#endif
|
||||
bool emergency_busy = command[0];
|
||||
#if MESH_USB_CONSOLE_COOPERATIVE
|
||||
emergency_busy = emergency_busy || the_mesh.hasPendingSerialOutput();
|
||||
#endif
|
||||
if (emergency_power.service(the_mesh, radio_driver, board,
|
||||
the_mesh.getPowerCLI(), emergency_busy, the_mesh.emergencyPowerBusy())) return;
|
||||
#endif
|
||||
the_mesh.loop();
|
||||
const bool morse_timing = mesh::morseTimingActive(&radio_driver);
|
||||
#if defined(USE_LR2021)
|
||||
@@ -466,11 +489,6 @@ void loop() {
|
||||
#endif
|
||||
#endif
|
||||
rtc_clock.tick();
|
||||
mesh::power::serviceRegulatorSettings();
|
||||
#if defined(MESH_SOLARXIAO_POWER) && MESH_SOLARXIAO_POWER
|
||||
mesh::solar::service();
|
||||
#endif
|
||||
board.loop();
|
||||
if (mesh::serviceUsbLoggingWatchdog(usbLoggingRecoverySafe)) board.reboot();
|
||||
#if MESH_USB_CONSOLE_COOPERATIVE
|
||||
#if MESH_ENABLE_HOST_KISS
|
||||
@@ -489,7 +507,9 @@ void loop() {
|
||||
&& !morse_timing) board.prepareProfileIdleCycle();
|
||||
#endif
|
||||
#if defined(ESP32_PLATFORM)
|
||||
#if !MESH_ENABLE_EMERGENCY_POWER
|
||||
board.servicePowerTestControls();
|
||||
#endif
|
||||
mesh::esp32::serviceCpuFrequencyPolicy(board, the_mesh.getNodePrefs()->cpu_freq_mhz,
|
||||
the_mesh.getNodePrefs()->powersaving_enabled != 0, millis(), false, the_mesh.isDualRadioActive());
|
||||
#endif
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
#include <Arduino.h> // needed for PlatformIO
|
||||
#include <Mesh.h>
|
||||
#include <helpers/PowerSavingDiagnostics.h>
|
||||
#include <helpers/EmergencyOtaWork.h>
|
||||
#if MESH_ENABLE_EMERGENCY_POWER && defined(ENABLE_OTA)
|
||||
#include <helpers/ota/OtaContext.h>
|
||||
#endif
|
||||
|
||||
#include "TimeSeriesData.h"
|
||||
|
||||
@@ -86,6 +90,18 @@ public:
|
||||
const char* getRole() override { return FIRMWARE_ROLE; }
|
||||
const char* getNodeName() { return _prefs.node_name; }
|
||||
NodePrefs* getNodePrefs() { return &_prefs; }
|
||||
#if MESH_ENABLE_EMERGENCY_POWER
|
||||
mesh::power::EmergencyOtaWork _emergency_ota_work;
|
||||
CommonCLI& getPowerCLI() { return _cli; }
|
||||
bool emergencyPowerBusy() const {
|
||||
#if defined(ENABLE_OTA)
|
||||
if (_emergency_ota_work.busy(mesh::ota::ota_context_if_active(), millis())) return true;
|
||||
#endif
|
||||
return hasPendingOtaApply() || _cli.hasPendingRadioSettings()
|
||||
|| _cli.hasActiveUserGpioTimer() || dirty_contacts_expiry != 0
|
||||
|| (saved_radio_apply_pending && !temp_radio_applied);
|
||||
}
|
||||
#endif
|
||||
uint32_t getPowerSaveSleepSeconds(uint32_t max_secs) const;
|
||||
uint32_t getPowerSaveSleepMilliseconds(uint32_t max_ms) const;
|
||||
bool isTrackerModeEnabled() const {
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
#include <helpers/HostKiss.h>
|
||||
#include <helpers/MorseLoopPolicy.h>
|
||||
#include <helpers/PowerManagementUtils.h>
|
||||
#include <helpers/EmergencyPowerRuntime.h>
|
||||
#if defined(NRF52_PLATFORM) && MESH_ENABLE_SENSOR_TRACKER
|
||||
#include <utility/SoftwareTimer.h>
|
||||
static SoftwareTimer tracker_wake_timer;
|
||||
@@ -412,6 +413,18 @@ void loop() {
|
||||
mesh::power::serviceRegulatorSettings();
|
||||
board.loop(); // let the board feed its watchdog, run periodic housekeeping
|
||||
|
||||
#if MESH_ENABLE_EMERGENCY_POWER
|
||||
static mesh::power::EmergencyPowerRuntime emergency_power;
|
||||
#if defined(ESP32_PLATFORM)
|
||||
board.servicePowerTestControls();
|
||||
#endif
|
||||
rtc_clock.tick();
|
||||
#ifdef HAS_EXTERNAL_WATCHDOG
|
||||
external_watchdog.loop();
|
||||
#endif
|
||||
if (emergency_power.service(the_mesh, radio_driver, board,
|
||||
the_mesh.getPowerCLI(), command[0], the_mesh.emergencyPowerBusy())) return;
|
||||
#endif
|
||||
the_mesh.loop();
|
||||
const bool morse_timing = mesh::morseTimingActive(&radio_driver);
|
||||
#if defined(USE_LR2021)
|
||||
@@ -442,7 +455,9 @@ void loop() {
|
||||
#endif
|
||||
|
||||
#if defined(ESP32_PLATFORM)
|
||||
#if !MESH_ENABLE_EMERGENCY_POWER
|
||||
board.servicePowerTestControls();
|
||||
#endif
|
||||
mesh::esp32::serviceCpuFrequencyPolicy(board, the_mesh.getNodePrefs()->cpu_freq_mhz,
|
||||
the_mesh.getNodePrefs()->powersaving_enabled != 0, millis(), false, the_mesh.isDualRadioActive());
|
||||
#endif
|
||||
|
||||
@@ -22,6 +22,32 @@ OTHER_ROLES = ("simple_room_server", "simple_sensor", "companion_radio", "simple
|
||||
"kiss_modem", "partition_expander", "esp32_partition_migrator", "esp32_partition_legacy_seed")
|
||||
COMPANION_DEFINITION = "MESH_ROLE_COMPANION"
|
||||
COMPANION_SOURCES = ("../examples/companion_radio/main.cpp", "../examples/companion_radio/MyMesh.cpp")
|
||||
EMERGENCY_DEFINITION = "MESH_ENABLE_EMERGENCY_POWER"
|
||||
|
||||
|
||||
def emergency_power_policy(src_filter, flags=(), cppdefines=()):
|
||||
"""Enable scheduled battery sleep only on complete supported infrastructure roles."""
|
||||
values = SOURCE_POLICY.flag_defines(flags, cppdefines)
|
||||
if EMERGENCY_DEFINITION in values:
|
||||
value = str(values[EMERGENCY_DEFINITION]).strip("\"'() ")
|
||||
if value not in ("0", "1"):
|
||||
raise ValueError(EMERGENCY_DEFINITION + " override must be 0 or 1")
|
||||
return {}
|
||||
if not any(name in values for name in ("ESP32_PLATFORM", "NRF52_PLATFORM")):
|
||||
return {}
|
||||
if str(values.get("MESH_ENABLE_BATTERY_FORWARDING", "1")).strip("\"'() ") == "0":
|
||||
return {}
|
||||
selected = SOURCE_POLICY.source_selected
|
||||
text = src_filter if isinstance(src_filter, str) else " ".join(map(str, src_filter))
|
||||
includes = re.findall(r"\+<\.\./examples/([^>]+)>", text.replace("\\", "/"))
|
||||
for role, mesh_source in (("simple_repeater", "MyMesh.cpp"),
|
||||
("simple_room_server", "MyMesh.cpp"),
|
||||
("simple_sensor", "SensorMesh.cpp")):
|
||||
if includes and all(path == role or path.startswith(role + "/") for path in includes) \
|
||||
and all(selected(src_filter, "../examples/" + role + "/" + source)
|
||||
for source in ("main.cpp", mesh_source)):
|
||||
return {EMERGENCY_DEFINITION: 1}
|
||||
return {}
|
||||
|
||||
|
||||
def companion_role_policy(src_filter, flags=(), cppdefines=()):
|
||||
@@ -83,6 +109,8 @@ def install(build_env):
|
||||
build_env.get("CPPDEFINES", ()))
|
||||
definitions.update(companion_role_policy(src_filter, build_env.get("BUILD_FLAGS", ()),
|
||||
build_env.get("CPPDEFINES", ())))
|
||||
definitions.update(emergency_power_policy(src_filter, build_env.get("BUILD_FLAGS", ()),
|
||||
build_env.get("CPPDEFINES", ())))
|
||||
if definitions:
|
||||
build_env.AppendUnique(CPPDEFINES=list(definitions.items()))
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include "helpers/UsbLogging.h"
|
||||
#include "helpers/BatteryChargeSupport.h"
|
||||
#include "helpers/BatteryCalibration.h"
|
||||
#include "helpers/EmergencyPowerConfig.h"
|
||||
#include "helpers/RegulatorMode.h"
|
||||
#include "helpers/PowerSavingDiagnostics.h"
|
||||
#include "helpers/LedPolicy.h"
|
||||
@@ -202,6 +203,15 @@ public:
|
||||
return false;
|
||||
}
|
||||
virtual void enterDeepSleep(uint32_t secs) { (void)secs; }
|
||||
#if MESH_ENABLE_EMERGENCY_POWER
|
||||
// Resumable CPU sleep after the caller has suspended RF and drained owners.
|
||||
// Capability describes the backend, not whether this attempt can sleep.
|
||||
virtual bool supportsEmergencySleep() const { return false; }
|
||||
virtual bool emergencySleepMilliseconds(uint32_t millisecs) {
|
||||
(void)millisecs;
|
||||
return false;
|
||||
}
|
||||
#endif
|
||||
// Called by example setup() functions to signal that boot is complete.
|
||||
// Boards may override to stop a boot-indicator LED sequence or similar.
|
||||
// Default no-op: boards that don't care need not implement anything.
|
||||
|
||||
@@ -24,8 +24,8 @@ static constexpr size_t RecordSize = 16;
|
||||
|
||||
struct Settings {
|
||||
uint8_t enabled = 0;
|
||||
uint8_t repeatBelow = 10;
|
||||
uint8_t floodBelow = 20;
|
||||
uint8_t repeatBelow = 15;
|
||||
uint8_t floodBelow = 25;
|
||||
// Zero keeps the board's compiled endpoints. nRF52 uses its voltage policy.
|
||||
uint16_t emptyMv = 0;
|
||||
uint16_t fullMv = 0;
|
||||
@@ -54,7 +54,7 @@ inline uint16_t checksum(const uint8_t* data, size_t size) {
|
||||
|
||||
inline void encode(uint8_t* out, const Settings& s) {
|
||||
memset(out, 0, RecordSize);
|
||||
out[0] = 'B'; out[1] = 'F'; out[2] = 'R'; out[3] = 1;
|
||||
out[0] = 'B'; out[1] = 'F'; out[2] = 'R'; out[3] = 2;
|
||||
out[4] = s.enabled; out[5] = s.repeatBelow; out[6] = s.floodBelow;
|
||||
out[8] = uint8_t(s.emptyMv); out[9] = uint8_t(s.emptyMv >> 8);
|
||||
out[10] = uint8_t(s.fullMv); out[11] = uint8_t(s.fullMv >> 8);
|
||||
@@ -65,13 +65,18 @@ inline void encode(uint8_t* out, const Settings& s) {
|
||||
inline bool decode(const uint8_t* in, size_t size, Settings& s) {
|
||||
s = Settings{};
|
||||
if (!in || size != RecordSize || in[0] != 'B' || in[1] != 'F'
|
||||
|| in[2] != 'R' || in[3] != 1 || in[7] || in[12] || in[13]
|
||||
|| in[2] != 'R' || (in[3] != 1 && in[3] != 2) || in[7] || in[12] || in[13]
|
||||
|| checksum(in, 14) != uint16_t(in[14] | (uint16_t(in[15]) << 8))) return false;
|
||||
Settings candidate;
|
||||
candidate.enabled = in[4]; candidate.repeatBelow = in[5]; candidate.floodBelow = in[6];
|
||||
candidate.emptyMv = uint16_t(in[8] | (uint16_t(in[9]) << 8));
|
||||
candidate.fullMv = uint16_t(in[10] | (uint16_t(in[11]) << 8));
|
||||
if (!validSettings(candidate)) return false;
|
||||
// Upgrade the old default pair once; v2 preserves an explicit 10/20 choice.
|
||||
if (in[3] == 1 && candidate.repeatBelow == 10 && candidate.floodBelow == 20) {
|
||||
candidate.repeatBelow = 15;
|
||||
candidate.floodBelow = 25;
|
||||
}
|
||||
s = candidate;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -222,6 +222,9 @@ void CommonCLI::loop() {
|
||||
#if MESH_ENABLE_BATTERY_FORWARDING
|
||||
loopBatteryForwarding();
|
||||
#endif
|
||||
#if MESH_ENABLE_EMERGENCY_POWER
|
||||
loopEmergencyPower();
|
||||
#endif
|
||||
#if MESH_ENABLE_MORSE_RX
|
||||
if (_morse_reply.pending()) {
|
||||
const ClientInfo* actor = _acl->getClient(_morse_reply.actor(), PUB_KEY_SIZE);
|
||||
@@ -1012,6 +1015,9 @@ static void formatSnrDbX4Short(char* dest, size_t dest_len, int16_t snr_x4) {
|
||||
void CommonCLI::loadPrefs(FILESYSTEM* fs) {
|
||||
#if MESH_ENABLE_BATTERY_FORWARDING
|
||||
loadBatteryForwarding(fs);
|
||||
#endif
|
||||
#if MESH_ENABLE_EMERGENCY_POWER
|
||||
loadEmergencyPower(fs);
|
||||
#endif
|
||||
mesh::hilStartupTrace("prefs_profiles_begin");
|
||||
_radio_profiles.begin(fs, _callbacks->getProfileRadio(), _rtc, true);
|
||||
@@ -3563,6 +3569,9 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
|
||||
#if MESH_ENABLE_BATTERY_FORWARDING
|
||||
if (handleBatteryForwardingCommand(command, reply)) return;
|
||||
#endif
|
||||
#if MESH_ENABLE_EMERGENCY_POWER
|
||||
if (handleEmergencyPowerCommand(command, reply)) return;
|
||||
#endif
|
||||
#if defined(WITH_UDP_BRIDGE)
|
||||
if (handleUDPBridgeCommand(sender_timestamp, command, reply)) return;
|
||||
#endif
|
||||
|
||||
+22
-2
@@ -19,6 +19,8 @@
|
||||
#include <helpers/WirelessControl.h>
|
||||
#include <helpers/FleetCommand.h>
|
||||
#include <helpers/BatteryForwarding.h>
|
||||
#include <helpers/EmergencyPowerConfig.h>
|
||||
#include <helpers/EmergencyPowerPolicy.h>
|
||||
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
|
||||
#include <helpers/SdCardCLI.h>
|
||||
#endif
|
||||
@@ -91,8 +93,8 @@ static constexpr uint8_t DEFAULT_ADVERT_LOC_POLICY = ADVERT_LOC_PREFS;
|
||||
#define FLOOD_RETRY_ADVERT_DEFAULT 0
|
||||
#define FLOOD_RETRY_GROUP_MAX_PATH_DEFAULT 1
|
||||
|
||||
#define BATTERY_ALERT_LOW_PERCENT_DEFAULT 20
|
||||
#define BATTERY_ALERT_CRITICAL_PERCENT_DEFAULT 10
|
||||
#define BATTERY_ALERT_LOW_PERCENT_DEFAULT 25
|
||||
#define BATTERY_ALERT_CRITICAL_PERCENT_DEFAULT 15
|
||||
|
||||
#ifndef FLOOD_RETRY_PREFIX_SLOTS
|
||||
#define FLOOD_RETRY_PREFIX_SLOTS 8
|
||||
@@ -938,6 +940,15 @@ class CommonCLI {
|
||||
void loadBatteryForwarding(FILESYSTEM*) {}
|
||||
void loopBatteryForwarding(bool = false) const {}
|
||||
bool handleBatteryForwardingCommand(const char*, char*) { return false; }
|
||||
#endif
|
||||
#if MESH_ENABLE_EMERGENCY_POWER
|
||||
FILESYSTEM* _emergency_power_fs = nullptr;
|
||||
mesh::emergencypower::State _emergency_power;
|
||||
bool _emergency_radio_sleeping = false;
|
||||
void loadEmergencyPower(FILESYSTEM* fs);
|
||||
bool saveEmergencyPower(const mesh::emergencypower::Settings& settings);
|
||||
void loopEmergencyPower();
|
||||
bool handleEmergencyPowerCommand(const char* command, char* reply);
|
||||
#endif
|
||||
uint16_t batteryEmptyMv() const;
|
||||
uint16_t batteryFullMv() const;
|
||||
@@ -1110,6 +1121,15 @@ public:
|
||||
#else
|
||||
bool batteryAllowsForward(bool) const { return true; }
|
||||
bool batteryAllowsTransmit(const mesh::Packet*) const { return true; }
|
||||
#endif
|
||||
#if MESH_ENABLE_EMERGENCY_POWER
|
||||
uint32_t emergencySleepMilliseconds();
|
||||
bool emergencyActive() const { return _emergency_power.active(); }
|
||||
void setEmergencyRadioSleeping(bool sleeping) { _emergency_radio_sleeping = sleeping; }
|
||||
#else
|
||||
uint32_t emergencySleepMilliseconds() { return 0; }
|
||||
bool emergencyActive() const { return false; }
|
||||
void setEmergencyRadioSleeping(bool) {}
|
||||
#endif
|
||||
uint8_t batteryPercent(uint16_t mv) const;
|
||||
bool hasActiveUserGpioTimer() const;
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
#include "CommonCLI.h"
|
||||
|
||||
#if MESH_ENABLE_EMERGENCY_POWER
|
||||
#include "CheckedSettingsFile.h"
|
||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||
#include "AtomicFileWriter.h"
|
||||
#else
|
||||
#include "ContactFileTransaction.h"
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
const char* const EMERGENCY_POWER_PATH = "/power_emergency";
|
||||
|
||||
bool readEmergencyPower(FILESYSTEM* fs, const char* path,
|
||||
mesh::emergencypower::Settings& settings) {
|
||||
uint8_t bytes[mesh::emergencypower::RecordSize];
|
||||
return mesh::storage::readFixedSettingsFile(fs, path, bytes, sizeof(bytes))
|
||||
&& mesh::emergencypower::decode(bytes, sizeof(bytes), settings);
|
||||
}
|
||||
|
||||
bool emergencyCommand(const char* command, const char* prefix, const char*& value) {
|
||||
const size_t length = strlen(prefix);
|
||||
if (strncmp(command, prefix, length) != 0
|
||||
|| (command[length] && command[length] != ' ' && command[length] != '\t')) return false;
|
||||
value = command + length;
|
||||
while (*value == ' ' || *value == '\t') ++value;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool emergencyValue(const char* value, const char* expected) {
|
||||
const size_t length = strlen(expected);
|
||||
if (strncmp(value, expected, length) != 0) return false;
|
||||
value += length;
|
||||
while (*value == ' ' || *value == '\t') ++value;
|
||||
return !*value;
|
||||
}
|
||||
}
|
||||
|
||||
void CommonCLI::loadEmergencyPower(FILESYSTEM* fs) {
|
||||
_emergency_power_fs = fs;
|
||||
mesh::emergencypower::Settings loaded;
|
||||
#if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM)
|
||||
if (!mesh::ContactFileTransaction::recover(fs, EMERGENCY_POWER_PATH))
|
||||
_emergency_power_fs = nullptr;
|
||||
else
|
||||
#endif
|
||||
if (!readEmergencyPower(fs, EMERGENCY_POWER_PATH, loaded))
|
||||
readEmergencyPower(fs, "/power_emergency.bak", loaded);
|
||||
_emergency_power.begin(loaded, millis());
|
||||
_emergency_radio_sleeping = false;
|
||||
}
|
||||
|
||||
bool CommonCLI::saveEmergencyPower(const mesh::emergencypower::Settings& settings) {
|
||||
if (!_emergency_power_fs) return false;
|
||||
uint8_t bytes[mesh::emergencypower::RecordSize];
|
||||
mesh::emergencypower::encode(bytes, settings);
|
||||
#if defined(NRF52_PLATFORM)
|
||||
return mesh::storage::writeCheckedSettingsFile(_emergency_power_fs, EMERGENCY_POWER_PATH,
|
||||
bytes, sizeof(bytes)) == mesh::AtomicFileWriter::Outcome::Committed;
|
||||
#else
|
||||
#if defined(STM32_PLATFORM)
|
||||
mesh::AtomicFileWriter writer(_emergency_power_fs, EMERGENCY_POWER_PATH);
|
||||
#else
|
||||
mesh::ContactFileTransaction writer(_emergency_power_fs, EMERGENCY_POWER_PATH);
|
||||
#endif
|
||||
if (!writer || writer.write(bytes, sizeof(bytes)) != sizeof(bytes) || !writer.commit()) return false;
|
||||
uint8_t verified[sizeof(bytes)];
|
||||
return mesh::storage::readFixedSettingsFile(_emergency_power_fs, EMERGENCY_POWER_PATH,
|
||||
verified, sizeof(verified)) && memcmp(bytes, verified, sizeof(bytes)) == 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
void CommonCLI::loopEmergencyPower() {
|
||||
const uint32_t now = millis();
|
||||
if (!_emergency_power.due(now)) return;
|
||||
const uint16_t mv = _board->getBattMilliVolts();
|
||||
_emergency_power.sample(now, mv, batteryPercent(mv),
|
||||
mesh::batteryforward::validEndpoints(batteryEmptyMv(), batteryFullMv()));
|
||||
}
|
||||
|
||||
uint32_t CommonCLI::emergencySleepMilliseconds() {
|
||||
loopEmergencyPower();
|
||||
return _emergency_power.sleepMilliseconds(millis(), _rtc->getCurrentTime());
|
||||
}
|
||||
|
||||
bool CommonCLI::handleEmergencyPowerCommand(const char* command, char* reply) {
|
||||
if (!command || !reply) return false;
|
||||
const char* value;
|
||||
if (emergencyCommand(command, "get power.emergency", value)) {
|
||||
if (*value) snprintf(reply, 157, "Error: use get power.emergency");
|
||||
else {
|
||||
const uint32_t sleep_ms = emergencySleepMilliseconds();
|
||||
char battery[12] = "unknown";
|
||||
if (_emergency_power.valid)
|
||||
snprintf(battery, sizeof(battery), "%u%%", unsigned(_emergency_power.percentage));
|
||||
snprintf(reply, 157, "> mode=%s active=%s battery=%s clock=%s window=%s radio=%s wake=%lus hold=%lus",
|
||||
_emergency_power.settings.manual ? "manual" : "auto", emergencyActive() ? "on" : "off",
|
||||
battery, mesh::emergencypower::State::usesRtc(_rtc->getCurrentTime()) ? "rtc" : "uptime",
|
||||
sleep_ms ? "sleep" : "awake", _emergency_radio_sleeping ? "sleep" : "awake",
|
||||
static_cast<unsigned long>((sleep_ms + 999UL) / 1000UL),
|
||||
static_cast<unsigned long>((_emergency_power.holdRemaining(millis()) + 999UL) / 1000UL));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (emergencyCommand(command, "set power.emergency", value)) {
|
||||
const bool manual = emergencyValue(value, "on");
|
||||
if (!manual && !emergencyValue(value, "auto")) {
|
||||
snprintf(reply, 157, "Error: use set power.emergency on|auto");
|
||||
return true;
|
||||
}
|
||||
if (manual && !_board->supportsEmergencySleep()) {
|
||||
snprintf(reply, 157, "Error: emergency sleep unsupported on this board");
|
||||
return true;
|
||||
}
|
||||
mesh::emergencypower::Settings candidate;
|
||||
candidate.manual = manual;
|
||||
if (candidate.manual != _emergency_power.settings.manual && !saveEmergencyPower(candidate))
|
||||
snprintf(reply, 157, "Error: emergency settings save unconfirmed; active policy unchanged");
|
||||
else {
|
||||
_emergency_power.setManual(manual, millis());
|
||||
snprintf(reply, 157, "OK - emergency %s", manual ? "on" : "auto");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (!emergencyCommand(command, "set power.awake", value)) return false;
|
||||
uint16_t minutes = 0;
|
||||
const char* p = value;
|
||||
bool digits = false;
|
||||
while (*p >= '0' && *p <= '9') {
|
||||
digits = true;
|
||||
const unsigned number = unsigned(minutes) * 10U + unsigned(*p++ - '0');
|
||||
if (number > mesh::emergencypower::MaximumAwakeMinutes) {
|
||||
snprintf(reply, 157, "Error: use set power.awake 0-10080 (minutes)");
|
||||
return true;
|
||||
}
|
||||
minutes = uint16_t(number);
|
||||
}
|
||||
while (*p == ' ' || *p == '\t') ++p;
|
||||
if (!digits || *p) snprintf(reply, 157, "Error: use set power.awake 0-10080 (minutes)");
|
||||
else {
|
||||
_emergency_power.stayAwake(minutes, millis());
|
||||
snprintf(reply, 157, "OK - awake for %u minutes", unsigned(minutes));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
#endif // MESH_ENABLE_EMERGENCY_POWER
|
||||
@@ -8,6 +8,9 @@
|
||||
#include "FileRead.h"
|
||||
#include "UserGpioPinPolicy.h"
|
||||
#include <helpers/esp32/ManagementPassword.h>
|
||||
#if MESH_ENABLE_EMERGENCY_POWER
|
||||
#include <esp_task_wdt.h>
|
||||
#endif
|
||||
|
||||
namespace mesh {
|
||||
namespace esp32_clock {
|
||||
@@ -1408,6 +1411,34 @@ void ESP32Board::powerOff() {
|
||||
enterDeepSleep(0); // Do not wakeup
|
||||
}
|
||||
|
||||
#if MESH_ENABLE_EMERGENCY_POWER
|
||||
bool ESP32Board::emergencySleepMilliseconds(uint32_t millisecs) {
|
||||
// The caller owns radio suspension and reversible wireless shutdown. Never
|
||||
// force a live WiFi/Bluetooth stack through explicit light sleep.
|
||||
if (millisecs == 0 || isRadioTestActive() || xPortInIsrContext()
|
||||
|| xTaskGetSchedulerState() != taskSCHEDULER_RUNNING
|
||||
|| !mesh::esp32::manualLightSleepReady()) return false;
|
||||
if (millisecs > 10000UL) millisecs = 10000UL;
|
||||
const esp_err_t watchdog_status = esp_task_wdt_status(nullptr);
|
||||
const bool watched = watchdog_status == ESP_OK;
|
||||
if (!watched && watchdog_status != ESP_ERR_NOT_FOUND
|
||||
&& watchdog_status != ESP_ERR_INVALID_STATE) return false;
|
||||
#if defined(CONFIG_ESP_TASK_WDT_TIMEOUT_S) && CONFIG_ESP_TASK_WDT_TIMEOUT_S > 0
|
||||
if (watched && millisecs > uint32_t(CONFIG_ESP_TASK_WDT_TIMEOUT_S) * 500UL)
|
||||
millisecs = uint32_t(CONFIG_ESP_TASK_WDT_TIMEOUT_S) * 500UL;
|
||||
#endif
|
||||
if (watched && esp_task_wdt_reset() != ESP_OK) return false;
|
||||
if (esp_sleep_enable_timer_wakeup(uint64_t(millisecs) * 1000ULL) != ESP_OK)
|
||||
return false;
|
||||
// Add no radio/GPIO wake. Existing button sources remain untouched. The
|
||||
// normal sleep path installs its own next timer; never leave ours armed.
|
||||
const esp_err_t slept = esp_light_sleep_start();
|
||||
const esp_err_t cleared = esp_sleep_disable_wakeup_source(ESP_SLEEP_WAKEUP_TIMER);
|
||||
const bool watchdog_ok = !watched || esp_task_wdt_reset() == ESP_OK;
|
||||
return slept == ESP_OK && cleared == ESP_OK && watchdog_ok;
|
||||
}
|
||||
#endif
|
||||
|
||||
void ESP32Board::enterDeepSleep(uint32_t secs) {
|
||||
shutdownPeripherals();
|
||||
|
||||
|
||||
@@ -349,6 +349,10 @@ public:
|
||||
virtual void shutdownPeripherals();
|
||||
virtual void powerOff() override;
|
||||
virtual void enterDeepSleep(uint32_t secs) override;
|
||||
#if MESH_ENABLE_EMERGENCY_POWER
|
||||
bool supportsEmergencySleep() const override { return true; }
|
||||
bool emergencySleepMilliseconds(uint32_t millisecs) override;
|
||||
#endif
|
||||
|
||||
uint32_t getIRQGpio() override {
|
||||
#ifdef P_LORA_DIO_1
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
#pragma once
|
||||
|
||||
#include "EmergencyPowerConfig.h"
|
||||
#include <stdint.h>
|
||||
|
||||
#if MESH_ENABLE_EMERGENCY_POWER
|
||||
namespace mesh { namespace power {
|
||||
|
||||
class EmergencyOtaWork {
|
||||
mutable uint32_t served_at_ = 0, serve_hold_ms_ = 0;
|
||||
public:
|
||||
template <typename Context>
|
||||
bool busy(const Context* context, uint32_t now) const {
|
||||
bool receiving = false, cleanup = false;
|
||||
if (context) {
|
||||
const auto& manager = context->manager;
|
||||
using Manager = decltype(context->manager);
|
||||
const auto state = manager.fetchState();
|
||||
receiving = state == Manager::WANT_MANIFEST || state == Manager::WANT_LEAVES
|
||||
|| state == Manager::VERIFYING_STAGED || state == Manager::FETCHING;
|
||||
if (manager.pendingServeJobs() || manager.pendingManifestJobs()) {
|
||||
// Keep listening between a proof and the peer's next block request.
|
||||
// Idle self-serving/catalog adverts never renew this bounded hold.
|
||||
const uint32_t retry = manager.fetchRetryTimeoutMs();
|
||||
serve_hold_ms_ = retry < 30000UL ? 30000UL : retry;
|
||||
served_at_ = now;
|
||||
}
|
||||
#if defined(OTA_SD_STORE)
|
||||
cleanup = context->sdCacheFetching();
|
||||
#endif
|
||||
}
|
||||
if (serve_hold_ms_ && uint32_t(now - served_at_) >= serve_hold_ms_) serve_hold_ms_ = 0;
|
||||
return receiving || cleanup || serve_hold_ms_ != 0;
|
||||
}
|
||||
};
|
||||
|
||||
}} // namespace mesh::power
|
||||
#endif
|
||||
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
// The source-role build policy enables this for supported infrastructure
|
||||
// images. Companion and constrained profiles retain their existing footprint.
|
||||
#ifndef MESH_ENABLE_EMERGENCY_POWER
|
||||
#define MESH_ENABLE_EMERGENCY_POWER 0
|
||||
#endif
|
||||
|
||||
#if MESH_ENABLE_EMERGENCY_POWER != 0 && MESH_ENABLE_EMERGENCY_POWER != 1
|
||||
#error "MESH_ENABLE_EMERGENCY_POWER must be 0 or 1"
|
||||
#endif
|
||||
@@ -0,0 +1,122 @@
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
namespace mesh { namespace emergencypower {
|
||||
|
||||
static constexpr uint32_t SampleIntervalMs = 30000;
|
||||
static constexpr uint32_t HourMs = 3600000;
|
||||
static constexpr uint32_t AwakeWindowMs = 900000;
|
||||
static constexpr uint32_t ReplyHoldMs = 5000;
|
||||
static constexpr uint32_t PlausibleEpoch = 1577836800; // 2020-01-01 UTC
|
||||
static constexpr uint16_t MaximumAwakeMinutes = 10080;
|
||||
static constexpr size_t RecordSize = 8;
|
||||
|
||||
struct Settings {
|
||||
bool manual = false;
|
||||
};
|
||||
|
||||
inline uint16_t checksum(const uint8_t* data, size_t size) {
|
||||
uint16_t crc = 0xffff;
|
||||
while (size--) {
|
||||
crc ^= uint16_t(*data++) << 8;
|
||||
for (unsigned bit = 0; bit < 8; ++bit)
|
||||
crc = (crc & 0x8000) ? uint16_t((crc << 1) ^ 0x1021) : uint16_t(crc << 1);
|
||||
}
|
||||
return crc;
|
||||
}
|
||||
|
||||
inline void encode(uint8_t* out, const Settings& settings) {
|
||||
out[0] = 'P'; out[1] = 'E'; out[2] = 'M'; out[3] = 1;
|
||||
out[4] = settings.manual ? 1 : 0; out[5] = 0;
|
||||
const uint16_t crc = checksum(out, 6);
|
||||
out[6] = uint8_t(crc); out[7] = uint8_t(crc >> 8);
|
||||
}
|
||||
|
||||
inline bool decode(const uint8_t* in, size_t size, Settings& settings) {
|
||||
settings = Settings{};
|
||||
if (!in || size != RecordSize || in[0] != 'P' || in[1] != 'E'
|
||||
|| in[2] != 'M' || in[3] != 1 || in[4] > 1 || in[5]
|
||||
|| checksum(in, 6) != uint16_t(in[6] | uint16_t(in[7]) << 8)) return false;
|
||||
settings.manual = in[4] != 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
struct State {
|
||||
Settings settings;
|
||||
uint32_t sampledAt = 0;
|
||||
uint16_t millivolts = 0;
|
||||
uint8_t percentage = 0;
|
||||
bool sampled = false;
|
||||
bool valid = false;
|
||||
bool lowBattery = false;
|
||||
uint32_t phaseMs = 0;
|
||||
uint32_t lastTick = 0;
|
||||
uint32_t activationStarted = 0;
|
||||
uint32_t awakeStarted = 0;
|
||||
uint32_t awakeDuration = 0;
|
||||
bool activationHold = false;
|
||||
|
||||
void begin(const Settings& loaded, uint32_t now) {
|
||||
*this = State{};
|
||||
settings = loaded;
|
||||
phaseMs = now % HourMs;
|
||||
lastTick = now;
|
||||
if (active()) holdReplies(now);
|
||||
}
|
||||
bool active() const { return settings.manual || lowBattery; }
|
||||
bool due(uint32_t now) const {
|
||||
return !sampled || uint32_t(now - sampledAt) >= SampleIntervalMs;
|
||||
}
|
||||
void holdReplies(uint32_t now) {
|
||||
activationStarted = now;
|
||||
activationHold = true;
|
||||
}
|
||||
void setManual(bool manual, uint32_t now) {
|
||||
if (settings.manual == manual) return;
|
||||
settings.manual = manual;
|
||||
if (active()) holdReplies(now);
|
||||
}
|
||||
void sample(uint32_t now, uint16_t mv, uint8_t pct, bool known) {
|
||||
const bool was_active = active();
|
||||
sampled = true; sampledAt = now; millivolts = mv; percentage = pct;
|
||||
valid = known && pct <= 100 && mv >= 2000 && mv <= 20000;
|
||||
// A failed reading cannot wake a node already protecting its battery.
|
||||
if (valid) lowBattery = lowBattery ? pct < 7 : pct <= 5;
|
||||
if (!was_active && active()) holdReplies(now);
|
||||
}
|
||||
bool stayAwake(uint16_t minutes, uint32_t now) {
|
||||
if (minutes > MaximumAwakeMinutes) return false;
|
||||
awakeStarted = now;
|
||||
awakeDuration = uint32_t(minutes) * 60000UL;
|
||||
return true;
|
||||
}
|
||||
uint32_t holdRemaining(uint32_t now) {
|
||||
uint32_t remaining = 0;
|
||||
if (activationHold) {
|
||||
const uint32_t elapsed = uint32_t(now - activationStarted);
|
||||
if (elapsed >= ReplyHoldMs) activationHold = false;
|
||||
else remaining = ReplyHoldMs - elapsed;
|
||||
}
|
||||
if (awakeDuration) {
|
||||
const uint32_t elapsed = uint32_t(now - awakeStarted);
|
||||
if (elapsed >= awakeDuration) awakeDuration = 0;
|
||||
else if (awakeDuration - elapsed > remaining) remaining = awakeDuration - elapsed;
|
||||
}
|
||||
return remaining;
|
||||
}
|
||||
static bool usesRtc(uint32_t epoch) { return epoch >= PlausibleEpoch; }
|
||||
uint32_t sleepMilliseconds(uint32_t now, uint32_t epoch) {
|
||||
// Accumulate the boot-relative phase across millis() wrap. RTC corrections
|
||||
// change only the hourly window, never the duration of an awake lease.
|
||||
phaseMs = (phaseMs + uint32_t(now - lastTick) % HourMs) % HourMs;
|
||||
lastTick = now;
|
||||
const uint32_t hold = holdRemaining(now);
|
||||
if (!active() || hold) return 0;
|
||||
const uint32_t phase = usesRtc(epoch) ? (epoch % 3600UL) * 1000UL : phaseMs;
|
||||
return phase < AwakeWindowMs ? 0 : HourMs - phase;
|
||||
}
|
||||
};
|
||||
|
||||
}} // namespace mesh::emergencypower
|
||||
@@ -0,0 +1,59 @@
|
||||
#pragma once
|
||||
|
||||
#include "EmergencyPowerConfig.h"
|
||||
|
||||
#if MESH_ENABLE_EMERGENCY_POWER
|
||||
#include <Arduino.h>
|
||||
#include "WirelessControl.h"
|
||||
|
||||
namespace mesh { namespace power {
|
||||
|
||||
class EmergencyPowerRuntime {
|
||||
bool radio_asleep_ = false;
|
||||
bool radio_was_available_ = false;
|
||||
public:
|
||||
template <typename Mesh, typename Radio, typename Board, typename CLI>
|
||||
bool service(Mesh& mesh, Radio& radio, Board& board, CLI& cli,
|
||||
bool entry_busy, bool work_required = false) {
|
||||
const uint32_t budget = cli.emergencySleepMilliseconds();
|
||||
if (!board.supportsEmergencySleep()) return false;
|
||||
const bool active_operation = work_required || board.isOTAUpdateRunning()
|
||||
|| board.isRadioTestActive() || radio.isMorseTxActive() || radio.isMorseRxActive();
|
||||
if (budget == 0 || active_operation) {
|
||||
if (radio_asleep_) {
|
||||
if (radio_was_available_) {
|
||||
if (!radio.setTrackerSleep(false)) radio.recoverRadio(true);
|
||||
mesh.setRadioAvailable(true);
|
||||
}
|
||||
radio_asleep_ = false;
|
||||
cli.setEmergencyRadioSleeping(false);
|
||||
}
|
||||
wireless::control().resumeAfterEmergency();
|
||||
return false;
|
||||
}
|
||||
if (!radio_asleep_) {
|
||||
// Complete live operations and command replies before taking ownership.
|
||||
// Queued future traffic cannot keep a critically low node awake forever.
|
||||
if (entry_busy || mesh.hasOutbound()) return false;
|
||||
if (!wireless::control().suspendForEmergency()) return false;
|
||||
radio_was_available_ = mesh.isRadioAvailable();
|
||||
if (radio_was_available_) {
|
||||
if (!radio.setTrackerSleep(true)) return false;
|
||||
mesh.setRadioAvailable(false);
|
||||
}
|
||||
radio_asleep_ = true;
|
||||
cli.setEmergencyRadioSleeping(true);
|
||||
}
|
||||
// Brief maintenance wakes retain voltage protection and watchdogs. Normal
|
||||
// radio/mesh/sensor/UI work remains suspended throughout the off window.
|
||||
if (!board.emergencySleepMilliseconds(budget > 10000 ? 10000 : budget)) {
|
||||
// A transient SDK/driver refusal must not turn battery protection into
|
||||
// a busy loop. Keep RF ownership and retry after yielding the CPU.
|
||||
delay(10);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
}} // namespace mesh::power
|
||||
#endif
|
||||
@@ -312,7 +312,7 @@ float NRF52Board::getAdcMultiplier() const {
|
||||
return mesh::MainBoard::getAdcMultiplier();
|
||||
}
|
||||
|
||||
#if NRF52_WATCHDOG_TIMEOUT_SECONDS > 0
|
||||
#if NRF52_WATCHDOG_TIMEOUT_SECONDS > 0 || MESH_ENABLE_EMERGENCY_POWER
|
||||
static void reloadWatchdogChannels() {
|
||||
// Ordinarily only RR0 is enabled. If a bootloader left the watchdog running
|
||||
// with a different reload channel, service every enabled channel.
|
||||
@@ -927,6 +927,32 @@ void NRF52Board::powerOff() {
|
||||
}
|
||||
}
|
||||
|
||||
#if MESH_ENABLE_EMERGENCY_POWER
|
||||
bool NRF52Board::emergencySleepMilliseconds(uint32_t millisecs) {
|
||||
// Blocking the sketch lets the existing RTOS idle/RTC machinery sleep the
|
||||
// CPU. SYSTEMOFF cannot provide a timed wake on every supported nRF52 board.
|
||||
if (millisecs == 0 || watchdog_disable_reset_pending || isRadioTestActive()
|
||||
|| __get_IPSR() != 0 || __get_PRIMASK() != 0 || __get_BASEPRI() != 0
|
||||
|| __get_FAULTMASK() != 0 || nrf_nvic_state.__cr_flag != 0
|
||||
|| xTaskGetSchedulerState() != taskSCHEDULER_RUNNING) return false;
|
||||
if (millisecs > 10000UL) millisecs = 10000UL;
|
||||
if (NRF_WDT->RUNSTATUS != 0) {
|
||||
// A bootloader may have left a shorter watchdog than this build's default.
|
||||
// Half its actual timeout leaves room for scheduling and wake processing.
|
||||
const uint32_t watchdog_budget_ms = (NRF_WDT->CRV / 32768UL) * 500UL;
|
||||
if (watchdog_budget_ms == 0) return false;
|
||||
if (millisecs > watchdog_budget_ms) millisecs = watchdog_budget_ms;
|
||||
}
|
||||
const TickType_t ticks = pdMS_TO_TICKS(millisecs);
|
||||
if (ticks == 0) return false;
|
||||
// Service inherited watchdogs even when this build does not start one.
|
||||
if (NRF_WDT->RUNSTATUS != 0) reloadWatchdogChannels();
|
||||
vTaskDelay(ticks);
|
||||
if (NRF_WDT->RUNSTATUS != 0) reloadWatchdogChannels();
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
|
||||
void NRF52Board::enterDeepSleep(uint32_t secs) {
|
||||
if (secs == 0) {
|
||||
powerOff(); // Not wake up
|
||||
|
||||
@@ -80,6 +80,10 @@ public:
|
||||
virtual void shutdownPeripherals();
|
||||
virtual void powerOff() override;
|
||||
virtual void enterDeepSleep(uint32_t secs) override;
|
||||
#if MESH_ENABLE_EMERGENCY_POWER
|
||||
bool supportsEmergencySleep() const override { return true; }
|
||||
bool emergencySleepMilliseconds(uint32_t millisecs) override;
|
||||
#endif
|
||||
virtual bool getBootloaderVersion(char* version, size_t max_len) override;
|
||||
virtual bool startOTAUpdate(const char *id, char reply[], bool force_ap = false) override;
|
||||
virtual bool stopOTAUpdate(char reply[]) override;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include "EmergencyPowerConfig.h"
|
||||
|
||||
namespace mesh {
|
||||
namespace wireless {
|
||||
@@ -59,6 +60,10 @@ class Control {
|
||||
uint8_t scope_ = 0, target_ = 0, saved_ = 0;
|
||||
bool pending_ = false, started_ = false, force_ = false, independent_requester_ = false;
|
||||
bool saved_valid_ = false, master_off_ = false;
|
||||
#if MESH_ENABLE_EMERGENCY_POWER
|
||||
bool emergency_suspended_ = false;
|
||||
uint8_t emergency_enabled_ = 0, emergency_blocked_ = 0;
|
||||
#endif
|
||||
uint32_t due_ = 0;
|
||||
const char* error_ = nullptr;
|
||||
|
||||
@@ -73,6 +78,9 @@ public:
|
||||
}
|
||||
bool masterOff() const { return master_off_; }
|
||||
bool allowService(uint8_t service) {
|
||||
#if MESH_ENABLE_EMERGENCY_POWER
|
||||
if (emergency_suspended_) return false;
|
||||
#endif
|
||||
if (master_off_ || pending_) return false;
|
||||
blocked_.store(blocked_.load() & ~service);
|
||||
return true;
|
||||
@@ -81,6 +89,9 @@ public:
|
||||
// disabled 2.4 GHz for this boot. Leave the other services disabled. Do not
|
||||
// race a pending shutdown, which could otherwise turn OTA off afterwards.
|
||||
bool allowWiFiForOTA() {
|
||||
#if MESH_ENABLE_EMERGENCY_POWER
|
||||
if (emergency_suspended_) return false;
|
||||
#endif
|
||||
if (pending_) return false;
|
||||
blocked_.store(blocked_.load() & ~WiFi);
|
||||
master_off_ = false;
|
||||
@@ -90,6 +101,40 @@ public:
|
||||
}
|
||||
uint8_t enabled() const { return backend_ ? backend_->enabled() : 0; }
|
||||
|
||||
#if MESH_ENABLE_EMERGENCY_POWER
|
||||
// A scheduled battery sleep borrows the live services without changing the
|
||||
// user's saved wireless policy or the ordinary off/on restore snapshot.
|
||||
bool suspendForEmergency() {
|
||||
if (!backend_) return true;
|
||||
if (pending_) return false;
|
||||
if (!emergency_suspended_) {
|
||||
emergency_enabled_ = enabled();
|
||||
emergency_blocked_ = blocked_.load();
|
||||
emergency_suspended_ = true;
|
||||
blocked_.store(emergency_blocked_ | All);
|
||||
}
|
||||
const uint8_t order[] = {WiFi, EspNow, Bluetooth};
|
||||
for (uint8_t service : order) {
|
||||
if ((emergency_enabled_ & service) && backend_->set(service, false) != Result::Done)
|
||||
return false;
|
||||
}
|
||||
return enabled() == 0;
|
||||
}
|
||||
|
||||
bool resumeAfterEmergency() {
|
||||
if (!emergency_suspended_) return true;
|
||||
const uint8_t order[] = {EspNow, WiFi, Bluetooth};
|
||||
for (uint8_t service : order) {
|
||||
if (!(emergency_enabled_ & service)) continue;
|
||||
blocked_.store(blocked_.load() & ~service);
|
||||
if (backend_->set(service, true) != Result::Done) return false;
|
||||
}
|
||||
blocked_.store(emergency_blocked_);
|
||||
emergency_suspended_ = false;
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
|
||||
bool handle(const char* text, char* reply, size_t size, uint32_t now, uint8_t requester) {
|
||||
const auto command = parse(text);
|
||||
if (command.action == Action::None || !reply || !size) return false;
|
||||
@@ -106,6 +151,12 @@ public:
|
||||
else snprintf(reply, size, "wifi=%s, bluetooth=%s, espnow=%s%s%s", state(WiFi), state(Bluetooth), state(EspNow), pending_ ? " (pending)" : "", error_ ? error_ : "");
|
||||
return true;
|
||||
}
|
||||
#if MESH_ENABLE_EMERGENCY_POWER
|
||||
if (emergency_suspended_) {
|
||||
snprintf(reply, size, "Error: emergency sleep; use set power.awake <minutes> first");
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
if (!(available & command.scope)) {
|
||||
snprintf(reply, size, "Error: requested wireless service unavailable in this build/boot");
|
||||
return true;
|
||||
|
||||
@@ -565,17 +565,17 @@ static bool handleVoltagePolicyCommand(const char*, char* reply, size_t capacity
|
||||
self.exercise(r'''
|
||||
CLI cli;
|
||||
assert(validSettings(cli.settings));
|
||||
assert(cli.settings.enabled == 0 && cli.settings.repeatBelow == 10
|
||||
&& cli.settings.floodBelow == 20 && cli.settings.emptyMv == 0 && cli.settings.fullMv == 0);
|
||||
assert(cli.settings.enabled == 0 && cli.settings.repeatBelow == 15
|
||||
&& cli.settings.floodBelow == 25 && cli.settings.emptyMv == 0 && cli.settings.fullMv == 0);
|
||||
cli.state.update(1, 3000, 0, true, cli.settings);
|
||||
assert(cli.state.allows(false) && cli.state.allows(true));
|
||||
assert(cli.run("get repeat.battery") && strcmp(cli.reply, "> off below=10%") == 0);
|
||||
assert(cli.run("get flood.battery") && strcmp(cli.reply, "> off below=20%") == 0);
|
||||
assert(cli.run("get repeat.battery") && strcmp(cli.reply, "> off below=15%") == 0);
|
||||
assert(cli.run("get flood.battery") && strcmp(cli.reply, "> off below=25%") == 0);
|
||||
assert(cli.saveCalls == 0);
|
||||
for (unsigned threshold = 1; threshold <= 100; ++threshold) {
|
||||
const std::string command = "set repeat.battery " + std::to_string(threshold);
|
||||
cli.ok(command.c_str());
|
||||
assert(cli.settings.repeatBelow == threshold && cli.settings.floodBelow == 20);
|
||||
assert(cli.settings.repeatBelow == threshold && cli.settings.floodBelow == 25);
|
||||
assert(cli.settings.enabled == Repeat);
|
||||
}
|
||||
cli.ok("set repeat.battery 13");
|
||||
@@ -683,21 +683,21 @@ static bool handleVoltagePolicyCommand(const char*, char* reply, size_t capacity
|
||||
Settings settings;
|
||||
settings.enabled = Repeat | Flood;
|
||||
State state;
|
||||
state.update(1, 3240, 20, true, settings);
|
||||
state.update(1, 3300, 25, true, settings);
|
||||
assert(state.allows(false) && state.allows(true));
|
||||
state.update(2, 3228, 19, true, settings);
|
||||
state.update(2, 3288, 24, true, settings);
|
||||
assert(state.allows(false) && !state.allows(true));
|
||||
state.update(3, 3120, 10, true, settings);
|
||||
state.update(3, 3180, 15, true, settings);
|
||||
assert(state.allows(false) && !state.allows(true));
|
||||
state.update(4, 3108, 9, true, settings);
|
||||
state.update(4, 3168, 14, true, settings);
|
||||
assert(!state.allows(false) && !state.allows(true));
|
||||
state.update(5, 3132, 11, true, settings);
|
||||
state.update(5, 3192, 16, true, settings);
|
||||
assert(!state.allows(false) && !state.allows(true));
|
||||
state.update(6, 3144, 12, true, settings);
|
||||
state.update(6, 3204, 17, true, settings);
|
||||
assert(state.allows(false) && !state.allows(true));
|
||||
state.update(7, 3252, 21, true, settings);
|
||||
state.update(7, 3312, 26, true, settings);
|
||||
assert(state.allows(false) && !state.allows(true));
|
||||
state.update(8, 3264, 22, true, settings);
|
||||
state.update(8, 3324, 27, true, settings);
|
||||
assert(state.allows(false) && state.allows(true));
|
||||
''')
|
||||
|
||||
@@ -838,6 +838,64 @@ static bool handleVoltagePolicyCommand(const char*, char* reply, size_t capacity
|
||||
assert(decode(bytes, RecordSize, actual) && same(actual, Settings{}));
|
||||
''')
|
||||
|
||||
def test_v1_default_pair_migrates_once_and_v2_custom_legacy_pair_is_preserved(self):
|
||||
self.exercise(r'''
|
||||
auto encodeVersion = [&](uint8_t* bytes, const Settings& settings, uint8_t version) {
|
||||
encode(bytes, settings);
|
||||
bytes[3] = version;
|
||||
const uint16_t crc = checksum(bytes, 14);
|
||||
bytes[14] = uint8_t(crc); bytes[15] = uint8_t(crc >> 8);
|
||||
};
|
||||
for (uint8_t enabled = 0; enabled <= (Repeat | Flood); ++enabled) {
|
||||
Settings saved;
|
||||
saved.enabled = enabled;
|
||||
saved.emptyMv = 6000; saved.fullMv = 8400;
|
||||
saved.repeatBelow = 10; saved.floodBelow = 20;
|
||||
uint8_t bytes[RecordSize]; encodeVersion(bytes, saved, 1);
|
||||
Settings loaded;
|
||||
assert(decode(bytes, sizeof(bytes), loaded));
|
||||
assert(loaded.repeatBelow == 15 && loaded.floodBelow == 25);
|
||||
assert(loaded.enabled == enabled && loaded.emptyMv == 6000 && loaded.fullMv == 8400);
|
||||
encode(bytes, saved);
|
||||
assert(bytes[3] == 2 && decode(bytes, sizeof(bytes), loaded) && same(saved, loaded));
|
||||
encodeVersion(bytes, saved, 1);
|
||||
bytes[3] = 2; // Version is covered by the record checksum.
|
||||
assert(!decode(bytes, sizeof(bytes), loaded));
|
||||
for (uint8_t version : {uint8_t(0), uint8_t(3), uint8_t(255)}) {
|
||||
encodeVersion(bytes, saved, version);
|
||||
assert(!decode(bytes, sizeof(bytes), loaded) && same(loaded, Settings{}));
|
||||
}
|
||||
}
|
||||
for (unsigned repeat = 1; repeat <= 100; ++repeat) {
|
||||
for (unsigned flood = 1; flood <= 100; ++flood) {
|
||||
Settings saved;
|
||||
saved.enabled = Repeat | Flood;
|
||||
saved.repeatBelow = uint8_t(repeat); saved.floodBelow = uint8_t(flood);
|
||||
uint8_t bytes[RecordSize]; encodeVersion(bytes, saved, 1);
|
||||
Settings loaded;
|
||||
assert(decode(bytes, sizeof(bytes), loaded));
|
||||
if (repeat == 10 && flood == 20) {
|
||||
assert(loaded.repeatBelow == 15 && loaded.floodBelow == 25);
|
||||
} else assert(same(saved, loaded));
|
||||
encode(bytes, saved);
|
||||
assert(decode(bytes, sizeof(bytes), loaded) && same(saved, loaded));
|
||||
}
|
||||
}
|
||||
Settings custom;
|
||||
custom.enabled = Repeat | Flood;
|
||||
custom.repeatBelow = 10; custom.floodBelow = 20;
|
||||
uint8_t bytes[RecordSize]; encode(bytes, custom);
|
||||
Settings loaded;
|
||||
assert(decode(bytes, sizeof(bytes), loaded) && same(custom, loaded));
|
||||
State state;
|
||||
state.update(0, 3240, 20, true, loaded);
|
||||
assert(state.allows(false) && state.allows(true));
|
||||
state.update(1, 3120, 10, true, loaded);
|
||||
assert(state.allows(false) && !state.allows(true));
|
||||
state.update(2, 3108, 9, true, loaded);
|
||||
assert(!state.allows(false) && !state.allows(true));
|
||||
''')
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -19,6 +19,27 @@ FIXTURE = ROOT / "test/fixtures/client_acl_spiffs"
|
||||
|
||||
|
||||
class ClientAclRoleTests(unittest.TestCase):
|
||||
def test_emergency_sleep_is_scoped_to_supported_infrastructure(self):
|
||||
definition = POLICY.EMERGENCY_DEFINITION
|
||||
for role in ("simple_repeater", "simple_room_server", "simple_sensor"):
|
||||
source = "+<helpers/*.cpp> +<../examples/" + role + "/*.cpp>"
|
||||
for platform in ("NRF52_PLATFORM", "ESP32_PLATFORM"):
|
||||
flags = "-D" + platform
|
||||
self.assertEqual(POLICY.emergency_power_policy(source, flags), {definition: 1})
|
||||
self.assertEqual(POLICY.emergency_power_policy(
|
||||
source, flags + " -DMESH_ENABLE_BATTERY_FORWARDING=0"), {})
|
||||
self.assertEqual(POLICY.emergency_power_policy(
|
||||
source, flags + " -D" + definition + "=0"), {})
|
||||
self.assertEqual(POLICY.emergency_power_policy(source, "-DSTM32_PLATFORM"), {})
|
||||
self.assertEqual(POLICY.emergency_power_policy(
|
||||
source + " +<../examples/companion_radio/*.cpp>", "-DNRF52_PLATFORM"), {})
|
||||
for source in ("", "+<*>", "+<../examples/custom_role/*.cpp>",
|
||||
"+<../examples/companion_radio/*.cpp>",
|
||||
"+<../examples/simple_repeater/main.cpp>"):
|
||||
self.assertEqual(POLICY.emergency_power_policy(source, "-DNRF52_PLATFORM"), {})
|
||||
with self.assertRaises(ValueError):
|
||||
POLICY.emergency_power_policy("", "-D" + definition + "=2")
|
||||
|
||||
def test_complete_exclusive_companion_role_uses_one_global_definition(self):
|
||||
definition = POLICY.COMPANION_DEFINITION
|
||||
for role in ("+<../examples/companion_radio>",
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
"""Exercise the emergency scheduler and actual CommonCLI boundary without hardware."""
|
||||
from pathlib import Path
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from test_replay_reset_integration import extract_braced
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
POLICY_PREFIX = r'''
|
||||
#include <helpers/EmergencyPowerPolicy.h>
|
||||
#include <cassert>
|
||||
#include <cstring>
|
||||
#include <initializer_list>
|
||||
#include <vector>
|
||||
using namespace mesh::emergencypower;
|
||||
static constexpr uint32_t epoch = PlausibleEpoch;
|
||||
'''
|
||||
|
||||
|
||||
class EmergencyPowerTests(unittest.TestCase):
|
||||
def exercise(self, body, prefix=POLICY_PREFIX, flags=()):
|
||||
with tempfile.TemporaryDirectory(prefix="emergency-power-") as folder:
|
||||
work = Path(folder)
|
||||
source, binary = work / "main.cpp", work / "check"
|
||||
source.write_text(prefix + "\nint main() {\n" + body + "\n}\n", encoding="ascii")
|
||||
command = [os.environ.get("CXX", "g++"), "-std=c++17", "-O1", "-g",
|
||||
"-Wall", "-Wextra", "-Werror", "-Wno-unused-function",
|
||||
"-fsanitize=address,undefined", "-fno-sanitize-recover=all",
|
||||
"-fno-omit-frame-pointer", "-fno-pie", "-no-pie", *flags,
|
||||
"-I", str(ROOT / "src"), str(source), "-o", str(binary)]
|
||||
built = subprocess.run(command, capture_output=True, text=True, timeout=60)
|
||||
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
|
||||
result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=30)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
def test_exact_low_boundary_hysteresis_invalid_readings_and_reply_hold(self):
|
||||
self.exercise(r'''
|
||||
for (unsigned pct = 0; pct <= 100; ++pct) {
|
||||
State state;
|
||||
state.begin(Settings{}, 100);
|
||||
assert(!state.active() && state.due(100));
|
||||
state.sample(100, 3000, uint8_t(pct), true);
|
||||
assert(state.active() == (pct <= 5));
|
||||
assert(!state.due(30099) && state.due(30100));
|
||||
}
|
||||
State state;
|
||||
state.begin(Settings{}, 0);
|
||||
state.sample(0, 3060, 5, true);
|
||||
assert(state.active() && state.holdRemaining(0) == 5000);
|
||||
assert(state.sleepMilliseconds(4999, epoch + 1800) == 0);
|
||||
assert(state.sleepMilliseconds(5000, epoch + 1800) == 1800000);
|
||||
state.sample(5001, 3072, 6, true);
|
||||
assert(state.active() && state.sleepMilliseconds(5001, epoch + 1800) == 1800000);
|
||||
for (uint16_t mv : {uint16_t(0), uint16_t(1999), uint16_t(20001), uint16_t(65535)}) {
|
||||
state.sample(5002, mv, 100, true);
|
||||
assert(!state.valid && state.active());
|
||||
}
|
||||
state.sample(5003, 4200, 255, true);
|
||||
assert(!state.valid && state.active());
|
||||
state.sample(5004, 4200, 100, false);
|
||||
assert(!state.valid && state.active());
|
||||
state.sample(5005, 3084, 7, true);
|
||||
assert(!state.active() && state.sleepMilliseconds(5005, epoch + 1800) == 0);
|
||||
state.sample(5006, 0, 0, true);
|
||||
assert(!state.active());
|
||||
state.sample(5007, 3000, 0, false);
|
||||
assert(!state.active());
|
||||
state.sample(5008, 3060, 5, true);
|
||||
assert(state.active() && state.holdRemaining(5008) == 5000);
|
||||
state.sample(5009, 3060, 5, true);
|
||||
assert(state.holdRemaining(5009) == 4999); // Sampling never renews an existing activation.
|
||||
''')
|
||||
|
||||
def test_hour_boundaries_clock_corrections_and_boot_relative_wrap(self):
|
||||
self.exercise(r'''
|
||||
Settings manual; manual.manual = true;
|
||||
State state;
|
||||
state.begin(manual, 0);
|
||||
assert(state.sleepMilliseconds(5000, epoch + 899) == 0);
|
||||
assert(state.sleepMilliseconds(5001, epoch + 900) == 2700000);
|
||||
assert(state.sleepMilliseconds(5002, epoch + 3599) == 1000);
|
||||
assert(state.sleepMilliseconds(5003, epoch + 3600) == 0);
|
||||
assert(state.sleepMilliseconds(5004, epoch + 7200 + 1800) == 1800000);
|
||||
assert(state.sleepMilliseconds(5005, epoch + 600) == 0); // RTC rollback selects the real hour.
|
||||
assert(state.sleepMilliseconds(900000, 0) == 2700000);
|
||||
assert(state.sleepMilliseconds(3599999, PlausibleEpoch - 1) == 1);
|
||||
assert(state.sleepMilliseconds(3600000, 0) == 0);
|
||||
assert(State::usesRtc(epoch) && !State::usesRtc(epoch - 1));
|
||||
|
||||
const uint32_t boot = 0xfffff000U;
|
||||
state.begin(manual, boot);
|
||||
uint32_t now = boot;
|
||||
uint32_t expected = boot % HourMs;
|
||||
for (uint32_t delta : {uint32_t(5000), uint32_t(899999), uint32_t(3600000),
|
||||
uint32_t(0xf0000000), uint32_t(0x10000100)}) {
|
||||
now += delta;
|
||||
expected = (expected + delta % HourMs) % HourMs;
|
||||
const uint32_t want = expected < AwakeWindowMs ? 0 : HourMs - expected;
|
||||
assert(state.sleepMilliseconds(now, 0) == want);
|
||||
assert(state.phaseMs == expected);
|
||||
}
|
||||
''')
|
||||
|
||||
def test_manual_transitions_awake_lease_renew_cancel_limits_and_wrap(self):
|
||||
self.exercise(r'''
|
||||
State state;
|
||||
state.begin(Settings{}, 0);
|
||||
state.setManual(true, 100);
|
||||
assert(state.active() && state.holdRemaining(100) == 5000);
|
||||
state.setManual(true, 101);
|
||||
assert(state.holdRemaining(101) == 4999);
|
||||
assert(state.sleepMilliseconds(5100, epoch + 1800) == 1800000);
|
||||
assert(state.stayAwake(1, 5101));
|
||||
assert(state.sleepMilliseconds(65100, epoch + 3599) == 0);
|
||||
assert(state.sleepMilliseconds(65101, epoch + 1800) == 1800000);
|
||||
assert(!state.awakeDuration);
|
||||
assert(state.sleepMilliseconds(5101, epoch + 1800) == 1800000); // Expired lease stays expired.
|
||||
assert(state.stayAwake(10080, 0xfffffff0U));
|
||||
assert(state.holdRemaining(0xfffffff0U) == 604800000);
|
||||
assert(!state.stayAwake(10081, 100) && state.awakeDuration == 604800000);
|
||||
assert(state.sleepMilliseconds(uint32_t(0xfffffff0U + 604799999U), epoch + 1800) == 0);
|
||||
assert(state.sleepMilliseconds(uint32_t(0xfffffff0U + 604800000U), epoch + 1800) == 1800000);
|
||||
assert(state.stayAwake(2, 200));
|
||||
assert(state.stayAwake(1, 300));
|
||||
assert(state.holdRemaining(400) == 59900);
|
||||
assert(state.stayAwake(0, 400) && state.holdRemaining(400) == 0);
|
||||
state.sample(401, 3060, 5, true);
|
||||
state.setManual(false, 402); // Auto still protects the low battery, with time to send this reply.
|
||||
assert(state.active() && state.holdRemaining(402) == 5000);
|
||||
state.sample(403, 3084, 7, true);
|
||||
assert(!state.active());
|
||||
state.setManual(true, 404);
|
||||
state.sample(405, 4200, 100, true);
|
||||
assert(state.active());
|
||||
state.setManual(false, 406);
|
||||
assert(!state.active());
|
||||
''')
|
||||
|
||||
def test_checked_record_roundtrip_all_bit_flips_lengths_and_semantics(self):
|
||||
self.exercise(r'''
|
||||
for (bool manual : {false, true}) {
|
||||
Settings expected; expected.manual = manual;
|
||||
uint8_t bytes[RecordSize]; encode(bytes, expected);
|
||||
Settings actual;
|
||||
assert(decode(bytes, sizeof(bytes), actual) && actual.manual == manual);
|
||||
for (size_t n = 0; n < RecordSize; ++n) {
|
||||
for (unsigned bit = 0; bit < 8; ++bit) {
|
||||
uint8_t corrupted[RecordSize]; memcpy(corrupted, bytes, sizeof(bytes));
|
||||
corrupted[n] ^= uint8_t(1U << bit);
|
||||
actual.manual = true;
|
||||
assert(!decode(corrupted, sizeof(corrupted), actual) && !actual.manual);
|
||||
}
|
||||
assert(!decode(bytes, n, actual) && !actual.manual);
|
||||
}
|
||||
std::vector<uint8_t> oversized(bytes, bytes + RecordSize); oversized.push_back(0);
|
||||
assert(!decode(oversized.data(), oversized.size(), actual));
|
||||
assert(!decode(nullptr, RecordSize, actual));
|
||||
for (size_t n : {size_t(3), size_t(4), size_t(5)}) {
|
||||
uint8_t invalid[RecordSize]; memcpy(invalid, bytes, sizeof(bytes)); invalid[n] = 2;
|
||||
const uint16_t crc = checksum(invalid, 6);
|
||||
invalid[6] = uint8_t(crc); invalid[7] = uint8_t(crc >> 8);
|
||||
assert(!decode(invalid, sizeof(invalid), actual) && !actual.manual);
|
||||
}
|
||||
}
|
||||
''')
|
||||
|
||||
def runtime_prefix(self):
|
||||
header = (ROOT / "src/helpers/CommonCLI.h").read_text(encoding="ascii")
|
||||
def api_block(marker):
|
||||
start = header.index(marker)
|
||||
return header[start:header.index("#endif", start) + len("#endif")]
|
||||
private = api_block("#if MESH_ENABLE_EMERGENCY_POWER\n FILESYSTEM* _emergency_power_fs")
|
||||
public = api_block("#if MESH_ENABLE_EMERGENCY_POWER\n uint32_t emergencySleepMilliseconds")
|
||||
implementation = (ROOT / "src/helpers/CommonCLI_Emergency.cpp").read_text(encoding="ascii")
|
||||
implementation = re.sub(r"^#include[^\n]*\n", "", implementation, flags=re.MULTILINE)
|
||||
battery = (ROOT / "src/helpers/CommonCLI_Battery.cpp").read_text(encoding="ascii")
|
||||
battery_methods = "\n".join(extract_braced(battery, signature) for signature in (
|
||||
"uint16_t CommonCLI::batteryEmptyMv(", "uint16_t CommonCLI::batteryFullMv(",
|
||||
"uint8_t CommonCLI::batteryPercent("))
|
||||
checked = (ROOT / "src/helpers/CheckedSettingsFile.h").read_text(encoding="ascii")
|
||||
checked_write = extract_braced(checked, "MESH_SETTINGS_FILE_SHARED inline AtomicFileWriter::Outcome writeCheckedSettingsFile(")
|
||||
prefix = POLICY_PREFIX + r'''
|
||||
#include <helpers/BatteryForwarding.h>
|
||||
#include <cstdio>
|
||||
#include <map>
|
||||
#include <string>
|
||||
static uint32_t nowMs;
|
||||
static uint32_t millis() { return nowMs; }
|
||||
struct FILESYSTEM {
|
||||
std::map<std::string, std::vector<uint8_t>> files;
|
||||
bool shortWrite = false, failCommit = false, badRead = false, corruptCommit = false;
|
||||
bool recoverOk = true;
|
||||
unsigned commits = 0, writes = 0;
|
||||
};
|
||||
namespace mesh {
|
||||
class AtomicFileWriter {
|
||||
FILESYSTEM* fs;
|
||||
std::string path;
|
||||
std::vector<uint8_t> bytes;
|
||||
public:
|
||||
enum class Outcome { NotCommitted, Committed, Indeterminate };
|
||||
AtomicFileWriter(FILESYSTEM* f, const char* p) : fs(f), path(p) {}
|
||||
explicit operator bool() const { return fs != nullptr; }
|
||||
size_t write(const uint8_t* p, size_t n) {
|
||||
++fs->writes;
|
||||
const size_t written = fs->shortWrite ? n - 1 : n;
|
||||
bytes.assign(p, p + written); return written;
|
||||
}
|
||||
bool commit() {
|
||||
++fs->commits;
|
||||
if (fs->failCommit) return false;
|
||||
fs->files[path] = bytes;
|
||||
if (fs->corruptCommit) fs->files[path][0] ^= 1;
|
||||
return true;
|
||||
}
|
||||
Outcome outcome() const { return Outcome::NotCommitted; }
|
||||
};
|
||||
class ContactFileTransaction : public AtomicFileWriter {
|
||||
public:
|
||||
using AtomicFileWriter::AtomicFileWriter;
|
||||
static bool recover(FILESYSTEM* fs, const char*) { return fs && fs->recoverOk; }
|
||||
};
|
||||
namespace storage {
|
||||
static bool readFixedSettingsFile(FILESYSTEM* fs, const char* path, uint8_t* bytes, size_t n) {
|
||||
if (!fs || fs->badRead) return false;
|
||||
auto found = fs->files.find(path);
|
||||
if (found == fs->files.end() || found->second.size() != n) return false;
|
||||
memcpy(bytes, found->second.data(), n); return true;
|
||||
}
|
||||
#define MESH_SETTINGS_FILE_SHARED
|
||||
''' + checked_write + r'''
|
||||
#undef MESH_SETTINGS_FILE_SHARED
|
||||
}}
|
||||
struct Board {
|
||||
uint16_t mv = 4200;
|
||||
unsigned reads = 0;
|
||||
bool emergencySupported = true;
|
||||
uint16_t getBattMilliVolts() { ++reads; return mv; }
|
||||
bool supportsEmergencySleep() const { return emergencySupported; }
|
||||
};
|
||||
struct Clock {
|
||||
uint32_t time = epoch + 1800;
|
||||
uint32_t getCurrentTime() { return time; }
|
||||
};
|
||||
class CommonCLI {
|
||||
public:
|
||||
Board* _board;
|
||||
Clock* _rtc;
|
||||
mesh::batteryforward::Settings _battery_forward_settings;
|
||||
''' + private + "\n" + public + r'''
|
||||
uint16_t batteryEmptyMv() const;
|
||||
uint16_t batteryFullMv() const;
|
||||
uint8_t batteryPercent(uint16_t) const;
|
||||
CommonCLI(Board& board, Clock& rtc) : _board(&board), _rtc(&rtc) {}
|
||||
};
|
||||
''' + battery_methods + "\n" + implementation
|
||||
return prefix
|
||||
|
||||
def test_actual_cli_sampling_independent_status_and_runtime_lease(self):
|
||||
self.exercise(r'''
|
||||
Board board; Clock clock; FILESYSTEM fs;
|
||||
CommonCLI cli(board, clock);
|
||||
nowMs = 0; board.mv = 3060;
|
||||
cli.loadEmergencyPower(&fs);
|
||||
assert(cli._battery_forward_settings.enabled == 0);
|
||||
assert(cli.emergencySleepMilliseconds() == 0 && cli.emergencyActive() && board.reads == 1);
|
||||
nowMs = 5000;
|
||||
assert(cli.emergencySleepMilliseconds() == 1800000 && board.reads == 1);
|
||||
char reply[160] = {};
|
||||
cli.setEmergencyRadioSleeping(false);
|
||||
assert(cli.handleEmergencyPowerCommand("get power.emergency", reply));
|
||||
assert(strstr(reply, "mode=auto active=on battery=5% clock=rtc window=sleep radio=awake"));
|
||||
assert(strstr(reply, "wake=1800s hold=0s"));
|
||||
cli.setEmergencyRadioSleeping(true);
|
||||
assert(cli.handleEmergencyPowerCommand("get power.emergency \t", reply));
|
||||
assert(strstr(reply, "radio=sleep"));
|
||||
nowMs = 5001;
|
||||
assert(cli.handleEmergencyPowerCommand("get power.emergency", reply));
|
||||
assert(cli._emergency_power.holdRemaining(nowMs) == 0); // Polls do not postpone sleep.
|
||||
assert(cli.handleEmergencyPowerCommand("set power.awake 1 \t", reply));
|
||||
assert(!strcmp(reply, "OK - awake for 1 minutes") && fs.writes == 0);
|
||||
assert(cli.emergencySleepMilliseconds() == 0);
|
||||
nowMs = 65000;
|
||||
assert(cli.emergencySleepMilliseconds() == 0);
|
||||
nowMs = 65001;
|
||||
assert(cli.emergencySleepMilliseconds() == 1800000);
|
||||
board.mv = 0; nowMs = 95001;
|
||||
assert(cli.emergencySleepMilliseconds() == 1800000 && cli.emergencyActive());
|
||||
clock.time = 0;
|
||||
assert(cli.handleEmergencyPowerCommand("get power.emergency", reply));
|
||||
assert(strstr(reply, "battery=unknown clock=uptime"));
|
||||
board.mv = 3084; nowMs += 30000;
|
||||
assert(cli.emergencySleepMilliseconds() == 0 && !cli.emergencyActive());
|
||||
cli._battery_forward_settings.emptyMv = 6000;
|
||||
cli._battery_forward_settings.fullMv = 8400;
|
||||
board.mv = 6120; nowMs += 30000;
|
||||
assert(cli.emergencySleepMilliseconds() == 0 && cli.emergencyActive());
|
||||
assert(cli._emergency_power.percentage == 5);
|
||||
assert(cli.handleEmergencyPowerCommand("set power.awake 10080", reply));
|
||||
assert(cli._emergency_power.awakeDuration == 604800000);
|
||||
assert(cli.handleEmergencyPowerCommand("set power.awake 0", reply));
|
||||
assert(!cli._emergency_power.awakeDuration);
|
||||
''', self.runtime_prefix(), flags=("-DESP32_PLATFORM", "-DMESH_ENABLE_EMERGENCY_POWER=1"))
|
||||
|
||||
def test_actual_cli_checked_save_reboot_fallback_and_failure_keeps_candidate_inactive(self):
|
||||
body = r'''
|
||||
Board board; Clock clock; FILESYSTEM fs;
|
||||
nowMs = 100;
|
||||
CommonCLI cli(board, clock); cli.loadEmergencyPower(&fs);
|
||||
char reply[160];
|
||||
assert(cli.handleEmergencyPowerCommand("set power.emergency on", reply));
|
||||
assert(!strcmp(reply, "OK - emergency on") && cli.emergencyActive());
|
||||
assert(fs.commits == 1 && cli._emergency_power.settings.manual);
|
||||
nowMs += 5000;
|
||||
assert(cli.emergencySleepMilliseconds() == 1800000);
|
||||
assert(cli.handleEmergencyPowerCommand("set power.emergency on", reply));
|
||||
assert(fs.commits == 1 && cli._emergency_power.holdRemaining(nowMs) == 0);
|
||||
CommonCLI reboot(board, clock); reboot.loadEmergencyPower(&fs);
|
||||
assert(reboot.emergencyActive() && reboot.emergencySleepMilliseconds() == 0);
|
||||
assert(!reboot._emergency_power.awakeDuration); // Awake lease is deliberately runtime-only.
|
||||
fs.files["/power_emergency.bak"] = fs.files["/power_emergency"];
|
||||
fs.files["/power_emergency"][0] ^= 1;
|
||||
CommonCLI backup(board, clock); backup.loadEmergencyPower(&fs);
|
||||
assert(backup.emergencyActive());
|
||||
fs.files["/power_emergency.bak"][0] ^= 1;
|
||||
CommonCLI corrupt(board, clock); corrupt.loadEmergencyPower(&fs);
|
||||
assert(!corrupt.emergencyActive());
|
||||
fs.files.clear();
|
||||
for (unsigned failure = 0; failure < 4; ++failure) {
|
||||
fs.shortWrite = failure == 0; fs.failCommit = failure == 1;
|
||||
fs.badRead = failure == 2; fs.corruptCommit = failure == 3;
|
||||
assert(cli.handleEmergencyPowerCommand("set power.emergency auto", reply));
|
||||
assert(strstr(reply, "save unconfirmed") && cli._emergency_power.settings.manual);
|
||||
assert(cli._emergency_power.holdRemaining(nowMs) == 0);
|
||||
}
|
||||
fs.shortWrite = fs.failCommit = fs.badRead = fs.corruptCommit = false;
|
||||
assert(cli.handleEmergencyPowerCommand("set power.emergency auto\t", reply));
|
||||
assert(!strcmp(reply, "OK - emergency auto") && !cli.emergencyActive());
|
||||
CommonCLI fresh(board, clock); fresh.loadEmergencyPower(&fs);
|
||||
assert(!fresh._emergency_power.settings.manual);
|
||||
unsigned writes = fs.writes;
|
||||
board.emergencySupported = false;
|
||||
assert(fresh.handleEmergencyPowerCommand("set power.emergency on", reply));
|
||||
assert(strstr(reply, "unsupported") && !fresh.emergencyActive() && fs.writes == writes);
|
||||
board.emergencySupported = true;
|
||||
for (const char* command : {"set power.emergency off", "set power.emergency", "set power.emergency on extra",
|
||||
"get power.emergency x", "set power.awake", "set power.awake -1", "set power.awake +1",
|
||||
"set power.awake 10081", "set power.awake 99999999999999", "set power.awake 1.0",
|
||||
"set power.awake 1 2", "set power.awake 1min", "set power.awake \n"}) {
|
||||
assert(fresh.handleEmergencyPowerCommand(command, reply));
|
||||
assert(!strncmp(reply, "Error:", 6) && fs.writes == writes && !fresh.emergencyActive());
|
||||
}
|
||||
for (const char* command : {"get power.emergencyx", "set power.emergencyx on", "set power.awakex 1",
|
||||
"get power.awake", "set unrelated on", ""}) {
|
||||
strcpy(reply, "unchanged");
|
||||
assert(!fresh.handleEmergencyPowerCommand(command, reply) && !strcmp(reply, "unchanged"));
|
||||
}
|
||||
assert(!fresh.handleEmergencyPowerCommand(nullptr, reply));
|
||||
assert(!fresh.handleEmergencyPowerCommand("get power.emergency", nullptr));
|
||||
'''
|
||||
for platform in ("ESP32_PLATFORM", "NRF52_PLATFORM", "STM32_PLATFORM"):
|
||||
with self.subTest(platform=platform):
|
||||
self.exercise(body, self.runtime_prefix(), flags=("-D" + platform,
|
||||
"-DMESH_ENABLE_EMERGENCY_POWER=1"))
|
||||
|
||||
def test_disabled_actual_declarations_and_source_retain_no_state_or_io(self):
|
||||
prefix = self.runtime_prefix()
|
||||
# A missing implementation at link time would reveal a retained runtime
|
||||
# call; the public disabled API is entirely inline and stateless.
|
||||
self.exercise(r'''
|
||||
Board board; Clock clock; FILESYSTEM fs;
|
||||
CommonCLI cli(board, clock);
|
||||
cli.setEmergencyRadioSleeping(true);
|
||||
assert(!cli.emergencyActive() && cli.emergencySleepMilliseconds() == 0);
|
||||
assert(board.reads == 0 && fs.writes == 0);
|
||||
static_assert(sizeof(CommonCLI) == sizeof(Board*) + sizeof(Clock*) + 8,
|
||||
"disabled emergency policy must not retain its scheduler or filesystem");
|
||||
''', prefix, flags=("-DSTM32_PLATFORM", "-DMESH_ENABLE_EMERGENCY_POWER=0"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Exercise the real role guards and OTA work hold without allocating OTA storage."""
|
||||
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]
|
||||
|
||||
|
||||
class EmergencyOtaTests(unittest.TestCase):
|
||||
def test_actual_three_role_guards_active_transfers_and_bounded_server_hold(self):
|
||||
enum_source = (ROOT / "src/helpers/ota/OtaManager.h").read_text(encoding="ascii")
|
||||
enum = extract_braced(enum_source, "enum FetchState : uint8_t") + ";"
|
||||
prefix = r'''
|
||||
#include <helpers/EmergencyOtaWork.h>
|
||||
#include <cassert>
|
||||
#include <cstdint>
|
||||
#include <initializer_list>
|
||||
static uint32_t nowMs;
|
||||
static uint32_t millis() { return nowMs; }
|
||||
namespace mesh { namespace ota {
|
||||
struct Manager {
|
||||
@ENUM@
|
||||
FetchState state = IDLE;
|
||||
uint8_t serveJobs = 0, manifestJobs = 0;
|
||||
uint32_t retryMs = 5000;
|
||||
FetchState fetchState() const { return state; }
|
||||
uint8_t pendingServeJobs() const { return serveJobs; }
|
||||
uint8_t pendingManifestJobs() const { return manifestJobs; }
|
||||
uint32_t fetchRetryTimeoutMs() const { return retryMs; }
|
||||
};
|
||||
struct Context {
|
||||
Manager manager;
|
||||
bool sdFetching = false;
|
||||
bool serving = true;
|
||||
uint32_t stagedSize = 100000;
|
||||
bool sdCacheFetching() const { return sdFetching; }
|
||||
};
|
||||
static Context* active = nullptr;
|
||||
static Context* ota_context_if_active() { return active; }
|
||||
Context& ota_ctx() = delete; // Merely checking power must never allocate a dynamic context.
|
||||
}}
|
||||
struct CLI {
|
||||
bool radio = false, gpio = false;
|
||||
bool hasPendingRadioSettings() const { return radio; }
|
||||
bool hasActiveUserGpioTimer() const { return gpio; }
|
||||
};
|
||||
struct Peer {
|
||||
bool waiting = false;
|
||||
bool awaitingPeer() const { return waiting; }
|
||||
};
|
||||
'''.replace("@ENUM@", enum)
|
||||
for index, path in enumerate(("examples/simple_repeater/MyMesh.h",
|
||||
"examples/simple_room_server/MyMesh.h",
|
||||
"examples/simple_sensor/SensorMesh.h")):
|
||||
method = extract_braced((ROOT / path).read_text(encoding="ascii"),
|
||||
"bool emergencyPowerBusy() const")
|
||||
prefix += r'''
|
||||
struct Role@INDEX@ {
|
||||
mesh::power::EmergencyOtaWork _emergency_ota_work;
|
||||
CLI _cli;
|
||||
Peer direct_ota_admin;
|
||||
bool apply = false, saved_radio_apply_pending = false, temp_radio_applied = false;
|
||||
uint32_t dirty_contacts_expiry = 0, _ota_update_at = 0;
|
||||
bool hasPendingOtaApply() const { return apply; }
|
||||
@METHOD@
|
||||
};
|
||||
'''.replace("@INDEX@", str(index)).replace("@METHOD@", method)
|
||||
prefix += r'''
|
||||
template <typename Role> void exercise() {
|
||||
using namespace mesh::ota;
|
||||
Role role;
|
||||
active = nullptr; nowMs = 0;
|
||||
assert(!role.emergencyPowerBusy());
|
||||
role.apply = true; assert(role.emergencyPowerBusy()); role.apply = false;
|
||||
role._cli.radio = true; assert(role.emergencyPowerBusy()); role._cli.radio = false;
|
||||
role._cli.gpio = true; assert(role.emergencyPowerBusy()); role._cli.gpio = false;
|
||||
role.dirty_contacts_expiry = 1; assert(role.emergencyPowerBusy()); role.dirty_contacts_expiry = 0;
|
||||
role.saved_radio_apply_pending = true;
|
||||
assert(role.emergencyPowerBusy());
|
||||
role.temp_radio_applied = true; assert(!role.emergencyPowerBusy());
|
||||
role.saved_radio_apply_pending = role.temp_radio_applied = false;
|
||||
#ifdef ENABLE_OTA
|
||||
Context context; active = &context;
|
||||
for (auto state : {Manager::IDLE, Manager::COMPLETE, Manager::FAILED, Manager::PAUSED}) {
|
||||
context.manager.state = state;
|
||||
assert(!role.emergencyPowerBusy()); // Existing images, idle serving and paused sessions are not activity.
|
||||
}
|
||||
for (auto state : {Manager::WANT_MANIFEST, Manager::WANT_LEAVES,
|
||||
Manager::VERIFYING_STAGED, Manager::FETCHING}) {
|
||||
context.manager.state = state;
|
||||
assert(role.emergencyPowerBusy());
|
||||
context.manager.state = Manager::COMPLETE;
|
||||
assert(!role.emergencyPowerBusy());
|
||||
}
|
||||
#ifdef OTA_SD_STORE
|
||||
context.sdFetching = true;
|
||||
assert(role.emergencyPowerBusy()); // Let serviceSdCache finalize/reset its completed receive owner.
|
||||
context.sdFetching = false; assert(!role.emergencyPowerBusy());
|
||||
#endif
|
||||
context.manager.serveJobs = 1;
|
||||
assert(role.emergencyPowerBusy()); // Activity at millis()==0 must arm a real hold.
|
||||
context.manager.serveJobs = 0; nowMs = 29999;
|
||||
assert(role.emergencyPowerBusy());
|
||||
nowMs = 30000; assert(!role.emergencyPowerBusy());
|
||||
nowMs = 0; assert(!role.emergencyPowerBusy()); // Retired hold cannot resurrect after wrap.
|
||||
context.manager.manifestJobs = 1; context.manager.retryMs = 60000;
|
||||
nowMs = 0xfffffff0U;
|
||||
assert(role.emergencyPowerBusy());
|
||||
nowMs += 100;
|
||||
assert(role.emergencyPowerBusy()); // Actual queued manifest work renews the hold.
|
||||
context.manager.manifestJobs = 0;
|
||||
nowMs += 59999; assert(role.emergencyPowerBusy());
|
||||
++nowMs; assert(!role.emergencyPowerBusy());
|
||||
context.manager.serveJobs = 1;
|
||||
assert(role.emergencyPowerBusy());
|
||||
context.manager.serveJobs = 0; active = nullptr;
|
||||
nowMs += 60000; assert(!role.emergencyPowerBusy()); // Released dynamic context is never reacquired.
|
||||
#endif
|
||||
}
|
||||
int main() {
|
||||
exercise<Role0>(); exercise<Role1>(); exercise<Role2>();
|
||||
mesh::ota::active = nullptr;
|
||||
Role0 repeater; Role1 room;
|
||||
repeater._ota_update_at = 1; room._ota_update_at = 1;
|
||||
assert(repeater.emergencyPowerBusy() && room.emergencyPowerBusy());
|
||||
repeater._ota_update_at = 0;
|
||||
#if MESH_ENABLE_DIRECT_OTA_ADMIN
|
||||
repeater.direct_ota_admin.waiting = true; assert(repeater.emergencyPowerBusy());
|
||||
#endif
|
||||
}
|
||||
'''
|
||||
for flags in (("-DENABLE_OTA", "-DOTA_SD_STORE", "-DMESH_ENABLE_DIRECT_OTA_ADMIN=1"),
|
||||
("-DENABLE_OTA", "-DMESH_ENABLE_DIRECT_OTA_ADMIN=0"),
|
||||
("-DMESH_ENABLE_DIRECT_OTA_ADMIN=0",)):
|
||||
with self.subTest(flags=flags), tempfile.TemporaryDirectory(prefix="emergency-ota-") as folder:
|
||||
source, binary = Path(folder) / "main.cpp", Path(folder) / "check"
|
||||
source.write_text(prefix, encoding="ascii")
|
||||
result = subprocess.run([
|
||||
os.environ.get("CXX", "g++"), "-std=c++17", "-O1", "-g", "-Wall", "-Wextra", "-Werror",
|
||||
"-Wno-unused-function", "-fsanitize=address,undefined", "-fno-sanitize-recover=all",
|
||||
"-fno-omit-frame-pointer", "-fno-pie", "-no-pie", "-DMESH_ENABLE_EMERGENCY_POWER=1",
|
||||
*flags, "-I", str(ROOT / "src"), str(source), "-o", str(binary)],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=30)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,360 @@
|
||||
"""Run actual emergency RF/wireless ownership and restoration with fake hardware."""
|
||||
from pathlib import Path
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
HARNESS = r'''
|
||||
#include <helpers/EmergencyPowerRuntime.h>
|
||||
#include <cassert>
|
||||
#include <cstdlib>
|
||||
#include <initializer_list>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
using namespace mesh::wireless;
|
||||
static std::vector<std::string> events;
|
||||
void delay(uint32_t milliseconds) {
|
||||
events.push_back("backoff:" + std::to_string(milliseconds));
|
||||
}
|
||||
static std::string serviceName(uint8_t bit) {
|
||||
return bit == WiFi ? "wifi" : bit == EspNow ? "espnow" : "ble";
|
||||
}
|
||||
struct Wireless : Backend {
|
||||
uint8_t active = All, supported = All, sessions = Independent;
|
||||
uint8_t pendingOn = 0, pendingOff = 0, failedOn = 0, failedOff = 0;
|
||||
uint8_t available() const override { return supported; }
|
||||
uint8_t enabled() const override { return active; }
|
||||
uint8_t clients() const override { return sessions; }
|
||||
Result set(uint8_t bit, bool on) override {
|
||||
events.push_back(serviceName(bit) + (on ? ":on" : ":off"));
|
||||
if ((on ? pendingOn : pendingOff) & bit) return Result::Pending;
|
||||
if ((on ? failedOn : failedOff) & bit) return Result::Failed;
|
||||
if (on) active |= bit; else active &= ~bit;
|
||||
return Result::Done;
|
||||
}
|
||||
};
|
||||
struct Mesh {
|
||||
bool available = true, inFlight = false;
|
||||
unsigned queuedFuturePackets = 20;
|
||||
bool hasOutbound() const { return inFlight; }
|
||||
bool isRadioAvailable() const { return available; }
|
||||
void setRadioAvailable(bool value) {
|
||||
events.push_back(value ? "mesh:on" : "mesh:off"); available = value;
|
||||
}
|
||||
};
|
||||
struct Radio {
|
||||
bool morseTx = false, morseRx = false, failSleep = false, failWake = false;
|
||||
bool recoveryOk = true;
|
||||
bool isMorseTxActive() const { return morseTx; }
|
||||
bool isMorseRxActive() const { return morseRx; }
|
||||
bool setTrackerSleep(bool sleeping) {
|
||||
events.push_back(sleeping ? "radio:sleep" : "radio:wake");
|
||||
return !(sleeping ? failSleep : failWake);
|
||||
}
|
||||
bool recoverRadio(bool hard) {
|
||||
assert(hard); events.push_back("radio:recover"); return recoveryOk;
|
||||
}
|
||||
};
|
||||
struct Board {
|
||||
bool supported = true, ota = false, radioTest = false, sleepOk = true;
|
||||
unsigned sleeps = 0;
|
||||
uint32_t lastBudget = 0;
|
||||
bool supportsEmergencySleep() const { return supported; }
|
||||
bool isOTAUpdateRunning() const { return ota; }
|
||||
bool isRadioTestActive() const { return radioTest; }
|
||||
bool emergencySleepMilliseconds(uint32_t budget) {
|
||||
events.push_back("cpu:" + std::to_string(budget));
|
||||
++sleeps; lastBudget = budget; return sleepOk;
|
||||
}
|
||||
};
|
||||
struct CLI {
|
||||
uint32_t budget = 2700000;
|
||||
bool radioSleeping = false;
|
||||
uint32_t emergencySleepMilliseconds() const { return budget; }
|
||||
void setEmergencyRadioSleeping(bool value) {
|
||||
events.push_back(value ? "status:sleep" : "status:awake"); radioSleeping = value;
|
||||
}
|
||||
};
|
||||
struct Fixture {
|
||||
Wireless wireless;
|
||||
Mesh mesh;
|
||||
Radio radio;
|
||||
Board board;
|
||||
CLI cli;
|
||||
mesh::power::EmergencyPowerRuntime runtime;
|
||||
Fixture() { control().begin(wireless); }
|
||||
bool service(bool entryBusy = false, bool workRequired = false) {
|
||||
return runtime.service(mesh, radio, board, cli, entryBusy, workRequired);
|
||||
}
|
||||
};
|
||||
static void command(Control& c, const char* text, uint32_t now = 100) {
|
||||
char reply[160] = {};
|
||||
assert(c.handle(text, reply, sizeof(reply), now, Independent));
|
||||
assert(strncmp(reply, "OK", 2) == 0);
|
||||
c.service(now + 250);
|
||||
assert(!c.pending());
|
||||
}
|
||||
static void refused(Control& c, const char* text) {
|
||||
char reply[160] = {};
|
||||
assert(c.handle(text, reply, sizeof(reply), 1000, Independent));
|
||||
assert(strstr(reply, "emergency sleep") && !c.pending());
|
||||
}
|
||||
int main(int argc, char** argv) {
|
||||
assert(argc == 2);
|
||||
const int scenario = std::atoi(argv[1]);
|
||||
if (scenario == 0) {
|
||||
Fixture f;
|
||||
assert(f.service());
|
||||
assert((events == std::vector<std::string>{"wifi:off", "espnow:off", "ble:off",
|
||||
"radio:sleep", "mesh:off", "status:sleep", "cpu:10000"}));
|
||||
assert(!f.mesh.available && f.cli.radioSleeping && f.wireless.active == 0);
|
||||
events.clear(); f.cli.budget = 417;
|
||||
assert(f.service(true)); // Reply-drain/partial-input guard does not steal already-suspended RF.
|
||||
assert((events == std::vector<std::string>{"cpu:417"}));
|
||||
events.clear(); f.cli.budget = 0;
|
||||
assert(!f.service());
|
||||
assert((events == std::vector<std::string>{"radio:wake", "mesh:on", "status:awake",
|
||||
"espnow:on", "wifi:on", "ble:on"}));
|
||||
assert(f.mesh.available && !f.cli.radioSleeping && f.wireless.active == All);
|
||||
events.clear(); assert(!f.service() && events.empty());
|
||||
} else if (scenario == 1) {
|
||||
Fixture f;
|
||||
f.board.supported = false; assert(!f.service());
|
||||
f.board.supported = true;
|
||||
assert(!f.service(true));
|
||||
assert(!f.service(false, true));
|
||||
f.board.ota = true; assert(!f.service()); f.board.ota = false;
|
||||
f.board.radioTest = true; assert(!f.service()); f.board.radioTest = false;
|
||||
f.mesh.inFlight = true; assert(!f.service()); f.mesh.inFlight = false;
|
||||
f.radio.morseTx = true; assert(!f.service()); f.radio.morseTx = false;
|
||||
f.radio.morseRx = true; assert(!f.service()); f.radio.morseRx = false;
|
||||
assert(events.empty() && f.mesh.available && !f.cli.radioSleeping && !f.board.sleeps);
|
||||
// Queued future packets are intentionally not consulted: normal mesh work
|
||||
// stays suspended, so an off-hour backlog cannot prevent battery protection.
|
||||
assert(f.mesh.queuedFuturePackets == 20 && f.service());
|
||||
assert(f.board.sleeps == 1 && !f.mesh.available);
|
||||
} else if (scenario == 2) {
|
||||
Fixture f;
|
||||
f.radio.failSleep = true;
|
||||
assert(!f.service() && f.mesh.available && !f.cli.radioSleeping && !f.board.sleeps);
|
||||
assert(f.wireless.active == 0);
|
||||
f.radio.failSleep = false; events.clear();
|
||||
assert(f.service() && !f.mesh.available && f.board.sleeps == 1);
|
||||
f.radio.failWake = true; f.radio.recoveryOk = false;
|
||||
f.cli.budget = 0; events.clear();
|
||||
assert(!f.service());
|
||||
assert((events == std::vector<std::string>{"radio:wake", "radio:recover", "mesh:on",
|
||||
"status:awake", "espnow:on", "wifi:on", "ble:on"}));
|
||||
// Return ownership to Dispatcher even after hardware recovery reports an
|
||||
// error, so its ordinary watchdog is not permanently disabled.
|
||||
assert(f.mesh.available && !f.cli.radioSleeping);
|
||||
} else if (scenario == 3) {
|
||||
Fixture f;
|
||||
for (unsigned cause = 0; cause != 5; ++cause) {
|
||||
assert(f.service() && !f.mesh.available);
|
||||
const unsigned sleeps = f.board.sleeps;
|
||||
f.board.ota = cause == 0;
|
||||
f.board.radioTest = cause == 1;
|
||||
f.radio.morseTx = cause == 2;
|
||||
f.radio.morseRx = cause == 3;
|
||||
const bool work = cause == 4;
|
||||
assert(!f.service(false, work));
|
||||
assert(f.mesh.available && !f.cli.radioSleeping && f.wireless.active == All);
|
||||
assert(f.board.sleeps == sleeps);
|
||||
assert(!f.service(false, work));
|
||||
assert(f.board.sleeps == sleeps); // Active work also prevents immediate re-entry.
|
||||
f.board.ota = f.board.radioTest = f.radio.morseTx = f.radio.morseRx = false;
|
||||
}
|
||||
} else if (scenario == 4) {
|
||||
Wireless b; Control c; c.begin(b);
|
||||
assert(c.suspendForEmergency());
|
||||
assert(b.active == 0 && c.blocked(All));
|
||||
assert(!c.allowService(Bluetooth));
|
||||
assert(!c.allowService(WiFi | EspNow));
|
||||
assert(!c.allowWiFiForOTA());
|
||||
assert(c.blocked(All) && !c.masterOff());
|
||||
for (const char* text : {"set wifi on", "set espnow on", "set 2.4ghz on all",
|
||||
"set 2.4ghz off force"}) refused(c, text);
|
||||
char reply[160];
|
||||
assert(c.handle("get 2.4ghz", reply, sizeof(reply), 1000, Independent));
|
||||
assert(strstr(reply, "wifi=off") && strstr(reply, "bluetooth=off"));
|
||||
assert(c.resumeAfterEmergency() && b.active == All && !c.blocked(All));
|
||||
assert(c.allowService(Bluetooth) && c.allowWiFiForOTA());
|
||||
} else if (scenario == 5) {
|
||||
for (uint8_t mask = 0; mask <= All; ++mask) {
|
||||
Wireless b; b.active = mask;
|
||||
Control c; c.begin(b);
|
||||
command(c, "set 2.4ghz off"); command(c, "set 2.4ghz on", 500);
|
||||
assert(b.active == mask && !c.masterOff());
|
||||
for (uint8_t bit : {uint8_t(WiFi), uint8_t(Bluetooth), uint8_t(EspNow)})
|
||||
assert(c.blocked(bit) == !(mask & bit));
|
||||
assert(c.suspendForEmergency() && b.active == 0 && c.blocked(All));
|
||||
assert(c.resumeAfterEmergency() && b.active == mask && !c.masterOff());
|
||||
for (uint8_t bit : {uint8_t(WiFi), uint8_t(Bluetooth), uint8_t(EspNow)})
|
||||
assert(c.blocked(bit) == !(mask & bit));
|
||||
const size_t calls = events.size();
|
||||
assert(c.resumeAfterEmergency() && events.size() == calls);
|
||||
}
|
||||
Wireless b; b.active = WiFi | Bluetooth;
|
||||
Control c; c.begin(b);
|
||||
command(c, "set 2.4ghz off");
|
||||
assert(c.masterOff() && b.active == 0);
|
||||
const size_t calls = events.size();
|
||||
assert(c.suspendForEmergency() && c.resumeAfterEmergency());
|
||||
assert(c.masterOff() && c.blocked(All) && b.active == 0 && events.size() == calls);
|
||||
char reply[160];
|
||||
assert(c.handle("set wifi on", reply, sizeof(reply), 500, Independent));
|
||||
assert(strstr(reply, "2.4ghz is off"));
|
||||
command(c, "set 2.4ghz on", 1000);
|
||||
assert(b.active == (WiFi | Bluetooth)); // Original ordinary restore snapshot survived.
|
||||
} else if (scenario == 6) {
|
||||
Fixture f;
|
||||
f.wireless.pendingOff = WiFi;
|
||||
assert(!f.service() && f.wireless.active == All && !f.board.sleeps && f.mesh.available);
|
||||
refused(control(), "set wifi on");
|
||||
assert(!control().allowService(WiFi) && !control().allowWiFiForOTA());
|
||||
f.wireless.pendingOff = 0;
|
||||
f.wireless.failedOff = EspNow;
|
||||
assert(!f.service() && f.wireless.active == (EspNow | Bluetooth));
|
||||
assert(f.mesh.available && !f.cli.radioSleeping && !f.board.sleeps);
|
||||
// The hour/lease can change before RF ever went to sleep. Restore services
|
||||
// already stopped by the incomplete suspend, then allow a later retry.
|
||||
f.wireless.failedOff = 0; f.cli.budget = 0;
|
||||
assert(!f.service() && f.wireless.active == All);
|
||||
f.cli.budget = 2700000; assert(f.service());
|
||||
f.cli.budget = 0; f.wireless.pendingOn = EspNow;
|
||||
assert(!f.service() && f.mesh.available && f.wireless.active == 0);
|
||||
refused(control(), "set 2.4ghz on all");
|
||||
assert(!control().allowService(Bluetooth));
|
||||
f.wireless.pendingOn = 0; f.wireless.failedOn = WiFi;
|
||||
assert(!f.service() && f.wireless.active == EspNow);
|
||||
refused(control(), "set wifi on");
|
||||
f.wireless.failedOn = 0;
|
||||
assert(!f.service() && f.wireless.active == All && !control().blocked(All));
|
||||
} else if (scenario == 7) {
|
||||
Fixture f;
|
||||
char reply[160];
|
||||
assert(control().handle("set wifi off", reply, sizeof(reply), 100, Independent));
|
||||
assert(control().pending());
|
||||
assert(!f.service() && events.empty() && f.mesh.available);
|
||||
control().service(350);
|
||||
assert(f.wireless.active == (Bluetooth | EspNow) && !control().pending());
|
||||
assert(f.service() && f.wireless.active == 0);
|
||||
f.cli.budget = 0;
|
||||
assert(!f.service() && f.wireless.active == (Bluetooth | EspNow));
|
||||
assert(control().blocked(WiFi) && !control().blocked(Bluetooth | EspNow));
|
||||
} else if (scenario == 8) {
|
||||
Fixture f;
|
||||
f.mesh.available = false; // Sensor Tracker already owns this radio's sleep.
|
||||
assert(f.service() && f.cli.radioSleeping && f.board.sleeps == 1);
|
||||
for (const auto& event : events)
|
||||
assert(event != "radio:sleep" && event != "mesh:off");
|
||||
events.clear(); f.cli.budget = 0;
|
||||
assert(!f.service() && !f.mesh.available);
|
||||
for (const auto& event : events)
|
||||
assert(event != "radio:wake" && event != "radio:recover" && event != "mesh:on");
|
||||
assert(f.wireless.active == All);
|
||||
} else if (scenario == 9) {
|
||||
Fixture f;
|
||||
f.board.sleepOk = false;
|
||||
assert(f.service() && !f.mesh.available && f.cli.radioSleeping);
|
||||
assert(events.back() == "backoff:10");
|
||||
events.clear();
|
||||
assert(f.service() && !f.mesh.available && f.cli.radioSleeping);
|
||||
assert((events == std::vector<std::string>{"cpu:10000", "backoff:10"}));
|
||||
// A later successful attempt keeps the existing suspension and skips backoff.
|
||||
events.clear(); f.board.sleepOk = true;
|
||||
assert(f.service());
|
||||
assert((events == std::vector<std::string>{"cpu:10000"}));
|
||||
// An awake lease still wakes RF after a failed CPU wait.
|
||||
events.clear(); f.cli.budget = 0;
|
||||
assert(!f.service() && f.mesh.available && !f.cli.radioSleeping);
|
||||
assert(f.wireless.active == All);
|
||||
} else assert(false);
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
class EmergencyPowerRuntimeTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.folder = tempfile.TemporaryDirectory(prefix="emergency-runtime-")
|
||||
work = Path(cls.folder.name)
|
||||
source, cls.binary = work / "main.cpp", work / "check"
|
||||
(work / "Arduino.h").write_text(
|
||||
"#pragma once\n#include <stdint.h>\nvoid delay(uint32_t);\n", encoding="ascii")
|
||||
source.write_text(HARNESS, encoding="ascii")
|
||||
result = subprocess.run([
|
||||
os.environ.get("CXX", "g++"), "-std=c++17", "-O1", "-g", "-Wall", "-Wextra", "-Werror",
|
||||
"-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-omit-frame-pointer",
|
||||
"-fno-pie", "-no-pie", "-DMESH_ENABLE_EMERGENCY_POWER=1", "-I", str(work),
|
||||
"-I", str(ROOT / "src"),
|
||||
str(source), "-o", str(cls.binary)], capture_output=True, text=True, timeout=60)
|
||||
if result.returncode:
|
||||
cls.folder.cleanup()
|
||||
raise AssertionError(result.stdout + result.stderr)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.folder.cleanup()
|
||||
|
||||
def scenario(self, number):
|
||||
result = subprocess.run([str(self.binary), str(number)], capture_output=True,
|
||||
text=True, timeout=30)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
def test_successful_sleep_wake_order_repeat_ownership_and_ten_second_budget(self):
|
||||
self.scenario(0)
|
||||
|
||||
def test_live_work_prevents_entry_but_future_queue_does_not(self):
|
||||
self.scenario(1)
|
||||
|
||||
def test_radio_refusal_and_failed_wake_return_dispatcher_watchdog_ownership(self):
|
||||
self.scenario(2)
|
||||
|
||||
def test_critical_work_started_while_suspended_wakes_before_further_cpu_sleep(self):
|
||||
self.scenario(3)
|
||||
|
||||
def test_all_wireless_start_paths_are_refused_during_emergency_ownership(self):
|
||||
self.scenario(4)
|
||||
|
||||
def test_wireless_subset_blocked_mask_master_off_and_saved_snapshot_survive(self):
|
||||
self.scenario(5)
|
||||
|
||||
def test_async_pending_errors_and_cancelled_partial_suspend_restore_correctly(self):
|
||||
self.scenario(6)
|
||||
|
||||
def test_existing_wireless_request_finishes_before_emergency_ownership(self):
|
||||
self.scenario(7)
|
||||
|
||||
def test_sensor_tracker_radio_ownership_is_not_stolen(self):
|
||||
self.scenario(8)
|
||||
|
||||
def test_cpu_sleep_refusal_yields_without_resuming_normal_rf_work(self):
|
||||
self.scenario(9)
|
||||
|
||||
def test_room_maintenance_runs_before_emergency_early_return(self):
|
||||
source = (ROOT / "examples/simple_room_server/main.cpp").read_text()
|
||||
loop = source[source.index("void loop() {"):]
|
||||
gate = loop.index("emergency_power.service(")
|
||||
for maintenance in ("mesh::power::serviceRegulatorSettings();",
|
||||
"mesh::solar::service();", "board.loop();"):
|
||||
self.assertEqual(loop.count(maintenance), 1, maintenance)
|
||||
self.assertLess(loop.index(maintenance), gate, maintenance)
|
||||
|
||||
def test_esp_uart_console_restore_stays_live_during_emergency_sleep(self):
|
||||
for role in ("simple_repeater", "simple_room_server", "simple_sensor"):
|
||||
source = (ROOT / f"examples/{role}/main.cpp").read_text()
|
||||
loop = source[source.index("void loop() {"):]
|
||||
gate = loop.index("emergency_power.service(")
|
||||
service = "board.servicePowerTestControls();"
|
||||
self.assertLess(loop.index(service), gate, role)
|
||||
self.assertIn("#if !MESH_ENABLE_EMERGENCY_POWER\n " + service + "\n#endif",
|
||||
loop[gate:], role)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,268 @@
|
||||
"""Execute resumable emergency CPU sleeps with native RTOS/SDK boundaries."""
|
||||
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from test_replay_reset_integration import extract_braced
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SANITIZERS = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all",
|
||||
"-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else [])
|
||||
|
||||
HARNESS = r'''
|
||||
#include <cassert>
|
||||
#include <cstdint>
|
||||
#include <limits>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
static std::vector<std::string> events;
|
||||
static bool radio_test = false, interrupt_context = false, wireless_ready = true;
|
||||
static unsigned primask = 0, basepri = 0, faultmask = 0;
|
||||
static unsigned __get_IPSR() { return interrupt_context; }
|
||||
static unsigned __get_PRIMASK() { return primask; }
|
||||
static unsigned __get_BASEPRI() { return basepri; }
|
||||
static unsigned __get_FAULTMASK() { return faultmask; }
|
||||
static bool xPortInIsrContext() { return interrupt_context; }
|
||||
static constexpr int taskSCHEDULER_RUNNING = 2;
|
||||
static int scheduler = taskSCHEDULER_RUNNING;
|
||||
static int xTaskGetSchedulerState() { return scheduler; }
|
||||
static struct { unsigned __cr_flag = 0; } nrf_nvic_state;
|
||||
using TickType_t = uint32_t;
|
||||
static uint32_t tick_ms = 1, delayed_ticks = 0;
|
||||
#define pdMS_TO_TICKS(ms) ((ms) / tick_ms)
|
||||
static void vTaskDelay(TickType_t ticks) {
|
||||
assert(ticks != 0);
|
||||
delayed_ticks = ticks;
|
||||
events.push_back("delay");
|
||||
}
|
||||
static unsigned nrf_feeds = 0;
|
||||
static constexpr uint32_t WDT_RR_RR_Reload = 0x6e524635;
|
||||
struct ReloadRegister {
|
||||
uint32_t value = 0;
|
||||
void operator=(uint32_t reload) {
|
||||
assert(reload == WDT_RR_RR_Reload);
|
||||
value = reload; ++nrf_feeds; events.push_back("nrf_feed");
|
||||
}
|
||||
};
|
||||
static struct {
|
||||
uint32_t RUNSTATUS = 0, CRV = 60 * 32768UL, RREN = 1;
|
||||
ReloadRegister RR[8]{};
|
||||
} nrf_watchdog;
|
||||
#define NRF_WDT (&nrf_watchdog)
|
||||
@NRF_RELOAD@
|
||||
|
||||
using esp_err_t = int;
|
||||
static constexpr int ESP_OK = 0, ESP_ERR_NOT_FOUND = 1, ESP_ERR_INVALID_STATE = 2;
|
||||
static constexpr int ESP_SLEEP_WAKEUP_TIMER = 3;
|
||||
static esp_err_t task_watchdog_status = ESP_ERR_INVALID_STATE;
|
||||
static unsigned task_watchdog_feeds = 0, fail_watchdog_feed = 0;
|
||||
static esp_err_t esp_task_wdt_status(void*) { return task_watchdog_status; }
|
||||
static esp_err_t esp_task_wdt_reset() {
|
||||
events.push_back("task_feed");
|
||||
return ++task_watchdog_feeds == fail_watchdog_feed ? 9 : ESP_OK;
|
||||
}
|
||||
static uint64_t timer_us = 0, last_armed_us = 0;
|
||||
static unsigned timer_calls = 0, sleep_calls = 0, clear_calls = 0;
|
||||
static uint32_t other_wake_sources = 0x71;
|
||||
static int timer_error = 0, sleep_error = 0, clear_error = 0;
|
||||
static esp_err_t esp_sleep_enable_timer_wakeup(uint64_t value) {
|
||||
events.push_back("timer"); ++timer_calls;
|
||||
if (timer_error) return timer_error;
|
||||
timer_us = last_armed_us = value;
|
||||
return ESP_OK;
|
||||
}
|
||||
static esp_err_t esp_light_sleep_start() {
|
||||
events.push_back("sleep"); ++sleep_calls;
|
||||
return sleep_error;
|
||||
}
|
||||
static esp_err_t esp_sleep_disable_wakeup_source(int source) {
|
||||
assert(source == ESP_SLEEP_WAKEUP_TIMER); // Never disable GPIO/ALL sources.
|
||||
events.push_back("clear_timer"); ++clear_calls;
|
||||
if (clear_error) return clear_error;
|
||||
timer_us = 0;
|
||||
return ESP_OK;
|
||||
}
|
||||
namespace mesh { namespace esp32 {
|
||||
static bool manualLightSleepReady() { return wireless_ready; }
|
||||
} }
|
||||
struct Base {
|
||||
@DEFAULT_SUPPORT@
|
||||
@DEFAULT_SLEEP@
|
||||
};
|
||||
struct NRF52Board : Base {
|
||||
bool watchdog_disable_reset_pending = false;
|
||||
bool isRadioTestActive() const { return radio_test; }
|
||||
@NRF_SUPPORT@
|
||||
bool emergencySleepMilliseconds(uint32_t millisecs) override;
|
||||
};
|
||||
struct ESP32Board : Base {
|
||||
bool isRadioTestActive() const { return radio_test; }
|
||||
@ESP_SUPPORT@
|
||||
bool emergencySleepMilliseconds(uint32_t millisecs) override;
|
||||
};
|
||||
@NRF_SLEEP@
|
||||
@ESP_SLEEP@
|
||||
static void reset() {
|
||||
events.clear(); radio_test = interrupt_context = false; wireless_ready = true;
|
||||
primask = basepri = faultmask = 0; nrf_nvic_state.__cr_flag = 0;
|
||||
scheduler = taskSCHEDULER_RUNNING;
|
||||
tick_ms = 1; delayed_ticks = nrf_feeds = 0;
|
||||
nrf_watchdog = {0, 60 * 32768UL};
|
||||
task_watchdog_status = ESP_ERR_INVALID_STATE;
|
||||
task_watchdog_feeds = fail_watchdog_feed = 0;
|
||||
timer_us = last_armed_us = 0; timer_calls = sleep_calls = clear_calls = 0;
|
||||
timer_error = sleep_error = clear_error = 0; other_wake_sources = 0x71;
|
||||
}
|
||||
int main() {
|
||||
Base unsupported;
|
||||
assert(!unsupported.supportsEmergencySleep());
|
||||
assert(!unsupported.emergencySleepMilliseconds(1000));
|
||||
NRF52Board nrf; ESP32Board esp;
|
||||
Base* supported[] = {&nrf, &esp};
|
||||
for (Base* board : supported) assert(board->supportsEmergencySleep());
|
||||
for (Base* board : supported) {
|
||||
reset(); assert(!board->emergencySleepMilliseconds(0)); assert(events.empty());
|
||||
radio_test = true;
|
||||
assert(!board->emergencySleepMilliseconds(100)); assert(events.empty());
|
||||
reset(); interrupt_context = true;
|
||||
assert(!board->emergencySleepMilliseconds(100)); assert(events.empty());
|
||||
reset(); scheduler = 0;
|
||||
assert(!board->emergencySleepMilliseconds(100)); assert(events.empty());
|
||||
}
|
||||
for (unsigned* masked : {&primask, &basepri, &faultmask, &nrf_nvic_state.__cr_flag}) {
|
||||
reset(); *masked = 1;
|
||||
assert(!nrf.emergencySleepMilliseconds(100)); assert(events.empty());
|
||||
}
|
||||
reset(); nrf.watchdog_disable_reset_pending = true;
|
||||
assert(!nrf.emergencySleepMilliseconds(100)); assert(events.empty());
|
||||
nrf.watchdog_disable_reset_pending = false;
|
||||
reset(); tick_ms = 100;
|
||||
assert(!nrf.emergencySleepMilliseconds(1)); assert(events.empty());
|
||||
reset(); assert(nrf.emergencySleepMilliseconds(123));
|
||||
assert(delayed_ticks == 123 && !nrf_watchdog.RUNSTATUS && !nrf_feeds);
|
||||
assert(events == std::vector<std::string>{"delay"});
|
||||
reset(); assert(nrf.emergencySleepMilliseconds(UINT32_MAX));
|
||||
assert(delayed_ticks == 10000); // Bound the maintenance absence.
|
||||
reset(); nrf_watchdog.RUNSTATUS = 1;
|
||||
assert(nrf.emergencySleepMilliseconds(10000));
|
||||
assert(delayed_ticks == 10000 && nrf_feeds == 2);
|
||||
assert((events == std::vector<std::string>{"nrf_feed", "delay", "nrf_feed"}));
|
||||
reset(); nrf_watchdog.RUNSTATUS = 1; nrf_watchdog.RREN = 0x82;
|
||||
assert(nrf.emergencySleepMilliseconds(100)); assert(nrf_feeds == 4);
|
||||
assert(nrf_watchdog.RR[1].value == WDT_RR_RR_Reload);
|
||||
assert(nrf_watchdog.RR[7].value == WDT_RR_RR_Reload);
|
||||
assert(nrf_watchdog.RR[0].value == 0 && nrf_watchdog.RR[6].value == 0);
|
||||
reset(); nrf_watchdog.RUNSTATUS = 1; nrf_watchdog.CRV = 2 * 32768;
|
||||
assert(nrf.emergencySleepMilliseconds(10000));
|
||||
assert(delayed_ticks == 1000 && nrf_feeds == 2); // Short inherited watchdog.
|
||||
reset(); nrf_watchdog.RUNSTATUS = 1; nrf_watchdog.CRV = 32767;
|
||||
assert(!nrf.emergencySleepMilliseconds(10000)); assert(events.empty());
|
||||
|
||||
reset(); wireless_ready = false;
|
||||
assert(!esp.emergencySleepMilliseconds(100)); assert(events.empty());
|
||||
reset(); task_watchdog_status = 99;
|
||||
assert(!esp.emergencySleepMilliseconds(100)); assert(events.empty());
|
||||
reset(); assert(esp.emergencySleepMilliseconds(UINT32_MAX));
|
||||
assert(sleep_calls == 1 && timer_calls == 1 && clear_calls == 1);
|
||||
assert(last_armed_us == 10000000ULL);
|
||||
assert(!timer_us && !task_watchdog_feeds && other_wake_sources == 0x71);
|
||||
assert((events == std::vector<std::string>{"timer", "sleep", "clear_timer"}));
|
||||
reset(); task_watchdog_status = ESP_ERR_NOT_FOUND;
|
||||
assert(esp.emergencySleepMilliseconds(250)); assert(!task_watchdog_feeds);
|
||||
reset(); task_watchdog_status = ESP_OK;
|
||||
assert(esp.emergencySleepMilliseconds(10000));
|
||||
assert(last_armed_us == 2000000ULL);
|
||||
assert(task_watchdog_feeds == 2 && other_wake_sources == 0x71);
|
||||
assert((events == std::vector<std::string>{"task_feed", "timer", "sleep", "clear_timer", "task_feed"}));
|
||||
for (int* failed : {&sleep_error, &clear_error}) {
|
||||
reset(); *failed = 9;
|
||||
assert(!esp.emergencySleepMilliseconds(100));
|
||||
assert(sleep_calls == 1 && clear_calls == 1 && other_wake_sources == 0x71);
|
||||
}
|
||||
reset(); timer_us = 555; timer_error = 9;
|
||||
assert(!esp.emergencySleepMilliseconds(100));
|
||||
assert(timer_us == 555 && sleep_calls == 0 && clear_calls == 0);
|
||||
reset(); task_watchdog_status = ESP_OK; fail_watchdog_feed = 1;
|
||||
assert(!esp.emergencySleepMilliseconds(100));
|
||||
assert(timer_calls == 0 && sleep_calls == 0 && clear_calls == 0);
|
||||
reset(); task_watchdog_status = ESP_OK; fail_watchdog_feed = 2;
|
||||
assert(!esp.emergencySleepMilliseconds(100)); assert(clear_calls == 1 && !timer_us);
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
class EmergencySleepBackendTests(unittest.TestCase):
|
||||
def test_actual_platform_waits_and_cleanup(self):
|
||||
compiler = shutil.which("g++") or shutil.which("clang++")
|
||||
self.assertIsNotNone(compiler)
|
||||
sources = {name: (ROOT / path).read_text() for name, path in (
|
||||
("core", "src/MeshCore.h"),
|
||||
("nrf", "src/helpers/NRF52Board.cpp"),
|
||||
("nrf_header", "src/helpers/NRF52Board.h"),
|
||||
("esp", "src/helpers/ESP32Board.cpp"),
|
||||
("esp_header", "src/helpers/ESP32Board.h"))}
|
||||
code = HARNESS
|
||||
for marker, source, signature in (
|
||||
("DEFAULT_SUPPORT", "core", "virtual bool supportsEmergencySleep() const"),
|
||||
("DEFAULT_SLEEP", "core", "virtual bool emergencySleepMilliseconds("),
|
||||
("NRF_RELOAD", "nrf", "static void reloadWatchdogChannels("),
|
||||
("NRF_SUPPORT", "nrf_header", "bool supportsEmergencySleep() const override"),
|
||||
("ESP_SUPPORT", "esp_header", "bool supportsEmergencySleep() const override"),
|
||||
("NRF_SLEEP", "nrf", "bool NRF52Board::emergencySleepMilliseconds("),
|
||||
("ESP_SLEEP", "esp", "bool ESP32Board::emergencySleepMilliseconds(")):
|
||||
code = code.replace("@" + marker + "@", extract_braced(sources[source], signature))
|
||||
with tempfile.TemporaryDirectory(prefix="emergency-sleep-backends-") as directory:
|
||||
work = Path(directory)
|
||||
cpp, binary = work / "test.cpp", work / "test"
|
||||
cpp.write_text(code)
|
||||
subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
|
||||
"-DCONFIG_ESP_TASK_WDT_TIMEOUT_S=4", *SANITIZERS,
|
||||
str(cpp), "-o", str(binary)], check=True,
|
||||
capture_output=True, text=True, timeout=60)
|
||||
subprocess.run([str(binary)], check=True, capture_output=True,
|
||||
text=True, timeout=20)
|
||||
|
||||
def test_disabled_feature_has_no_new_base_virtuals(self):
|
||||
compiler = shutil.which("g++") or shutil.which("clang++")
|
||||
self.assertIsNotNone(compiler)
|
||||
core = (ROOT / "src/MeshCore.h").read_text()
|
||||
start = core.index("#if MESH_ENABLE_EMERGENCY_POWER\n", core.index("class MainBoard"))
|
||||
block = core[start:core.index("#endif", start) + len("#endif")]
|
||||
code = r'''
|
||||
#include <cassert>
|
||||
#include <cstdint>
|
||||
#include <type_traits>
|
||||
#include <utility>
|
||||
struct Board {
|
||||
@API@
|
||||
};
|
||||
template<typename T, typename = void> struct HasApi : std::false_type {};
|
||||
template<typename T> struct HasApi<T, std::void_t<decltype(
|
||||
std::declval<T>().supportsEmergencySleep())>> : std::true_type {};
|
||||
static_assert(HasApi<Board>::value == bool(MESH_ENABLE_EMERGENCY_POWER));
|
||||
int main() {
|
||||
#if MESH_ENABLE_EMERGENCY_POWER
|
||||
Board b; assert(!b.supportsEmergencySleep() && !b.emergencySleepMilliseconds(1));
|
||||
#endif
|
||||
}
|
||||
'''.replace("@API@", block)
|
||||
with tempfile.TemporaryDirectory(prefix="emergency-sleep-feature-") as directory:
|
||||
work = Path(directory)
|
||||
cpp, binary = work / "test.cpp", work / "test"
|
||||
cpp.write_text(code)
|
||||
for enabled in (0, 1):
|
||||
subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
|
||||
f"-DMESH_ENABLE_EMERGENCY_POWER={enabled}", str(cpp),
|
||||
"-o", str(binary)], check=True,
|
||||
capture_output=True, text=True, timeout=60)
|
||||
subprocess.run([str(binary)], check=True, capture_output=True,
|
||||
text=True, timeout=20)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user