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

+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()