Fix Companion pairing PIN display and truthful identity startup

This commit is contained in:
mikecarper committed 2026-10-02 12:42:26 -07:00
1 parent a2854f8f43
commit 202930de8f
31 files changed
+1915 -148

No files matched your search

+5
View File
@@ -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
+13
View File
@@ -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
@@ -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.
@@ -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)
+25 -2
View File
@@ -1,4 +1,5 @@
#include <helpers/ui/DisplayPowerSettings.h>
#include <helpers/ui/StartupScreen.h>
#include "NotificationSettingsFile.h"
#include "MyMesh.h"
#include <helpers/CompanionTxRoutingCLI.h>
@@ -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();
+4 -1
View File
@@ -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);
+78 -35
View File
@@ -1,10 +1,15 @@
#include <Arduino.h> // needed for PlatformIO
#include <helpers/ui/StartupScreen.h>
#include <helpers/ui/DisplayPowerSettings.h>
#include <Mesh.h>
#include <helpers/BluetoothMac.h>
#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 <helpers/SerialPacketLog.h>
#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();
+25 -8
View File
@@ -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<MsgPreviewScreen*>(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<HomeScreen*>(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
+2 -1
View File
@@ -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:
+62 -44
View File
@@ -1,6 +1,8 @@
#include <Arduino.h> // needed for PlatformIO
#include <Mesh.h>
#include <helpers/IdentityGeneration.h>
#include <helpers/ui/StartupScreen.h>
#include <helpers/ui/DisplayPowerSettings.h>
#if MESH_PACKET_LOGGING
#include <helpers/SerialPacketLog.h>
#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)
+40 -22
View File
@@ -1,6 +1,8 @@
#include <Arduino.h> // needed for PlatformIO
#include <Mesh.h>
#include <helpers/IdentityGeneration.h>
#include <helpers/ui/StartupScreen.h>
#include <helpers/ui/DisplayPowerSettings.h>
#if MESH_PACKET_LOGGING
#include <helpers/SerialPacketLog.h>
#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)
+50 -29
View File
@@ -1,5 +1,7 @@
#include "SensorMesh.h"
#include <helpers/IdentityGeneration.h>
#include <helpers/ui/StartupScreen.h>
#include <helpers/ui/DisplayPowerSettings.h>
#if defined(NRF52_PLATFORM)
#include <helpers/nrf52/InternalPrimaryFsBoot.h>
#include <helpers/nrf52/RamFallbackFileSystem.h>
@@ -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
+81
View File
@@ -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}
+<helpers/ota/*.cpp>
+<helpers/ota/detools/detools.c>
lib_deps =
${env:Heltec_v3_companion_radio_full.lib_deps}
h2zero/NimBLE-Arduino @ 2.5.1
lib_ignore = ESP32 BLE Arduino
+91
View File
@@ -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}
+<helpers/ota/*.cpp>
+<helpers/ota/detools/detools.c>
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>
+34
View File
@@ -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;
+5 -1
View File
@@ -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
+2
View File
@@ -4,6 +4,7 @@
#include <Mesh.h>
#include <RadioLib.h>
#include <helpers/IdentityGeneration.h>
#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);
+4
View File
@@ -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;
+130
View File
@@ -0,0 +1,130 @@
#pragma once
#include "DisplayDriver.h"
#include <Arduino.h>
#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<StartupScreen*>(context)->service();
}
};
} }
+38
View File
@@ -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_<name>/` directory builds into its **own** GoogleTest program
+6
View File
@@ -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; }
+19 -2
View File
@@ -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();
+3
View File
@@ -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")
+150
View File
@@ -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 <stdint.h>
namespace mesh {
struct Identity { uint8_t pub_key[32] = {}; };
struct LocalIdentity : Identity {};
}
'''
HARNESS = r'''
#include <helpers/IdentityGeneration.h>
#include <cassert>
#include <string>
#include <vector>
#define DISPLAY_CLASS TestDisplay
#define MESH_DEBUG_PRINTLN(...) do {} while (0)
namespace mesh { namespace ui {
struct StartupScreen {
std::string status;
std::vector<std::string> 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<std::string>{"Loading identity", "Starting..."}));
reset();
store.loaded = false;
bootIdentity();
assert(generated == 1 && store.saves == 1 && board.reboots == 0);
assert((screen.phases == std::vector<std::string>{
"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<std::string>{"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()
+12 -3
View File
@@ -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"));
}
}
}
}
+199
View File
@@ -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 <Arduino.h>
#include <helpers/ui/DisplayDriver.h>
#include <helpers/ui/BluetoothPairingUiPolicy.h>
#include <helpers/ui/CompanionHomeLayout.h>
#include <cassert>
#include <string>
#include <vector>
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<std::string> 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()
@@ -1,6 +1,8 @@
#include <gtest/gtest.h>
#include <helpers/ui/DisplayDriver.h>
#include <helpers/ui/StartupScreen.h>
#include <helpers/IdentityGeneration.h>
#include <helpers/ui/CompanionHomeLayout.h>
#include <helpers/ui/DisplayTextLayout.h>
#include <helpers/ui/SmallMessageText.h>
@@ -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");
@@ -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<unsigned*>(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();
+129
View File
@@ -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 <helpers/IdentityGeneration.h>
#include <cassert>
#include <vector>
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<PhysicalLayer*>(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()
+53
View File
@@ -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 <Arduino.h>
#include <helpers/UsbLogging.h>
#include "../../examples/companion_radio/MyMesh.h"
#include "../../examples/companion_radio/ui-new/UITask.h"
#include <target.h>
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<uint8_t*>(record), 2062);
}
}
#endif
+342
View File
@@ -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())