From 837847f8910221d190578f68b2e09599be2202a0 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Fri, 23 Oct 2026 13:18:04 -0700 Subject: [PATCH] Record SPI9 hardware checks and fix idle sleep test fixture --- docs/test-results/index.md | 1 + docs/test-results/saved_spi9_2026-10-23.md | 81 ++++++++ .../saved_spi9_2026-10-23.safe.json | 192 ++++++++++++++++++ .../radio_profiles/switch_tuning_test.cpp | 43 ++++ test/test_esp32_power_test_controls.py | 4 +- 5 files changed, 319 insertions(+), 2 deletions(-) create mode 100644 docs/test-results/saved_spi9_2026-10-23.md create mode 100644 docs/test-results/saved_spi9_2026-10-23.safe.json diff --git a/docs/test-results/index.md b/docs/test-results/index.md index 1de696954..2159a352e 100644 --- a/docs/test-results/index.md +++ b/docs/test-results/index.md @@ -11,6 +11,7 @@ Completed validation reports, experiments, and test guides are collected here. O ## Radio and RF +- [Saved SPI option 9: native, build and V3/V4 persistence checks, 2026-10-23](saved_spi9_2026-10-23.md) - [Heltec V3/V4 8 MHz versus 8.889 MHz radio switching measurements, 2026-10-23](esp32_spi_clock_retune_2026-10-23.md) - [Five-board eight-peer WiFi UDP bridge hardware qualification, 2026-10-22](udp_bridge_hardware_2026-10-22.md) - [Eight-peer WiFi UDP bridge: V3/V4/R8 Full build and host validation, 2026-10-22](udp_bridge_2026-10-22.md) diff --git a/docs/test-results/saved_spi9_2026-10-23.md b/docs/test-results/saved_spi9_2026-10-23.md new file mode 100644 index 000000000..318e369ab --- /dev/null +++ b/docs/test-results/saved_spi9_2026-10-23.md @@ -0,0 +1,81 @@ + + + + +# Saved SPI option 9, 23 October 2026 + +Status: **Passed native, build and V3/V4 setting checks.** Firmware source: +`d6853f4521b6676298260c1c49095369a106000e`. + +```text +get radio.fast.caps +set radio.fast.spi 9 +get radio.fast.spi +``` + +The integer label `9` selects 8,888,888 Hz, approximately 8.889 MHz. It does +not select 9,000,000 Hz. The setting is saved without floating-point storage +or a file-format change. `0` restores the board's original default. + +## Availability and defaults + +The option is advertised by tunable radio HALs on the supported ESP32, +ESP32-S3 and ESP32-C3 boards when the permitted clock is at least 8,888,888 Hz. +The pinned ESP32-C6 core cannot select this divider and does not advertise it. +Current nRF52, RP2040 and STM32 radio buses do not offer it either. Unsupported +selections are rejected before changing or saving the policy. + +Seven older ESP32 targets now use a tunable bytewise HAL: Heltec V2, GEPRC +Linkflow 900, T3S3 SX1276, T-Beam SX1276, T-LoRa V2.1, Meshnology W12 and +Waveshare ESP32-S3 LR1121. They retain their 2 MHz default, existing pins, +SPI controller, bus ownership and byte transfers. Existing buffered targets +retain their defaults too; enabling this option does not enable 16 MHz on +targets that previously lacked it. + +The existing capability record remains two bytes and the saved policy remains +24 bytes at format version 1. Native tests cover strict integer parsing, +actual SPISettings mapping, unavailable choices, clock limits, saved and early +boot restore, transaction failure handling and output bounds. The constructor +test executes all eleven paths in the seven older targets. A further CLI case +covers clock-only radios with no other acceleration features. + +## Builds and physical checks + +Canonical builds passed for V3 Full observer MQTT Repeater, V4 Full observer +MQTT Repeater, Heltec V2 BLE Companion and XIAO ESP32-C3 USB Companion. Their +normal capacity and capability checks passed without feature reductions. +The first C3 attempt was stopped during a slow dependency download; the final +build used the identical cached, pinned RadioLib commit. + +Both physical repeaters were updated through application-only USB writes. +Complete application readback matched the candidate images, partition tables +and OTA selection metadata stayed unchanged, and readable saved settings were +preserved. + +| Physical board | Verified body | Saved 9 after one normal reboot | RX profile switch count | Switch errors | +| --- | --- | --- | ---: | ---: | +| Heltec V3 | `B05EC4F8E75A2A24` | Passed, 8888888 Hz | 8 to 332 | 0 | +| Heltec V4.3 OLED | `C4E28F80E5FBDF2E` | Passed, 8888888 Hz | 15 to 329 | 0 | + +The short runtime check used primary 917 MHz and secondary 918 MHz, both +BW 62.5 kHz, SF7, CR5, preamble 32, with the secondary receive-only. No test +packets were deliberately transmitted. Core flags stayed zero and watchdog +recovery counters did not increase within either measured startup epoch. + +Both boards retain the normal new firmware with the option available. Cleanup +restored their original selection `0` at 8 MHz, all seven fast features on, +radio2 off, temporary profiles cleared and their saved preferences unchanged. +The deliberate reboot resets volatile counters; it is recorded separately +from unexpected recoveries. + +Production getters report the requested/resolved SPISettings clock. They are +not hardware-divider measurements. The earlier +[physical V3/V4 comparison](esp32_spi_clock_retune_2026-10-23.md) verified the +80 MHz / 9 divider; this change adds a saved selector for the same requested +clock. The timing campaign was not repeated. These checks qualify saving, +reboot persistence and retune liveness, not RF packet delivery, BLE, CPU sleep +or all firmware roles on physical hardware. + +The [sanitized evidence](saved_spi9_2026-10-23.safe.json) includes build and +hardware results and hashes of the retained private receipts. Raw device +identities and CLI logs are excluded. diff --git a/docs/test-results/saved_spi9_2026-10-23.safe.json b/docs/test-results/saved_spi9_2026-10-23.safe.json new file mode 100644 index 000000000..6a6682caa --- /dev/null +++ b/docs/test-results/saved_spi9_2026-10-23.safe.json @@ -0,0 +1,192 @@ +{ + "schema": 1, + "passed": true, + "source_commit": "d6853f4521b6676298260c1c49095369a106000e", + "selection": 9, + "requested_spi_hz": 8888888, + "defaults_changed": false, + "capability_storage_bytes": 2, + "saved_record_bytes": 24, + "saved_record_version": 1, + "availability": { + "supported_current_mcus": [ + "esp32", + "esp32s3", + "esp32c3" + ], + "unsupported_current_mcus": [ + "esp32c6", + "nrf52", + "rp2040", + "stm32" + ], + "note": "Advertised only on supporting tunable HALs; current C6 core cannot select this divider." + }, + "scope": "Saving SPI9, normal reboot persistence and RX retune liveness; no RF delivery, BLE or CPU sleep qualification.", + "clock_evidence": "Production getters report requested/resolved SPISettings. Physical divider was measured in the earlier V3/V4 HIL campaign, not repeated here.", + "native_checks": { + "focused_repository_methods_passed": 30, + "legacy_constructor_paths_passed": 11, + "additional_clock_only_cli_case_passed": true, + "private_installer_methods_passed": 25, + "private_controller_methods_passed": 12 + }, + "builds": { + "Heltec_v3_repeater_observer_mqtt": { + "passed": true, + "platform": "ESP32_PLATFORM", + "mcu": "esp32s3", + "build_profile": "full", + "application_bytes": 2302216, + "application_sha256": "9c7ad5ae3704fded2ab75ef42da3b1101c09cd6a2db2ca66fb35b2b5c163c755", + "capacity_passed": true, + "available_internal_bytes": 208768, + "required_heap_bytes": 207024, + "largest_internal_region_bytes": 176000, + "required_contiguous_bytes": 19168, + "reductions": [], + "elf_sha256": "fe75b5759e308738e6912ca9a6b0c62cfa4c156b3b271b3c37da5622066570f8", + "memory_receipt_sha256": "9988e39bd1b5053c448bd6ee88a78e7461f0b2a78ac1eabb56bfb1c3a64838c2", + "capabilities_receipt_sha256": "52c025efe6fc67e5086fa4becd39fe64500d2d35e99c184e2ab86b9a6ecca78f", + "build_summary_sha256": "4c26c4e0e504081da322b5fcb8d6d1cd57082b3401639c37888015575989c8fc" + }, + "heltec_v4_repeater_observer_mqtt": { + "passed": true, + "platform": "ESP32_PLATFORM", + "mcu": "esp32s3", + "build_profile": "full", + "application_bytes": 2343800, + "application_sha256": "daf6fd466aa39c32d4a155e9bbdfdee3bb6c5e316e7bcc84f2dfab2a580a9beb", + "capacity_passed": true, + "available_internal_bytes": 201880, + "required_heap_bytes": 195760, + "largest_internal_region_bytes": 169112, + "required_contiguous_bytes": 19168, + "reductions": [], + "elf_sha256": "e1943b021faf410302582ebd665fb1354d279589f56df70696ba2735f3422f98", + "memory_receipt_sha256": "9754cf3f6eebca5724f71be6e0924ef253d498ee8992baf048a8a7a3de2897fe", + "capabilities_receipt_sha256": "da714d8f23735f5b395f527cfc83561fd45a6d15b6b5ac7a595b4bce84d0cb65", + "build_summary_sha256": "f3fee6f98707378063c2f36703066f7657ee171acd63493e7e4d6b6930899574" + }, + "Heltec_v2_companion_radio_ble": { + "passed": true, + "platform": "ESP32_PLATFORM", + "mcu": "esp32", + "build_profile": "auto", + "application_bytes": 1821016, + "application_sha256": "378df81ff7b0e60b46a5078927c737aa80166d5ede7a7776866cb5f1c7939575", + "capacity_passed": true, + "available_internal_bytes": 181272, + "required_heap_bytes": 141104, + "largest_internal_region_bytes": 113840, + "required_contiguous_bytes": 18464, + "reductions": [], + "elf_sha256": "281229fe997ce4b854af5ccd5a2d4fbb7bcc2eb28d2f26afbcf7345ca54711ab", + "memory_receipt_sha256": "e59953d945260ed23deea74effd1ef3c9c9d186784a41f7bf4a79d3d96ec67f6", + "capabilities_receipt_sha256": "a2cb45d0e410261bc414d6cf1fb0dc83eb6a33d8e3427a5f9d4f37840616ea59", + "build_summary_sha256": "03edad5a409fd997478dfca6684a0ec60b68518e913bb316ee00e30fa23bac92" + }, + "Xiao_C3_companion_radio_usb": { + "passed": true, + "platform": "ESP32_PLATFORM", + "mcu": "esp32c3", + "build_profile": "auto", + "application_bytes": 1494728, + "application_sha256": "15f29a99e9915d9217fc71991f67a56c9693c7f6710f316ccdd23bdc37cb0901", + "capacity_passed": true, + "available_internal_bytes": 119792, + "required_heap_bytes": 72736, + "largest_internal_region_bytes": 119792, + "required_contiguous_bytes": 18464, + "reductions": [], + "elf_sha256": "9d4598abd647292c4c4d84f85917f35396cd038932e3c525c8f3fbfe9ac9cebe", + "memory_receipt_sha256": "72b704a15c5cb778bcdde1b4406565b9a111e372b71e3e2ab50d0b3007115fa9", + "capabilities_receipt_sha256": "a77d314543cbd72052eac4a337c101ec42d17c541137de13f6b05811ce62066d", + "build_summary_sha256": "70320556ee234398363cced43fd939f3afd28e34db507f3f77da87fa37e208ca" + } + }, + "hardware": { + "v3": { + "passed": true, + "source_commit": "d6853f4521b6676298260c1c49095369a106000e", + "application_body": "B05EC4F8E75A2A24", + "application_sha256": "9c7ad5ae3704fded2ab75ef42da3b1101c09cd6a2db2ca66fb35b2b5c163c755", + "app_only_install_and_full_readback_passed": true, + "partition_table_and_ota_selection_unchanged": true, + "readable_settings_preserved": true, + "spi9_save_and_getters_passed": true, + "capability_clocks": [ + 0, + 2, + 4, + 8, + 9 + ], + "requested_spi_hz": 8888888, + "fast_features": { + "warm": "on", + "fs": "on", + "packet": "on", + "iq": "on", + "modem": "on", + "batch": "on", + "buffer": "on" + }, + "single_application_reboot_persistence_passed": true, + "pre_reboot_uptime_secs": 38, + "post_reboot_uptime_secs": 5, + "runtime_switches_before": 8, + "runtime_switches_after": 332, + "runtime_switch_errors": 0, + "new_core_flags": 0, + "new_watchdog_recoveries": 0, + "cleanup_passed": true, + "restored_spi_selection": 0, + "restored_resolved_spi_hz": 8000000, + "temporary_profiles_cleared": true, + "install_receipt_sha256": "4f92d878d603496515a79f69632cf7356a16fe8b9dc254a8f57b7b53cc7142d2", + "focus_receipt_sha256": "fdb2fd99ea266eec62ea12571a382520318b1a3a4618d666bc94eac73fbbd80c" + }, + "v4": { + "passed": true, + "source_commit": "d6853f4521b6676298260c1c49095369a106000e", + "application_body": "C4E28F80E5FBDF2E", + "application_sha256": "daf6fd466aa39c32d4a155e9bbdfdee3bb6c5e316e7bcc84f2dfab2a580a9beb", + "app_only_install_and_full_readback_passed": true, + "partition_table_and_ota_selection_unchanged": true, + "readable_settings_preserved": true, + "spi9_save_and_getters_passed": true, + "capability_clocks": [ + 0, + 2, + 4, + 8, + 9 + ], + "requested_spi_hz": 8888888, + "fast_features": { + "warm": "on", + "fs": "on", + "packet": "on", + "iq": "on", + "modem": "on", + "batch": "on", + "buffer": "on" + }, + "single_application_reboot_persistence_passed": true, + "pre_reboot_uptime_secs": 48, + "post_reboot_uptime_secs": 7, + "runtime_switches_before": 15, + "runtime_switches_after": 329, + "runtime_switch_errors": 0, + "new_core_flags": 0, + "new_watchdog_recoveries": 0, + "cleanup_passed": true, + "restored_spi_selection": 0, + "restored_resolved_spi_hz": 8000000, + "temporary_profiles_cleared": true, + "install_receipt_sha256": "718c11c61ec972c2988a2e1e28d16bc83e6184c21da23028a62ee159a45732aa", + "focus_receipt_sha256": "1baad7dd0242a95c2a42ed958a871355ea3e647ee22cf83359fa6dc5d35896ce" + } + } +} diff --git a/test/fixtures/radio_profiles/switch_tuning_test.cpp b/test/fixtures/radio_profiles/switch_tuning_test.cpp index 4b73eadab..82f479ade 100644 --- a/test/fixtures/radio_profiles/switch_tuning_test.cpp +++ b/test/fixtures/radio_profiles/switch_tuning_test.cpp @@ -232,6 +232,48 @@ static void roundedClockRequiresCapabilityAndBootAdmission() { puts("Fast tuning: rounded SPI 9 uses exact divider Hz, persists unchanged and rejects unsupported clocks"); } +static void clockOnlyLegacyHalKeepsFeaturesUnavailable() { + TuningFixture f; + f.radio.caps.flags = f.radio.tuning.flags = 0; + f.radio.caps.spi_clocks = 2 | 4 | 8 | mesh::RadioSwitchCapabilities::Spi9; + f.radio.default_spi_hz = 2000000; + assert(!strcmp(f.cmd("get radio.fast.caps"), "> features:; spi=0,2,4,8,9")); + assert(!strcmp(f.cmd("get radio.fast"), + "> spi=0 (2000000Hz) warm=na fs=na packet=na iq=na modem=na batch=na buffer=na")); + f.cmd("set radio.fast.spi 9"); + assert(!f.radio.tuning.flags && f.radio.tuning.spi_mhz == 9); + assert(f.radio.switchSpiHz() == 8888888); + const auto saved = f.fs.files; + TuningRadio reboot; + reboot.caps = f.radio.caps; + reboot.tuning.flags = 0; + reboot.default_spi_hz = 2000000; + mesh::RadioProfileCLI::configureBootTuning(&f.fs, reboot); + assert(!reboot.tuning.flags && reboot.tuning.spi_mhz == 9); + assert(reboot.switchSpiHz() == 8888888 && reboot.boot_configurations == 1); + mesh::RadioProfileCLI restored; + restored.begin(&f.fs, &reboot, &f.clock); + assert(reboot.tuning == f.radio.tuning && f.fs.files == saved); + + const auto original = f.radio.tuning; + const auto attempts = f.radio.attempts; + const auto persists = f.radio.persists; + for (const char* feature : {"warm", "fs", "packet", "iq", "modem", "batch", "buffer"}) { + char command[48]; + snprintf(command, sizeof(command), "set radio.fast.%s on", feature); + f.cmd(command, false); + assert(f.radio.tuning == original && f.fs.files == saved); + assert(f.radio.attempts == attempts && f.radio.persists == persists); + } + f.cmd("set radio.fast off"); + assert(!f.radio.tuning.flags && f.radio.tuning.spi_mhz == 2); + assert(f.radio.switchSpiHz() == 2000000); + f.cmd("set radio.fast on"); + assert(!f.radio.tuning.flags && !f.radio.tuning.spi_mhz); + assert(f.radio.switchSpiHz() == 2000000); + puts("Fast tuning: clock-only legacy HAL saves SPI 9 without exposing cached feature controls"); +} + static void localOnlyAndBusyTransactions() { TuningFixture f; f.cmd("set radio.fast.fs off"); @@ -447,6 +489,7 @@ int main() { controlsAndPersistence(); strictInputsAndUnsupportedFeatures(); roundedClockRequiresCapabilityAndBootAdmission(); + clockOnlyLegacyHalKeepsFeaturesUnavailable(); localOnlyAndBusyTransactions(); storageFailureKeepsCommittedPolicy(); bootRecoveryAndDeferredAdmission(); diff --git a/test/test_esp32_power_test_controls.py b/test/test_esp32_power_test_controls.py index 31520911f..21cb8abd8 100644 --- a/test/test_esp32_power_test_controls.py +++ b/test/test_esp32_power_test_controls.py @@ -84,9 +84,9 @@ struct Mesh { RadioLibWrapper& radio; bool getDualRadioIdleDelay(uint32_t& out){return radio.getProfileIdleDelay(out);} }; -struct Board {unsigned calls=0;uint32_t deadline=0;void sleepUntilMicroseconds(uint32_t at){++calls;deadline=at;}}; +struct ProfileIdleBoard {unsigned calls=0;uint32_t deadline=0;void sleepUntilMicroseconds(uint32_t at){++calls;deadline=at;}}; int main(){ - RadioLibWrapper w;ready(w);Mesh m{w};Board b; + RadioLibWrapper w;ready(w);Mesh m{w};ProfileIdleBoard b; auto start=w._esp32_profile_visit_started_us;auto visit=w._esp32_profile_idle_visit_us; elapsed_us=start+visit-14000;assert(idle(w,10)); assert(change("set powersaving.test guard_ms 9"));assert(idle(w,5));