From 85fd38290b0069e7d443652267a2a8c8238a0034 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Tue, 27 Oct 2026 13:26:28 -0700 Subject: [PATCH] Show first-boot key progress with accumulating e-paper dots --- docs/cli_commands.md | 20 ++- src/helpers/ui/StartupScreen.h | 78 +++++++- .../test_display_driver.cpp | 168 +++++++++++++++++- 3 files changed, 245 insertions(+), 21 deletions(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 1f793625f..d364ab75c 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -1430,11 +1430,18 @@ and sensors do not gain this Companion-only normal-UI boot wake. On display-equipped Companions, repeaters, room servers, and sensors, a small `Starting...` screen appears as soon as the board, primary filesystem, and display are initialized, before radio initialization and serial-settle delays. -If a new identity is needed, it changes to `Generating key` with an animated -spinner during radio entropy collection. OLED/TFT updates are paced at 150 ms; -e-paper uses a slower one-second cadence. Saved `off` and pairing-only modes -still suppress this startup screen. The normal UI's boot timeout starts only -when that UI is ready, so key generation does not consume it. Filesystem +If a new identity is needed, it changes to `Generating key`. OLED/TFT screens +show a spinner and elapsed seconds, updating at most twice a second; very +small screens omit the elapsed time. E-paper adds dots from left to right, +row by row, preserving the text and earlier dots. New dots are at least five +seconds apart, slowing further when refreshes are expensive, and stop when +the grid is full. These indicators show activity rather than a percentage. + +First-boot key generation and saving temporarily appear even in saved `off` +or pairing-only modes. The selected mode resumes afterward without changing +preferences. Normal startup with an existing key still respects those modes. +The normal UI's boot timeout starts only when that UI is ready, so key +generation does not consume it. Filesystem recovery and the display driver's own initialization can still delay the first visible frame; the firmware cannot draw before those operations finish. @@ -1451,7 +1458,8 @@ 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. +- `off`: stays dark during normal boot and pairing; first-boot key generation + has the temporary exception described above. - `on`: stays on while the device is awake. - `button`: a physical button, keyboard/navigation input, or supported touch wakes the display and restarts the timeout. diff --git a/src/helpers/ui/StartupScreen.h b/src/helpers/ui/StartupScreen.h index 992c3e2f9..b841b9450 100644 --- a/src/helpers/ui/StartupScreen.h +++ b/src/helpers/ui/StartupScreen.h @@ -2,6 +2,7 @@ #include "DisplayDriver.h" #include +#include #if defined(COMPANION_PAIRING_UI_HIL) void companionStartupUiHilFrame(const char* status); @@ -13,13 +14,38 @@ namespace mesh { namespace ui { // its boot timeout must start when the real UI is ready, not during keygen. class StartupScreen { DisplayDriver* _display = nullptr; + bool _quiet = false; + bool _keygen_visible = false; bool _generating = false; const char* _status = "Starting..."; uint8_t _phase = 0; uint32_t _last_frame = 0; + uint32_t _generation_started = 0; + uint32_t _eink_interval = 5000; + uint16_t _dot_columns = 0, _dot_rows = 0; + uint32_t _dots_drawn = 0; + int _dot_top = 0; + + void appendEinkDot() { + if (_dots_drawn >= uint32_t(_dot_columns) * _dot_rows) return; + const uint32_t started = millis(); + _display->setColor(UIColor::primary_txt); + _display->fillRect(4 + int(_dots_drawn % _dot_columns) * 8, + _dot_top + int(_dots_drawn / _dot_columns) * 8, 3, 3); + ++_dots_drawn; + // Preserve the initial label and every previous dot in the framebuffer. + _display->endFrame(); + _last_frame = millis(); + // A partial refresh can block for a second. Give entropy collection ten + // times its measured cost, capped at a minute. Exclude the initial full + // refresh and saturate before multiplying an unbounded duration. + const uint32_t duration = uint32_t(_last_frame - started); + _eink_interval = duration >= 6000 ? 60000 : duration * 10; + if (_eink_interval < 5000) _eink_interval = 5000; + } void draw() { - if (_display == nullptr) return; + if (_display == nullptr || (_quiet && !_keygen_visible)) return; DisplayDriver& display = *_display; display.startFrame(); display.setTextSize(1); @@ -39,19 +65,35 @@ class StartupScreen { 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"; } } + 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); + const bool eink_progress = _generating && display.isEink(); + const bool elapsed = _generating && !eink_progress + && text_height + spinner_size + 4 + line <= display.height(); + const int total_height = text_height + + (_generating && !eink_progress ? spinner_size + 2 : 0) + + (elapsed ? line + 2 : 0); int y = (display.height() - total_height) / 2; + if (eink_progress) y = 4; 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) { + if (eink_progress) { + _dot_top = y + text_height + 8; + _dot_columns = display.width() >= 11 ? (display.width() - 11) / 8 + 1 : 0; + _dot_rows = display.height() >= _dot_top + 7 + ? (display.height() - _dot_top - 7) / 8 + 1 : 0; + _dots_drawn = 0; + if (_dot_columns && _dot_rows) { + display.fillRect(4, _dot_top, 3, 3); + _dots_drawn = 1; + } + } else 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] = { @@ -65,6 +107,14 @@ class StartupScreen { display.fillRect(x + dots[index][0], top + dots[index][1], 2, 2); } } + if (elapsed) { + char seconds[16]; + snprintf(seconds, sizeof(seconds), "%lus", + (unsigned long)(uint32_t(millis() - _generation_started) / 1000)); + if (display.getTextWidth(seconds) <= display.width()) + display.drawTextCentered(display.width() / 2, + top + spinner_size + 2, seconds); + } } display.endFrame(); #if defined(COMPANION_PAIRING_UI_HIL) @@ -78,9 +128,11 @@ public: const auto& prefs = displayPowerPrefs(); const auto mode = (usb_power ? prefs.usb : prefs.battery).mode; _display = display; - if (mode == DisplayMode::Off || mode == DisplayMode::Pairing) { + _quiet = mode == DisplayMode::Off || mode == DisplayMode::Pairing; + _keygen_visible = false; + _generating = false; + if (_quiet) { if (_display != nullptr && _display->isOn()) _display->turnOff(); - _display = nullptr; return; } if (_display == nullptr) return; @@ -90,7 +142,9 @@ public: void starting() { _generating = false; + _keygen_visible = false; _status = "Starting..."; + if (_quiet && _display != nullptr && _display->isOn()) _display->turnOff(); draw(); } @@ -107,17 +161,25 @@ public: } void generatingKey() { + _keygen_visible = true; _generating = true; _status = "Generating key"; _phase = 0; + _generation_started = millis(); + _eink_interval = 5000; + if (_display != nullptr && !_display->isOn()) _display->turnOn(); 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; + const uint32_t interval = _display->isEink() ? _eink_interval : 500UL; if (uint32_t(millis() - _last_frame) < interval) return; + if (_display->isEink()) { + appendEinkDot(); + return; + } _phase = (_phase + 1) & 7; draw(); } diff --git a/test/test_display_driver/test_display_driver.cpp b/test/test_display_driver/test_display_driver.cpp index 128523f4e..878054519 100644 --- a/test/test_display_driver/test_display_driver.cpp +++ b/test/test_display_driver/test_display_driver.cpp @@ -133,13 +133,17 @@ public: bool on = false; bool eink = false; int frames = 0; + int clears = 0; + int text_writes = 0; + uint32_t refresh_ms = 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; } + void startFrame(ColorVal) override { ++clears; rows.clear(); fills.clear(); } + void print(const char* text) override { ++text_writes; TestDisplay::print(text); } + void endFrame() override { ++frames; g_mock_millis += refresh_ms; } }; } // namespace @@ -162,14 +166,14 @@ TEST(StartupScreen, StartsImmediatelyAndAnimatesInsideOneGeneratorCall) { mesh::ui::StartupScreen::progress, &screen); EXPECT_TRUE(mesh::generateUsableLocalIdentity(identity, [&]() { // Simulate entropy collection: the key generator has not returned yet. - g_mock_millis += 149; + g_mock_millis += 499; 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; + g_mock_millis += 500; mesh::serviceIdentityGenerationProgress(); EXPECT_EQ(4, display.frames); mesh::LocalIdentity key; @@ -194,7 +198,7 @@ TEST(StartupScreen, StartsImmediatelyAndAnimatesInsideOneGeneratorCall) { EXPECT_FALSE(policy.on()); } -TEST(StartupScreen, SavedModesAndSelectedPowerSourceAreRespected) { +TEST(StartupScreen, FirstKeyTemporarilyOverridesQuietModesWithoutChangingPrefs) { for (bool usb : {false, true}) for (auto mode : { mesh::ui::DisplayMode::Off, mesh::ui::DisplayMode::On, mesh::ui::DisplayMode::Button, mesh::ui::DisplayMode::Pairing, @@ -207,16 +211,32 @@ TEST(StartupScreen, SavedModesAndSelectedPowerSourceAreRespected) { 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 ? 1 : 0, display.frames); + // Loading an existing identity must never override the saved quiet mode. + screen.loadingIdentity(); + EXPECT_EQ(visible, display.on); EXPECT_EQ(visible ? 2 : 0, display.frames); + screen.generatingKey(); + EXPECT_TRUE(display.on); + EXPECT_EQ("Generating key", display.rows[0].text); + screen.savingIdentity(); + EXPECT_TRUE(display.on); + EXPECT_EQ("Saving identity", display.rows[0].text); + const int saved_frames = display.frames; + screen.starting(); + EXPECT_EQ(visible, display.on); + EXPECT_EQ(saved_frames + (visible ? 1 : 0), display.frames); + EXPECT_EQ(mode, (usb ? prefs.usb : prefs.battery).mode); + EXPECT_EQ(mesh::ui::DisplayMode::Off, (usb ? prefs.battery : prefs.usb).mode); } mesh::ui::StartupScreen missing; missing.begin(nullptr, true); missing.generatingKey(); missing.service(); + missing.savingIdentity(); missing.starting(); mesh::ui::StartupScreen::progress(nullptr); } @@ -291,12 +311,146 @@ TEST(StartupScreen, EpaperIsThrottledAndMillisWrapIsSafe) { mesh::ui::StartupScreen screen; screen.begin(&display, true); screen.generatingKey(); - g_mock_millis += 999; + const auto initial_rows = display.rows.size(); + const int initial_writes = display.text_writes; + ASSERT_EQ(1U, display.fills.size()); + g_mock_millis += 4999; screen.service(); EXPECT_EQ(2, display.frames); ++g_mock_millis; screen.service(); EXPECT_EQ(3, display.frames); + ASSERT_EQ(2U, display.fills.size()); + EXPECT_EQ(2, display.clears); // Starting + Generating; progress never clears. + EXPECT_EQ(initial_rows, display.rows.size()); + EXPECT_EQ(initial_writes, display.text_writes); + g_mock_millis += 5000; + screen.service(); + ASSERT_EQ(3U, display.fills.size()); + EXPECT_EQ(display.fills[0].x + 8, display.fills[1].x); + EXPECT_EQ(display.fills[0].y, display.fills[1].y); + EXPECT_EQ(2, display.clears); +} + +TEST(StartupScreen, EpaperAccumulatesRowsAndStopsWhenGridIsFull) { + mesh::ui::displayPowerPrefs() = mesh::ui::DisplayPowerPrefs(); + mesh::ui::displayPowerPrefs().usb.mode = mesh::ui::DisplayMode::Off; + StartupTestDisplay display(40, 72, 6); + display.eink = true; + mesh::ui::StartupScreen screen; + screen.begin(&display, true); + screen.generatingKey(); + EXPECT_TRUE(display.on); + EXPECT_EQ(1, display.clears); + const int text_writes = display.text_writes; + for (int i = 0; i < 100; ++i) { + g_mock_millis += 5000; + screen.service(); + } + // Four columns, five rows below the two-line label; every dot remains. + ASSERT_EQ(20U, display.fills.size()); + EXPECT_EQ(20, display.frames); + EXPECT_EQ(1, display.clears); + EXPECT_EQ(text_writes, display.text_writes); + for (size_t i = 0; i < display.fills.size(); ++i) { + const auto& dot = display.fills[i]; + EXPECT_EQ(4 + int(i % 4) * 8, dot.x); + EXPECT_EQ(display.fills[0].y + int(i / 4) * 8, dot.y); + EXPECT_LE(dot.x + dot.width, display.width()); + EXPECT_LE(dot.y + dot.height, display.height()); + EXPECT_GE(dot.y, display.rows.back().y + display.textLineHeight()); + } + screen.savingIdentity(); + EXPECT_EQ("Saving", display.rows[0].text); + EXPECT_TRUE(display.fills.empty()); + EXPECT_EQ(2, display.clears); + screen.starting(); + EXPECT_FALSE(display.on); + EXPECT_EQ(2, display.clears); +} + +TEST(StartupScreen, EpaperWithoutDotSpaceDoesNotRefreshProgress) { + mesh::ui::displayPowerPrefs() = mesh::ui::DisplayPowerPrefs(); + StartupTestDisplay display(128, 16, 6); + display.eink = true; + mesh::ui::StartupScreen screen; + screen.begin(&display, true); + screen.generatingKey(); + const int frames = display.frames; + for (int i = 0; i < 10; ++i) { + g_mock_millis += 5000; + screen.service(); + } + EXPECT_EQ(frames, display.frames); + EXPECT_TRUE(display.fills.empty()); +} + +TEST(StartupScreen, EpaperBacksOffForMeasuredDotRefreshAcrossMillisWrap) { + mesh::ui::displayPowerPrefs() = mesh::ui::DisplayPowerPrefs(); + g_mock_millis = UINT32_MAX - 11100; + StartupTestDisplay display(250, 122, 12); + display.eink = true; + display.refresh_ms = 3000; // Initial full-screen refresh is deliberately slow. + mesh::ui::StartupScreen screen; + screen.begin(&display, true); + screen.generatingKey(); + display.refresh_ms = 800; + g_mock_millis += 4999; + screen.service(); + EXPECT_EQ(2, display.frames); + ++g_mock_millis; + screen.service(); // This append's 800 ms refresh crosses millis wrap. + EXPECT_EQ(3, display.frames); + ASSERT_EQ(2U, display.fills.size()); + g_mock_millis += 7999; + screen.service(); + EXPECT_EQ(3, display.frames); + ++g_mock_millis; + display.refresh_ms = UINT32_MAX / 10 + 100; + screen.service(); + EXPECT_EQ(4, display.frames); + // Even an extreme measured duration saturates before multiplication. + g_mock_millis += 59999; + screen.service(); + EXPECT_EQ(4, display.frames); + ++g_mock_millis; + display.refresh_ms = 0; + screen.service(); + EXPECT_EQ(5, display.frames); + g_mock_millis += 4999; + screen.service(); + EXPECT_EQ(5, display.frames); + ++g_mock_millis; + screen.service(); + EXPECT_EQ(6, display.frames); // Fast refreshes return to the five-second floor. + EXPECT_EQ(2, display.clears); +} + +TEST(StartupScreen, ElapsedSecondsAreWrapSafeAndOmittedOnShortScreens) { + mesh::ui::displayPowerPrefs() = mesh::ui::DisplayPowerPrefs(); + g_mock_millis = UINT32_MAX - 100; + StartupTestDisplay display(128, 64, 6); + mesh::ui::StartupScreen screen; + screen.begin(&display, true); + screen.generatingKey(); + ASSERT_EQ(2U, display.rows.size()); + EXPECT_EQ("0s", display.rows.back().text); + g_mock_millis += 1500; + screen.service(); + EXPECT_EQ("1s", display.rows.back().text); + g_mock_millis += 2500; + screen.service(); + EXPECT_EQ("4s", display.rows.back().text); + + StartupTestDisplay short_display(128, 32, 6); + screen.begin(&short_display, true); + screen.generatingKey(); + ASSERT_EQ(1U, short_display.rows.size()); + EXPECT_EQ("Generating key", short_display.rows[0].text); + EXPECT_EQ(2U, short_display.fills.size()); + g_mock_millis += 2000; + screen.service(); + EXPECT_EQ(1U, short_display.rows.size()); } TEST(DisplayDriver, EllipsizesOnlyAtUTF8CodepointBoundaries) {