From a8c079dc7609154b70b96926ada039990fcb47cf Mon Sep 17 00:00:00 2001 From: mikecarper Date: Fri, 2 Oct 2026 01:15:46 -0700 Subject: [PATCH] Add early startup display and V3 Full NimBLE preview --- docs/cli_commands.md | 13 +- docs/test-results/heltec_v3_preview3_ram.md | 92 ++++++++++++ examples/companion_radio/MyMesh.cpp | 12 +- examples/companion_radio/MyMesh.h | 5 +- examples/companion_radio/main.cpp | 107 +++++++++----- examples/simple_repeater/main.cpp | 102 +++++++------ examples/simple_room_server/main.cpp | 58 +++++--- examples/simple_sensor/main.cpp | 75 ++++++---- platformio.heltec-v3-preview3.ini | 81 +++++++++++ src/helpers/IdentityGeneration.h | 34 +++++ src/helpers/radiolib/RadioLibWrappers.h | 2 + src/helpers/ui/StartupScreen.h | 101 +++++++++++++ .../test_display_driver.cpp | 137 ++++++++++++++++++ test/test_heltec_v3_preview_profile.py | 61 ++++++++ .../test_identity_generation.cpp | 24 +++ test/test_startup_screen.py | 123 ++++++++++++++++ 16 files changed, 894 insertions(+), 133 deletions(-) create mode 100644 docs/test-results/heltec_v3_preview3_ram.md create mode 100644 platformio.heltec-v3-preview3.ini create mode 100644 src/helpers/ui/StartupScreen.h create mode 100644 test/test_heltec_v3_preview_profile.py create mode 100644 test/test_startup_screen.py diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 33eedecb0..f43030e0d 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -914,7 +914,18 @@ All display-equipped Companion builds show the screen at boot in the default selected profile's timeout (15 seconds by default); button input restarts that timer. This also applies to previously saved button modes. Saved `off` and pairing-only modes still suppress the boot screen. Repeaters, room servers, -and sensors do not gain this Companion-only boot wake. +and sensors do not gain this Companion-only normal-UI boot wake. + +On display-equipped Companions, repeaters, room servers, and sensors, a small +`Starting...` screen appears as soon as the board, primary filesystem, and +display are initialized, before radio initialization and serial-settle delays. +If a new identity is needed, it changes to `Generating key` with an animated +spinner during radio entropy collection. OLED/TFT updates are paced at 150 ms; +e-paper uses a slower one-second cadence. Saved `off` and pairing-only modes +still suppress this startup screen. The normal UI's boot timeout starts only +when that UI is ready, so key generation does not consume it. Filesystem +recovery and the display driver's own initialization can still delay the first +visible frame; the firmware cannot draw before those operations finish. - `off`: stays dark, including boot and pairing. - `on`: stays on while the device is awake. diff --git a/docs/test-results/heltec_v3_preview3_ram.md b/docs/test-results/heltec_v3_preview3_ram.md new file mode 100644 index 000000000..caf3fa0ef --- /dev/null +++ b/docs/test-results/heltec_v3_preview3_ram.md @@ -0,0 +1,92 @@ +# Full Companion RAM audit and Heltec V3 preview 3 + +Date: 2026-10-02. Baseline: `0ccac453` plus the early startup-screen changes +included in this revision. All 13 selected ordinary Full builds passed the +existing linked runtime RAM guard. This is a targeted audit, not a claim that +every Companion has been rebuilt or hardware-qualified. + +## Builds needing the most attention + +| Hardware | Available bytes | Reserved bytes | Margin bytes | +| --- | ---: | ---: | ---: | +| Generic ESP-NOW | 143,872 | 142,336 | 1,536 | +| Tracker V2 (FEM on) | 180,000 | 177,746 | 2,254 | +| Heltec V3 | 185,032 | 182,016 | 3,016 | +| XIAO ESP32-C3 | 146,048 | 142,336 | 3,712 | +| Heltec CT62 | 146,240 | 142,336 | 3,904 | +| Wireless Tracker | 184,304 | 178,946 | 5,358 | +| Heltec T096 (FEM on) | 84,152 | 75,298 | 8,854 | +| Heltec T1 | 84,488 | 75,298 | 9,190 | +| LilyGo TLora V2.1.1.6 | 185,304 | 173,840 | 11,464 | +| Heltec V2 | 187,176 | 173,840 | 13,336 | +| TBeam SX1276 | 194,160 | 169,744 | 24,416 | +| TBeam SX1262 | 196,112 | 169,744 | 26,368 | +| Heltec T114 | 84,336 | 46,880 | 37,456 | + +The six smallest margins belong to non-PSRAM ESP32 Full Companions. They pass +their existing release capacities but have little room for further growth. +Heltec T096 and T1 are the next constrained screen-equipped nRF52 Full builds. +T114 Full passes; the failed older T114 BLE-only recipe is not a Full failure. + +Values come from the firmware RAM guard in the retained local +`out/companion-ram-*-before.log` build logs. Available bytes exclude statically +reserved memory and unavailable internal regions; the reservation covers +startup allocations and a transient allowance. The margin is not measured +live free heap or a prediction of time to failure. No safety budget was lowered. + +## V3 solution under test + +The opt-in `platformio.heltec-v3-preview3.ini` profile uses the existing +NimBLE-Arduino adapter, pinned to version 2.5.1, instead of Bluedroid. It retains +the current ordinary V3 Full capacities: 100 contacts, 40 channels, and 256 +offline frames, including during mOTA. It adds no new queue-sharing or storage +changes and does not enable the larger 350-contact capacity trial. + +| V3 image | Available internal bytes | Reserved bytes | Margin bytes | +| --- | ---: | ---: | ---: | +| Published preview 2 | 185,040 | 182,016 | 3,024 | +| Preview 3 | 200,152 | 182,016 | 18,136 | + +Compared with published preview 2, preview 3 recovers 15,112 bytes of internal +RAM without removing Full features. Its bootloader and partition-table bytes +match preview 2. The normal release matrix is unchanged. + +Bluetooth still provides the Companion server, encrypted PIN pairing, +bonding, MAC policy and stealth controls. Existing Bluedroid bonds are not +migrated: forget the old device in the phone's Bluetooth settings and pair +again after installation. This does not require erasing contacts or identity. + +The preview also contains the early `Starting...` screen and animated key +generation display. The saved Off and pairing-only display modes remain +respected. Normal UI timeout starts after startup. Fresh installs still +default to Bluetooth on and infrastructure WiFi off; saved WiFi-on settings +remain on. Use `set wifi.enabled 1` to enable WiFi. + +## Verification and remaining work + +The real embedded build passes the unchanged RAM guard and fits both existing +WiFi OTA slots. Capability checks inspect the linked ELF, packaged application +and WebConfig gzip asset. The package includes ELF-bound RAM proof and hashes. + +Adapter regressions exercise actual NimBLE transport code under sanitizers, +including identity/bond failures, encrypted PIN authentication, stealth, +notifications, MTU gating and frame retry. Startup-screen, display policy, +Companion preferences, USB defaults, identity and frame-queue regressions are +also checked. Embedded compilation uses the actual pinned NimBLE library. + +This is still a hardware-test preview. V3 first-boot screen, phone pairing and +reconnection, contact sync, WiFi/BLE coexistence, mOTA sending, and sustained +load need physical verification. A build passing RAM checks does not establish +that all reported V3 boot symptoms are fixed. Do not make this stack the default +on every board before those checks. + +Reproduce with one PlatformIO process at a time: + +```sh +pio run -c platformio.heltec-v3-preview3.ini \ + -e Heltec_v3_companion_radio_full_preview3 -t mergebin +python3 -B test/test_heltec_v3_preview_profile.py +python3 -B test/test_nimble_companion.py +python3 -B test/test_startup_screen.py +python3 -B test/test_firmware_ram.py +``` diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index c586592bc..6c24b4202 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1,4 +1,5 @@ #include +#include #include "NotificationSettingsFile.h" #include "MyMesh.h" #include @@ -2339,7 +2340,8 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe if (_ui != nullptr) _ui->setShutdownGuard(this); } -void MyMesh::begin(bool has_display, bool radio_available) { +void MyMesh::begin(bool has_display, bool radio_available, + mesh::ui::StartupScreen* startup_screen) { _radio_available = radio_available; setRadioAvailable(radio_available); initializeContactStorage(); @@ -2366,8 +2368,16 @@ void MyMesh::begin(bool has_display, bool radio_available) { bool identity_ready = true; if (is_new_install) { +#ifdef DISPLAY_CLASS + if (startup_screen != nullptr) startup_screen->generatingKey(); + mesh::ScopedIdentityGenerationProgress progress( + mesh::ui::StartupScreen::progress, startup_screen); +#endif identity_ready = mesh::generateUsableLocalIdentity(self_id, radio_new_identity); if (identity_ready) identity_ready = _store->saveMainIdentity(self_id); +#ifdef DISPLAY_CLASS + if (startup_screen != nullptr) startup_screen->starting(); +#endif } #if defined(ESP32_PLATFORM) diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 66fff5fe4..bd6bf67c5 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -161,6 +161,8 @@ struct DiscoveredNode { }; #endif +namespace mesh { namespace ui { class StartupScreen; } } + class MyMesh : public BaseChatMesh, public DataStoreHost, public UIShutdownGuard #if COMPANION_FEATURE_NOTIFICATIONS , public mesh::notify::Sink, public mesh::notify::Store @@ -179,7 +181,8 @@ public: return handleCommand(command, 0, reply); } - void begin(bool has_display, bool radio_available = true); + void begin(bool has_display, bool radio_available = true, + mesh::ui::StartupScreen* startup_screen = nullptr); void activateRadio(); bool isRadioReady() const { return _radio_available; } void startInterface(BaseSerialInterface &serial); diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 35f1db982..5604d418d 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -1,4 +1,6 @@ #include // needed for PlatformIO +#include +#include #include #include #include "MyMesh.h" @@ -2788,6 +2790,65 @@ void setup() { external_watchdog.begin(); #endif +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) +#if defined(NRF52_PLATFORM) + // InternalFileSystem::begin() auto-formats the entire primary store after a + // mount failure. The base mount plus a full raw scan distinguishes a virgin + // erased device from nonblank/corrupt identity storage. Only proven-erased + // media may be initialized automatically. A nonblank failure gets three + // additional mounts three seconds apart before the unusable store is erased + // so the node can still recover without physical access. + const mesh::storage::InternalSecondaryFsBootResult primary_fs_boot = + mesh::storage::beginInternalPrimaryFilesystemSafely( + InternalFS, +#if defined(EXTRAFS) && !defined(QSPIFLASH) + ResilientInternalExtraFS::primaryConfig() +#else + nullptr +#endif + ); + if (!mesh::storage::internalPrimaryFilesystemReady(primary_fs_boot)) { + mesh::storage::RamFallbackFileSystem* ram_primary_fs = + mesh::storage::createRamFallbackFileSystem(); + if (ram_primary_fs != nullptr) { + MESH_DEBUG_PRINTLN( + "InternalFS: physical storage unusable; running from volatile RAM filesystem"); + store.useVolatilePrimaryFS(ram_primary_fs->filesystem()); + } else { + MESH_DEBUG_PRINTLN( + "InternalFS: physical storage and RAM fallback initialization failed"); + store.markPrimaryFSUnavailable(); + } + } +#else + InternalFS.begin(); +#endif +#elif defined(RP2040_PLATFORM) + LittleFS.begin(); +#elif defined(ESP32) + SPIFFS.begin(true); +#else + #error "need to define filesystem" +#endif + +#ifdef DISPLAY_CLASS + // Load only the display policy before radio and secondary-storage startup. + FILESYSTEM* display_prefs_fs = store.getPrimaryFS(); +#if defined(NRF52_PLATFORM) + // Never open files on a primary store whose mount and RAM fallback failed. + if (!mesh::storage::internalPrimaryFilesystemReady(primary_fs_boot) + && !store.isVolatilePrimaryFS()) display_prefs_fs = nullptr; +#endif + mesh::ui::loadDisplayPowerSettings(display_prefs_fs, +#ifdef BLE_PIN_CODE + true, +#else + false, +#endif + true); + mesh::ui::StartupScreen startup_screen; +#endif + #ifdef DISPLAY_CLASS DisplayDriver* disp = NULL; if (display.begin()) { @@ -2796,7 +2857,8 @@ void setup() { && UI_WIFI_SETUP_HOME_PAGE != 1 companion_setup_display = disp; #endif - disp->turnOff(); // No boot content before the saved policy is loaded. + startup_screen.begin(disp, + board.isExternalPowered() || board.isUsbHostConnected()); } #if defined(ESP32) && defined(WIFI_SSID) companion_display_available = disp != NULL; @@ -2846,38 +2908,6 @@ void setup() { #endif #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) -#if defined(NRF52_PLATFORM) - // InternalFileSystem::begin() auto-formats the entire primary store after a - // mount failure. The base mount plus a full raw scan distinguishes a virgin - // erased device from nonblank/corrupt identity storage. Only proven-erased - // media may be initialized automatically. A nonblank failure gets three - // additional mounts three seconds apart before the unusable store is erased - // so the node can still recover without physical access. - const mesh::storage::InternalSecondaryFsBootResult primary_fs_boot = - mesh::storage::beginInternalPrimaryFilesystemSafely( - InternalFS, -#if defined(EXTRAFS) && !defined(QSPIFLASH) - ResilientInternalExtraFS::primaryConfig() -#else - nullptr -#endif - ); - if (!mesh::storage::internalPrimaryFilesystemReady(primary_fs_boot)) { - mesh::storage::RamFallbackFileSystem* ram_primary_fs = - mesh::storage::createRamFallbackFileSystem(); - if (ram_primary_fs != nullptr) { - MESH_DEBUG_PRINTLN( - "InternalFS: physical storage unusable; running from volatile RAM filesystem"); - store.useVolatilePrimaryFS(ram_primary_fs->filesystem()); - } else { - MESH_DEBUG_PRINTLN( - "InternalFS: physical storage and RAM fallback initialization failed"); - store.markPrimaryFSUnavailable(); - } - } -#else - InternalFS.begin(); -#endif #if defined(QSPIFLASH) if (!QSPIFlash.begin()) { // debug output might not be available at this point, might be too early. maybe should fall back to InternalFS here? @@ -2934,6 +2964,9 @@ void setup() { #endif , radio_available + #ifdef DISPLAY_CLASS + , &startup_screen + #endif ); #if defined(NRF52_PLATFORM) if (store.isVolatilePrimaryFS()) { @@ -2945,7 +2978,6 @@ void setup() { } #endif #elif defined(RP2040_PLATFORM) - LittleFS.begin(); store.begin(); the_mesh.begin( #ifdef DISPLAY_CLASS @@ -2955,9 +2987,11 @@ void setup() { #endif , radio_available + #ifdef DISPLAY_CLASS + , &startup_screen + #endif ); #elif defined(ESP32) - SPIFFS.begin(true); store.begin(); the_mesh.begin( #ifdef DISPLAY_CLASS @@ -2967,6 +3001,9 @@ void setup() { #endif , radio_available + #ifdef DISPLAY_CLASS + , &startup_screen + #endif ); #else #error "need to define filesystem" diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index 457dd2428..08c91e537 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -1,6 +1,8 @@ #include // needed for PlatformIO #include #include +#include +#include #if MESH_PACKET_LOGGING #include #endif @@ -101,27 +103,66 @@ void setup() { #if MESH_PACKET_LOGGING mesh::serialLogBegin(); #endif - delay(1000); - board.begin(); #ifdef HAS_EXTERNAL_WATCHDOG external_watchdog.begin(); #endif + FILESYSTEM* fs; +#if defined(NRF52_PLATFORM) + bool volatile_primary_fs = false; + mesh::storage::RamFallbackFileSystem* ram_primary_fs = nullptr; + const auto primary_fs_boot = + mesh::storage::beginInternalPrimaryFilesystemSafely(InternalFS); + if (mesh::storage::internalPrimaryFilesystemReady(primary_fs_boot)) { + fs = &InternalFS; + } else { + ram_primary_fs = mesh::storage::createRamFallbackFileSystem(); + if (ram_primary_fs == nullptr) { + MESH_DEBUG_PRINTLN("InternalFS and RAM fallback initialization failed; rebooting"); + board.reboot(); + return; + } + fs = &ram_primary_fs->filesystem(); + volatile_primary_fs = true; + } + IdentityStore store(*fs, ""); +#elif defined(STM32_PLATFORM) + InternalFS.begin(); + fs = &InternalFS; + IdentityStore store(InternalFS, ""); +#elif defined(ESP32) + SPIFFS.begin(true); + fs = &SPIFFS; + IdentityStore store(SPIFFS, "/identity"); +#elif defined(RP2040_PLATFORM) + LittleFS.begin(); + fs = &LittleFS; + IdentityStore store(LittleFS, "/identity"); + store.begin(); +#else + #error "need to define filesystem" +#endif +#ifdef DISPLAY_CLASS + mesh::ui::loadDisplayPowerSettings(fs, false); + mesh::ui::StartupScreen startup_screen; + display_ready = display.begin(); + if (display_ready) { + startup_screen.begin(&display, + board.isExternalPowered() || board.isUsbHostConnected()); + } +#endif + + // Let serial settle after the display is already showing startup. + delay(1000); + #if defined(MESH_DEBUG) && defined(NRF52_PLATFORM) // give some extra time for serial to settle so // boot debug messages can be seen on terminal delay(5000); #endif -#ifdef DISPLAY_CLASS - display_ready = display.begin(); - if (display_ready) { - display.turnOff(); // Stay dark until the saved display policy is loaded. - } -#endif - int radioinit_attempts = 0; while (!radio_init()) { ++radioinit_attempts; @@ -158,41 +199,6 @@ void setup() { fast_rng.begin(radio_driver.getRngSeed()); - FILESYSTEM* fs; -#if defined(NRF52_PLATFORM) - bool volatile_primary_fs = false; - mesh::storage::RamFallbackFileSystem* ram_primary_fs = nullptr; - const auto primary_fs_boot = - mesh::storage::beginInternalPrimaryFilesystemSafely(InternalFS); - if (mesh::storage::internalPrimaryFilesystemReady(primary_fs_boot)) { - fs = &InternalFS; - } else { - ram_primary_fs = mesh::storage::createRamFallbackFileSystem(); - if (ram_primary_fs == nullptr) { - MESH_DEBUG_PRINTLN("InternalFS and RAM fallback initialization failed; rebooting"); - board.reboot(); - return; - } - fs = &ram_primary_fs->filesystem(); - volatile_primary_fs = true; - } - IdentityStore store(*fs, ""); -#elif defined(STM32_PLATFORM) - InternalFS.begin(); - fs = &InternalFS; - IdentityStore store(InternalFS, ""); -#elif defined(ESP32) - SPIFFS.begin(true); - fs = &SPIFFS; - IdentityStore store(SPIFFS, "/identity"); -#elif defined(RP2040_PLATFORM) - LittleFS.begin(); - fs = &LittleFS; - IdentityStore store(LittleFS, "/identity"); - store.begin(); -#else - #error "need to define filesystem" -#endif #if defined(NRF52_PLATFORM) IdentityLoadResult identity_load = volatile_primary_fs ? store.loadResult("_main", the_mesh.self_id) @@ -217,9 +223,17 @@ void setup() { && mesh::hasReservedIdentityPrefix(the_mesh.self_id)); bool identity_ready = identity_load != IdentityLoadResult::Unreadable; if (needs_identity) { +#ifdef DISPLAY_CLASS + startup_screen.generatingKey(); + mesh::ScopedIdentityGenerationProgress progress( + mesh::ui::StartupScreen::progress, &startup_screen); +#endif MESH_DEBUG_PRINTLN("Generating new keypair"); identity_ready = mesh::generateUsableLocalIdentity(the_mesh.self_id, radio_new_identity); if (identity_ready) identity_ready = store.saveWithRetry("_main", the_mesh.self_id); +#ifdef DISPLAY_CLASS + startup_screen.starting(); +#endif } #if defined(ESP32_PLATFORM) diff --git a/examples/simple_room_server/main.cpp b/examples/simple_room_server/main.cpp index c7d71d78d..0a389f198 100644 --- a/examples/simple_room_server/main.cpp +++ b/examples/simple_room_server/main.cpp @@ -1,6 +1,8 @@ #include // needed for PlatformIO #include #include +#include +#include #if MESH_PACKET_LOGGING #include #endif @@ -57,34 +59,12 @@ void setup() { #if MESH_PACKET_LOGGING mesh::serialLogBegin(); #endif - delay(1000); - board.begin(); #ifdef HAS_EXTERNAL_WATCHDOG external_watchdog.begin(); #endif -#ifdef DISPLAY_CLASS - display_ready = display.begin(); - if (display_ready) { - display.turnOff(); // Stay dark until the saved display policy is loaded. - } -#endif - - int radioinit_attempts = 0; - while (!radio_init()) { - ++radioinit_attempts; - MESH_DEBUG_PRINTLN("Radio init failed! (attempt %d)", radioinit_attempts); - if (radioinit_attempts >= 3) { - MESH_DEBUG_PRINTLN("Radio init failed 3x - rebooting"); - board.reboot(); - } - delay(500); - } - - fast_rng.begin(radio_driver.getRngSeed()); - FILESYSTEM* fs; #if defined(NRF52_PLATFORM) bool volatile_primary_fs = false; @@ -116,6 +96,32 @@ void setup() { #else #error "need to define filesystem" #endif +#ifdef DISPLAY_CLASS + mesh::ui::loadDisplayPowerSettings(fs, false); + mesh::ui::StartupScreen startup_screen; + display_ready = display.begin(); + if (display_ready) { + startup_screen.begin(&display, + board.isExternalPowered() || board.isUsbHostConnected()); + } +#endif + + // Let serial settle after the display is already showing startup. + delay(1000); + + int radioinit_attempts = 0; + while (!radio_init()) { + ++radioinit_attempts; + MESH_DEBUG_PRINTLN("Radio init failed! (attempt %d)", radioinit_attempts); + if (radioinit_attempts >= 3) { + MESH_DEBUG_PRINTLN("Radio init failed 3x - rebooting"); + board.reboot(); + } + delay(500); + } + + fast_rng.begin(radio_driver.getRngSeed()); + #if defined(NRF52_PLATFORM) IdentityLoadResult identity_load = volatile_primary_fs ? store.loadResult("_main", the_mesh.self_id) @@ -140,8 +146,16 @@ void setup() { && mesh::hasReservedIdentityPrefix(the_mesh.self_id)); bool identity_ready = identity_load != IdentityLoadResult::Unreadable; if (needs_identity) { +#ifdef DISPLAY_CLASS + startup_screen.generatingKey(); + mesh::ScopedIdentityGenerationProgress progress( + mesh::ui::StartupScreen::progress, &startup_screen); +#endif identity_ready = mesh::generateUsableLocalIdentity(the_mesh.self_id, radio_new_identity); if (identity_ready) identity_ready = store.saveWithRetry("_main", the_mesh.self_id); +#ifdef DISPLAY_CLASS + startup_screen.starting(); +#endif } #if defined(ESP32_PLATFORM) diff --git a/examples/simple_sensor/main.cpp b/examples/simple_sensor/main.cpp index c45ecf124..08ab82043 100644 --- a/examples/simple_sensor/main.cpp +++ b/examples/simple_sensor/main.cpp @@ -1,5 +1,7 @@ #include "SensorMesh.h" #include +#include +#include #if defined(NRF52_PLATFORM) #include #include @@ -13,6 +15,7 @@ #ifdef DISPLAY_CLASS #include "UITask.h" static UITask ui_task(display); + static bool display_ready = false; #endif class MyMesh : public SensorMesh { @@ -73,39 +76,12 @@ void setup() { #if MESH_ESP32_USB_CONSOLE_COOPERATIVE mesh::beginUsbLoggingPort(); #endif - delay(1000); - board.begin(); #ifdef HAS_EXTERNAL_WATCHDOG external_watchdog.begin(); #endif -#if defined(MESH_DEBUG) && defined(NRF52_PLATFORM) - // Allow the USB console to settle before printing boot diagnostics. - delay(5000); -#endif - -#ifdef DISPLAY_CLASS - if (display.begin()) { - display.turnOff(); - // Saved display policy is applied by UITask::begin(). - } -#endif - - int radioinit_attempts = 0; - while (!radio_init()) { - ++radioinit_attempts; - MESH_DEBUG_PRINTLN("Radio init failed! (attempt %d)", radioinit_attempts); - if (radioinit_attempts >= 3) { - MESH_DEBUG_PRINTLN("Radio init failed 3x - rebooting"); - board.reboot(); - } - delay(500); - } - - fast_rng.begin(radio_driver.getRngSeed()); - FILESYSTEM* fs; #if defined(NRF52_PLATFORM) bool volatile_primary_fs = false; @@ -141,6 +117,37 @@ void setup() { #else #error "need to define filesystem" #endif +#ifdef DISPLAY_CLASS + mesh::ui::loadDisplayPowerSettings(fs, false); + mesh::ui::StartupScreen startup_screen; + display_ready = display.begin(); + if (display_ready) { + startup_screen.begin(&display, + board.isExternalPowered() || board.isUsbHostConnected()); + } +#endif + + // Let serial settle after the display is already showing startup. + delay(1000); + +#if defined(MESH_DEBUG) && defined(NRF52_PLATFORM) + // Allow the USB console to settle before printing boot diagnostics. + delay(5000); +#endif + + int radioinit_attempts = 0; + while (!radio_init()) { + ++radioinit_attempts; + MESH_DEBUG_PRINTLN("Radio init failed! (attempt %d)", radioinit_attempts); + if (radioinit_attempts >= 3) { + MESH_DEBUG_PRINTLN("Radio init failed 3x - rebooting"); + board.reboot(); + } + delay(500); + } + + fast_rng.begin(radio_driver.getRngSeed()); + #if defined(NRF52_PLATFORM) IdentityLoadResult identity_load = volatile_primary_fs ? store.loadResult("_main", the_mesh.self_id) @@ -165,9 +172,17 @@ void setup() { && mesh::hasReservedIdentityPrefix(the_mesh.self_id)); bool identity_ready = identity_load != IdentityLoadResult::Unreadable; if (needs_identity) { +#ifdef DISPLAY_CLASS + startup_screen.generatingKey(); + mesh::ScopedIdentityGenerationProgress progress( + mesh::ui::StartupScreen::progress, &startup_screen); +#endif MESH_DEBUG_PRINTLN("Generating new keypair"); identity_ready = mesh::generateUsableLocalIdentity(the_mesh.self_id, radio_new_identity); if (identity_ready) identity_ready = store.saveWithRetry("_main", the_mesh.self_id); +#ifdef DISPLAY_CLASS + startup_screen.starting(); +#endif } #if defined(ESP32_PLATFORM) @@ -201,7 +216,9 @@ void setup() { #endif #ifdef DISPLAY_CLASS - ui_task.begin(the_mesh.getNodePrefs(), FIRMWARE_BUILD_DATE, FIRMWARE_VERSION); + if (display_ready) { + ui_task.begin(the_mesh.getNodePrefs(), FIRMWARE_BUILD_DATE, FIRMWARE_VERSION); + } #endif // send out initial zero hop Advertisement to the mesh @@ -264,7 +281,7 @@ void loop() { the_mesh.loop(); sensors.loop(); #ifdef DISPLAY_CLASS - ui_task.loop(); + if (display_ready) ui_task.loop(); #endif rtc_clock.tick(); #if MESH_ESP32_USB_CONSOLE_COOPERATIVE diff --git a/platformio.heltec-v3-preview3.ini b/platformio.heltec-v3-preview3.ini new file mode 100644 index 000000000..00a2097f6 --- /dev/null +++ b/platformio.heltec-v3-preview3.ini @@ -0,0 +1,81 @@ +; Heltec V3 Full Companion 1.17.1.8 preview3. See README.txt for source and pairing. +[platformio] +extra_configs = + platformio.ini + variants/*/platformio.ini + +[env:Heltec_v3_companion_radio_full_preview3] +extends = env:Heltec_v3_companion_radio_full +board_build.flash_mode = dio +board_build.partitions = default_8MB.csv +build_unflags = + -DMAX_CONTACTS=350 + -DLORA_FREQ=869.618 + -DLORA_SF=8 + -DDISABLE_WIFI_OTA=1 + -fexceptions +build_flags = + ${env:Heltec_v3_companion_radio_full.build_flags} + -D MAX_CONTACTS=100 + -D COMPANION_RADIO_FULL=1 + -D ENABLE_USB_INTERFACE=1 + -D BLE_PIN_CODE=123456 + -D ENABLE_OTA=1 + -UOTA_FLASH_STORE + -UOTA_SD_STORE + -UDISABLE_LORA_OTA + -UWEBCONFIG_DISABLED + -UDISABLE_WIFI_OTA + -D OTA_SEEDER_ONLY=1 + -D MOTA_TARGET_ID=0 + -D WIFI_OTA_SEEDER=1 + -D COMPANION_FEATURE_TEMP_RADIO=1 + -D COMPANION_FEATURE_OTA_CLI=1 + -D COMPANION_FEATURE_USB_MOTA_SOURCE=1 + -D COMPANION_FEATURE_NETWORK_TERMINAL=1 + -D COMPANION_FEATURE_MEMORY_DIAGNOSTICS=1 + -D MESHCORE_EXPANDED_PARTITION_PROFILE=1 + -D LIGHTWEIGHT_WIFI_OTA=1 + -D MESH_DEBUG=1 + -D MESH_PACKET_LOGGING=1 + -D MESH_USE_NIMBLE_ARDUINO=1 + -D CONFIG_BT_NIMBLE_MAX_CONNECTIONS=1 + -D CONFIG_BT_NIMBLE_ROLE_CENTRAL_DISABLED=1 + -D CONFIG_BT_NIMBLE_ROLE_OBSERVER_DISABLED=1 + -D LORA_FREQ=910.525 + -D LORA_BW=62.5 + -D LORA_SF=7 + -D LORA_CR=5 + -D MESHCORE_USA_RADIO_PRESET=1 + -D CASCADE_PROFILE=1 + -D DEFAULT_PATH_HASH_MODE=2 + -D DEFAULT_LOOP_DETECT=1 + -D DEFAULT_CAD_ENABLED=0 + -D DEFAULT_RX_DELAY_BASE=2.0f + -D DEFAULT_AGC_RESET_INTERVAL_SECONDS=8 + -D DEFAULT_ADVERT_INTERVAL_MINUTES=0 + -D DEFAULT_FLOOD_ADVERT_INTERVAL_HOURS=83 + -D DEFAULT_MULTI_ACKS=1 + -D DEFAULT_MANUAL_ADD_CONTACTS=1 + -D DEFAULT_AUTOADD_CONFIG=0 + -D DEFAULT_POWERSAVING_ENABLED=1 + -D DEFAULT_RXPS_ENABLED=1 + -D DEFAULT_RXPS_LEVEL=8 + -D DEFAULT_RXPS_PREAMBLE=16 + -D RXPS_FIXED_ENABLED=1 + -D RXPS_FIXED_LEVEL=8 + -D RXPS_FIXED_PREAMBLE=16 + -D DEFAULT_WIFI_POWER_SAVE_MODE=0 + -D FIRMWARE_VERSION='"v1.17.1.8-preview3"' + -D FIRMWARE_BUILD_DATE='"02-Oct-2026"' + -D FIRMWARE_BUILD_EPOCH=1790928488 + -D OTA_VARIANT='"Heltec_v3_companion_radio_full"' + -fno-exceptions +build_src_filter = + ${env:Heltec_v3_companion_radio_full.build_src_filter} + + + + +lib_deps = + ${env:Heltec_v3_companion_radio_full.lib_deps} + h2zero/NimBLE-Arduino @ 2.5.1 +lib_ignore = ESP32 BLE Arduino diff --git a/src/helpers/IdentityGeneration.h b/src/helpers/IdentityGeneration.h index afb766776..4b6fdd812 100644 --- a/src/helpers/IdentityGeneration.h +++ b/src/helpers/IdentityGeneration.h @@ -10,6 +10,38 @@ namespace mesh { // unlikely while still allowing callers to fail closed. constexpr size_t MAX_LOCAL_IDENTITY_GENERATION_ATTEMPTS = 11; +// Startup-only cooperative progress hook. Radio entropy collection calls this +// between bytes, so displays can animate without threads or changing entropy. +struct IdentityGenerationProgress { + void (*callback)(void*) = nullptr; + void* context = nullptr; +}; + +inline IdentityGenerationProgress& identityGenerationProgress() { + static IdentityGenerationProgress progress; + return progress; +} + +inline void serviceIdentityGenerationProgress() { + const auto progress = identityGenerationProgress(); + if (progress.callback != nullptr) progress.callback(progress.context); +} + +class ScopedIdentityGenerationProgress { + IdentityGenerationProgress _previous; +public: + ScopedIdentityGenerationProgress(void (*callback)(void*), void* context) + : _previous(identityGenerationProgress()) { + identityGenerationProgress().callback = callback; + identityGenerationProgress().context = context; + } + ~ScopedIdentityGenerationProgress() { + identityGenerationProgress() = _previous; + } + ScopedIdentityGenerationProgress(const ScopedIdentityGenerationProgress&) = delete; + ScopedIdentityGenerationProgress& operator=(const ScopedIdentityGenerationProgress&) = delete; +}; + inline bool hasReservedIdentityPrefix(const Identity& identity) { return identity.pub_key[0] == 0x00 || identity.pub_key[0] == 0xFF; } @@ -19,7 +51,9 @@ bool generateUsableLocalIdentity(LocalIdentity& identity, Generator generator) { for (size_t attempt = 0; attempt < MAX_LOCAL_IDENTITY_GENERATION_ATTEMPTS; ++attempt) { + serviceIdentityGenerationProgress(); identity = generator(); + serviceIdentityGenerationProgress(); if (!hasReservedIdentityPrefix(identity)) return true; } return false; diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index f5eed90d6..e8dc6784b 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -4,6 +4,7 @@ #include #include +#include #include "CadTiming.h" #include "RadioAirtime.h" #include "RXPowerSaving.h" @@ -389,6 +390,7 @@ public: // hardware entropy is mixed in without becoming the sole source. for (size_t i = 0; i < sz; i++) { dest[i] = _radio->randomByte() ^ (::random(0, 256) & 0xFF); + mesh::serviceIdentityGenerationProgress(); } #ifdef USE_CC310_HW_CRYPTO mesh::mixCC310Random(dest, sz); diff --git a/src/helpers/ui/StartupScreen.h b/src/helpers/ui/StartupScreen.h new file mode 100644 index 000000000..cc732dc65 --- /dev/null +++ b/src/helpers/ui/StartupScreen.h @@ -0,0 +1,101 @@ +#pragma once + +#include "DisplayDriver.h" +#include + +namespace mesh { namespace ui { + +// Used before UITask exists. Do not initialize the normal power policy here: +// its boot timeout must start when the real UI is ready, not during keygen. +class StartupScreen { + DisplayDriver* _display = nullptr; + bool _generating = false; + uint8_t _phase = 0; + uint32_t _last_frame = 0; + + void draw() { + if (_display == nullptr) return; + DisplayDriver& display = *_display; + display.startFrame(); + display.setTextSize(1); + display.setCompactText(false); + display.setColor(UIColor::primary_txt); + const int line = display.textLineHeight(); + const char* first = _generating ? "Generating key" : "Starting..."; + const char* second = nullptr; + if (display.getTextWidth(first) > display.width()) { + first = _generating ? "Generating" : "Starting"; + if (_generating) second = "key"; + if (display.getTextWidth(first) > display.width()) { + first = _generating ? "Key" : "Boot"; + if (_generating) second = "gen"; + } + } + const int spinner_size = 12; + const int text_height = line * (second == nullptr ? 1 : 2); + const int total_height = text_height + (_generating ? spinner_size + 2 : 0); + int y = (display.height() - total_height) / 2; + if (y < 0) y = 0; + display.drawTextCentered(display.width() / 2, y, first); + if (second != nullptr) + display.drawTextCentered(display.width() / 2, y + line, second); + if (_generating) { + // A rotating bright pair on an eight-dot ring works on monochrome and + // color panels, without depending on a font's spinner characters. + static const uint8_t dots[8][2] = { + {5, 0}, {9, 1}, {10, 5}, {9, 9}, + {5, 10}, {1, 9}, {0, 5}, {1, 1}}; + const int x = (display.width() - spinner_size) / 2; + const int top = y + text_height + 2; + if (x >= 0 && top + spinner_size <= display.height()) { + for (uint8_t i = 0; i < 2; ++i) { + const uint8_t index = (_phase + i) & 7; + display.fillRect(x + dots[index][0], top + dots[index][1], 2, 2); + } + } + } + display.endFrame(); + _last_frame = millis(); + } + +public: + void begin(DisplayDriver* display, bool usb_power) { + const auto& prefs = displayPowerPrefs(); + const auto mode = (usb_power ? prefs.usb : prefs.battery).mode; + _display = display; + if (mode == DisplayMode::Off || mode == DisplayMode::Pairing) { + if (_display != nullptr && _display->isOn()) _display->turnOff(); + _display = nullptr; + return; + } + if (_display == nullptr) return; + if (!_display->isOn()) _display->turnOn(); + starting(); + } + + void starting() { + _generating = false; + draw(); + } + + void generatingKey() { + _generating = true; + _phase = 0; + draw(); + } + + void service() { + if (_display == nullptr || !_generating) return; + // E-paper's partial refresh is slower and needs a gentler refresh cadence. + const uint32_t interval = _display->isEink() ? 1000UL : 150UL; + if (uint32_t(millis() - _last_frame) < interval) return; + _phase = (_phase + 1) & 7; + draw(); + } + + static void progress(void* context) { + if (context != nullptr) static_cast(context)->service(); + } +}; + +} } diff --git a/test/test_display_driver/test_display_driver.cpp b/test/test_display_driver/test_display_driver.cpp index 086a8432f..87aebbd8e 100644 --- a/test/test_display_driver/test_display_driver.cpp +++ b/test/test_display_driver/test_display_driver.cpp @@ -1,6 +1,8 @@ #include #include +#include +#include #include #include #include @@ -126,8 +128,143 @@ public: } }; +class StartupTestDisplay : public TestDisplay { +public: + bool on = false; + bool eink = false; + int frames = 0; + using TestDisplay::TestDisplay; + bool isOn() override { return on; } + bool isEink() override { return eink; } + void turnOn() override { on = true; } + void turnOff() override { on = false; } + void startFrame(ColorVal) override { rows.clear(); fills.clear(); } + void endFrame() override { ++frames; } +}; + } // namespace +TEST(StartupScreen, StartsImmediatelyAndAnimatesInsideOneGeneratorCall) { + g_mock_millis = 100; + mesh::ui::displayPowerPrefs() = mesh::ui::DisplayPowerPrefs(); + StartupTestDisplay display(128, 64, 6); + mesh::ui::StartupScreen screen; + screen.begin(&display, false); + EXPECT_TRUE(display.on); + ASSERT_EQ(1, display.frames); + ASSERT_EQ(1U, display.rows.size()); + EXPECT_EQ("Starting...", display.rows[0].text); + screen.generatingKey(); + const auto first = display.fills; + mesh::LocalIdentity identity; + { + mesh::ScopedIdentityGenerationProgress progress( + mesh::ui::StartupScreen::progress, &screen); + EXPECT_TRUE(mesh::generateUsableLocalIdentity(identity, [&]() { + // Simulate entropy collection: the key generator has not returned yet. + g_mock_millis += 149; + mesh::serviceIdentityGenerationProgress(); + EXPECT_EQ(2, display.frames); + g_mock_millis += 1; + mesh::serviceIdentityGenerationProgress(); + EXPECT_EQ(3, display.frames); + EXPECT_NE(first[0].x, display.fills[0].x); + g_mock_millis += 150; + mesh::serviceIdentityGenerationProgress(); + EXPECT_EQ(4, display.frames); + mesh::LocalIdentity key; + key.pub_key[0] = 0x42; + return key; + })); + } + EXPECT_EQ(nullptr, mesh::identityGenerationProgress().callback); + screen.starting(); + const int frames = display.frames; + g_mock_millis += 15000; + screen.service(); + EXPECT_EQ(frames, display.frames); + // The normal policy starts only after the startup UI hands over. + mesh::ui::displayPowerPrefs().wake_on_boot = true; + mesh::ui::DisplayPowerPolicy policy; + policy.update(mesh::ui::displayPowerPrefs(), false, false, false, g_mock_millis); + EXPECT_TRUE(policy.on()); + policy.update(mesh::ui::displayPowerPrefs(), false, false, false, g_mock_millis + 14999); + EXPECT_TRUE(policy.on()); + policy.update(mesh::ui::displayPowerPrefs(), false, false, false, g_mock_millis + 15000); + EXPECT_FALSE(policy.on()); +} + +TEST(StartupScreen, SavedModesAndSelectedPowerSourceAreRespected) { + for (bool usb : {false, true}) for (auto mode : { + mesh::ui::DisplayMode::Off, mesh::ui::DisplayMode::On, + mesh::ui::DisplayMode::Button, mesh::ui::DisplayMode::Pairing, + mesh::ui::DisplayMode::ButtonPairing, mesh::ui::DisplayMode::Automatic}) { + mesh::ui::displayPowerPrefs() = mesh::ui::DisplayPowerPrefs(); + auto& prefs = mesh::ui::displayPowerPrefs(); + prefs.battery.mode = prefs.usb.mode = mesh::ui::DisplayMode::Off; + (usb ? prefs.usb : prefs.battery).mode = mode; + StartupTestDisplay display(128, 64, 6); + display.on = true; // Some drivers power the panel as part of begin(). + mesh::ui::StartupScreen screen; + screen.begin(&display, usb); + screen.generatingKey(); + const bool visible = mode != mesh::ui::DisplayMode::Off + && mode != mesh::ui::DisplayMode::Pairing; + EXPECT_EQ(visible, display.on); + EXPECT_EQ(visible ? 2 : 0, display.frames); + } + mesh::ui::StartupScreen missing; + missing.begin(nullptr, true); + missing.generatingKey(); + missing.service(); + missing.starting(); + mesh::ui::StartupScreen::progress(nullptr); +} + +TEST(StartupScreen, FitsSmallRotatedOledTftAndEpaperPanels) { + const int sizes[][3] = { + {128, 64, 6}, {128, 32, 6}, {72, 40, 6}, {40, 72, 6}, + {64, 48, 6}, {48, 64, 6}, {64, 128, 6}, {32, 128, 6}, + {160, 80, 6}, {80, 160, 6}, {240, 135, 6}, {250, 122, 12}, + {320, 240, 12}, {240, 320, 12}, {480, 480, 12}}; + mesh::ui::displayPowerPrefs() = mesh::ui::DisplayPowerPrefs(); + for (const auto& size : sizes) { + StartupTestDisplay display(size[0], size[1], size[2]); + mesh::ui::StartupScreen screen; + screen.begin(&display, true); + screen.generatingKey(); + ASSERT_FALSE(display.rows.empty()); + EXPECT_EQ(2U, display.fills.size()); // Spinner must fit, too. + for (const auto& row : display.rows) { + EXPECT_GE(row.x, 0); + EXPECT_LE(row.x + display.getTextWidth(row.text.c_str()), display.width()); + EXPECT_GE(row.y, 0); + EXPECT_LE(row.y + display.textLineHeight(), display.height()); + } + for (const auto& dot : display.fills) { + EXPECT_GE(dot.x, 0); EXPECT_GE(dot.y, 0); + EXPECT_LE(dot.x + dot.width, display.width()); + EXPECT_LE(dot.y + dot.height, display.height()); + } + } +} + +TEST(StartupScreen, EpaperIsThrottledAndMillisWrapIsSafe) { + mesh::ui::displayPowerPrefs() = mesh::ui::DisplayPowerPrefs(); + g_mock_millis = UINT32_MAX - 100; + StartupTestDisplay display(250, 122, 12); + display.eink = true; + mesh::ui::StartupScreen screen; + screen.begin(&display, true); + screen.generatingKey(); + g_mock_millis += 999; + screen.service(); + EXPECT_EQ(2, display.frames); + ++g_mock_millis; + screen.service(); + EXPECT_EQ(3, display.frames); +} + TEST(DisplayDriver, EllipsizesOnlyAtUTF8CodepointBoundaries) { TestDisplay display; display.drawTextEllipsized(0, 0, 5, "AB\xF0\x9F\x98\x80" "CDE"); diff --git a/test/test_heltec_v3_preview_profile.py b/test/test_heltec_v3_preview_profile.py new file mode 100644 index 000000000..15e6b5f11 --- /dev/null +++ b/test/test_heltec_v3_preview_profile.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Keep the opt-in V3 preview Full, capacity-preserving, and reproducible.""" + +import configparser +from pathlib import Path +import re +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +class HeltecV3PreviewProfileTest(unittest.TestCase): + def setUp(self): + self.config = configparser.ConfigParser(interpolation=None) + self.config.read(ROOT / "platformio.heltec-v3-preview3.ini") + self.env = self.config["env:Heltec_v3_companion_radio_full_preview3"] + + def test_full_features_and_existing_capacities(self): + flags = self.env["build_flags"] + for name in ( + "COMPANION_RADIO_FULL", "ENABLE_USB_INTERFACE", "ENABLE_OTA", + "OTA_SEEDER_ONLY", "WIFI_OTA_SEEDER", + "COMPANION_FEATURE_TEMP_RADIO", "COMPANION_FEATURE_OTA_CLI", + "COMPANION_FEATURE_USB_MOTA_SOURCE", + "COMPANION_FEATURE_NETWORK_TERMINAL", + "COMPANION_FEATURE_MEMORY_DIAGNOSTICS"): + self.assertIn("-D " + name + "=1", flags) + self.assertIn("-D BLE_PIN_CODE=123456", flags) + self.assertIn("-D MAX_CONTACTS=100", flags) + self.assertNotIn("-D MAX_GROUP_CHANNELS", flags) + self.assertNotIn("-D OFFLINE_QUEUE_SIZE", flags) + self.assertNotIn("-D OTA_SHARED_COMPANION_QUEUE", flags) + self.assertNotIn("-D ONE_KEY_DM_SHARED_OFFLINE_QUEUE", flags) + self.assertNotIn("-D MESH_MIN_RUNTIME_HEAP", flags) + self.assertEqual(self.env["extends"], "env:Heltec_v3_companion_radio_full") + self.assertIn("${env:Heltec_v3_companion_radio_full.build_flags}", flags) + + def test_nimble_is_pinned_and_not_in_the_ordinary_release_matrix(self): + self.assertIn("-D MESH_USE_NIMBLE_ARDUINO=1", self.env["build_flags"]) + self.assertIn("h2zero/NimBLE-Arduino @ 2.5.1", self.env["lib_deps"]) + self.assertIn("${env:Heltec_v3_companion_radio_full.lib_deps}", + self.env["lib_deps"]) + self.assertEqual(self.env["lib_ignore"], "ESP32 BLE Arduino") + base = configparser.ConfigParser(interpolation=None, strict=False) + base.read(ROOT / "platformio.ini") + self.assertNotIn("platformio.heltec-v3-preview3.ini", + base["platformio"]["extra_configs"]) + + def test_version_and_partition_contract(self): + self.assertEqual(self.env["board_build.flash_mode"], "dio") + self.assertEqual(self.env["board_build.partitions"], "default_8MB.csv") + self.assertIn("v1.17.1.8-preview3", self.env["build_flags"]) + self.assertNotIn("DISABLE_LORA_OTA=1", self.env["build_flags"]) + # Never set a flag that removes the WiFi/BLE/sensor/display features. + self.assertNotRegex(self.env["build_flags"], re.compile( + r"-D\s*(WEBCONFIG_DISABLED|DISABLE_WIFI_OTA|UI_NO_\w+|" + r"ENV_EXCLUDE_\w+)(?:=|\s|$)")) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_identity_generation/test_identity_generation.cpp b/test/test_identity_generation/test_identity_generation.cpp index 0f65f3d58..dd1557802 100644 --- a/test/test_identity_generation/test_identity_generation.cpp +++ b/test/test_identity_generation/test_identity_generation.cpp @@ -74,6 +74,30 @@ TEST(IdentityGeneration, FailsClosedAfterProvisionedAttempts) { EXPECT_TRUE(mesh::hasReservedIdentityPrefix(identity)); } +TEST(IdentityGeneration, ProgressScopesRestoreHooksAfterSuccessAndFailure) { + unsigned outer = 0, inner = 0; + const auto increment = [](void* context) { ++*static_cast(context); }; + mesh::LocalIdentity identity; + { + mesh::ScopedIdentityGenerationProgress progress(increment, &outer); + EXPECT_TRUE(mesh::generateUsableLocalIdentity( + identity, []() { return identityWithPrefix(0x42); })); + EXPECT_EQ(2U, outer); + { + mesh::ScopedIdentityGenerationProgress nested(increment, &inner); + EXPECT_FALSE(mesh::generateUsableLocalIdentity( + identity, []() { return identityWithPrefix(0x00); })); + EXPECT_EQ(2 * mesh::MAX_LOCAL_IDENTITY_GENERATION_ATTEMPTS, inner); + } + mesh::serviceIdentityGenerationProgress(); + EXPECT_EQ(3U, outer); + } + EXPECT_EQ(nullptr, mesh::identityGenerationProgress().callback); + EXPECT_EQ(nullptr, mesh::identityGenerationProgress().context); + mesh::serviceIdentityGenerationProgress(); + EXPECT_EQ(3U, outer); +} + int main(int argc, char** argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); diff --git a/test/test_startup_screen.py b/test/test_startup_screen.py new file mode 100644 index 000000000..e65ec669c --- /dev/null +++ b/test/test_startup_screen.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""Startup ordering and cooperative radio entropy/display integration.""" + +from pathlib import Path +import subprocess +import tempfile +import unittest + +from test_replay_reset_integration import extract_braced + +ROOT = Path(__file__).resolve().parents[1] + + +class StartupScreenTest(unittest.TestCase): + def test_screen_precedes_radio_keygen_and_settle_delays(self): + for role in ("companion_radio", "simple_repeater", "simple_room_server", "simple_sensor"): + with self.subTest(role=role): + main = (ROOT / "examples" / role / "main.cpp").read_text() + setup = extract_braced(main, "void setup()") + frame = setup.index("startup_screen.begin(") + self.assertLess(setup.index("board.begin()"), frame) + self.assertLess(setup.index("loadDisplayPowerSettings("), frame) + self.assertLess(setup.index("display.begin()"), frame) + self.assertLess(frame, setup.index("radio_init()")) + self.assertLess(frame, setup.index("the_mesh.begin(")) + for delay in ("delay(1000)", "delay(5000)"): + if delay in setup: + self.assertLess(frame, setup.index(delay)) + for mount in ("SPIFFS.begin(true)", "LittleFS.begin()", "beginInternalPrimaryFilesystemSafely("): + if mount in setup: + self.assertLess(setup.index(mount), frame) + identity_source = setup + if role == "companion_radio": + mesh = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text() + identity_source = extract_braced(mesh, "void MyMesh::begin(") + self.assertIn(", &startup_screen", setup) + self.assertIn("&& !store.isVolatilePrimaryFS()) display_prefs_fs = nullptr;", setup) + self.assertLess(identity_source.index("generatingKey()"), + identity_source.index("generateUsableLocalIdentity(")) + self.assertLess(identity_source.index("ScopedIdentityGenerationProgress progress("), + identity_source.index("generateUsableLocalIdentity(")) + self.assertLess(identity_source.index("generateUsableLocalIdentity("), + identity_source.index("startup_screen" + ("->" if role == "companion_radio" else ".") + "starting()")) + + def test_actual_entropy_loop_services_progress_without_changing_bytes(self): + source = (ROOT / "src/helpers/radiolib/RadioLibWrappers.h").read_text() + noise_listener = extract_braced(source, "class RadioNoiseListener") + ";\n" + program = r''' +#include +#include +#include +static unsigned random_calls = 0, progress_calls = 0; +static long random(long low, long high) { + assert(low == 0 && high == 256); + ++random_calls; + return 0x55; +} +class PhysicalLayer { +public: + unsigned calls = 0; + uint8_t randomByte() { return uint8_t(++calls); } +}; +namespace mesh { +static unsigned hardware_calls = 0; +static void mixCC310Random(uint8_t* bytes, size_t size) { + ++hardware_calls; + for (size_t i = 0; i < size; ++i) bytes[i] ^= 0x22; +} +static void mixESP32TrueRandom(uint8_t* bytes, size_t size) { + ++hardware_calls; + for (size_t i = 0; i < size; ++i) bytes[i] ^= 0x11; +} +} +@LISTENER@ +int main() { + PhysicalLayer radio; + RadioNoiseListener rng(radio); + uint8_t seed[32]; + { + mesh::ScopedIdentityGenerationProgress progress([](void* context) { + auto& radio = *static_cast(context); + ++progress_calls; + // This happens between bytes, before the RNG has returned. + assert(progress_calls == radio.calls && radio.calls <= 32); + }, &radio); + rng.random(seed, sizeof(seed)); + } + assert(random_calls == 32 && radio.calls == 32 && progress_calls == 32); + for (size_t i = 0; i < sizeof(seed); ++i) { + uint8_t expected = uint8_t(i + 1) ^ 0x55; +#ifdef USE_CC310_HW_CRYPTO + expected ^= 0x22; +#endif +#ifdef ESP32_PLATFORM + expected ^= 0x11; +#endif + assert(seed[i] == expected); + } + assert(mesh::identityGenerationProgress().callback == nullptr); + rng.random(seed, sizeof(seed)); // No stale UI callback after key generation. + assert(progress_calls == 32 && radio.calls == 64); +} +'''.replace("@LISTENER@", noise_listener) + with tempfile.TemporaryDirectory(prefix="mesh-startup-entropy-") as directory: + cpp, exe = Path(directory) / "test.cpp", Path(directory) / "test" + cpp.write_text(program) + for defines in ([], ["ESP32_PLATFORM"], ["USE_CC310_HW_CRYPTO"], + ["ESP32_PLATFORM", "USE_CC310_HW_CRYPTO"]): + with self.subTest(defines=defines): + build = subprocess.run([ + "c++", "-std=c++11", "-Wall", "-Wextra", "-Werror", + "-Wno-unused-function", "-Wno-unused-variable", + "-fsanitize=address,undefined", "-fno-pie", "-no-pie", + *["-D" + flag for flag in defines], + "-I", str(ROOT / "test/mocks"), "-I", str(ROOT / "src"), + str(cpp), "-o", str(exe)], capture_output=True, text=True) + self.assertEqual(build.returncode, 0, build.stderr) + run = subprocess.run([str(exe)], capture_output=True, text=True) + self.assertEqual(run.returncode, 0, run.stderr) + + +if __name__ == "__main__": + unittest.main()