Show first-boot key progress with accumulating e-paper dots

This commit is contained in:
mikecarper committed 2026-10-27 13:26:28 -07:00
1 parent 9cfcd1c7a6
commit 85fd38290b
3 files changed
+245 -21

No files matched your search

+14 -6
View File
@@ -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.
+70 -8
View File
@@ -2,6 +2,7 @@
#include "DisplayDriver.h"
#include <Arduino.h>
#include <stdio.h>
#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();
}
@@ -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) {