Show first-boot key progress with accumulating e-paper dots
This commit is contained in:
1 parent
9cfcd1c7a6
commit
85fd38290b
3 files changed
+245
-21
No files matched your search
+14
-6
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user