From a0105731f5535b3f4925d5b37311e21b5de97651 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Fri, 2 Oct 2026 12:42:26 -0700 Subject: [PATCH] Fix Companion pairing PIN display and truthful identity startup --- .github/workflows/run-unit-tests.yml | 5 + docs/cli_commands.md | 13 + .../heltec-v4-preview4-bluetooth-oled.md | 114 ++++++ docs/test-results/preview-1.17.1.8/index.md | 4 + examples/companion_radio/MyMesh.cpp | 27 +- examples/companion_radio/MyMesh.h | 5 +- examples/companion_radio/main.cpp | 113 ++++-- examples/companion_radio/ui-new/UITask.cpp | 33 +- examples/companion_radio/ui-new/UITask.h | 3 +- examples/simple_repeater/main.cpp | 106 +++--- examples/simple_room_server/main.cpp | 62 ++-- examples/simple_sensor/main.cpp | 79 ++-- platformio.heltec-v3-preview4.ini | 81 +++++ platformio.heltec-v4-preview4.ini | 91 +++++ src/helpers/IdentityGeneration.h | 34 ++ src/helpers/esp32/SerialBLEInterface.cpp | 6 +- src/helpers/radiolib/RadioLibWrappers.h | 2 + src/helpers/ui/SSD1306Display.h | 4 + src/helpers/ui/StartupScreen.h | 130 +++++++ test/README.md | 38 ++ .../nimble_companion/mocks/NimBLEDevice.h | 6 + .../test_nimble_companion.cpp | 21 +- test/test_bluetooth_pairing_ui.py | 3 + test/test_companion_identity_startup.py | 150 ++++++++ test/test_companion_inbox.py | 15 +- test/test_companion_pairing_display.py | 199 ++++++++++ .../test_display_driver.cpp | 171 +++++++++ .../test_identity_generation.cpp | 24 ++ test/test_startup_screen.py | 129 +++++++ tools/hil/CompanionPairingUiProbe.cpp | 53 +++ tools/hil/v4_pairing_stealth.py | 342 ++++++++++++++++++ 31 files changed, 1915 insertions(+), 148 deletions(-) create mode 100644 docs/test-results/preview-1.17.1.8/heltec-v4-preview4-bluetooth-oled.md create mode 100644 platformio.heltec-v3-preview4.ini create mode 100644 platformio.heltec-v4-preview4.ini create mode 100644 src/helpers/ui/StartupScreen.h create mode 100644 test/test_companion_identity_startup.py create mode 100644 test/test_companion_pairing_display.py create mode 100644 test/test_startup_screen.py create mode 100644 tools/hil/CompanionPairingUiProbe.cpp create mode 100644 tools/hil/v4_pairing_stealth.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index f95e677e..95dda489 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -262,6 +262,11 @@ jobs: - name: Verify Companion Bluetooth settings and WebConfig run: | + python3 -B test/test_nimble_companion.py + python3 -B test/test_bluetooth_pairing_ui.py + python3 -B test/test_companion_pairing_display.py + python3 -B test/test_companion_identity_startup.py + python3 -B test/test_startup_screen.py node test/test_notification_builder.js python3 -B test/test_notification_settings.py python3 -B test/test_display_power.py diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 80c909e1..d27d05e0 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -916,6 +916,19 @@ 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. +Startup shows `Loading identity` while reading a saved identity. `Generating key` +and its spinner are used only when creating a replacement/new identity; +they end before flash persistence, which shows `Saving identity`. A normal +boot with a valid saved identity never regenerates it. An erased filesystem +does require a new identity unless one is restored. + +On Full Companion screens, an unpaired Bluetooth PIN takes priority over a +USB/TCP connected label. Button wake shows the PIN banner without requiring +USB to be disconnected; a second navigation press can leave this preview. +An actual phone pairing request wakes pairing-enabled display modes and keeps +the banner visible until completion or its two-minute expiry. Bluetooth +scanning alone is not a pairing request. + - `off`: stays dark, including boot and pairing. - `on`: stays on while the device is awake. - `button`: a physical button, keyboard/navigation input, or supported touch diff --git a/docs/test-results/preview-1.17.1.8/heltec-v4-preview4-bluetooth-oled.md b/docs/test-results/preview-1.17.1.8/heltec-v4-preview4-bluetooth-oled.md new file mode 100644 index 00000000..db78c9ce --- /dev/null +++ b/docs/test-results/preview-1.17.1.8/heltec-v4-preview4-bluetooth-oled.md @@ -0,0 +1,114 @@ +# Heltec V4 preview4 Bluetooth/OLED regression validation + +Date: 2026-10-02. These are observations on one physical Heltec V4.3 OLED, +not qualification of every board or phone. The preview4 images are local, +unreleased builds with uncommitted changes on `keymindCascade` at +`7324bf06efef236009dac2d507775fa4e19bc856`. + +## Target and method + +- Host: MercerWoodMesh Raspberry Pi, built-in BlueZ Bluetooth controller. +- Target: Heltec V4.3 OLED, ESP32-S3 revision 0.2, 16 MB flash, 2 MB PSRAM; + exact USB serial `44:1B:F6:69:CF:98`. +- Version under test: `v1.17.1.8-preview4`, build 02-Oct-2026, USA Cascade + Full Companion, OLED, no expansion, NimBLE-Arduino 2.5.1. +- Diagnostic environment: `heltec_v4_companion_pairing_ui_hil_preview4` in + `platformio.heltec-v4-preview4.ini`. +- Diagnostic application SHA-256: + `df0c109e7c3e99e43ec388a4017da8971f41572f51afeac779cddc4a56e69d14`. +- Helper: `tools/hil/v4_pairing_stealth.py`; Linux `bleak`, `dbus-fast`, + `pyserial`, target-restricted passkey agent. Test PIN **246810**, deliberately + different from NimBLE's 123456 callback sentinel. + +A complete, hash-checked 16 MB original flash backup was taken first. Only +`otadata` and the application were written for the temporary diagnostic image; +the bootloader, partition table, NVS and SPIFFS were retained. No other Pi radio +was flashed. The helper did not request LoRa transmissions; ordinary background +radio activity was not disabled. + +`COMPANION_PAIRING_UI_HIL` is an opt-in diagnostic macro, absent from production +builds. It records actual startup phases, display power/pairing/USB state, public +identity, and the real 1024-byte SSD1306 framebuffer. It does not access the +private key. Frame capture occurred during an actual outstanding passkey +request, not a manually invoked firmware callback. + +## Observed hardware results + +| Check | Result | +| --- | --- | +| Fresh pairing with a non-default PIN | Passed twice: exactly one actual passkey request per fresh pairing; BlueZ reported Paired and Bonded. | +| PIN display while USB remains connected | Passed twice: display on, pairing active, USB present, and captured frame containing `PAIR PIN 246810`. This verifies the display driver's real framebuffer and wake state, not an optical photograph. | +| Protected BLE transport | Passed: MTU 179, 82-byte device-info response and 176-byte ATT command payload, live CLI/status responses, zero core error flags. | +| Custom MAC with stealth | Passed: fresh discovery/pairing, live `bonded-peer-only advertising` status, bonded reconnect with no new PIN, then USB-commanded reboot and another bonded reconnect with no new PIN. | +| Saved random MAC | Passed: USB-commanded reboot retained its address with stealth enabled. | +| `random-after-connect`, unused boot | Passed: reboot over USB, without BLE authentication, retained the address. | +| `random-after-connect`, authenticated boot | Passed: actual authenticated BLE connection armed the next boot; address stayed unchanged during that session and changed on the following USB reboot. The new address was discoverable with stealth retained. | +| `random-every-boot` | Passed: two successive USB-commanded boots used different addresses, with stealth enabled. | +| Saved identity startup | Passed: Starting/Loading phases only, no Generating/Saving phase. The same public identity was retained throughout the run. | + +The suite finished with `suite_passed`, six observed application addresses, +two captured OLED frames, and `identity_preserved: true`. An earlier harness +attempt stopped because it did not recognize a terminal prompt immediately +followed by a diagnostic line; the parser was corrected and the complete suite +rerun. This was not a BLE pairing failure. + +Stealth on ESP32 uses minimal, unnamed, allowlisted advertising after pairing; +it is not radio invisibility. Bonded reconnection and policy/rotation behavior +were tested here. Actual advertising-payload capture and rejection of an +unbonded second central were not part of this run. + +## Original target restored + +After the suite, the complete original 16 MB image was written back. Esptool +verified the flash data hash. Live serial readback then confirmed: + +- Original version: + `v1.18.0.1-halo-keymind-cascade-dev-app-wifi-test-6f6d1a2a`, + protocol 14, build 01-Oct-2026. +- Original board and public identity unchanged. +- Bluetooth on, factory/default MAC, stealth off. +- Original default Bluetooth name, USB display mode `button-pairing`, and + USB logging off. + +The full backup remains private on the Pi. Raw diagnostic logs and captured +OLED pixels remain in the ignored local preview4 output folder; neither the +backup nor unreviewed radio logs are published. Only test-created BlueZ device +records were removed by the helper. + +## Automated regression coverage and builds + +- 117 targeted native cases passed across identity generation, display layout, + serial routing, and Companion preferences. This was not the entire native + suite. +- 39 Python cases passed across the real NimBLE adapter, pairing UI, saved/new + identity startup, startup display, display power, MAC/stealth contracts, + settings persistence, inbox rendering, preference transaction, and Bluetooth + control suites. Executable C++ fixtures use the production implementation; + sanitizer-enabled fixtures also passed. +- The unit-test workflow now includes the real NimBLE callback dispatch, + USB-connected pairing display, identity-startup, and startup-screen suites. + No GitHub run was triggered for these uncommitted changes. +- Production V3 and V4 Full Companion preview4 builds passed linked-memory + qualification. Available/required internal heap: V3 200,144/182,016 bytes; + V4 252,152/177,920 bytes. Diagnostic V4: 252,136/177,920 bytes. + +The NimBLE regression fixture reproduces the pinned library's dispatch rule: +a non-default static passkey bypasses `onPassKeyDisplay()`. Firmware now leaves +NimBLE's sentinel at 123456 and returns the actual configured PIN through that +callback, preserving authentication while notifying the UI. Tests cover random +and saved PINs, USB coexistence, button wake/navigation, disabled display/BLE, +saved identity, generation, persistence failures, and removal of the generation +label once generation finishes. + +## Remaining hardware gates + +- Physical Heltec V3, including its user button; the Pi board here was V4. +- Optical OLED inspection and physical button gestures (covered in executable + host fixtures, not remotely actuated on this board). +- Android/iOS MeshCore apps, privacy-address reconnection, and an unbonded + second physical central. +- Actual post-pairing advertising payload capture. +- All-power-removed cold boot; this run used commanded reboots only. +- Destructive missing/corrupt identity and bond/write-failure injection on + hardware; these branches were exercised by host fixtures without erasing + the real node's identity. diff --git a/docs/test-results/preview-1.17.1.8/index.md b/docs/test-results/preview-1.17.1.8/index.md index 79529d61..37e8a5d3 100644 --- a/docs/test-results/preview-1.17.1.8/index.md +++ b/docs/test-results/preview-1.17.1.8/index.md @@ -4,6 +4,10 @@ This preview contains USA Cascade builds for RAK3401 Repeater and Heltec V4 OLED The [RAK3401 source test run](https://github.com/mikecarper/MeshCore/actions/runs/36810565194) and [Heltec V4 source test run](https://github.com/mikecarper/MeshCore/actions/runs/36826971285) passed. The RAK3401 UF2 and DFU application payloads match, with verified DFU CRCs. Both RAK3401 packages passed capability and memory checks. The four Heltec V4 OLED variants passed capability and memory qualification. These are build/package checks, not physical on-device tests of every variant. +## Preview4 local regression validation + +- [Heltec V4 Bluetooth pairing, USB OLED, stealth and MAC rotation hardware results](heltec-v4-preview4-bluetooth-oled.md) — 2026-10-02, unreleased local preview4 build; the original Pi V4 firmware and settings were restored afterward. + ## RAK3401 package results - [Build information](BUILD-INFO.json) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index c586592b..2347860c 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(); @@ -2354,6 +2356,9 @@ void MyMesh::begin(bool has_display, bool radio_available) { mesh::ota::ota_set_context_config_loader(mesh::ota::loadCompanionOtaConfig); #endif +#ifdef DISPLAY_CLASS + if (startup_screen != nullptr) startup_screen->loadingIdentity(); +#endif const bool identity_loaded = _store->loadMainIdentity(self_id); const bool is_new_install = !identity_loaded || mesh::hasReservedIdentityPrefix(self_id); @@ -2366,9 +2371,27 @@ void MyMesh::begin(bool has_display, bool radio_available) { bool identity_ready = true; if (is_new_install) { - identity_ready = mesh::generateUsableLocalIdentity(self_id, radio_new_identity); + { +#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); + } +#ifdef DISPLAY_CLASS + // Generation has finished. Flash persistence must never retain its label + // or cooperative animation callback. + if (startup_screen != nullptr) { + if (identity_ready) startup_screen->savingIdentity(); + else startup_screen->starting(); + } +#endif if (identity_ready) identity_ready = _store->saveMainIdentity(self_id); } +#ifdef DISPLAY_CLASS + if (startup_screen != nullptr) startup_screen->starting(); +#endif #if defined(ESP32_PLATFORM) mesh::discardESP32TrueRandom(); diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 66fff5fe..bd6bf67c 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 35f1db98..3d7cffb9 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -1,10 +1,15 @@ #include // needed for PlatformIO +#include +#include #include #include #include "MyMesh.h" #include "CompanionBluetooth.h" #include "CompanionWireless.h" #include "CompanionWiFi.h" +#if defined(COMPANION_PAIRING_UI_HIL) +void companionPairingUiHilProbe(); +#endif #if MESH_PACKET_LOGGING #include #endif @@ -2788,6 +2793,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 +2860,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 +2911,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 +2967,9 @@ void setup() { #endif , radio_available + #ifdef DISPLAY_CLASS + , &startup_screen + #endif ); #if defined(NRF52_PLATFORM) if (store.isVolatilePrimaryFS()) { @@ -2945,7 +2981,6 @@ void setup() { } #endif #elif defined(RP2040_PLATFORM) - LittleFS.begin(); store.begin(); the_mesh.begin( #ifdef DISPLAY_CLASS @@ -2955,9 +2990,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 +3004,9 @@ void setup() { #endif , radio_available + #ifdef DISPLAY_CLASS + , &startup_screen + #endif ); #else #error "need to define filesystem" @@ -3310,6 +3350,9 @@ void loop() { #else ui_task.loop(); #endif +#endif +#if defined(COMPANION_PAIRING_UI_HIL) + companionPairingUiHilProbe(); #endif rtc_clock.tick(); board.loop(); diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index fd57caf7..9690e131 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -840,11 +840,7 @@ public: const char* pairing_value = nullptr; int pairing_value_size = 2; char pairing_pin[16]; - if (client_label != nullptr && !_task->isPairingPromptActive()) { - pairing_label = expanded_home && strcmp(client_label, "BLUETOOTH") == 0 ? "BLE" : client_label; - pairing_value = expanded_home ? "LINKED" : "CONNECTED"; - if (expanded_home) pairing_value_size = 3; - } else if (mesh::ui::shouldDisplayBluetoothPairingPin( + if (mesh::ui::shouldDisplayBluetoothPairingPin( bluetooth_enabled, bluetooth_connected, bluetooth_pin) && (!_task->isPairingPromptActive() || display.height() > 64)) { @@ -853,6 +849,10 @@ public: (unsigned int)bluetooth_pin); pairing_value = pairing_pin; pairing_value_size = 3; + } else if (client_label != nullptr && !_task->isPairingPromptActive()) { + pairing_label = expanded_home && strcmp(client_label, "BLUETOOTH") == 0 ? "BLE" : client_label; + pairing_value = expanded_home ? "LINKED" : "CONNECTED"; + if (expanded_home) pairing_value_size = 3; } if (pairing_value != nullptr) { @@ -901,7 +901,8 @@ public: const char* client_label = _task->connectedClientLabel(); if (client_label != nullptr || show_bluetooth_pin) { char pairing_pin[16]; - const bool show_client = client_label != nullptr && !_task->isPairingPromptActive(); + const bool show_client = client_label != nullptr && !show_bluetooth_pin + && !_task->isPairingPromptActive(); const char* pairing_label = show_client ? client_label : "BLUETOOTH PIN"; const char* pairing_value = "CONNECTED"; if (!show_client) { @@ -2150,11 +2151,16 @@ void UITask::renderPairingBanner() { _display->drawTextCentered(_display->width() / 2, 2, prompt); } -void UITask::showPairingPin() { +void UITask::showPairingPin(bool from_button) { if (!isBluetoothEnabled()) return; const unsigned long now = millis(); - _pairing_screen_until = now + BLE_PAIRING_DISPLAY_MILLIS; + _pairing_from_button = from_button; + const auto& prefs = mesh::ui::displayPowerPrefs(); + const auto& profile = (_board->isExternalPowered() || _board->isUsbHostConnected()) + ? prefs.usb : prefs.battery; + _pairing_screen_until = now + (from_button + ? uint32_t(profile.seconds) * 1000U : BLE_PAIRING_DISPLAY_MILLIS); if (curr == msg_preview && _msgcount > 0) { _deferred_msg_preview = true; } @@ -2168,6 +2174,7 @@ void UITask::showPairingPin() { void UITask::finishPairingScreen(bool timed_out) { _pairing_screen_until = 0; + _pairing_from_button = false; if (_deferred_msg_preview && !hasConnection() && static_cast(msg_preview)->hasMessages()) { @@ -2424,6 +2431,12 @@ void UITask::loop() { } #endif + // A button-wake PIN is a preview, not a modal pairing request. The next + // navigation gesture must still work; real phone requests retain priority. + if (_pairing_from_button && c != 0) { + _pairing_screen_until = 0; + _pairing_from_button = false; + } if (isPairingScreenActive()) { // Pairing has visual priority over navigation and asynchronous screens. static_cast(home)->showFirstPage(); @@ -2553,6 +2566,10 @@ char UITask::checkDisplayOn(char c) { const bool was_on = _display->isOn(); _display->wake(mesh::ui::DisplayWake::Button); if (!was_on) { + if (_display->isOn() && mesh::ui::shouldDisplayBluetoothPairingPin( + isBluetoothEnabled(), hasBluetoothConnection(), the_mesh.getBLEPin())) { + showPairingPin(true); + } c = 0; } _next_refresh = 0; // trigger refresh diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index e53d28c9..d1bb39ff 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -114,7 +114,8 @@ class UITask : public AbstractUITask { void setCurrScreen(UIScreen* c); bool isPairingScreenActive() const; void renderPairingBanner(); - void showPairingPin(); + void showPairingPin(bool from_button = false); + bool _pairing_from_button = false; void finishPairingScreen(bool timed_out); public: diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index 457dd242..640fd71f 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,21 @@ 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); +#ifdef DISPLAY_CLASS + if (identity_ready) startup_screen.savingIdentity(); + else startup_screen.starting(); +#endif 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 c7d71d78..66d0c4b3 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,20 @@ 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); +#ifdef DISPLAY_CLASS + if (identity_ready) startup_screen.savingIdentity(); + else startup_screen.starting(); +#endif 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 c45ecf12..1e8db473 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,21 @@ 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); +#ifdef DISPLAY_CLASS + if (identity_ready) startup_screen.savingIdentity(); + else startup_screen.starting(); +#endif 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 +220,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 +285,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-preview4.ini b/platformio.heltec-v3-preview4.ini new file mode 100644 index 00000000..824c8428 --- /dev/null +++ b/platformio.heltec-v3-preview4.ini @@ -0,0 +1,81 @@ +; Heltec V3 Full Companion 1.17.1.8 preview4. See the preview4 report under docs/test-results/preview-1.17.1.8/. +[platformio] +extra_configs = + platformio.ini + variants/*/platformio.ini + +[env:Heltec_v3_companion_radio_full_preview4] +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-preview4"' + -D FIRMWARE_BUILD_DATE='"02-Oct-2026"' + -D FIRMWARE_BUILD_EPOCH=1790966697 + -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/platformio.heltec-v4-preview4.ini b/platformio.heltec-v4-preview4.ini new file mode 100644 index 00000000..f6939afc --- /dev/null +++ b/platformio.heltec-v4-preview4.ini @@ -0,0 +1,91 @@ +; Heltec V4 OLED, no expansion. Full Companion USA Cascade preview4. +[platformio] +extra_configs = + platformio.ini + variants/*/platformio.ini + +[env:heltec_v4_companion_radio_full_preview4] +extends = env:heltec_v4_2_v4_3_companion_radio_full_femon +board_build.flash_mode = dio +board_build.partitions = default_16MB.csv +build_unflags = + -DMAX_CONTACTS=350 + -DLORA_FREQ=869.618 + -DLORA_SF=8 + -DDISABLE_WIFI_OTA=1 + -fexceptions +build_flags = + ${env:heltec_v4_2_v4_3_companion_radio_full_femon.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-preview4"' + -D FIRMWARE_BUILD_DATE='"02-Oct-2026"' + -D FIRMWARE_BUILD_EPOCH=1790966697 + -D OTA_VARIANT='"heltec_v4_2_v4_3_companion_radio_full_femon"' + -fno-exceptions +build_src_filter = + ${env:heltec_v4_2_v4_3_companion_radio_full_femon.build_src_filter} + + + + +lib_deps = + ${env:heltec_v4_2_v4_3_companion_radio_full_femon.lib_deps} + h2zero/NimBLE-Arduino @ 2.5.1 +lib_ignore = ESP32 BLE Arduino + +; Diagnostic-only image: ASCII OLED telemetry, not for release/install use. +[env:heltec_v4_companion_pairing_ui_hil_preview4] +extends = env:heltec_v4_companion_radio_full_preview4 +build_flags = + ${env:heltec_v4_companion_radio_full_preview4.build_flags} + -D COMPANION_PAIRING_UI_HIL=1 +build_src_filter = + ${env:heltec_v4_companion_radio_full_preview4.build_src_filter} + +<../tools/hil/CompanionPairingUiProbe.cpp> diff --git a/src/helpers/IdentityGeneration.h b/src/helpers/IdentityGeneration.h index afb76677..4b6fdd81 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/esp32/SerialBLEInterface.cpp b/src/helpers/esp32/SerialBLEInterface.cpp index e07655d8..bb278319 100644 --- a/src/helpers/esp32/SerialBLEInterface.cpp +++ b/src/helpers/esp32/SerialBLEInterface.cpp @@ -396,7 +396,11 @@ bool SerialBLEInterface::begin(const char* prefix, const char* name, BLEDevice::setMTU(MAX_FRAME_SIZE + 3); #if defined(MESH_USE_NIMBLE_ARDUINO) - BLEDevice::setSecurityPasskey(pin_code); + // NimBLE-Arduino 2.5.1 bypasses onPassKeyDisplay() for a non-default + // static passkey. Keep its callback sentinel here: our callback returns + // the actual random/saved _pin_code and also wakes the pairing display. + // This does not change the PIN used on air or weaken authentication. + BLEDevice::setSecurityPasskey(123456); BLEDevice::setSecurityIOCap(BLE_HS_IO_DISPLAY_ONLY); BLEDevice::setSecurityAuth(true, true, true); #else diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index f5eed90d..e8dc6784 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/SSD1306Display.h b/src/helpers/ui/SSD1306Display.h index aeda876b..b27c9a62 100644 --- a/src/helpers/ui/SSD1306Display.h +++ b/src/helpers/ui/SSD1306Display.h @@ -41,6 +41,10 @@ public: bool begin(); bool isOn() override { return _isOn; } +#if defined(COMPANION_PAIRING_UI_HIL) + // Test-only access to the same pixels sent by endFrame(), never a mock UI. + const uint8_t* hilFramebuffer() { return display.getBuffer(); } +#endif bool supportsRotation() const override { return true; } bool setRotationDegrees(uint16_t degrees) override; void setFlipped(bool flipped) override; diff --git a/src/helpers/ui/StartupScreen.h b/src/helpers/ui/StartupScreen.h new file mode 100644 index 00000000..992c3e2f --- /dev/null +++ b/src/helpers/ui/StartupScreen.h @@ -0,0 +1,130 @@ +#pragma once + +#include "DisplayDriver.h" +#include + +#if defined(COMPANION_PAIRING_UI_HIL) +void companionStartupUiHilFrame(const char* status); +#endif + +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; + const char* _status = "Starting..."; + 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 = _status; + const char* second = nullptr; + if (display.getTextWidth(first) > display.width()) { + first = _generating ? "Generating" : + strcmp(_status, "Loading identity") == 0 ? "Loading" : + strcmp(_status, "Saving identity") == 0 ? "Saving" : "Starting"; + if (_generating) second = "key"; + else if (strcmp(_status, "Starting...") != 0) second = "identity"; + if (display.getTextWidth(first) > display.width()) { + first = _generating ? "Key" : + strcmp(_status, "Loading identity") == 0 ? "Load" : + strcmp(_status, "Saving identity") == 0 ? "Save" : "Boot"; + if (_generating) second = "gen"; + else if (second != nullptr && display.getTextWidth(second) > display.width()) + second = "ID"; + } + } + 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(); +#if defined(COMPANION_PAIRING_UI_HIL) + companionStartupUiHilFrame(_status); +#endif + _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; + _status = "Starting..."; + draw(); + } + + void loadingIdentity() { + _generating = false; + _status = "Loading identity"; + draw(); + } + + void savingIdentity() { + _generating = false; + _status = "Saving identity"; + draw(); + } + + void generatingKey() { + _generating = true; + _status = "Generating key"; + _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/README.md b/test/README.md index fa5dc55e..7d110e36 100644 --- a/test/README.md +++ b/test/README.md @@ -24,6 +24,10 @@ python3 test/test_color_theme.py # shared color-display dark-pale python3 test/test_indicator_font_recovery.py # Indicator TLS/SD font recovery contract python3 test/test_companion_terminal_profile.py # Companion CLI capability gates python3 test/test_bluetooth_mac_contract.py # MAC policies, separate stealth flag and BLE backend integration +python3 test/test_nimble_companion.py # real adapter, fixed/random PIN callback dispatch and bond/stealth transport +python3 test/test_companion_pairing_display.py # real OLED pairing wake, USB coexistence and button navigation +python3 test/test_companion_identity_startup.py # real saved/missing/unreadable identity branch and truthful startup phases +python3 test/test_startup_screen.py # early display ordering and cooperative entropy progress python3 test/test_companion_settings_persistence_contract.py # Atomic settings and appended Bluetooth fields python3 test/test_webconfig_ui.py # Web controls, reboot handling and generated-page consistency python3 test/test_webconfig_ui_runtime.py # Real Chromium, including independent stealth toggle @@ -160,6 +164,40 @@ does not reflect the GoogleTest count -- run the built binary directly | `test_trace_retry` | `src/Mesh.cpp`, `RTCClock`, `ClockSyncUtils.h`, retry and relay policy | app-v2 and boot-v3 traffic sharing `PAYLOAD_TYPE_OTA=0x0C` and the TempRadio suspend policy; opaque OTA relay behavior; background discovery priority; immediate primary transfer relay, receive-delay bypass, fast CAD retry, and no generic flood retry; trace and non-OTA flood retry timing; backward RTC correction; clock consensus/path policy and the 10-minute default drift threshold; advert receive/forward limits and matching read-only CLI listings | | `test_utils` | `src/Utils.cpp` | `Utils::toHex` (upstream) | +## V4 Bluetooth/OLED hardware regression + +`tools/hil/v4_pairing_stealth.py` complements the host tests with real NimBLE +pairing, protected GATT commands, USB-connected OLED frame capture, bonded +stealth reconnection, and MAC rotation triggers. The current helper deliberately +accepts only the MercerWoodMesh V4 USB identity; it is not a generic flasher. + +1. Record the original settings and make a verified **complete 16 MB flash + backup** before changing anything. The backup contains private keys: keep it + private and outside the repository. +2. Build the diagnostic-only `heltec_v4_companion_pairing_ui_hil_preview4` + environment with `platformio.heltec-v4-preview4.ini`. Run only one PlatformIO + process at a time. Do not distribute this instrumented image as firmware. +3. Preserve bootloader, partitions, NVS and SPIFFS when temporarily installing + the diagnostic application. On a compatible default-16MB layout, write the + matching `boot_app0.bin` at `0xe000` and application at `0x10000`. +4. On the Linux/BlueZ host with `pyserial`, `bleak`, and `dbus-fast`, run: + + ```sh + python3 tools/hil/v4_pairing_stealth.py \ + --serial /dev/serial/by-id/usb-Espressif_USB_JTAG_serial_debug_unit_44:1B:F6:69:CF:98-if00 \ + --output /private/test-results --allow-settings-changes + ``` + +5. Regardless of test outcome, restore the **complete original flash backup**. + Use `--inspect` before and after to verify firmware and settings. The helper + cleans up its test-created host pairing records but does **not** restore + target settings or firmware itself. + +The instrumented build records the real SSD1306 framebuffer, not a mock. This +does not replace optical display inspection, phone-app testing, a second +unbonded-central rejection test, or a true all-power-removed cold boot. See the +[preview4 hardware report](../docs/test-results/preview-1.17.1.8/heltec-v4-preview4-bluetooth-oled.md). + ## Conventions (and how to add a suite) - Each `test/test_/` directory builds into its **own** GoogleTest program diff --git a/test/fixtures/nimble_companion/mocks/NimBLEDevice.h b/test/fixtures/nimble_companion/mocks/NimBLEDevice.h index 951077f1..2b77b4aa 100644 --- a/test/fixtures/nimble_companion/mocks/NimBLEDevice.h +++ b/test/fixtures/nimble_companion/mocks/NimBLEDevice.h @@ -114,6 +114,12 @@ struct NimBLEServer { callbacks = cb; owns_callbacks = take_ownership; } void advertiseOnDisconnect(bool value) { auto_advertise = value; } + uint32_t displayPairingPasskey() { + // NimBLE-Arduino 2.5.1 only invokes the display callback when the + // configured static passkey is its 123456 sentinel. + return fake_nimble::pin == 123456 + ? callbacks->onPassKeyDisplay() : fake_nimble::pin; + } bool start() { return true; } uint8_t getConnectedCount() const { return connected; } uint16_t getPeerMTU(uint16_t) const { return fake_nimble::mtu; } diff --git a/test/fixtures/nimble_companion/test_nimble_companion.cpp b/test/fixtures/nimble_companion/test_nimble_companion.cpp index f17d2ea4..ea9d5aa2 100644 --- a/test/fixtures/nimble_companion/test_nimble_companion.cpp +++ b/test/fixtures/nimble_companion/test_nimble_companion.cpp @@ -33,14 +33,14 @@ static void test_identity_modes() { auto* server = NimBLEDevice::server.get(); assert(!server->owns_callbacks && !server->auto_advertise); assert(!server->advertising.active); - assert(pin == 246810 && bonding && mitm && sc && io_cap == BLE_HS_IO_DISPLAY_ONLY); + assert(pin == 123456 && bonding && mitm && sc && io_cap == BLE_HS_IO_DISPLAY_ONLY); interface.enable(); assert(server->advertising.active); assert(std::find(calls.begin(), calls.end(), "address-type") < std::find(calls.begin(), calls.end(), "advertise")); assert(server->advertising.name == "MeshCore-trial"); assert(server->advertising.service == "6E400001-B5A3-F393-E0A9-E50E24DCCA9E"); - assert(server->callbacks->onPassKeyDisplay() == 246810); + assert(server->displayPairingPasskey() == 246810); assert(interface.takePairingRequest()); assert(!interface.takePairingRequest()); NimBLEDevice::deinit(true); // Must never delete the application interface. @@ -55,6 +55,22 @@ static void test_identity_modes() { NimBLEDevice::deinit(true); } +static void test_pairing_wake_for_random_and_saved_pins() { + for (uint32_t expected : {100000u, 246810u, 123456u, 999999u}) { + reset(); + SerialBLEInterface interface; + assert(interface.begin("MeshCore-", "pairing", expected)); + interface.enable(); + assert(!interface.takePairingRequest()); // Scanning alone is not pairing. + auto* server = NimBLEDevice::server.get(); + assert(server->displayPairingPasskey() == expected); + assert(interface.takePairingRequest()); + assert(!interface.takePairingRequest()); + assert(bonding && mitm && sc && io_cap == BLE_HS_IO_DISPLAY_ONLY); + NimBLEDevice::deinit(true); + } +} + static void test_startup_failures() { const uint8_t custom[] = {0xc2, 0x12, 0x34, 0x56, 0x78, 0x9a}; for (const char* step : {"clear-bonds", "address", "address-type", "server", "service", "tx", "rx"}) { @@ -185,6 +201,7 @@ static void test_real_transport_frames() { int main() { test_identity_modes(); + test_pairing_wake_for_random_and_saved_pins(); test_startup_failures(); test_authentication_and_bond_lifecycle(); test_real_transport_frames(); diff --git a/test/test_bluetooth_pairing_ui.py b/test/test_bluetooth_pairing_ui.py index b8108487..09f33a9f 100644 --- a/test/test_bluetooth_pairing_ui.py +++ b/test/test_bluetooth_pairing_ui.py @@ -87,6 +87,9 @@ static_assert(mesh::ui::isBluetoothPairingPromptActive( self.assertIn("if (client_label != nullptr || show_bluetooth_pin)", full_ui) self.assertIn("drawBottomPairingBlock(display, home_content_top,", full_ui) self.assertIn("_pairing_screen_until = 0;", full_ui) + self.assertIn("client_label != nullptr && !show_bluetooth_pin", full_ui) + self.assertIn("showPairingPin(true);", full_ui) + self.assertIn("if (_pairing_from_button && c != 0)", full_ui) def test_incoming_pairing_overrides_setup_screen_with_top_pin(self): abstract_ui = ABSTRACT_UI.read_text(encoding="utf-8") diff --git a/test/test_companion_identity_startup.py b/test/test_companion_identity_startup.py new file mode 100644 index 00000000..15aeb22c --- /dev/null +++ b/test/test_companion_identity_startup.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +"""Execute the actual Companion identity-loading branch and its UI transitions.""" +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + +IDENTITY_STUB = r''' +#pragma once +#include +namespace mesh { +struct Identity { uint8_t pub_key[32] = {}; }; +struct LocalIdentity : Identity {}; +} +''' + +HARNESS = r''' +#include +#include +#include +#include +#define DISPLAY_CLASS TestDisplay +#define MESH_DEBUG_PRINTLN(...) do {} while (0) +namespace mesh { namespace ui { +struct StartupScreen { + std::string status; + std::vector phases; + void set(const char* next) { status = next; phases.push_back(status); } + void starting() { set("Starting..."); } + void loadingIdentity() { set("Loading identity"); } + void generatingKey() { set("Generating key"); } + void savingIdentity() { set("Saving identity"); } + static void progress(void*) {} +}; +}} +mesh::ui::StartupScreen screen; +struct Board { + unsigned reboots = 0; + void reboot() { ++reboots; } +} board; +struct Store { + bool loaded = true, allowed = true, save_ok = true; + unsigned saves = 0; + uint8_t prefix = 0x5a; + bool loadMainIdentity(mesh::LocalIdentity& identity) { + assert(screen.status == "Loading identity"); + identity.pub_key[0] = prefix; + return loaded; + } + bool canCreateMainIdentity() const { return allowed; } + bool saveMainIdentity(const mesh::LocalIdentity&) { + assert(screen.status == "Saving identity"); + assert(mesh::identityGenerationProgress().callback == nullptr); + ++saves; + return save_ok; + } +} store; +unsigned generated = 0; +bool valid_generated_key = true; +mesh::LocalIdentity radio_new_identity() { + assert(screen.status == "Generating key"); + ++generated; + mesh::LocalIdentity identity; + identity.pub_key[0] = valid_generated_key ? 0x42 : 0xff; + return identity; +} +mesh::LocalIdentity self_id; +static void bootIdentity() { + Store* _store = &store; + mesh::ui::StartupScreen* startup_screen = &screen; + @IDENTITY_BRANCH@ +} +static void reset() { + screen = {}; + store = {}; + board = {}; + generated = 0; + valid_generated_key = true; + self_id = {}; +} +int main() { + reset(); + store.allowed = false; // A valid saved identity never needs creation permission. + bootIdentity(); + assert(generated == 0 && store.saves == 0 && board.reboots == 0); + assert(self_id.pub_key[0] == 0x5a); + assert((screen.phases == std::vector{"Loading identity", "Starting..."})); + + reset(); + store.loaded = false; + bootIdentity(); + assert(generated == 1 && store.saves == 1 && board.reboots == 0); + assert((screen.phases == std::vector{ + "Loading identity", "Generating key", "Saving identity", "Starting..."})); + + reset(); + store.loaded = false; + store.allowed = false; // Unreadable identity: fail closed, do not replace it. + bootIdentity(); + assert(generated == 0 && store.saves == 0 && board.reboots == 1); + assert((screen.phases == std::vector{"Loading identity"})); + + reset(); + store.prefix = 0xff; // Existing reserved-prefix policy is unchanged. + bootIdentity(); + assert(generated == 1 && store.saves == 1 && self_id.pub_key[0] == 0x42); + + reset(); + store.loaded = false; + store.save_ok = false; + bootIdentity(); + assert(generated == 1 && store.saves == 1 && board.reboots == 1); + assert(screen.status == "Starting..."); + + reset(); + store.loaded = false; + valid_generated_key = false; + bootIdentity(); + assert(generated == mesh::MAX_LOCAL_IDENTITY_GENERATION_ATTEMPTS); + assert(store.saves == 0 && board.reboots == 1 && screen.status == "Starting..."); + assert(mesh::identityGenerationProgress().callback == nullptr); +} +''' + + +class CompanionIdentityStartupTests(unittest.TestCase): + def test_saved_missing_unreadable_and_failed_identity_transitions(self): + source = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text() + begin = source.index("void MyMesh::begin(") + start = source.index("#ifdef DISPLAY_CLASS\n if (startup_screen != nullptr) startup_screen->loadingIdentity();", begin) + end = source.index("#if defined(ENABLE_OTA)\n mesh::ota::ota_refresh_seeder_identity", start) + program = HARNESS.replace("@IDENTITY_BRANCH@", source[start:end]) + with tempfile.TemporaryDirectory(prefix="mesh-identity-startup-") as temp: + cpp, binary = Path(temp) / "test.cpp", Path(temp) / "test" + (Path(temp) / "Identity.h").write_text(IDENTITY_STUB) + cpp.write_text(program) + result = subprocess.run([ + "c++", "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-fsanitize=address,undefined", "-fno-pie", "-no-pie", + "-I", temp, "-I", str(ROOT / "src"), str(cpp), "-o", str(binary), + ], text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + result = subprocess.run([str(binary)], text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_companion_inbox.py b/test/test_companion_inbox.py index 826ab8cd..18ae07cb 100644 --- a/test/test_companion_inbox.py +++ b/test/test_companion_inbox.py @@ -25,7 +25,10 @@ UI_MEMBERS = r''' const char* inboxTitle() const; const char* client = "USB"; const char* connectedClientLabel() const { return connected ? client : nullptr; } - bool hasBluetoothConnection() const { return connected && std::string(client) == "BLUETOOTH"; } + bool hasBluetoothConnection() const { + return connected && (std::string(client) == "BLUETOOTH" + || std::string(client) == "BLE + USB"); + } bool isBluetoothEnabled() const { return true; } bool isPairingPromptActive() const { return pairing; } ''' @@ -270,8 +273,14 @@ int main() { renderHome(display, &task); assert(display.contains("HISTORY: 12")); assert(display.contains("Pending: 0")); - assert(display.contains("CONNECTED")); - if (dims.second >= 135) assert(display.contains(client)); + if (task.hasBluetoothConnection()) { + assert(display.contains("CONNECTED")); + if (dims.second >= 135) assert(display.contains(client)); + } else { + // A wired/TCP connection must not conceal an unpaired BLE PIN. + assert(display.contains("123456")); + assert(!display.contains("CONNECTED")); + } } } } diff --git a/test/test_companion_pairing_display.py b/test/test_companion_pairing_display.py new file mode 100644 index 00000000..d3bed4ba --- /dev/null +++ b/test/test_companion_pairing_display.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +"""Run the production pairing UI methods and OLED selection with USB present.""" +from pathlib import Path +import subprocess +import tempfile +import unittest + +from test_replay_reset_integration import extract_braced + +ROOT = Path(__file__).resolve().parents[1] + +HARNESS = r''' +#include +#include +#include +#include +#include +#include +#include +ColorVal UIColor::window_bkg = 0, UIColor::title_bkg = 0, UIColor::title_txt = 1; +ColorVal UIColor::primary_txt = 1, UIColor::secondary_txt = 1, UIColor::warning_txt = 1; +ColorVal UIColor::popup_bkg = 0, UIColor::popup_txt = 1, UIColor::corp_blue = 1; +constexpr unsigned long BLE_PAIRING_DISPLAY_MILLIS = 120000; +struct TestDisplay : DisplayDriver { + bool on = false; + std::vector text; + TestDisplay() : DisplayDriver(128, 64) {} + bool isOn() override { return on; } + void turnOn() override { on = true; } + void turnOff() override { on = false; } + void clear() override {} + void startFrame(ColorVal) override { text.clear(); } + void setTextSize(int) override {} + void setColor(ColorVal) override {} + void setCursor(int, int) override {} + void print(const char* value) override { text.emplace_back(value); } + void fillRect(int, int, int, int) override {} + void drawRect(int, int, int, int) override {} + void drawXbm(int, int, const uint8_t*, int, int) override {} + uint16_t getTextWidth(const char* value) override { return strlen(value) * 6; } + void endFrame() override {} +}; +struct Board { + bool usb = true; + bool isExternalPowered() const { return usb; } + bool isUsbHostConnected() const { return usb; } +}; +struct Interfaces { + bool pending = false, enabled = true, usb = true, ble = false; + bool takePairingRequest() { bool result = pending; pending = false; return result; } +}; +struct UIScreen {}; +struct HomeScreen : UIScreen { void showFirstPage() {} }; +struct MsgPreviewScreen : UIScreen { bool hasMessages() const { return false; } }; +struct Mesh { + uint32_t pin = 246810; + uint32_t getBLEPin() const { return pin; } + void notificationButton() {} +} the_mesh; +struct UITask { + TestDisplay* _display; + Board* _board; + Interfaces* _interfaceManager; + HomeScreen home_screen; + MsgPreviewScreen preview_screen; + UIScreen* home = &home_screen; + UIScreen* msg_preview = &preview_screen; + UIScreen* curr = home; + unsigned long _pairing_screen_until = 0, _next_refresh = 0; + bool _pairing_from_button = false, _deferred_msg_preview = false; + int _msgcount = 0; + UITask(TestDisplay* display, Board* board, Interfaces* interfaces) + : _display(display), _board(board), _interfaceManager(interfaces) {} + bool isBluetoothEnabled() const { return _interfaceManager->enabled; } + bool hasBluetoothConnection() const { return _interfaceManager->ble; } + bool hasConnection() const { return _interfaceManager->ble || _interfaceManager->usb; } + const char* connectedClientLabel() const { + return _interfaceManager->ble ? "BLUETOOTH" : _interfaceManager->usb ? "USB" : nullptr; + } + void setCurrScreen(UIScreen* screen) { curr = screen; _next_refresh = 100; } + void gotoHomeScreen() { setCurrScreen(home); } + bool isPairingPromptActive() const { return isPairingScreenActive(); } + bool isPairingScreenActive() const; + void renderPairingBanner(); + void showPairingPin(bool from_button = false); + void finishPairingScreen(bool timed_out); + void servicePairingState(); + char checkDisplayOn(char c); + void navigation(char c); +}; +@METHODS@ +static void homePairing(UITask* _task, TestDisplay& display) { + const int home_content_top = 20; + @HOME_SELECTION@ +} +int main() { + using namespace mesh::ui; + g_mock_millis = 100; + displayPowerPrefs() = DisplayPowerPrefs(); + displayPowerPrefs().usb.mode = DisplayMode::ButtonPairing; + Board board; + Interfaces interfaces; + TestDisplay display; + UITask ui{&display, &board, &interfaces}; + ui.servicePairingState(); + assert(!display.isOn() && !ui.isPairingScreenActive()); + interfaces.pending = true; // Real PIN request, while USB remains connected. + ui.servicePairingState(); + assert(display.isOn() && ui.isPairingScreenActive()); + assert(!ui._pairing_from_button && interfaces.usb && !interfaces.ble); + ui.renderPairingBanner(); + assert(display.text.back() == "PAIR PIN 246810"); + ui.navigation('n'); // Navigation may not steal a real phone pairing prompt. + assert(ui.isPairingScreenActive()); + g_mock_millis += 120000; + ui.servicePairingState(); + assert(!ui.isPairingScreenActive() && !display.isOn()); + + // Manual button wake must expose the PIN despite USB and still allow paging. + assert(ui.checkDisplayOn('n') == 0); + assert(display.isOn() && ui.isPairingScreenActive() && ui._pairing_from_button); + assert(ui._pairing_screen_until - millis() == 15000); + ui.renderPairingBanner(); + assert(display.text.back() == "PAIR PIN 246810"); + ui.navigation('n'); + assert(!ui.isPairingScreenActive() && !ui._pairing_from_button); + display.text.clear(); + homePairing(&ui, display); + assert(display.text.back() == "246810"); // USB must not replace the idle PIN. + + interfaces.pending = true; + ui.servicePairingState(); + interfaces.ble = true; // Successful BLE pairing, not merely a USB connection. + ui.servicePairingState(); + assert(!ui.isPairingScreenActive()); + display.text.clear(); + homePairing(&ui, display); + assert(display.text.back() == "CONNECTED"); + + // Explicit off must remain off; disabling BLE must not reveal a stale PIN. + interfaces.ble = false; + displayPowerPrefs().usb.mode = DisplayMode::Off; + ui.servicePairingState(); + interfaces.pending = true; + ui.servicePairingState(); + assert(!display.isOn()); + ui.finishPairingScreen(false); + ui.checkDisplayOn('n'); + assert(!display.isOn() && !ui._pairing_from_button); + displayPowerPrefs().usb.mode = DisplayMode::ButtonPairing; + interfaces.enabled = false; + ui.servicePairingState(); + ui.checkDisplayOn('n'); + assert(display.isOn() && !ui.isPairingScreenActive()); + display.text.clear(); + homePairing(&ui, display); + assert(display.text.back() == "CONNECTED"); // Only USB status remains. +} +''' + + +class CompanionPairingDisplayTests(unittest.TestCase): + def test_real_pairing_methods_and_usb_oled_selection(self): + source = (ROOT / "examples/companion_radio/ui-new/UITask.cpp").read_text() + signatures = ( + "bool UITask::isPairingScreenActive() const", + "void UITask::renderPairingBanner()", + "void UITask::showPairingPin(bool from_button)", + "void UITask::finishPairingScreen(bool timed_out)", + "void UITask::servicePairingState()", + "char UITask::checkDisplayOn(char c)", + ) + methods = "\n".join(extract_braced(source, signature) + "\n" + for signature in signatures) + navigation = extract_braced(source, "if (_pairing_from_button && c != 0)") + methods += "void UITask::navigation(char c) {" + navigation + "}\n" + marker = "#else\n const bool bluetooth_enabled = _task->isBluetoothEnabled();" + compact = source[source.index(marker) + len("#else\n"):] + condition = "if (client_label != nullptr || show_bluetooth_pin)" + selection = compact[:compact.index(condition)] + extract_braced(compact, condition) + "\n" + program = HARNESS.replace("@METHODS@", methods).replace("@HOME_SELECTION@", selection) + with tempfile.TemporaryDirectory(prefix="mesh-pairing-display-") as temp: + cpp, binary = Path(temp) / "test.cpp", Path(temp) / "test" + cpp.write_text(program) + result = subprocess.run([ + "c++", "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-Wno-unused-parameter", + "-fsanitize=address,undefined", "-fno-pie", "-no-pie", + "-isystem", str(ROOT / "test/mocks"), "-I", str(ROOT / "src"), + str(cpp), str(ROOT / "src/helpers/ui/DisplayDriver.cpp"), + "-o", str(binary), + ], text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + result = subprocess.run([str(binary)], text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_display_driver/test_display_driver.cpp b/test/test_display_driver/test_display_driver.cpp index 086a8432..128523f4 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,177 @@ 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, LoadingSavedIdentityNeverClaimsGeneration) { + mesh::ui::displayPowerPrefs() = mesh::ui::DisplayPowerPrefs(); + StartupTestDisplay display(128, 64, 6); + mesh::ui::StartupScreen screen; + screen.begin(&display, true); + screen.loadingIdentity(); + ASSERT_EQ(1U, display.rows.size()); + EXPECT_EQ("Loading identity", display.rows[0].text); + EXPECT_TRUE(display.fills.empty()); + const int frames = display.frames; + g_mock_millis += 1000; + screen.service(); + EXPECT_EQ(frames, display.frames); + screen.starting(); + EXPECT_EQ("Starting...", display.rows[0].text); +} + +TEST(StartupScreen, SavingIdentityClearsGenerationAndSpinnerImmediately) { + mesh::ui::displayPowerPrefs() = mesh::ui::DisplayPowerPrefs(); + StartupTestDisplay display(128, 64, 6); + mesh::ui::StartupScreen screen; + screen.begin(&display, true); + screen.generatingKey(); + EXPECT_EQ("Generating key", display.rows[0].text); + EXPECT_FALSE(display.fills.empty()); + screen.savingIdentity(); + EXPECT_EQ("Saving identity", display.rows[0].text); + EXPECT_TRUE(display.fills.empty()); + const int frames = display.frames; + g_mock_millis += 1000; + screen.service(); + EXPECT_EQ(frames, display.frames); +} + +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_identity_generation/test_identity_generation.cpp b/test/test_identity_generation/test_identity_generation.cpp index 0f65f3d5..dd155780 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 00000000..32db71a4 --- /dev/null +++ b/test/test_startup_screen.py @@ -0,0 +1,129 @@ +#!/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()")) + save_call = "_store->saveMainIdentity(" if role == "companion_radio" else "store.saveWithRetry(" + self.assertLess(identity_source.index("savingIdentity()"), + identity_source.index(save_call)) + if role == "companion_radio": + self.assertLess(identity_source.index("loadingIdentity()"), + identity_source.index("_store->loadMainIdentity(")) + + 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() diff --git a/tools/hil/CompanionPairingUiProbe.cpp b/tools/hil/CompanionPairingUiProbe.cpp new file mode 100644 index 00000000..43f8671a --- /dev/null +++ b/tools/hil/CompanionPairingUiProbe.cpp @@ -0,0 +1,53 @@ +// Opt-in V4 OLED instrumentation. Not linked into any production image. +// Keep USB logging enabled while collecting this ASCII-only test stream. +#if defined(COMPANION_PAIRING_UI_HIL) +#include +#include +#include "../../examples/companion_radio/MyMesh.h" +#include "../../examples/companion_radio/ui-new/UITask.h" +#include + +extern MyMesh the_mesh; +extern UITask ui_task; +static uint8_t startup_phases = 0; + +void companionStartupUiHilFrame(const char* status) { + if (strcmp(status, "Starting...") == 0) startup_phases |= 1; + if (strcmp(status, "Loading identity") == 0) startup_phases |= 2; + if (strcmp(status, "Generating key") == 0) startup_phases |= 4; + if (strcmp(status, "Saving identity") == 0) startup_phases |= 8; +} + +void companionPairingUiHilProbe() { + if (!mesh::isUsbLoggingEnabled()) return; + const bool pairing = ui_task.isPairingPromptActive(); + const bool on = display.isOn(); + const bool ble = ui_task.hasBluetoothConnection(); + const bool usb = board.isUsbHostConnected(); + const uint32_t state = on | (pairing << 1) | (ble << 2) | (usb << 3) + | (uint32_t(startup_phases) << 4); + static uint32_t previous = UINT32_MAX; + if (state == previous) return; + const bool capture = pairing && on && (previous == UINT32_MAX || !(previous & 2)); + previous = state; + char public_key[65]; + mesh::Utils::toHex(public_key, the_mesh.self_id.pub_key, 32); + mesh::usbLoggingPort().printf( + "UIHIL ms=%lu on=%u pairing=%u ble=%u usb=%u startup=%u pin=%lu key=%s\n", + (unsigned long)millis(), on, pairing, ble, usb, startup_phases, + (unsigned long)the_mesh.getBLEPin(), public_key); + if (capture) { + const uint8_t* pixels = display.hilFramebuffer(); + // One bounded diagnostic write; never read private key material. + char record[2070]; + memcpy(record, "UIHIL pixels=", 13); + static const char hex[] = "0123456789abcdef"; + for (size_t i = 0; i < 1024; ++i) { + record[13 + i * 2] = hex[pixels[i] >> 4]; + record[14 + i * 2] = hex[pixels[i] & 15]; + } + record[2061] = '\n'; + mesh::usbLoggingPort().write(reinterpret_cast(record), 2062); + } +} +#endif diff --git a/tools/hil/v4_pairing_stealth.py b/tools/hil/v4_pairing_stealth.py new file mode 100644 index 00000000..34cb5d90 --- /dev/null +++ b/tools/hil/v4_pairing_stealth.py @@ -0,0 +1,342 @@ +#!/usr/bin/env python3 +"""Exact-target BLE/OLED regression test; changes settings and reboots the V4. + +Use only after a verified full-flash backup. The caller restores that backup +afterward. The helper does not read contacts/messages/private keys or request +LoRa transmissions; normal background radio activity is not disabled. +Requires pyserial, bleak and dbus-fast on the Bluetooth test host. +""" +import argparse +import asyncio +from contextlib import asynccontextmanager +import json +from pathlib import Path +import re +import threading +import time + +import serial +from bleak import BleakClient, BleakScanner +from bleak.backends.device import BLEDevice +from dbus_fast import BusType, DBusError, Message, MessageType +from dbus_fast.aio import MessageBus +from dbus_fast.service import ServiceInterface, method + +RX = '6e400002-b5a3-f393-e0a9-e50e24dcca9e' +TX = '6e400003-b5a3-f393-e0a9-e50e24dcca9e' +PIN = 246810 # Deliberately not NimBLE's 123456 callback sentinel. + + +def report(event, **values): + print(json.dumps(dict(event=event, **values)), flush=True) + + +async def call(bus, path, interface, member, signature='', body=None): + reply = await asyncio.wait_for(bus.call(Message( + destination='org.bluez', path=path, interface=interface, member=member, + signature=signature, body=body or [])), 15) + if reply.message_type == MessageType.ERROR: + raise RuntimeError(f'{member}: {reply.error_name}: {reply.body}') + return reply.body + + +async def props(bus, path): + values = (await call(bus, path, 'org.freedesktop.DBus.Properties', + 'GetAll', 's', ['org.bluez.Device1']))[0] + return {key: value.value for key, value in values.items()} + + +class Agent(ServiceInterface): + def __init__(self, target): + super().__init__('org.bluez.Agent1') + self.target = target + self.requests = 0 + + def check(self, device): + if device != self.target: + raise DBusError('org.bluez.Error.Rejected', 'Not the test V4') + + @method() + async def RequestPasskey(self, device: 'o') -> 'u': + self.check(device) + self.requests += 1 + report('passkey_requested', address=device) + # Leave the real pairing request outstanding long enough to capture + # the physical display driver's frame, not a manually invoked callback. + await asyncio.sleep(3) + return PIN + + @method() + def RequestPinCode(self, device: 'o') -> 's': + self.check(device) + return str(PIN) + + @method() + def RequestAuthorization(self, device: 'o'): + self.check(device) + + @method() + def AuthorizeService(self, device: 'o', uuid: 's'): + self.check(device) + + @method() + def Cancel(self): + report('pairing_cancelled') + + @method() + def Release(self): + pass + + +class USB: + def __init__(self, path, output): + self.output = output + self.data = bytearray() + self.lock = threading.Lock() + self.stop = threading.Event() + self.port = serial.Serial(None, 115200, timeout=.05, exclusive=True) + self.port.rts = False + self.port.dtr = True + self.port.port = path + self.port.open() + self.reader = threading.Thread(target=self.read, daemon=True) + self.reader.start() + + def read(self): + while not self.stop.is_set(): + value = self.port.read(max(1, self.port.in_waiting)) + with self.lock: + self.data.extend(value) + + def snapshot(self): + with self.lock: + return bytes(self.data).decode('ascii', errors='replace') + + def command_sync(self, command, timeout=8): + start = len(self.snapshot()) + self.port.write((command + '\r\n').encode('ascii')) + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + response = self.snapshot()[start:] + # Logging can follow a prompt immediately, without a newline. + # Reply values are indented (" > ..."), unlike the actual prompt. + if command in response and '\n> ' in response: + if 'Error:' in response or 'ERROR:' in response: + raise RuntimeError(f'{command}: {response}') + return response + time.sleep(.05) + raise RuntimeError(f'USB timeout for {command}: {self.snapshot()[start:]}') + + async def command(self, command): + value = await asyncio.to_thread(self.command_sync, command) + # Do not publish the framebuffer or unrelated diagnostics as CLI text. + clean = '\n'.join(line for line in value.splitlines() + if not line.startswith('UIHIL pixels=')) + report('usb', command=command, reply=clean) + return value + + async def reboot(self): + self.port.write(b'reboot\r\n') + await asyncio.sleep(7) + value = await self.command('ver') + assert 'v1.17.1.8-preview4' in value, value + + def close(self): + self.stop.set() + self.reader.join(timeout=1) + self.output.joinpath('usb.log').write_text(self.snapshot()) + self.port.close() + + +class Suite: + def __init__(self, args): + self.args = args + self.output = Path(args.output) + self.output.mkdir(parents=True, exist_ok=True) + self.usb = USB(args.serial, self.output) + self.bus = None + self.addresses = set() + self.frames = 0 + + def path(self, address): + return '/org/bluez/hci0/dev_' + address.replace(':', '_') + + async def scan(self, expected=None): + def matches(device, adv): + return adv.local_name == self.args.name + device = await BleakScanner.find_device_by_filter( + matches, timeout=20, bluez={'adapter': 'hci0'}) + assert device is not None, 'Named target advertisement absent' + address = device.address.upper() + if expected: + assert address == expected, (address, expected) + self.addresses.add(address) + report('discovery', address=address, name=self.args.name) + return address + + async def forget(self, address): + try: + await props(self.bus, self.path(address)) + except RuntimeError: + return + await call(self.bus, '/org/bluez/hci0', 'org.bluez.Adapter1', + 'RemoveDevice', 'o', [self.path(address)]) + + @asynccontextmanager + async def connect(self, address, fresh=True, expect_oled=False): + if fresh: + await self.forget(address) + assert await BleakScanner.find_device_by_address(address, timeout=15) + info = await props(self.bus, self.path(address)) + device = BLEDevice(address, info.get('Name'), + {'path': self.path(address), 'props': info}) + agent_path = '/org/meshcore/v4_preview4_pairing_agent' + agent = Agent(self.path(address)) + self.bus.export(agent_path, agent) + await call(self.bus, '/org/bluez', 'org.bluez.AgentManager1', + 'RegisterAgent', 'os', [agent_path, 'KeyboardOnly']) + await call(self.bus, '/org/bluez', 'org.bluez.AgentManager1', + 'RequestDefaultAgent', 'o', [agent_path]) + start = len(self.usb.snapshot()) + client = BleakClient(device, pair=fresh, timeout=40) + queue = asyncio.Queue() + try: + await client.connect() + await client._backend._acquire_mtu() + await client.start_notify(TX, lambda _, data: queue.put_nowait(bytes(data))) + info = await props(self.bus, self.path(address)) + assert info.get('Paired') and info.get('Bonded'), info + assert agent.requests == (1 if fresh else 0), agent.requests + + async def request(payload, expected): + while not queue.empty(): + queue.get_nowait() + await client.write_gatt_char(RX, payload, response=True) + while True: + value = await asyncio.wait_for(queue.get(), 10) + if value and value[0] == expected: + return value + assert not value or value[0] != 1, value.hex() + + info = await request(b'\x16\x0e', 13) + assert b'Heltec' in info[20:60] or b'heltec' in info[20:60], info + assert b'v1.17.1.8-preview4' in info[60:80], info + assert await request(b'\x16\x0e' + bytes(174), 13) == info + stats = await request(b'\x38\x00', 24) + assert int.from_bytes(stats[8:10], 'little') == 0, stats.hex() + await asyncio.sleep(2) + stealth = await request(b'\x42get bluetooth.stealth', 29) + assert b'bonded-peer-only' in stealth, stealth + report('protected_ble_session', address=address, fresh=fresh, + mtu=client.mtu_size, pin_requests=agent.requests, + error_flags=0, stealth=stealth[1:].decode()) + if expect_oled: + trace = self.usb.snapshot()[start:] + assert re.search(r'UIHIL .*on=1 pairing=1 ble=0 usb=1', trace), trace + frames = re.findall(r'UIHIL pixels=([0-9a-f]{2048})', trace) + assert frames, 'No complete real OLED framebuffer captured' + for frame in frames: + self.frames += 1 + self.output.joinpath(f'pairing-{self.frames}.oled.bin').write_bytes(bytes.fromhex(frame)) + report('oled_pairing_wake', usb_connected=True, frames=len(frames), + pin=PIN, bytes_per_frame=1024) + yield client + finally: + if client.is_connected: + await client.disconnect() + await call(self.bus, '/org/bluez', 'org.bluez.AgentManager1', + 'UnregisterAgent', 'o', [agent_path]) + self.bus.unexport(agent_path) + + async def mode(self, value): + await self.usb.command('set bluetooth.mac ' + value) + await self.usb.reboot() + return await self.scan() + + async def run(self): + self.bus = await MessageBus(bus_type=BusType.SYSTEM).connect() + try: + await asyncio.sleep(7) + assert 'v1.17.1.8-preview4' in await self.usb.command('ver') + for command in (f'set pin {PIN}', 'set bluetooth on', + 'set bluetooth.name ' + self.args.name, + 'set display.usb.mode button-pairing', + 'set usb.logging on reboot'): + await self.usb.command(command) + await self.usb.reboot() + assert re.search(r'UIHIL .*startup=3\b', self.usb.snapshot()), 'Saved-key boot must only show Starting/Loading' + assert 'startup=7' not in self.usb.snapshot() and 'startup=15' not in self.usb.snapshot() + report('saved_identity_startup', phases=['Starting', 'Loading identity'], generated=False) + await self.usb.command('set display.usb.mode pairing') + await self.usb.command('set bluetooth.stealth on') + address = await self.mode(self.args.custom_address) + async with self.connect(address, expect_oled=True): + pass + async with self.connect(address, fresh=False): + pass + await self.usb.reboot() + async with self.connect(address, fresh=False): + pass + report('stealth_bonded_reconnect_and_reboot', address=address) + first = await self.mode('random') + await self.usb.reboot() + await self.scan(first) + report('saved_random_retained', address=first) + first = await self.mode('random-after-connect') + await self.usb.reboot() + await self.scan(first) + report('unused_boot_retained', address=first) + async with self.connect(first, expect_oled=True): + pass + assert 'armed for next boot' in await self.usb.command('get bluetooth.mac') + await self.usb.reboot() + second = await self.scan() + assert first != second, (first, second) + report('authenticated_boot_rotated', before=first, after=second, stealth=True) + first = await self.mode('random-every-boot') + await self.usb.reboot() + second = await self.scan() + assert first != second, (first, second) + report('every_boot_rotated', before=first, after=second, stealth=True) + keys = set(re.findall(r'UIHIL .*key=([0-9A-Fa-f]{64})', self.usb.snapshot())) + assert len(keys) == 1, ('Public identity changed during test', keys) + report('suite_passed', addresses=len(self.addresses), oled_frames=self.frames, + identity_preserved=True) + finally: + for address in self.addresses: + try: + await self.forget(address) + except Exception as exc: + report('cleanup_warning', address=address, error=str(exc)) + self.usb.close() + self.bus.disconnect() + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--serial', required=True) + parser.add_argument('--output', required=True) + parser.add_argument('--name', default='HIL-V4-preview4') + parser.add_argument('--custom-address', default='DE:04:26:10:02:01', type=str.upper) + parser.add_argument('--inspect', action='store_true', help='Read-only pre/post-flash identity and settings snapshot') + parser.add_argument('--allow-settings-changes', action='store_true') + args = parser.parse_args() + assert '44:1B:F6:69:CF:98' in args.serial, 'Unexpected USB serial identity' + assert re.fullmatch(r'[A-Za-z0-9_-]{1,24}', args.name), 'Invalid diagnostic name' + assert re.fullmatch(r'[C-F][0-9A-F](?::[0-9A-F]{2}){5}', args.custom_address), 'Not a random-static address' + if args.inspect: + output = Path(args.output) + output.mkdir(parents=True, exist_ok=True) + usb = USB(args.serial, output) + try: + snapshot = {command: usb.command_sync(command) for command in ( + 'ver', 'board', 'get bluetooth', + 'get bluetooth.mac', 'get bluetooth.stealth', 'get bluetooth.name', + 'get display.usb.mode', 'get usb.logging')} + output.joinpath('snapshot.json').write_text(json.dumps(snapshot, indent=2)) + report('snapshot', **snapshot) + finally: + usb.close() + else: + assert args.allow_settings_changes, 'Full-flash backup and --allow-settings-changes required' + asyncio.run(Suite(args).run())