Add an hourly emergency battery sleep schedule for infrastructure nodes

This commit is contained in:
mikecarper committed 2026-10-28 02:06:58 -07:00
1 parent 744692fb93
commit 0c7dec9f19
30 files changed
+2020 -33

No files matched your search

+4
View File
@@ -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
View File
@@ -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
+21
View File
@@ -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)
+16
View File
@@ -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
View File
@@ -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
+16
View File
@@ -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; }
+25 -5
View File
@@ -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
+16
View File
@@ -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 {
+15
View File
@@ -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
+28
View File
@@ -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()))
+10
View File
@@ -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.
+9 -4
View File
@@ -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;
}
+9
View File
@@ -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
View File
@@ -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;
+146
View File
@@ -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
+31
View File
@@ -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();
+4
View File
@@ -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
+38
View File
@@ -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
+11
View File
@@ -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
+122
View File
@@ -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
+59
View File
@@ -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
+27 -1
View File
@@ -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
+4
View File
@@ -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;
+51
View File
@@ -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;
+71 -13
View File
@@ -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()
+21
View File
@@ -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>",
+386
View File
@@ -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()
+155
View File
@@ -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()
+360
View File
@@ -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()
+268
View File
@@ -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()