Document emergency power controls and record three-board wake tests
This commit is contained in:
1 parent
c2ae465ece
commit
e86710eda2
9 files changed
+1449
-5
No files matched your search
@@ -598,6 +598,7 @@ jobs:
|
||||
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_power_bench.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
|
||||
|
||||
@@ -3141,6 +3141,9 @@ 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.
|
||||
On ESP32, an active native USB connection uses short CPU idle waits to keep
|
||||
the console usable while the radio sleeps. Timed light sleep resumes after
|
||||
USB detaches and its disconnect grace expires.
|
||||
|
||||
```text
|
||||
get power.emergency
|
||||
@@ -3161,8 +3164,9 @@ up to one ten-second maintenance interval while the node is sleeping.
|
||||
|
||||
`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
|
||||
hold. With an implausible clock timestamp it reports `clock=uptime` and follows
|
||||
the same 15/45-minute cycle from boot. Keep the clock set to UTC: a plausible
|
||||
timestamp alone does not prove it is synchronized. Queued
|
||||
future traffic does not keep a critically low node awake indefinitely.
|
||||
|
||||
This schedule uses resumable timed sleep, preserving identity and settings.
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
<!-- meshcore-hosted-doc-link:start -->
|
||||
<p class="meshcore-hosted-doc-link"><a href="https://mikecarper.github.io/MeshCore/test-results/emergency_power_2026-10-28/">View this page on MeshCore Docs</a>.</p>
|
||||
<!-- meshcore-hosted-doc-link:end -->
|
||||
|
||||
# Emergency battery schedule, 28 October 2026
|
||||
|
||||
Native checks and **nine packaged firmware builds pass**, using
|
||||
[`f4f945324`](https://github.com/mikecarper/MeshCore/commit/f4f9453248d351bd91be269ca0a58a6edb51882e),
|
||||
plus the ESP32 USB recovery fix in
|
||||
[`be6d636c7`](https://github.com/mikecarper/MeshCore/commit/be6d636c7).
|
||||
SolarXiao 33S, RAK3401 and Heltec V4.3 passed physical sleep, override, automatic
|
||||
wake and reboot-persistence trials. Current savings and operation with a real
|
||||
battery at 5% were not measured.
|
||||
|
||||
## Behavior
|
||||
|
||||
Supported infrastructure images enter automatic emergency mode at **5% or
|
||||
less**, and recover at **7% or more**, using a voltage-derived battery estimate
|
||||
sampled every 30 seconds. Invalid readings neither activate the mode nor clear
|
||||
an existing low-battery state. Existing hard low-voltage shutdown still applies.
|
||||
|
||||
Normal work runs during the first **15 minutes of each UTC hour**. Outside that
|
||||
window, LoRa sleeps and normal forwarding, sensor and display work pauses.
|
||||
With an implausible clock timestamp, the same cycle follows monotonic uptime
|
||||
from boot. A plausible timestamp still needs synchronization to UTC.
|
||||
Brief maintenance wakes, at intervals of **10 seconds or less**, service commands,
|
||||
clock and watchdogs. Active updates and unfinished critical work defer sleep.
|
||||
|
||||
```text
|
||||
get power.emergency
|
||||
set power.emergency on
|
||||
set power.awake 30
|
||||
set power.awake 0
|
||||
set power.emergency auto
|
||||
```
|
||||
|
||||
`on` persists manual emergency mode; `auto` restores the default automatic
|
||||
policy. Entry allows five seconds for the reply. `power.awake` grants a runtime
|
||||
override of 0–10080 minutes; zero cancels it and reboot clears it. LoRa commands
|
||||
require an awake window. Local USB can wait for the next maintenance wake.
|
||||
Status separates the desired `window` from actual radio ownership.
|
||||
|
||||
nRF52 retains state through timed RTOS delay and idle sleep; ESP32 uses
|
||||
timer-woken light sleep. Neither path uses a rebooting deep-sleep cycle.
|
||||
ESP32 keeps an attached native USB console usable with short CPU idle waits
|
||||
instead of peripheral-stopping light sleep; detached operation uses timed light sleep.
|
||||
Wireless services restore their previous live subset and preserve manual
|
||||
wireless-off settings. RAK3401 places its amplifier in standby and restores it
|
||||
before radio wake, without cutting the shared supply rail.
|
||||
|
||||
Forwarding defaults move from 10/20% to **15/25%**. The exact old pair migrates
|
||||
from a v1 record; other pairs, enabled controls and voltage endpoints survive.
|
||||
A v2 record preserves an explicitly selected 10/20 pair. Companion and profiles
|
||||
that omit battery-forwarding controls retain their existing feature scope.
|
||||
|
||||
## Checks
|
||||
|
||||
**85 native tests across eleven suites pass**: policy/CLI 7, runtime 13, platform
|
||||
backends 3, OTA work 1, battery forwarding 20, build-role policy 8, wireless 4,
|
||||
logging/sleep 3, sleep deadlines 3, CPU clock 9, and bench harness 14. The OTA test compiles
|
||||
three configurations and executes the actual guards for all three roles.
|
||||
Coverage includes rollover, failed persistence, reply holds, active transfers,
|
||||
bounded serving holds, wireless restoration failures and prior Tracker ownership.
|
||||
|
||||
| Packaged firmware | Capability checks | Available / required RAM, bytes |
|
||||
| --- | ---: | ---: |
|
||||
| SolarXiao 33S Repeater Full | 11/11 | 151,348 / 82,784 |
|
||||
| SolarXiao 33S Repeater Reduced | 9/9 | 152,528 / 80,736 |
|
||||
| RAK3401 Repeater Full | 14/14 | 80,124 / 75,920 |
|
||||
| RAK3401 Repeater Reduced | 12/12 | 82,648 / 73,872 |
|
||||
| Heltec V4 Full MQTT Repeater | 16/16 | 240,112 / 238,732 |
|
||||
| Heltec V4 Full MQTT Repeater, USB fix | 16/16 | 240,112 / 238,732 |
|
||||
| SolarXiao 33S Room Server Full | 7/7 | 157,108 / 67,328 |
|
||||
| SolarXiao 33S Room Server Reduced | 5/5 | 158,264 / 65,280 |
|
||||
| Heltec V4 Sensor Full | 9/9 | 271,872 / 150,592 |
|
||||
|
||||
Memory admission and packaged update-path checks pass. These are linked capacity
|
||||
estimates, not measurements of peak runtime heap. PlatformIO ran sequentially.
|
||||
|
||||
The [safe machine-readable report](emergency_power_2026-10-28.safe.json) records
|
||||
ELF, package and manifest hashes. Package hashes were independently compared
|
||||
with every corresponding memory-manifest entry.
|
||||
|
||||
## Physical trials
|
||||
|
||||
Each board entered manual radio sleep, woke for a real one-minute override,
|
||||
returned to sleep when it expired, and retained manual mode after reboot.
|
||||
Two accelerated hourly boundaries then produced queued adverts on an
|
||||
independent RAW receiver before any further USB write to the target.
|
||||
|
||||
| Target | Independent receiver | Passive capture intervals |
|
||||
| --- | --- | --- |
|
||||
| SolarXiao 33S | RAK3401 | 31.586 / 31.587 s |
|
||||
| RAK3401 | SolarXiao 33S | 31.586 / 31.583 s |
|
||||
| Heltec V4.3 OLED | RAK3401 | 21.730 / 21.677 s |
|
||||
|
||||
The clock was set near the hour boundary; these intervals are evidence of
|
||||
autonomous RF resumption, not a measurement of the CPU's exact wake latency.
|
||||
The V4's initial build lost its USB console during off-window light sleep.
|
||||
The corrected build kept USB responsive while RF slept and passed the whole
|
||||
trial. WiFi/NTP was temporarily stopped so it could not reset the test clock.
|
||||
|
||||
All identities, existing settings and present clocks were checked after
|
||||
cleanup. SolarXiao retains the tested Full Repeater in automatic mode with no
|
||||
awake hold. RAK3401 has its original unified Repeater; V4 has its original Full
|
||||
Companion and verified original NVS/filesystem. The Pi collector is active
|
||||
with its original configuration and serial ownership.
|
||||
|
||||
## Limits
|
||||
|
||||
Trials used manual entry and USB power. They do not qualify real 5% battery
|
||||
entry, current draw, or a continuous 45-minute sleep interval. V4 exercised
|
||||
the USB-held CPU idle path; detached light-sleep current remains unmeasured.
|
||||
Solar LTC4162 telemetry was unavailable, and the self ADC is not carrier state
|
||||
of charge. Room and Sensor hooks have native/build coverage, not physical
|
||||
trials. Sustained RF delivery and maximum runtime load need separate evidence.
|
||||
@@ -0,0 +1,513 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"date": "2026-10-28",
|
||||
"source_commit": "f4f9453248d351bd91be269ca0a58a6edb51882e",
|
||||
"implementation_commits": [
|
||||
"0c7dec9f1",
|
||||
"f4f945324",
|
||||
"5cbff1946",
|
||||
"be6d636c7"
|
||||
],
|
||||
"scope": "Native policy/runtime checks, selected production packaging, and three restored hardware trials; current draw and real low-battery entry remain unmeasured.",
|
||||
"policy": {
|
||||
"automatic_enter_percent_max": 5,
|
||||
"automatic_release_percent_min": 7,
|
||||
"sample_interval_ms": 30000,
|
||||
"utc_awake_window_seconds": [
|
||||
0,
|
||||
900
|
||||
],
|
||||
"hour_seconds": 3600,
|
||||
"fallback_clock": "boot-relative monotonic uptime",
|
||||
"reply_hold_ms": 5000,
|
||||
"maximum_cpu_sleep_slice_ms": 10000,
|
||||
"maximum_awake_override_minutes": 10080,
|
||||
"default_mode": "auto",
|
||||
"manual_mode_persisted": true,
|
||||
"awake_override_persisted": false,
|
||||
"hard_low_voltage_protection_preserved": true,
|
||||
"platform_sleep": {
|
||||
"nrf52": "Retained RTOS timed delay with idle sleep and watchdog service; no SYSTEMOFF/reboot.",
|
||||
"esp32": "Timed light sleep when detached; native USB uses bounded RTOS idle waits and a reserved post-wake USB service interval."
|
||||
},
|
||||
"forwarding_defaults": {
|
||||
"repeat_below_percent": 15,
|
||||
"flood_below_percent": 25,
|
||||
"v1_migration": "Only the exact legacy 10/20 pair migrates to 15/25; enabled bits/endpoints and other pairs are preserved.",
|
||||
"v2_custom_10_20_preserved": true
|
||||
},
|
||||
"commands": [
|
||||
"get power.emergency",
|
||||
"set power.emergency on",
|
||||
"set power.emergency auto",
|
||||
"set power.awake <0..10080 minutes>"
|
||||
]
|
||||
},
|
||||
"native_tests": {
|
||||
"status": "passed",
|
||||
"total_test_methods": 85,
|
||||
"suites": {
|
||||
"test_emergency_power.py": 7,
|
||||
"test_emergency_power_runtime.py": 13,
|
||||
"test_emergency_sleep_backends.py": 3,
|
||||
"test_emergency_power_ota.py": 1,
|
||||
"test_battery_forwarding.py": 20,
|
||||
"test_client_acl_role.py": 8,
|
||||
"test_wireless_control.py": 4,
|
||||
"test_logging_sleep_contract.py": 3,
|
||||
"test_power_saving_deadlines.py": 3,
|
||||
"test_emergency_power_bench.py": 14,
|
||||
"test_esp32_cpu_clock_board.py": 9
|
||||
},
|
||||
"ota_role_test_compile_configurations": 3,
|
||||
"qualification": "Native fakes validate state and call ordering; they do not measure physical current or battery state."
|
||||
},
|
||||
"builds": [
|
||||
{
|
||||
"artifact_target": "solarxiao_33S_repeater-full-ota",
|
||||
"pio_environment": "solarxiao_33S_repeater",
|
||||
"platform": "NRF52_PLATFORM",
|
||||
"mcu": "nrf52840",
|
||||
"passed": true,
|
||||
"capability_checks": {
|
||||
"passed": 11,
|
||||
"total": 11
|
||||
},
|
||||
"ota_update_verified": true,
|
||||
"memory": {
|
||||
"passed": true,
|
||||
"elf_sha256": "e70e0dc40d7b57b026ac79dc2bbaf5ceb1517e8ec2f92a0ae65acb71125a1634",
|
||||
"available_internal_bytes": 151348,
|
||||
"largest_internal_region_bytes": 151348,
|
||||
"required_heap_bytes": 82784,
|
||||
"required_contiguous_bytes": 19168
|
||||
},
|
||||
"memory_manifest_sha256": "5c9b97e0f5643fe4c48e446d373d78278fb3ce1446522f3166c1eb352a189bf4",
|
||||
"capabilities_manifest_sha256": "f487331b66faaff19771d713468ff9b02f77a60cadf18d104527d3fa32332ab3",
|
||||
"artifact_files": {
|
||||
"solarxiao_33S_repeater-full-ota-v1.18.0.2-halo-keymind-cascade-dev-f4f94532.uf2": {
|
||||
"sha256": "8baab7d62feed91eb7382f2e38304bcedabff15f92dfca889cde1d33c879359e",
|
||||
"size_bytes": 1255424
|
||||
},
|
||||
"solarxiao_33S_repeater-full-ota-v1.18.0.2-halo-keymind-cascade-dev-f4f94532.zip": {
|
||||
"sha256": "da62778bc5add51749deb9e36e5fbe781c9e277d404a3117ca655b0ab57cb6a0",
|
||||
"size_bytes": 628363
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"artifact_target": "solarxiao_33S_repeater-reduced-ota",
|
||||
"pio_environment": "solarxiao_33S_repeater",
|
||||
"platform": "NRF52_PLATFORM",
|
||||
"mcu": "nrf52840",
|
||||
"passed": true,
|
||||
"capability_checks": {
|
||||
"passed": 9,
|
||||
"total": 9
|
||||
},
|
||||
"ota_update_verified": true,
|
||||
"memory": {
|
||||
"passed": true,
|
||||
"elf_sha256": "b3568203b8550be591a0937355c4764bd3de5cdc7007be27cd2bf33a33ef8e83",
|
||||
"available_internal_bytes": 152528,
|
||||
"largest_internal_region_bytes": 152528,
|
||||
"required_heap_bytes": 80736,
|
||||
"required_contiguous_bytes": 19168
|
||||
},
|
||||
"memory_manifest_sha256": "b5cf910705fff6ad5333f65a9c7e282f9757ce53a1ad5d26385c70a9ff525ba5",
|
||||
"capabilities_manifest_sha256": "f1148c7fbc1448bde7a6b244ac179f34c0cb47d366736eed7a1f94e3a1dbf07b",
|
||||
"artifact_files": {
|
||||
"solarxiao_33S_repeater-reduced-ota-v1.18.0.2-halo-keymind-cascade-dev-f4f94532.uf2": {
|
||||
"sha256": "1f7dc7d181704b975f4ac598551ea8c8b6cebf89b301826bfb551821e33f37bd",
|
||||
"size_bytes": 1181696
|
||||
},
|
||||
"solarxiao_33S_repeater-reduced-ota-v1.18.0.2-halo-keymind-cascade-dev-f4f94532.zip": {
|
||||
"sha256": "fc8345308fee06c0059b8f0791ded9c04a8d3a5fdb9212fde64961fa32bb4e0d",
|
||||
"size_bytes": 591611
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"artifact_target": "RAK_3401_repeater_lora_ota_no_external_sensors-full-ota",
|
||||
"pio_environment": "RAK_3401_repeater",
|
||||
"platform": "NRF52_PLATFORM",
|
||||
"mcu": "nrf52840",
|
||||
"passed": true,
|
||||
"capability_checks": {
|
||||
"passed": 14,
|
||||
"total": 14
|
||||
},
|
||||
"ota_update_verified": true,
|
||||
"memory": {
|
||||
"passed": true,
|
||||
"elf_sha256": "8e4d72940ae57e8c4aaf4c1e14efdd42a26c9f1c717bef2dfae19ddc2217507b",
|
||||
"available_internal_bytes": 80124,
|
||||
"largest_internal_region_bytes": 80124,
|
||||
"required_heap_bytes": 75920,
|
||||
"required_contiguous_bytes": 19168
|
||||
},
|
||||
"memory_manifest_sha256": "fdc17070df586a0b8a5341c4934bb6697b9dfad5d12943383166211b0e442f23",
|
||||
"capabilities_manifest_sha256": "fb661625a4851dfb53b889e524aec4ee4c604a4eff675f9513a47c65c32ea768",
|
||||
"artifact_files": {
|
||||
"RAK_3401_repeater_lora_ota_no_external_sensors-full-ota-v1.18.0.2-halo-keymind-cascade-dev-f4f94532.uf2": {
|
||||
"sha256": "498fbfb12cb2abd0969456752bf39adbaa82134c53e63be3d6fa594be04cb593",
|
||||
"size_bytes": 1255936
|
||||
},
|
||||
"RAK_3401_repeater_lora_ota_no_external_sensors-full-ota-v1.18.0.2-halo-keymind-cascade-dev-f4f94532.zip": {
|
||||
"sha256": "3d979e38b38d1b4af77d68b21ca67b7a2d9cf3f2cf3ed4e35051b354dafb4807",
|
||||
"size_bytes": 628603
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"artifact_target": "RAK_3401_repeater_lora_ota_no_external_sensors-reduced-ota",
|
||||
"pio_environment": "RAK_3401_repeater",
|
||||
"platform": "NRF52_PLATFORM",
|
||||
"mcu": "nrf52840",
|
||||
"passed": true,
|
||||
"capability_checks": {
|
||||
"passed": 12,
|
||||
"total": 12
|
||||
},
|
||||
"ota_update_verified": true,
|
||||
"memory": {
|
||||
"passed": true,
|
||||
"elf_sha256": "d2862c5939afcd4401bbe8fdb31f980ee9a48e92f89b45e6e840bb3a990a5213",
|
||||
"available_internal_bytes": 82648,
|
||||
"largest_internal_region_bytes": 82648,
|
||||
"required_heap_bytes": 73872,
|
||||
"required_contiguous_bytes": 19168
|
||||
},
|
||||
"memory_manifest_sha256": "923ec558f2a9e4c236f543c0c31121bd601ffbaa753268d20ab52725daea37b1",
|
||||
"capabilities_manifest_sha256": "4e4a91cba3cecab699ddcdb215d2b020f766fd1843d45e19244cd6dfa432ca1f",
|
||||
"artifact_files": {
|
||||
"RAK_3401_repeater_lora_ota_no_external_sensors-reduced-ota-v1.18.0.2-halo-keymind-cascade-dev-f4f94532.uf2": {
|
||||
"sha256": "eac7a79110c130dfde00b771cd58dd2fd791c559b8b96df2f0369c99d8a6d742",
|
||||
"size_bytes": 1137152
|
||||
},
|
||||
"RAK_3401_repeater_lora_ota_no_external_sensors-reduced-ota-v1.18.0.2-halo-keymind-cascade-dev-f4f94532.zip": {
|
||||
"sha256": "81f5c2285a6407346cdcdcbf33c090ece3590ee1f497817aea07f5b1da098b1c",
|
||||
"size_bytes": 569367
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"artifact_target": "heltec_v4_repeater_observer_mqtt",
|
||||
"pio_environment": "heltec_v4_repeater_observer_mqtt",
|
||||
"platform": "ESP32_PLATFORM",
|
||||
"mcu": "esp32s3",
|
||||
"passed": true,
|
||||
"capability_checks": {
|
||||
"passed": 16,
|
||||
"total": 16
|
||||
},
|
||||
"ota_update_verified": true,
|
||||
"memory": {
|
||||
"passed": true,
|
||||
"elf_sha256": "fe7c84f61b24ed87b3451b9d7d62e5df38dd2382a7456f3b3b838901d064b13d",
|
||||
"available_internal_bytes": 240112,
|
||||
"largest_internal_region_bytes": 207344,
|
||||
"required_heap_bytes": 238732,
|
||||
"required_contiguous_bytes": 19168
|
||||
},
|
||||
"memory_manifest_sha256": "d8a38eceb3563314ac8659466d57e0ba225173fcbb89ed469d26f025c0e4a4eb",
|
||||
"capabilities_manifest_sha256": "26c650a6e3086486a35fc7e6404f31d74e56930ab2497d36de5dff3b126f6956",
|
||||
"artifact_files": {
|
||||
"heltec_v4_repeater_observer_mqtt-full-usb-wifi-ota-v1.18.0.2-halo-keymind-cascade-dev-f4f94532.bin": {
|
||||
"sha256": "e4814e3a0304be78ec26f3ec54514dced662c6a851b49508e37c2096f33b6e88",
|
||||
"size_bytes": 2176680
|
||||
},
|
||||
"heltec_v4_repeater_observer_mqtt-full-usb-wifi-ota-v1.18.0.2-halo-keymind-cascade-dev-f4f94532-merged.bin": {
|
||||
"sha256": "958f0aae6cb8d87b3fcd9fe04616988b5e48b7a6ec2a521252b06333ffce8cad",
|
||||
"size_bytes": 2242216
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"artifact_target": "solarxiao_33S_room_server_lora_ota-full-ota",
|
||||
"pio_environment": "solarxiao_33S_room_server_lora_ota",
|
||||
"platform": "NRF52_PLATFORM",
|
||||
"mcu": "nrf52840",
|
||||
"passed": true,
|
||||
"capability_checks": {
|
||||
"passed": 7,
|
||||
"total": 7
|
||||
},
|
||||
"ota_update_verified": true,
|
||||
"memory": {
|
||||
"passed": true,
|
||||
"elf_sha256": "e6d9ae0909d1bb9d34cd434e0fe50ce7752672cb5b9e4651e86580c6938a831f",
|
||||
"available_internal_bytes": 157108,
|
||||
"largest_internal_region_bytes": 157108,
|
||||
"required_heap_bytes": 67328,
|
||||
"required_contiguous_bytes": 10256
|
||||
},
|
||||
"memory_manifest_sha256": "398652d19d46ea3011404a91cd7cd9b4d5916ae26732f239f5054fee362d9b47",
|
||||
"capabilities_manifest_sha256": "e6e68d818c297f2c3dd3732c94c92314ade500f10f4384fb13ffac132ebd4398",
|
||||
"artifact_files": {
|
||||
"solarxiao_33S_room_server_lora_ota-full-ota-v1.18.0.2-halo-keymind-cascade-dev-f4f94532.uf2": {
|
||||
"sha256": "dda7c6d03e4a7a507ea174cde1a6e7ef478e783637b2cd5144d63c670c439780",
|
||||
"size_bytes": 1095680
|
||||
},
|
||||
"solarxiao_33S_room_server_lora_ota-full-ota-v1.18.0.2-halo-keymind-cascade-dev-f4f94532.zip": {
|
||||
"sha256": "f12af1af797d502963d23c34f44c3934d3791dee2c5f2e6ac910fa6ec102063a",
|
||||
"size_bytes": 548491
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"artifact_target": "solarxiao_33S_room_server_lora_ota-reduced-ota",
|
||||
"pio_environment": "solarxiao_33S_room_server_lora_ota",
|
||||
"platform": "NRF52_PLATFORM",
|
||||
"mcu": "nrf52840",
|
||||
"passed": true,
|
||||
"capability_checks": {
|
||||
"passed": 5,
|
||||
"total": 5
|
||||
},
|
||||
"ota_update_verified": true,
|
||||
"memory": {
|
||||
"passed": true,
|
||||
"elf_sha256": "05d808a0d74a76bc0e3b14d4d54b75e059e36193f366601605b3c78bece1cbc4",
|
||||
"available_internal_bytes": 158264,
|
||||
"largest_internal_region_bytes": 158264,
|
||||
"required_heap_bytes": 65280,
|
||||
"required_contiguous_bytes": 10256
|
||||
},
|
||||
"memory_manifest_sha256": "1b27404c085b6dc2b6363b4e6a47ef2cfc6afc11ee32c5f1a9bf6bf22a2bf884",
|
||||
"capabilities_manifest_sha256": "b64e5229c28253c2df916dbe2ac1eba65076594ca640d0b51c10d58385920412",
|
||||
"artifact_files": {
|
||||
"solarxiao_33S_room_server_lora_ota-reduced-ota-v1.18.0.2-halo-keymind-cascade-dev-f4f94532.uf2": {
|
||||
"sha256": "a56a7cfae82bb28f0579206a0c19d1f75e04ff366510a01c7fb978af0a045e11",
|
||||
"size_bytes": 1022976
|
||||
},
|
||||
"solarxiao_33S_room_server_lora_ota-reduced-ota-v1.18.0.2-halo-keymind-cascade-dev-f4f94532.zip": {
|
||||
"sha256": "b93ba11be67c9e74fb0dab4b21310fed65f47d856652a7b037b139f01cf22867",
|
||||
"size_bytes": 512115
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"artifact_target": "heltec_v4_sensor",
|
||||
"pio_environment": "heltec_v4_sensor",
|
||||
"platform": "ESP32_PLATFORM",
|
||||
"mcu": "esp32s3",
|
||||
"passed": true,
|
||||
"capability_checks": {
|
||||
"passed": 9,
|
||||
"total": 9
|
||||
},
|
||||
"ota_update_verified": true,
|
||||
"memory": {
|
||||
"passed": true,
|
||||
"elf_sha256": "f8956d176b5a15df8818f0955779934340d735b7f2f8a30044dd1ddb0d574ab5",
|
||||
"available_internal_bytes": 271872,
|
||||
"largest_internal_region_bytes": 239104,
|
||||
"required_heap_bytes": 150592,
|
||||
"required_contiguous_bytes": 18464
|
||||
},
|
||||
"memory_manifest_sha256": "fa55caceb0882d2db0c8fca5bf12ffa5b84289d6347da99521e132646b063069",
|
||||
"capabilities_manifest_sha256": "1b713a6e1073731e76cc360563087cea10f9385d69aec3992bb166aaf1498f5c",
|
||||
"artifact_files": {
|
||||
"heltec_v4_sensor-full-ota-v1.18.0.2-halo-keymind-cascade-dev-f4f94532.bin": {
|
||||
"sha256": "b16e69620277fa98079bcd9516a6f9d4254a04a07914dc8951d293c10dab3fb8",
|
||||
"size_bytes": 1541288
|
||||
},
|
||||
"heltec_v4_sensor-full-ota-v1.18.0.2-halo-keymind-cascade-dev-f4f94532-merged.bin": {
|
||||
"sha256": "3fe8fbe319cf6d2ba389f9fd088fdbee04d7195fafa3220d4c14a7bb87ab8a1e",
|
||||
"size_bytes": 1606824
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"additional_builds": {
|
||||
"heltec_v4_native_usb_fix": {
|
||||
"source_commit": "be6d636c79413fbadf7f23a1fc97cd9f92b76ba6",
|
||||
"artifact_target": "heltec_v4_repeater_observer_mqtt",
|
||||
"pio_environment": "heltec_v4_repeater_observer_mqtt",
|
||||
"platform": "ESP32_PLATFORM",
|
||||
"mcu": "esp32s3",
|
||||
"passed": true,
|
||||
"capability_checks": {
|
||||
"passed": 16,
|
||||
"total": 16
|
||||
},
|
||||
"memory": {
|
||||
"passed": true,
|
||||
"elf_sha256": "4c74d111ae0c535a69657ba5807f5dcc2b2e5b4b9aabdaa371f6d3a3ddb33d42",
|
||||
"available_internal_bytes": 240112,
|
||||
"largest_internal_region_bytes": 207344,
|
||||
"required_heap_bytes": 238732,
|
||||
"required_contiguous_bytes": 19168
|
||||
},
|
||||
"memory_manifest_sha256": "d2663a83298dbd4daa12e675a8737e8948fc16b5cdda71a9ded57f3c528e9820",
|
||||
"capabilities_manifest_sha256": "afb6fc8e8f883f2b03e436461aef3c4acedbeaddef7ba55b0b42215439566c62",
|
||||
"artifact_files": {
|
||||
"heltec_v4_repeater_observer_mqtt-full-usb-wifi-ota-v1.18.0.2-halo-keymind-cascade-dev-be6d636c.bin": {
|
||||
"sha256": "145afa4085b37f9c2eec0e5ba39c1aad045c46dfd0c476bc2ed1977d1dd0213a",
|
||||
"size_bytes": 2177160
|
||||
},
|
||||
"heltec_v4_repeater_observer_mqtt-full-usb-wifi-ota-v1.18.0.2-halo-keymind-cascade-dev-be6d636c-merged.bin": {
|
||||
"sha256": "a78920426a9f7edff7620425cca813e76df8dde228022573c744823bbbbee065",
|
||||
"size_bytes": 2242696
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"physical_validation": {
|
||||
"status": "passed_with_documented_limits",
|
||||
"schema_version": 1,
|
||||
"bench_profile": {
|
||||
"frequency_mhz": 909.5,
|
||||
"bandwidth_khz": 500,
|
||||
"spreading_factor": 5,
|
||||
"coding_rate": 5,
|
||||
"configured_tx_dbm": -9
|
||||
},
|
||||
"boards": [
|
||||
{
|
||||
"board": "SolarXiao 33S",
|
||||
"tested_version": "v1.18.0.2-halo-keymind-cascade-dev-f4f94532 (Build: 28-Oct-2026)",
|
||||
"installed_version": "v1.18.0.2-halo-keymind-cascade-dev-f4f94532 (Build: 28-Oct-2026)",
|
||||
"trial_passed": true,
|
||||
"manual_radio_sleep_verified": true,
|
||||
"independent_witness_board": "RAK3401",
|
||||
"baseline_rf_advert_received": true,
|
||||
"awake_lease": {
|
||||
"requested_seconds": 60,
|
||||
"real_time_expiry_verified": true,
|
||||
"clock_not_accelerated_during_lease": true
|
||||
},
|
||||
"accelerated_hourly_wake_cycles": [
|
||||
{
|
||||
"no_target_usb_writes_seconds": 31.586,
|
||||
"independent_raw_capture_before_status_query": true,
|
||||
"expected_hour_boundary_timestamp": true,
|
||||
"radio_awake_after_capture": true
|
||||
},
|
||||
{
|
||||
"no_target_usb_writes_seconds": 31.587,
|
||||
"independent_raw_capture_before_status_query": true,
|
||||
"expected_hour_boundary_timestamp": true,
|
||||
"radio_awake_after_capture": true
|
||||
}
|
||||
],
|
||||
"manual_mode_survived_reboot": true,
|
||||
"cleanup_passed": true,
|
||||
"final_state": {
|
||||
"role": "Repeater",
|
||||
"identity_equal": true,
|
||||
"existing_settings_equal": true,
|
||||
"present_clock_confirmed": true,
|
||||
"emergency_cli_supported": true,
|
||||
"emergency_mode": "auto",
|
||||
"active": "off",
|
||||
"radio": "awake",
|
||||
"awake_hold_seconds": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"board": "RAK3401",
|
||||
"tested_version": "v1.18.0.2-halo-keymind-cascade-dev-f4f94532 (Build: 28-Oct-2026)",
|
||||
"installed_version": "v1.18.0.1-halo-keymind-cascade-dev-4c0f2acf (Build: 23-Oct-2026)",
|
||||
"trial_passed": true,
|
||||
"manual_radio_sleep_verified": true,
|
||||
"independent_witness_board": "SolarXiao 33S",
|
||||
"baseline_rf_advert_received": true,
|
||||
"awake_lease": {
|
||||
"requested_seconds": 60,
|
||||
"real_time_expiry_verified": true,
|
||||
"clock_not_accelerated_during_lease": true
|
||||
},
|
||||
"accelerated_hourly_wake_cycles": [
|
||||
{
|
||||
"no_target_usb_writes_seconds": 31.586,
|
||||
"independent_raw_capture_before_status_query": true,
|
||||
"expected_hour_boundary_timestamp": true,
|
||||
"radio_awake_after_capture": true
|
||||
},
|
||||
{
|
||||
"no_target_usb_writes_seconds": 31.583,
|
||||
"independent_raw_capture_before_status_query": true,
|
||||
"expected_hour_boundary_timestamp": true,
|
||||
"radio_awake_after_capture": true
|
||||
}
|
||||
],
|
||||
"manual_mode_survived_reboot": true,
|
||||
"cleanup_passed": true,
|
||||
"final_state": {
|
||||
"role": "Repeater",
|
||||
"identity_equal": true,
|
||||
"existing_settings_equal": true,
|
||||
"present_clock_confirmed": true,
|
||||
"emergency_cli_supported": false,
|
||||
"auto_and_zero_awake_hold_confirmed_before_original_application_restore": true
|
||||
},
|
||||
"original_unified_application_restored": true
|
||||
},
|
||||
{
|
||||
"board": "Heltec V4.3 OLED",
|
||||
"tested_version": "v1.18.0.2-halo-keymind-cascade-dev-observer-be6d636c (Build: 28-Oct-2026)",
|
||||
"installed_version": "Companion v1.18.0.2-kiss-test-ec4aba8a (protocol 14, build 27-Oct-2026)",
|
||||
"trial_passed": true,
|
||||
"manual_radio_sleep_verified": true,
|
||||
"independent_witness_board": "RAK3401",
|
||||
"baseline_rf_advert_received": true,
|
||||
"awake_lease": {
|
||||
"requested_seconds": 60,
|
||||
"real_time_expiry_verified": true,
|
||||
"clock_not_accelerated_during_lease": true
|
||||
},
|
||||
"accelerated_hourly_wake_cycles": [
|
||||
{
|
||||
"no_target_usb_writes_seconds": 21.73,
|
||||
"independent_raw_capture_before_status_query": true,
|
||||
"expected_hour_boundary_timestamp": true,
|
||||
"radio_awake_after_capture": true
|
||||
},
|
||||
{
|
||||
"no_target_usb_writes_seconds": 21.677,
|
||||
"independent_raw_capture_before_status_query": true,
|
||||
"expected_hour_boundary_timestamp": true,
|
||||
"radio_awake_after_capture": true
|
||||
}
|
||||
],
|
||||
"manual_mode_survived_reboot": true,
|
||||
"cleanup_passed": true,
|
||||
"final_state": {
|
||||
"role": "Full Companion",
|
||||
"identity_equal": true,
|
||||
"existing_settings_equal": true,
|
||||
"present_clock_confirmed": true,
|
||||
"emergency_cli_supported": false,
|
||||
"auto_and_zero_awake_hold_confirmed_before_original_application_restore": true
|
||||
},
|
||||
"usb_console_usable_while_radio_asleep": true,
|
||||
"usb_held_cpu_idle_fallback": true,
|
||||
"wireless_runtime_subset_restored": true,
|
||||
"original_application_and_data_readbacks_verified": true,
|
||||
"earlier_usb_sleep_regression_reproduced_and_fixed": true
|
||||
}
|
||||
],
|
||||
"collector": {
|
||||
"active": true,
|
||||
"unit_configuration_equal": true,
|
||||
"serial_owned_by_collector": true
|
||||
},
|
||||
"bench_native_regressions": {
|
||||
"tests": 14,
|
||||
"passed": true
|
||||
},
|
||||
"measurement_limits": [
|
||||
"All physical emergency tests used manual entry while USB powered; the actual 5% battery trigger and charging hysteresis were not physically exercised.",
|
||||
"No current draw or power-saving percentage was measured.",
|
||||
"Hourly boundaries used accelerated isolated RTC values; this was not a real 45-minute sleep-duration test.",
|
||||
"V4 used the USB-held CPU idle fallback while RF/wireless slept; detached USB light-sleep current was not measured.",
|
||||
"Physical tests used Repeater firmware; Room and Sensor hooks were validated separately by native tests and builds."
|
||||
],
|
||||
"current_draw_measured": false,
|
||||
"real_five_percent_battery_qualified": false,
|
||||
"continuous_45_minute_sleep_qualified": false,
|
||||
"bench_source_sha256": "2c4799cb239f9d092d5cf46c0366408aa95f766f786f56af4b913e07b636418f"
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,7 @@ Completed validation reports, experiments, and test guides are collected here. O
|
||||
|
||||
## Build, storage, memory, and checklists
|
||||
|
||||
- [Emergency battery schedule: native and firmware build checks, 2026-10-28](emergency_power_2026-10-28.md)
|
||||
- [RAK4631 Full/Reduced RAM repair and RAK3401 Room Server radio/sleep qualification, 2026-10-22](rak_release_fixes_2026-10-22.md)
|
||||
- [Shared battery ADC calibration validation, 2026-10-22](battery_calibration_2026-10-22.md)
|
||||
- [Heltec V2 BLE RAM reserve repair, 2026-10-22](heltec_v2_ble_memory_2026-10-22.md)
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
"""Host harness guards; these do not claim hardware or battery qualification."""
|
||||
from contextlib import contextmanager
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import types
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
|
||||
PATH = Path(__file__).resolve().parents[1] / "tools/hil/emergency_power_bench.py"
|
||||
SPEC = importlib.util.spec_from_file_location("emergency_power_bench", PATH)
|
||||
bench = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(bench)
|
||||
|
||||
|
||||
def reply(command, value):
|
||||
return command + "\n -> > " + value + "\n"
|
||||
|
||||
|
||||
class EmergencyBench(unittest.TestCase):
|
||||
def test_status_requires_every_field_and_seconds(self):
|
||||
text = "mode=manual active=on battery=5% clock=rtc window=sleep radio=sleep wake=2699s hold=0s"
|
||||
state = bench.status(text)
|
||||
self.assertEqual(state["wake"], 2699)
|
||||
self.assertEqual(state["hold"], 0)
|
||||
self.assertEqual(state["mode"], "manual")
|
||||
for bad in (text.replace("2699s", "2699m"), text.replace(" radio=sleep", ""),
|
||||
text.replace("mode=manual", "mode=unknown")):
|
||||
with self.assertRaises(RuntimeError):
|
||||
bench.status(bad)
|
||||
|
||||
def test_raw_advert_uses_actual_route_path_identity_and_timestamp(self):
|
||||
identity = bytes(range(32))
|
||||
epoch = 1793000000
|
||||
payload = identity + epoch.to_bytes(4, "little") + bytes(64) + b"node"
|
||||
for route in range(4):
|
||||
for width in (1, 2, 3):
|
||||
raw = bytes((16 | route,)) + (bytes(4) if route in (0, 3) else b"")
|
||||
raw += bytes((((width - 1) << 6) | 2,)) + b"x" * (width * 2) + payload
|
||||
self.assertEqual(bench.advert_timestamp("2026 RAW: " + raw.hex(), identity), epoch)
|
||||
self.assertEqual(bench.advert_timestamp("R" + raw.hex(), identity), epoch)
|
||||
self.assertIsNone(bench.advert_timestamp("TX RAW? " + raw.hex(), identity))
|
||||
self.assertIsNone(bench.advert_timestamp("R" + raw.hex(), bytes(32)))
|
||||
self.assertIsNone(bench.advert_timestamp("R" + raw[:10].hex(), identity))
|
||||
for header, path in ((16, 192), (16, 127), (20, 0), (80, 0)):
|
||||
self.assertIsNone(bench.advert_timestamp("R" + (bytes((header, path)) + payload).hex(), identity))
|
||||
|
||||
def test_reply_and_identity_require_complete_exact_key(self):
|
||||
self.assertEqual(bench.public_key(reply("get public.key", "12" * 32)), b"\x12" * 32)
|
||||
for value in ("12" * 31, "12" * 33, "x" * 64, "pending"):
|
||||
with self.assertRaises(RuntimeError):
|
||||
bench.public_key(reply("get public.key", value))
|
||||
with self.assertRaises(RuntimeError):
|
||||
bench.reply_value("get radio\n> partial")
|
||||
|
||||
def test_rxps_restore_preserves_level_or_manual_timings(self):
|
||||
self.assertEqual(bench.rxps_restore("off,level=8,preamble=16,rx=39657,sleep=15975"), "set radio.rxps off")
|
||||
self.assertEqual(bench.rxps_restore("on,level=8,preamble=16,rx=39657,sleep=15975"), "set radio.rxps level 8 preamble 16")
|
||||
self.assertEqual(bench.rxps_restore("on,level=0,preamble=0,rx=65625,sleep=60000"), "set radio.rxps 65625 60000")
|
||||
for bad in ("unknown setting", "on", "on,level=8,preamble=16,rx=1"):
|
||||
with self.assertRaises(RuntimeError):
|
||||
bench.rxps_restore(bad)
|
||||
|
||||
def test_clock_uses_real_minute_precision_cli(self):
|
||||
epoch = bench.clock_epoch(reply("clock", "12:34 - 28/10/2026 UTC"))
|
||||
self.assertEqual(epoch % 3600, 34 * 60)
|
||||
with self.assertRaises(RuntimeError):
|
||||
bench.clock_epoch(reply("clock", "OK"))
|
||||
|
||||
def test_passive_capture_matches_key_and_minimum_epoch_without_command(self):
|
||||
identity = bytes(range(32))
|
||||
witness = types.SimpleNamespace(condition=threading.Condition(), failed=False)
|
||||
raw = bytes((17, 0)) + identity + (200).to_bytes(4, "little") + bytes(64)
|
||||
witness.lines = [(8, "RAW: " + raw.hex()), (12, "RAW: " + raw.hex())]
|
||||
observed = bench.await_advert(witness, identity, after=10, minimum_epoch=200, timeout=.1)
|
||||
self.assertEqual(observed, {"received_monotonic": 12, "advert_epoch": 200})
|
||||
with self.assertRaises(RuntimeError):
|
||||
bench.await_advert(witness, identity, after=10, minimum_epoch=201, timeout=.01)
|
||||
with self.assertRaises(RuntimeError):
|
||||
bench.await_advert(witness, identity, after=10, minimum_epoch=100,
|
||||
maximum_epoch=150, timeout=.01)
|
||||
|
||||
def test_preexisting_manual_mode_is_not_changed_during_failed_preflight(self):
|
||||
calls = []
|
||||
collector_events = []
|
||||
|
||||
class FakeConsole:
|
||||
def __init__(self, serial_number, path):
|
||||
self.serial_number = serial_number
|
||||
|
||||
def command(self, command, **kwargs):
|
||||
calls.append(command)
|
||||
values = {"get public.key": "12" * 32, "board": "SolarXiao 33S" if self.serial_number.startswith("9E") else "RAK 3401",
|
||||
"ver": "verified build", "get power.emergency": "mode=manual active=on battery=unknown clock=rtc window=sleep radio=sleep wake=200s hold=0s"}
|
||||
return reply(command, values[command])
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
@contextmanager
|
||||
def collector():
|
||||
collector_events.append("pause")
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
collector_events.append("restore")
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
backup = root / "baseline.json"
|
||||
backup.write_text(json.dumps({"usb_serial": bench.TARGETS["solarxiao33s"][0],
|
||||
"queries": {"get public.key": reply("get public.key", "12" * 32)}}))
|
||||
args = types.SimpleNamespace(run_dir=root / "run", identity_backup=backup,
|
||||
target="solarxiao33s", witness=None, expected_version="verified build")
|
||||
with mock.patch.object(bench, "Console", FakeConsole), mock.patch.object(bench, "paused_collector", collector), \
|
||||
mock.patch.object(bench, "snapshot", return_value={}), mock.patch("builtins.print"):
|
||||
with self.assertRaisesRegex(RuntimeError, "automatic mode"):
|
||||
bench.run_trial(args)
|
||||
self.assertFalse(any(command.startswith(("set ", "time ", "clkreboot")) for command in calls))
|
||||
self.assertEqual(collector_events, ["pause", "restore"])
|
||||
report = json.loads((args.run_dir / "report.json").read_text())
|
||||
self.assertFalse(report["passed"])
|
||||
self.assertEqual(report["cleanup"], "complete")
|
||||
|
||||
def test_default_plan_opens_no_port_or_service(self):
|
||||
arguments = ["bench", "--target", "rak3401", "--expected-version", "verified", "--identity-backup", "unused.json", "--run-dir", "unused"]
|
||||
with mock.patch("sys.argv", arguments), mock.patch.object(bench, "run_trial") as run, mock.patch("builtins.print"):
|
||||
bench.main()
|
||||
run.assert_not_called()
|
||||
|
||||
def test_console_initialization_delimits_stale_input_without_reset_pulse(self):
|
||||
writes = []
|
||||
|
||||
class Port:
|
||||
def __init__(self, **kwargs):
|
||||
self.dtr = self.rts = None
|
||||
|
||||
def open(self):
|
||||
self_values = (self.dtr, self.rts, self.port)
|
||||
if self_values != (True, False, "/dev/ttyACM10"):
|
||||
raise AssertionError(self_values)
|
||||
|
||||
def write(self, value):
|
||||
writes.append(value)
|
||||
|
||||
def flush(self):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
serial_module = types.SimpleNamespace(Serial=Port)
|
||||
ports = types.SimpleNamespace(comports=lambda: [types.SimpleNamespace(
|
||||
serial_number="9EBCE8853076EE86", interface="TinyUSB Serial", device="/dev/ttyACM10")])
|
||||
with tempfile.TemporaryDirectory() as directory, \
|
||||
mock.patch.dict("sys.modules", {"serial": serial_module, "serial.tools": types.SimpleNamespace(list_ports=ports)}), \
|
||||
mock.patch.object(bench.subprocess, "run", return_value=types.SimpleNamespace(returncode=1, stdout=b"", stderr=b"")), \
|
||||
mock.patch.object(bench.threading, "Thread"), mock.patch.object(bench.time, "sleep"):
|
||||
console = bench.Console("9EBCE8853076EE86", Path(directory) / "private.log")
|
||||
console.close()
|
||||
self.assertEqual(writes, [b"\r"])
|
||||
|
||||
def test_snapshot_preserves_disabled_radio2_ownership_annotation(self):
|
||||
values = {"radio": "910.5250244,62.5,7,5", "tx": "-6", "repeat": "on", "tempradio": "off",
|
||||
"radio.rxps.config": "off,level=0,preamble=0,rx=65625,sleep=60000",
|
||||
"usb.logging": "on", "usb.logging.always": "off", "radioat": "-none-",
|
||||
"tempradioat": "-none-", "radioat2": "slots:", "tempradioat2": "slots:"}
|
||||
console = types.SimpleNamespace(command=lambda command, **kwargs: reply(command, values[command[4:]]))
|
||||
for disabled in ("off", "off (manual)", "off (automatic allowed)"):
|
||||
values["radio2"] = disabled
|
||||
self.assertEqual(bench.snapshot(console)["radio2"], disabled)
|
||||
values["radio2"] = "911.150,250,7,5,rxtx,32"
|
||||
with self.assertRaisesRegex(RuntimeError, "inactive"):
|
||||
bench.snapshot(console)
|
||||
values["radio2"] = "off"
|
||||
for key in ("radioat", "tempradioat", "radioat2", "tempradioat2"):
|
||||
previous = values[key]
|
||||
values[key] = "1:911.5,125,7,5@1900000000"
|
||||
with self.assertRaisesRegex(RuntimeError, "empty future"):
|
||||
bench.snapshot(console)
|
||||
values[key] = previous
|
||||
|
||||
def test_radio_admission_never_confuses_reboot_required_with_live_retune(self):
|
||||
calls = []
|
||||
def command(text):
|
||||
calls.append(text)
|
||||
return reply(text, "OK - reboot to apply")
|
||||
console = types.SimpleNamespace(command=command)
|
||||
with mock.patch.object(bench.time, "sleep") as sleep:
|
||||
with self.assertRaisesRegex(RuntimeError, "saved getter"):
|
||||
bench.apply_radio(console, bench.BENCH_RADIO)
|
||||
self.assertEqual(calls, ["set radio " + bench.BENCH_RADIO])
|
||||
sleep.assert_not_called()
|
||||
|
||||
def test_rf_witness_cannot_share_an_original_main_frequency(self):
|
||||
target = {"radio": "910.5250244,62.5,7,5"}
|
||||
bench.require_distinct_original_frequencies(target, {"radio": "917,62.5,7,5"})
|
||||
with self.assertRaisesRegex(RuntimeError, "different original"):
|
||||
bench.require_distinct_original_frequencies(target, {"radio": "910.525,125,8,5"})
|
||||
|
||||
def test_wireless_snapshot_distinguishes_pending_and_failure(self):
|
||||
console = types.SimpleNamespace(command=lambda command: reply(command,
|
||||
"wifi=on, bluetooth=unavailable, espnow=off (pending)"))
|
||||
self.assertEqual(bench.wireless_snapshot(console),
|
||||
{"wifi": "on", "bluetooth": "unavailable", "espnow": "off", "pending": True})
|
||||
console.command = lambda command: reply(command,
|
||||
"wifi=off, bluetooth=unavailable, espnow=off (failed: retry command)")
|
||||
with self.assertRaisesRegex(RuntimeError, "failed transition"):
|
||||
bench.wireless_snapshot(console)
|
||||
|
||||
def test_network_clock_hold_restores_only_original_runtime_subset(self):
|
||||
calls = []
|
||||
live = {"wifi": "on", "bluetooth": "unavailable", "espnow": "off"}
|
||||
refused = [False]
|
||||
def command(text, **kwargs):
|
||||
calls.append(text)
|
||||
if text == "get 2.4ghz":
|
||||
return reply(text, ", ".join(key + "=" + value for key, value in live.items()))
|
||||
if text == "get power.emergency":
|
||||
return reply(text, "mode=manual active=on battery=100% clock=rtc window=awake radio=awake wake=0s hold=60s")
|
||||
if text == "set wifi off force":
|
||||
if not refused[0]:
|
||||
refused[0] = True
|
||||
return reply(text, "Error: emergency sleep; use set power.awake <minutes> first")
|
||||
live["wifi"] = "off"
|
||||
elif text == "set wifi on":
|
||||
live["wifi"] = "on"
|
||||
elif text not in ("set power.awake 0", "set power.awake 1"):
|
||||
raise AssertionError(text)
|
||||
return reply(text, "OK")
|
||||
console = types.SimpleNamespace(command=command)
|
||||
original = bench.wireless_snapshot(console)
|
||||
with mock.patch.object(bench.time, "sleep"):
|
||||
bench.hold_network_clock(console)
|
||||
self.assertEqual(live["wifi"], "off")
|
||||
bench.restore_wireless(console, original)
|
||||
self.assertEqual(bench.wireless_snapshot(console), original)
|
||||
self.assertEqual(calls.count("set wifi off force"), 2)
|
||||
self.assertEqual(calls.count("set wifi on"), 1)
|
||||
self.assertFalse(any("ssid" in value or "pwd" in value or "password" in value for value in calls))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -23,6 +23,7 @@ HARNESS = r'''
|
||||
static std::vector<std::string> events;
|
||||
static bool radio_test = false, interrupt_context = false, wireless_ready = true;
|
||||
static bool usb_held = false;
|
||||
static bool attach_on_delay = false;
|
||||
static unsigned primask = 0, basepri = 0, faultmask = 0;
|
||||
static unsigned __get_IPSR() { return interrupt_context; }
|
||||
static unsigned __get_PRIMASK() { return primask; }
|
||||
@@ -40,6 +41,7 @@ static void vTaskDelay(TickType_t ticks) {
|
||||
assert(ticks != 0);
|
||||
delayed_ticks = ticks;
|
||||
events.push_back("delay");
|
||||
if (attach_on_delay) usb_held = true; // Model the USB SOF hook observing a new host.
|
||||
}
|
||||
static unsigned nrf_feeds = 0;
|
||||
static constexpr uint32_t WDT_RR_RR_Reload = 0x6e524635;
|
||||
@@ -112,6 +114,7 @@ struct ESP32Board : Base {
|
||||
static void reset() {
|
||||
events.clear(); radio_test = interrupt_context = false; wireless_ready = true;
|
||||
usb_held = false;
|
||||
attach_on_delay = false;
|
||||
primask = basepri = faultmask = 0; nrf_nvic_state.__cr_flag = 0;
|
||||
scheduler = taskSCHEDULER_RUNNING;
|
||||
tick_ms = 1; delayed_ticks = nrf_feeds = 0;
|
||||
@@ -293,6 +296,12 @@ int main() {
|
||||
reset(); usb_held = true; wireless_ready = false;
|
||||
assert(esp.emergencySleepMilliseconds(10000));
|
||||
assert(delayed_ticks == 100 && !sleep_calls); // Existing host still bypasses physical USB sleep.
|
||||
reset(); attach_on_delay = true;
|
||||
assert(esp.emergencySleepMilliseconds(10000) && usb_held);
|
||||
assert(sleep_calls == 1 && clear_calls == 1);
|
||||
events.clear();
|
||||
assert(esp.emergencySleepMilliseconds(10000));
|
||||
assert(events == std::vector<std::string>{"delay"} && sleep_calls == 1);
|
||||
}
|
||||
'''
|
||||
self.run_code(code, ("-DMESH_ESP32_USB_CONSOLE_COOPERATIVE=1",))
|
||||
|
||||
@@ -186,9 +186,11 @@ int main(){
|
||||
clock_service=source.index("mesh::esp32::serviceCpuFrequencyPolicy(",loop_start)
|
||||
# Emergency maintenance also services UART restoration before its
|
||||
# early return. Keep this fixture anchored to the clock policy.
|
||||
start=source.rindex(" board.servicePowerTestControls();",loop_start,clock_service)
|
||||
end=source.index("#endif",clock_service)
|
||||
service=source[start:end]
|
||||
quiet_service=source.rindex(" board.servicePowerTestControls();",loop_start,clock_service)
|
||||
# Execute the two real calls without importing unrelated enclosing
|
||||
# feature guards (early emergency placement is tested separately).
|
||||
service=source[quiet_service:source.index(";",quiet_service)+1]+"\n"
|
||||
service+=source[clock_service:source.index(";",clock_service)+1]+"\n"
|
||||
tests="void role_service(){\n"+service+"}\n"+r'''
|
||||
int main(){
|
||||
prefs.cpu_freq_mhz=160;role_service();assert(actual_mhz==160&&board.quiet_services==1);
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Guarded USB emergency-power trials with an independent RAW radio witness.
|
||||
|
||||
Run on the Mercerwood Pi after separately flashing a verified application.
|
||||
The default is a plan only. --execute permits CLI changes and temporarily
|
||||
stops only mctomqtt.service, restoring it in finally. No flashing, erasing,
|
||||
power cycling, credential changes, or firmware downloads occur here.
|
||||
Private transcripts remain in a newly created0700 run directory.
|
||||
"""
|
||||
import argparse
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
|
||||
TARGETS = {
|
||||
"solarxiao33s": ("9EBCE8853076EE86", "SolarXiao 33S"),
|
||||
"rak4631": ("9AB3B64C641BA927", "RAK4631"),
|
||||
"heltec_v43": ("44:1B:F6:69:C9:C0", "Heltec V4"),
|
||||
"rak3401": ("0B81C9C68D8D01B4", "RAK3401"),
|
||||
}
|
||||
WITNESSES = {"rak3401": "0B81C9C68D8D01B4", "rak4631": "9AB3B64C641BA927",
|
||||
"solarxiao33s": "9EBCE8853076EE86"}
|
||||
BENCH_RADIO = "909.5,500,5,5"
|
||||
STATUS_RE = re.compile(
|
||||
r"mode=(manual|auto) active=(on|off) battery=(unknown|\d+%) "
|
||||
r"clock=(rtc|uptime) window=(awake|sleep) radio=(awake|sleep) "
|
||||
r"wake=(\d+)s hold=(\d+)s\b")
|
||||
|
||||
|
||||
def private_json(path, value):
|
||||
with os.fdopen(os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "w") as stream:
|
||||
json.dump(value, stream, indent=2)
|
||||
stream.write("\n")
|
||||
|
||||
|
||||
def status(reply):
|
||||
match = STATUS_RE.search(reply)
|
||||
if not match:
|
||||
raise RuntimeError("Incomplete emergency-power status")
|
||||
return dict(zip(("mode", "active", "battery", "clock", "window", "radio", "wake", "hold"),
|
||||
(*match.groups()[:6], int(match[7]), int(match[8]))))
|
||||
|
||||
|
||||
def reply_value(reply):
|
||||
match = re.search(r"(?m)^ -> (.*)\r?$", reply)
|
||||
if not match:
|
||||
raise RuntimeError("Missing completed infrastructure CLI reply")
|
||||
return match[1].removeprefix("> ").strip()
|
||||
|
||||
|
||||
def public_key(reply):
|
||||
value = reply_value(reply)
|
||||
if not re.fullmatch(r"[0-9A-Fa-f]{64}", value):
|
||||
raise RuntimeError("Cannot verify the complete public identity")
|
||||
return bytes.fromhex(value)
|
||||
|
||||
|
||||
def advert_timestamp(line, identity):
|
||||
"""Parse the actual Packet wire layout; never match TX/detail log lines."""
|
||||
match = re.search(r"(?:^|\s)RAW: ([0-9A-Fa-f]+)\s*$", line)
|
||||
if not match:
|
||||
match = re.fullmatch(r"R([0-9A-Fa-f]+)", line.strip())
|
||||
if not match or len(match[1]) % 2:
|
||||
return None
|
||||
raw = bytes.fromhex(match[1])
|
||||
if len(raw) < 2 or raw[0] >> 6 or (raw[0] >> 2) & 15 != 4:
|
||||
return None
|
||||
index = 5 if (raw[0] & 3) in (0, 3) else 1
|
||||
if len(raw) <= index:
|
||||
return None
|
||||
count, width = raw[index] & 63, (raw[index] >> 6) + 1
|
||||
if width == 4 or count * width > 64:
|
||||
return None
|
||||
index += 1 + count * width
|
||||
if len(raw) < index + 100 or raw[index:index + 32] != identity:
|
||||
return None
|
||||
return int.from_bytes(raw[index + 32:index + 36], "little")
|
||||
|
||||
|
||||
def rxps_restore(value):
|
||||
match = re.fullmatch(r"(on|off),level=(\d+),preamble=(\d+),rx=(\d+),sleep=(\d+)", value)
|
||||
if not match:
|
||||
raise RuntimeError("Cannot safely preserve RX power-saving configuration")
|
||||
enabled, level, preamble, rx, sleep = match.groups()
|
||||
if enabled == "off":
|
||||
return "set radio.rxps off"
|
||||
if int(level):
|
||||
return f"set radio.rxps level {level} preamble {preamble}"
|
||||
return f"set radio.rxps {rx} {sleep}"
|
||||
|
||||
|
||||
def clock_epoch(reply):
|
||||
value = reply_value(reply)
|
||||
try:
|
||||
return int(datetime.strptime(value, "%H:%M - %d/%m/%Y UTC").replace(tzinfo=timezone.utc).timestamp())
|
||||
except ValueError:
|
||||
raise RuntimeError("Unrecognized current clock response") from None
|
||||
|
||||
|
||||
class Console:
|
||||
"""Single reader records RAW traffic even while no USB writes are made."""
|
||||
def __init__(self, serial_number, transcript):
|
||||
import serial
|
||||
from serial.tools import list_ports
|
||||
matches = [p for p in list_ports.comports()
|
||||
if (p.serial_number or "").upper() == serial_number.upper()
|
||||
and p.interface != "MeshCore Logging"]
|
||||
if len(matches) != 1:
|
||||
raise RuntimeError("Missing or ambiguous exact application USB identity")
|
||||
guard = subprocess.run(["sudo", "-n", "fuser", matches[0].device],
|
||||
capture_output=True, timeout=5)
|
||||
if guard.returncode != 1 or guard.stdout or guard.stderr:
|
||||
raise RuntimeError("Serial ownership could not be proved idle")
|
||||
self.serial_number = serial_number
|
||||
self.port = serial.Serial(port=None, baudrate=115200, timeout=.03, exclusive=True, dsrdtr=True)
|
||||
self.port.dtr, self.port.rts = True, False
|
||||
self.port.port = matches[0].device
|
||||
self.port.open()
|
||||
self.port.rts, self.port.dtr = False, True
|
||||
self.log = os.fdopen(os.open(transcript, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "wb", buffering=0)
|
||||
self.condition = threading.Condition()
|
||||
self.lines = []
|
||||
self.running = True
|
||||
self.failed = False
|
||||
self.last_write = 0
|
||||
self.thread = threading.Thread(target=self._read, daemon=True)
|
||||
self.thread.start()
|
||||
# Boot banners and a prior host's partial input can share a line.
|
||||
# End that input before the first exact-echo transaction; never replay
|
||||
# a timed-out command or infer a reply from an unframed startup tail.
|
||||
self.port.write(b"\r")
|
||||
self.port.flush()
|
||||
time.sleep(.3)
|
||||
|
||||
def _read(self):
|
||||
pending = bytearray()
|
||||
try:
|
||||
while self.running:
|
||||
chunk = self.port.read(min(self.port.in_waiting or 1, 4096))
|
||||
if not chunk:
|
||||
continue
|
||||
self.log.write(chunk)
|
||||
pending.extend(chunk)
|
||||
if len(pending) > 65536:
|
||||
raise RuntimeError("Unterminated serial line exceeds capture bound")
|
||||
while b"\n" in pending:
|
||||
line, _, pending = pending.partition(b"\n")
|
||||
with self.condition:
|
||||
self.lines.append((time.monotonic(), line.decode(errors="replace").rstrip("\r")))
|
||||
self.condition.notify_all()
|
||||
except Exception:
|
||||
with self.condition:
|
||||
self.failed = True
|
||||
self.condition.notify_all()
|
||||
|
||||
def command(self, command, timeout=25, allow_error=False):
|
||||
if not command or any(c in command for c in "\r\n\0") or self.failed:
|
||||
raise RuntimeError("Unsafe command or desynchronized CLI session")
|
||||
with self.condition:
|
||||
first = len(self.lines)
|
||||
self.port.write((command + "\r").encode("ascii"))
|
||||
self.port.flush()
|
||||
self.last_write = time.monotonic()
|
||||
deadline = self.last_write + timeout
|
||||
while time.monotonic() < deadline and not self.failed:
|
||||
text = "\n".join(line for _, line in self.lines[first:]) + "\n"
|
||||
echo = re.search(r"(?:^|\n)(?:> )?" + re.escape(command) + r"\n", text)
|
||||
if echo:
|
||||
done = re.search(r"(?m)^ -> .*\n", text[echo.end():])
|
||||
if done:
|
||||
reply = text[echo.start():echo.end() + done.end()]
|
||||
if not allow_error and re.search(r"\b(?:Error|ERROR|ERR|Unknown)\b|(?m:^ -> \?\?:)", reply):
|
||||
raise RuntimeError(f"CLI rejected {command.split()[0]} request; see private transcript")
|
||||
return reply
|
||||
self.condition.wait(min(.1, max(0, deadline - time.monotonic())))
|
||||
self.failed = True
|
||||
raise RuntimeError("CLI response incomplete; session will not be reused")
|
||||
|
||||
def reboot(self, command, transcript, expected_key, after_identity=None):
|
||||
if command not in ("reboot", "clkreboot") or self.failed:
|
||||
raise RuntimeError("Unverified reboot request")
|
||||
self.port.write((command + "\r").encode("ascii"))
|
||||
self.port.flush()
|
||||
self.close()
|
||||
time.sleep(3)
|
||||
deadline = time.monotonic() + 40
|
||||
while True:
|
||||
try:
|
||||
candidate = Console(self.serial_number, transcript)
|
||||
break
|
||||
except (RuntimeError, OSError):
|
||||
if time.monotonic() > deadline:
|
||||
raise
|
||||
time.sleep(1)
|
||||
try:
|
||||
if public_key(candidate.command("get public.key")) != expected_key:
|
||||
raise RuntimeError("Identity changed after reboot")
|
||||
if after_identity:
|
||||
after_identity(candidate)
|
||||
except BaseException:
|
||||
candidate.close()
|
||||
raise
|
||||
return candidate
|
||||
|
||||
def close(self):
|
||||
self.running = False
|
||||
self.thread.join(timeout=2)
|
||||
self.port.close()
|
||||
self.log.close()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def paused_collector():
|
||||
"""Restore the existing unit state without reading its credentials."""
|
||||
unit = "/etc/systemd/system/mctomqtt.service"
|
||||
digest = hashlib.sha256(Path(unit).read_bytes()).digest()
|
||||
active = subprocess.run(["systemctl", "is-active", "--quiet", "mctomqtt.service"]).returncode == 0
|
||||
try:
|
||||
if active:
|
||||
subprocess.run(["sudo", "-n", "systemctl", "stop", "mctomqtt.service"], check=True, timeout=30)
|
||||
yield
|
||||
finally:
|
||||
if hashlib.sha256(Path(unit).read_bytes()).digest() != digest:
|
||||
raise RuntimeError("Collector unit changed during bench run; inspect before restoring")
|
||||
if active:
|
||||
subprocess.run(["sudo", "-n", "systemctl", "start", "mctomqtt.service"], check=True, timeout=30)
|
||||
subprocess.run(["systemctl", "is-active", "--quiet", "mctomqtt.service"], check=True, timeout=15)
|
||||
|
||||
|
||||
def snapshot(console):
|
||||
keys = ("radio", "tx", "repeat", "radio2", "tempradio", "radio.rxps.config",
|
||||
"usb.logging", "usb.logging.always", "radioat", "tempradioat", "radioat2", "tempradioat2")
|
||||
values = {key: reply_value(console.command("get " + key, allow_error=True)) for key in keys}
|
||||
for key in ("radioat", "tempradioat", "radioat2", "tempradioat2"):
|
||||
if values[key] != ("-none-" if key in ("radioat", "tempradioat") else "slots:"):
|
||||
raise RuntimeError("Bench requires confirmed empty future radio schedules")
|
||||
if not re.fullmatch(r"\d+(?:\.\d+)?,\d+(?:\.\d+)?,\d+,\d+", values["radio"]):
|
||||
raise RuntimeError("Unrecognized primary radio configuration")
|
||||
if values["tempradio"] != "off" or values["radio2"] not in ("off", "off (manual)", "off (automatic allowed)"):
|
||||
raise RuntimeError("Bench requires inactive temporary and secondary radio profiles")
|
||||
if values["repeat"] not in ("on", "off") or not re.fullmatch(r"-?\d+", values["tx"]):
|
||||
raise RuntimeError("Cannot safely restore TX/repeater configuration")
|
||||
values["rxps_restore"] = rxps_restore(values["radio.rxps.config"])
|
||||
return values
|
||||
|
||||
|
||||
def require_distinct_original_frequencies(target, witness):
|
||||
# A baseline RF success must not be two failed retunes remaining together
|
||||
# on a shared original channel. These guarded bench pairs start apart.
|
||||
if abs(float(target["radio"].split(",")[0]) - float(witness["radio"].split(",")[0])) < .001:
|
||||
raise RuntimeError("Independent witness must start on a different original main frequency")
|
||||
|
||||
|
||||
def apply_radio(console, value):
|
||||
admission = reply_value(console.command("set radio " + value))
|
||||
if "reboot" in admission.lower() or "applying in 2s" not in admission:
|
||||
raise RuntimeError("Live radio application not confirmed; a saved getter cannot prove an RF retune")
|
||||
time.sleep(4) # CLI admits primary radio changes with a2-second reply grace.
|
||||
actual = tuple(map(float, reply_value(console.command("get radio")).split(",")))
|
||||
wanted = tuple(map(float, value.split(",")))
|
||||
if any(abs(a - b) > .001 for a, b in zip(actual, wanted)):
|
||||
raise RuntimeError("Saved primary radio differs after applying the bench profile")
|
||||
|
||||
|
||||
def isolate(console, saved, witness=False):
|
||||
console.command("set radio.rxps off")
|
||||
console.command("set repeat off")
|
||||
console.command("set tx -9")
|
||||
apply_radio(console, BENCH_RADIO)
|
||||
if witness:
|
||||
console.command("set usb.logging on")
|
||||
if saved["usb.logging.always"] in ("on", "off"):
|
||||
console.command("set usb.logging.always on")
|
||||
|
||||
|
||||
def restore(console, saved):
|
||||
apply_radio(console, saved["radio"])
|
||||
console.command("set tx " + saved["tx"])
|
||||
console.command(saved["rxps_restore"])
|
||||
console.command("set repeat " + saved["repeat"])
|
||||
for key in ("usb.logging.always", "usb.logging"):
|
||||
if saved[key] in ("on", "off"):
|
||||
console.command("set " + key + " " + saved[key])
|
||||
current = snapshot(console)
|
||||
for key in ("radio", "tx", "repeat", "radio2", "radio.rxps.config", "usb.logging", "usb.logging.always"):
|
||||
if key == "radio":
|
||||
if any(abs(float(a) - float(b)) > .001 for a, b in zip(current[key].split(","), saved[key].split(","))):
|
||||
raise RuntimeError("Original radio was not restored")
|
||||
elif current[key] != saved[key]:
|
||||
raise RuntimeError("Original " + key + " was not restored")
|
||||
|
||||
|
||||
def await_status(console, expected, timeout=30):
|
||||
deadline = time.monotonic() + timeout
|
||||
while True:
|
||||
observed = status(console.command("get power.emergency"))
|
||||
if all(observed[key] == value for key, value in expected.items()):
|
||||
return observed
|
||||
if time.monotonic() > deadline:
|
||||
raise RuntimeError("Emergency status did not reach " + repr(expected))
|
||||
time.sleep(.5)
|
||||
|
||||
|
||||
def wireless_snapshot(console):
|
||||
value = reply_value(console.command("get 2.4ghz"))
|
||||
match = re.fullmatch(r"wifi=(on|off|unavailable), bluetooth=(on|off|unavailable), "
|
||||
r"espnow=(on|off|unavailable)( \(pending\))?", value)
|
||||
if not match:
|
||||
raise RuntimeError("Wireless runtime state is unavailable or reports a failed transition")
|
||||
return dict(zip(("wifi", "bluetooth", "espnow", "pending"),
|
||||
(*match.groups()[:3], bool(match[4]))))
|
||||
|
||||
|
||||
def hold_network_clock(console):
|
||||
"""Keep NTP from replacing accelerated RTC values; credentials stay saved."""
|
||||
console.command("set power.awake 1")
|
||||
await_status(console, {"radio": "awake", "window": "awake"})
|
||||
deadline = time.monotonic() + 35
|
||||
while True:
|
||||
value = reply_value(console.command("set wifi off force", allow_error=True))
|
||||
if value.startswith("OK"):
|
||||
break
|
||||
if not value.startswith("Error: emergency sleep") or time.monotonic() > deadline:
|
||||
raise RuntimeError("WiFi runtime clock isolation was rejected")
|
||||
time.sleep(.1) # Wait for the completed awake request to resume its backend.
|
||||
while True:
|
||||
live = wireless_snapshot(console)
|
||||
if live["wifi"] == "off" and not live["pending"]:
|
||||
break
|
||||
if time.monotonic() > deadline:
|
||||
raise RuntimeError("WiFi/NTP did not stop for accelerated clock testing")
|
||||
time.sleep(.1)
|
||||
console.command("set power.awake 0")
|
||||
|
||||
|
||||
def restore_wireless(console, original):
|
||||
for service in ("espnow", "wifi"):
|
||||
live = wireless_snapshot(console)
|
||||
if original[service] != live[service]:
|
||||
if original[service] == "unavailable":
|
||||
raise RuntimeError("Wireless availability changed during the test")
|
||||
console.command("set " + service + " " + original[service]
|
||||
+ (" force" if original[service] == "off" else ""))
|
||||
deadline = time.monotonic() + 35
|
||||
while True:
|
||||
live = wireless_snapshot(console)
|
||||
if live[service] == original[service] and not live["pending"]:
|
||||
break
|
||||
if time.monotonic() > deadline:
|
||||
raise RuntimeError("Original wireless runtime subset was not restored")
|
||||
time.sleep(.1)
|
||||
if wireless_snapshot(console) != original:
|
||||
raise RuntimeError("Original wireless runtime subset differs after restoration")
|
||||
|
||||
|
||||
def await_advert(witness, identity, after, minimum_epoch, timeout=20, maximum_epoch=None):
|
||||
deadline = time.monotonic() + timeout
|
||||
with witness.condition:
|
||||
while True:
|
||||
for stamp, line in witness.lines:
|
||||
epoch = advert_timestamp(line, identity)
|
||||
if (stamp >= after and epoch is not None and epoch >= minimum_epoch
|
||||
and (maximum_epoch is None or epoch < maximum_epoch)):
|
||||
return {"received_monotonic": stamp, "advert_epoch": epoch}
|
||||
if witness.failed or time.monotonic() > deadline:
|
||||
raise RuntimeError("Independent witness did not receive the target advertisement")
|
||||
witness.condition.wait(.1)
|
||||
|
||||
|
||||
def run_trial(args):
|
||||
args.run_dir.mkdir(mode=0o700, parents=False, exist_ok=False)
|
||||
original = json.loads(args.identity_backup.read_text())
|
||||
serial_number, board_label = TARGETS[args.target]
|
||||
if original.get("usb_serial", "").upper() != serial_number.upper():
|
||||
raise RuntimeError("Private identity backup is for a different USB board")
|
||||
original_key = public_key(original["queries"]["get public.key"])
|
||||
report = {"board": board_label, "usb_serial": serial_number,
|
||||
"real_minute_lease": False, "accelerated_hourly_cycles": [],
|
||||
"manual_mode_survived_reboot": False, "cleanup": "not needed",
|
||||
"limitations": ["USB-powered manual entry; battery5% threshold/current savings not measured",
|
||||
"Hourly boundaries accelerated with RTC; no real45-minute sleep duration claim",
|
||||
"Application restoration is a separate guarded flashing step"]}
|
||||
target = witness = None
|
||||
target_saved = witness_saved = None
|
||||
wireless_before = None
|
||||
clock_guard = hold_network_clock if args.target == "heltec_v43" else None
|
||||
if clock_guard:
|
||||
report["limitations"].append("USB-held CPU idle fallback; detached USB light-sleep current not measured")
|
||||
target_changed = witness_changed = False
|
||||
changed_clock = False
|
||||
try:
|
||||
with paused_collector():
|
||||
try:
|
||||
target = Console(serial_number, args.run_dir / "target.private.log")
|
||||
witness_name = args.witness or ("solarxiao33s" if args.target == "rak3401" else "rak3401")
|
||||
if WITNESSES[witness_name] == serial_number:
|
||||
raise RuntimeError("Target and independent witness must be different boards")
|
||||
witness = Console(WITNESSES[witness_name], args.run_dir / "witness.private.log")
|
||||
if public_key(target.command("get public.key")) != original_key:
|
||||
raise RuntimeError("Target public identity differs from its private baseline backup")
|
||||
actual_board = reply_value(target.command("board"))
|
||||
if re.sub(r"[^a-z0-9]", "", board_label.lower()) not in re.sub(r"[^a-z0-9]", "", actual_board.lower()):
|
||||
raise RuntimeError("Unexpected target board label")
|
||||
version = reply_value(target.command("ver"))
|
||||
if version != args.expected_version:
|
||||
raise RuntimeError("Running firmware differs from the explicitly selected verified build")
|
||||
witness_board = re.sub(r"[^a-z0-9]", "", reply_value(witness.command("board")).lower())
|
||||
if re.sub(r"[^a-z0-9]", "", TARGETS[witness_name][1].lower()) not in witness_board:
|
||||
raise RuntimeError("Wrong independent witness board")
|
||||
report["firmware"] = version
|
||||
target_saved, witness_saved = snapshot(target), snapshot(witness)
|
||||
if clock_guard:
|
||||
wireless_before = wireless_snapshot(target)
|
||||
if wireless_before["pending"] or wireless_before["wifi"] == "unavailable":
|
||||
raise RuntimeError("WiFi must expose a settled reversible runtime state")
|
||||
private_json(args.run_dir / "settings.private.json", {"target": target_saved, "witness": witness_saved,
|
||||
"wireless": wireless_before})
|
||||
initial = status(target.command("get power.emergency"))
|
||||
if initial["mode"] != "auto" or initial["hold"]:
|
||||
raise RuntimeError("Target must start with automatic mode and no awake lease")
|
||||
if witness_saved["usb.logging.always"] not in ("on", "off"):
|
||||
raise RuntimeError("Witness needs the current logging override and live radio CLI")
|
||||
require_distinct_original_frequencies(target_saved, witness_saved)
|
||||
witness_changed = True
|
||||
isolate(witness, witness_saved, witness=True)
|
||||
target_changed = True
|
||||
isolate(target, target_saved)
|
||||
report["cleanup"] = "required"
|
||||
target.command("advert.zerohop")
|
||||
report["baseline_advert"] = await_advert(witness, original_key, target.last_write, 0)
|
||||
target.command("set power.awake 0")
|
||||
target.command("set power.emergency auto")
|
||||
if clock_guard:
|
||||
clock_guard(target)
|
||||
# Past test clocks avoid future advert timestamps in the witness's
|
||||
# neighbour cache. RF stays isolated until present time is proven.
|
||||
future = ((int(time.time()) // 3600) - 48) * 3600
|
||||
changed_clock = True
|
||||
target = target.reboot("clkreboot", args.run_dir / "target.bench-clock.private.log", original_key, clock_guard)
|
||||
target.command(f"time {future + 1800}")
|
||||
target.command("set power.emergency on")
|
||||
report["manual_entry"] = await_status(target, {"mode": "manual", "active": "on", "radio": "sleep", "window": "sleep", "hold": 0})
|
||||
started = time.monotonic()
|
||||
target.command("set power.awake 1")
|
||||
awake = await_status(target, {"radio": "awake", "window": "awake"})
|
||||
if not 30 <= awake["hold"] <= 60:
|
||||
raise RuntimeError("The one-minute awake lease was not observed")
|
||||
target.command("advert.zerohop")
|
||||
report["lease_advert"] = await_advert(witness, original_key, target.last_write, future,
|
||||
maximum_epoch=future + 3600)
|
||||
time.sleep(max(0, started + 65 - time.monotonic()))
|
||||
await_status(target, {"radio": "sleep", "window": "sleep", "hold": 0})
|
||||
report["real_minute_lease"] = True
|
||||
for cycle in range(2):
|
||||
cycle_hour = future + cycle * 3600
|
||||
if cycle:
|
||||
target.command(f"time {cycle_hour + 1800}")
|
||||
await_status(target, {"radio": "sleep", "window": "sleep"})
|
||||
# Defer signing/dispatch until the mesh loop runs at timer wake.
|
||||
target.command("advert.zerohop")
|
||||
target.command(f"time {cycle_hour + 3580}")
|
||||
last_write = target.last_write
|
||||
received = await_advert(witness, original_key, last_write, cycle_hour + 3600, timeout=45,
|
||||
maximum_epoch=cycle_hour + 3660)
|
||||
if target.last_write != last_write:
|
||||
raise RuntimeError("Target USB write occurred during the passive timer-wake interval")
|
||||
received["no_usb_write_seconds"] = time.monotonic() - last_write
|
||||
received["status_after_passive_capture"] = await_status(target, {"radio": "awake", "window": "awake"})
|
||||
report["accelerated_hourly_cycles"].append(received)
|
||||
target.command(f"time {future + 2 * 3600 + 1800}")
|
||||
await_status(target, {"mode": "manual", "radio": "sleep"})
|
||||
target = target.reboot("reboot", args.run_dir / "target.after-reboot.private.log", original_key, clock_guard)
|
||||
report["after_reboot"] = await_status(target, {"mode": "manual", "active": "on", "hold": 0})
|
||||
target.command(f"time {future + 3 * 3600 + 1800}")
|
||||
report["after_reboot_sleep"] = await_status(target, {"mode": "manual", "radio": "sleep", "window": "sleep"})
|
||||
report["manual_mode_survived_reboot"] = True
|
||||
report["passed"] = True
|
||||
finally:
|
||||
cleanup_errors = []
|
||||
if target and target_changed:
|
||||
try:
|
||||
target.command("set power.awake 0")
|
||||
target.command("set power.emergency auto")
|
||||
await_status(target, {"mode": "auto", "radio": "awake"})
|
||||
if changed_clock:
|
||||
target = target.reboot("clkreboot", args.run_dir / "target.clock-restored.private.log", original_key, clock_guard)
|
||||
target.command(f"time {int(time.time())}")
|
||||
current_time = clock_epoch(target.command("clock"))
|
||||
if abs(current_time - time.time()) > 90:
|
||||
raise RuntimeError("Present clock not confirmed; retaining isolated RF")
|
||||
restore(target, target_saved)
|
||||
report["final_power"] = status(target.command("get power.emergency"))
|
||||
if wireless_before:
|
||||
restore_wireless(target, wireless_before)
|
||||
report["wireless_runtime_restored"] = True
|
||||
except Exception as error:
|
||||
cleanup_errors.append("Target cleanup: " + str(error))
|
||||
if witness and witness_changed:
|
||||
try:
|
||||
witness_key = public_key(witness.command("get public.key"))
|
||||
if abs(clock_epoch(witness.command("clock")) - time.time()) > 90:
|
||||
witness = witness.reboot("clkreboot", args.run_dir / "witness.clock-restored.private.log", witness_key)
|
||||
witness.command(f"time {int(time.time())}")
|
||||
if abs(clock_epoch(witness.command("clock")) - time.time()) > 90:
|
||||
raise RuntimeError("Witness present clock not confirmed; retaining isolated RF")
|
||||
restore(witness, witness_saved)
|
||||
except Exception as error:
|
||||
cleanup_errors.append("Witness cleanup: " + str(error))
|
||||
for console in (target, witness):
|
||||
if console:
|
||||
console.close()
|
||||
report["cleanup"] = "complete" if not cleanup_errors else "NEEDS MANUAL RECOVERY"
|
||||
if cleanup_errors:
|
||||
report["cleanup_errors"] = cleanup_errors
|
||||
raise RuntimeError("Bench cleanup incomplete; inspect the private report before further tests")
|
||||
except BaseException as error:
|
||||
report["passed"] = False
|
||||
report["error"] = str(error)
|
||||
raise
|
||||
finally:
|
||||
report["finished_utc"] = datetime.now(timezone.utc).isoformat()
|
||||
private_json(args.run_dir / "report.json", report)
|
||||
print(json.dumps({key: report.get(key) for key in ("board", "passed", "cleanup", "real_minute_lease", "manual_mode_survived_reboot")}))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--target", choices=TARGETS, required=True)
|
||||
parser.add_argument("--witness", choices=WITNESSES, help="Default RAK3401, or qualified SolarXiao when testing the3401")
|
||||
parser.add_argument("--expected-version", required=True, help="Exact ver response from the verified new build")
|
||||
parser.add_argument("--identity-backup", type=Path, required=True, help="Private preflash JSON for the selected USB identity")
|
||||
parser.add_argument("--run-dir", type=Path, required=True, help="New private run directory (must not already exist)")
|
||||
parser.add_argument("--execute", action="store_true", help="Permit the documented CLI changes and collector pause")
|
||||
args = parser.parse_args()
|
||||
if not args.execute:
|
||||
print("Plan only: isolated909.5/BW500/SF5/CR5, manual sleep, real60s lease, two accelerated hourly timer wakes, reboot persistence, finally restore clock/RF/collector. No hardware touched.")
|
||||
return
|
||||
run_trial(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user