Fix Companion pairing PIN display and truthful identity startup
This commit is contained in:
1 parent
a2854f8f43
commit
202930de8f
31 files changed
+1915
-148
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user