Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,884 changes: 1,884 additions & 0 deletions docs/superpowers/plans/2026-08-18-unified-status-screen.md

Large diffs are not rendered by default.

3,493 changes: 3,493 additions & 0 deletions docs/superpowers/specs/2026-08-13-status-page-atlas.html

Large diffs are not rendered by default.

183 changes: 183 additions & 0 deletions docs/superpowers/specs/2026-08-18-unified-status-screen-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
# Unified status/onboarding/error screen — design

Status: approved for planning. Source: `docs/superpowers/specs/2026-08-13-status-page-atlas.html`
(browser-based design exploration across all 21 EE02–EE05 panel variants), refined
through direct user review over several iterations before this port began.

## Motivation

Today the firmware draws three visually unrelated screens for what is really one
job — "tell the person standing there what's going on, and how to fix it if
something's wrong":

- `ui.cpp`'s `drawStatusScreen()` — KEY1 status view (tiles + QR + legend)
- `net.cpp`'s `showProvisioningScreen()` — first-boot / stale-credential Wi-Fi setup
- `display.cpp`'s `showError()` — fetch/connect failure

They share no code, no layout logic, and no visual language. `layout_math.h`
already has two font tiers (large/small) with fixed per-role sizes chosen by the
panel's short side — reasonable for the panels it was tuned against, but with no
mechanism to size *content* to what a specific panel can hold, and no path below
"small" for anything smaller than roughly 300px.

This change replaces all three with one shared renderer and one content-fitting
engine, ported from the atlas after that design was validated (0 layout overflow,
0 clipped text, guaranteed-scannable QR) across all 21 panel variants in a real
browser.

## Scope and what "done" means here

Only an EE02 is physically available to verify against. This change still covers
all four boards' code paths (EE03/EE04/EE05 included), but verification is split:

- **Pure logic** (size-ladder selection, the shrink pass, content-line building,
QR scale math, the reduction cascade) lives in `src/logic/` with no Arduino
dependency, and gets real unit tests run via `pio test -e native` — this is
genuine verification, not a hardware substitute, but it does cover the exact
code path every board runs.
- **Rendering** (the actual `epaper.*` drawing calls) is verified by a successful
`pio run` build on all four board environments (`ee02`/`ee03`/`ee04`/`ee05`),
catching macro/combo-specific compile errors — but a clean build is not proof
a panel renders correctly.
- **Only EE02 gets a real runtime check** — flashed and confirmed on the actual
connected device. EE03/EE04/EE05 are implemented and unit-tested, not
hardware-verified. This will be stated plainly when the work is reported done,
not implied away.

## Architecture

One shared renderer, three thin call sites — existing entry points and their
timing are preserved, only what they draw changes.

```
main.cpp
├─ KEY1 press ──────────► drawStatusScreen() [ui.cpp, existing signature]
├─ connectWifi() portal ─► showProvisioningScreen() [net.cpp, existing signature]
└─ fetch/connect fail ──► showError(msg) [display.cpp, existing signature]
│
▼
drawFrameScreen(ScreenState, ScreenContent) [ui.cpp, new]
```

`drawFrameScreen()` implements the atlas's four-zone grid (header → status →
action → legend) and takes a `ScreenState` enum (`Normal`/`Onboarding`/`Error`)
plus a `ScreenContent` struct carrying whatever that state needs (battery %,
Wi-Fi info, QR payload, error message, etc.). Each of the three existing
functions becomes a thin wrapper: gather its own data (from `prefs`, `WiFi.*`,
its error string, ...), build a `ScreenContent`, call `drawFrameScreen()`.

This keeps each screen's *control flow* exactly where it already correctly is —
provisioning still draws before Wi-Fi connects (so instructions are visible
before the portal needs to be reachable), error still only draws when
`interactive` — only the drawing implementation becomes shared.

## Content model

Four zones, ported directly from the atlas:

1. **Header** — `"Hello ePaper (ver.<hash6>)"` then `"XIAO EE0X"`, one line each.
Reuses `FW_GIT_HASH` (already shipped). Version drops from the first line
under space pressure; the board line is always present.
2. **Status** — battery / Wi-Fi / next-fetch, each an icon + compact value
(`"98%"` not `"battery 98%"`), with an optional `" · "`-joined detail suffix
(exact voltage, SSID, last-fetch time) shown when there's room and dropped
first under pressure.
3. **Action** — QR + a one-line instruction + the URL/AP name as text. The QR
always encodes what the instruction line says in words: the settings URL
normally, `WIFI:S:<AP_NAME>;;` during onboarding.
4. **Legend** — three `KEY1`/`KEY2`/`KEY3` rows, each a small keycap glyph + a
short action phrase (`"Configuration"`, `"Refresh"`, `"Pin"` for Normal;
`"Retry"` instead of `"Refresh"` for Error; `"Show Screen"` / blanked for
Onboarding).

Per-state field values:

| Field | Normal | Onboarding | Error |
|---|---|---|---|
| Battery | real % + voltage | real % (no detail) | real % + voltage |
| Wi-Fi | strength label + SSID | `"--"` | `"failed"` + SSID |
| Next-fetch | next time + last-fetch | `"--"` | next time + last-fetch |
| Action | settings URL | join-hotspot QR | settings URL, failure reason folded into the instruction line |
| Legend | Configuration / Refresh / Pin | Show Screen / — / — | Configuration / Retry / Pin |

## Sizing engine (`src/logic/layout_math.h`)

Replaces the current two-tier `computeLayout()` with per-role, per-panel fitting:

- **Font ladders**, largest first, every rung a real compiled-in asset:
- title: `FreeSansBold24pt7b`(56) → `18pt7b`(42) → `12pt7b`(29) → `9pt7b`(22) → classic Font2(16) → classic Font1(8)
- stat: `FreeSans18pt7b`(42) → `12pt7b`(29) → `9pt7b`(22) → classic Font2(16) → classic Font1(8)
- chrome: `FreeSans12pt7b`(29) → classic Font2(16) → classic Font1(8)
- **`fitSize()`**: for a role, walk its ladder and return the largest size at
which every line sharing that role fits its own available width — measured
with `epaper.textWidth()` against the real font object, not estimated.
- **Icon-column reserve**: stat and legend lines sit behind a shared icon/keycap
column; their available width is the row width *minus* that column, not the
full row (the atlas's "strong" → "stro" bug came from missing exactly this).
- **Height-aware shrink pass**: `fitSize()` is width-only, so a short string
like `"98%"` can pick an oversized rung that doesn't fit vertically once
stacked. After the width-only pick, shrink one role at a time — **chrome
first, then stat, title last** — until the stack fits. Fixed priority, not
"whichever role is currently biggest": the atlas found that heuristic let the
board line render bigger than the title above it.
- **QR sizing**: same scale-selection loop as today's `computeLayout()` (largest
scale that fits the remaining vertical budget), now also width-checked
against the row (today's code doesn't check width at all — the atlas found
this breaks on narrow panels: the QR sized off leftover height alone and
rendered wider than the panel). A **2px/module floor** is enforced as a
guaranteed-scannable minimum — a small QR is worse than none, since the URL
is already shown as text. Below the floor, the reduction cascade keeps
cutting other content or, as an absolute last resort, drops the QR.
- **Content reduction cascade**: unchanged priority order from the atlas —
legend detail → legend dropped entirely → stat detail suffix → caption
detail → URL text → "next" row → version number → caption dropped → scan
instruction → QR (last resort). Applied only when the fitted sizes still
don't stack within the panel's height.

## Icons (`src/icons.h`, new)

Hand-picked from [pixelarticons](https://github.com/halfmage/pixelarticons)
(MIT licensed): `battery-full`/`battery-medium`/`battery-low` (selected by real
percent), `wifi`, `refresh`. Rasterized once to one fixed bitmap size (not one
per font tier — icons are small enough that a fixed size next to scaling text
isn't expected to look wrong), stored as 1-bit `PROGMEM` byte arrays, drawn with
`epaper.drawBitmap()`.

**Cleanup**: `drawBatteryIcon()`, `drawWifiIcon()`, and `drawNextPhotoIcon()` in
`display.cpp`/`display.h` have no callers once `ui.cpp`'s old
`drawStatusScreen()` is replaced — removed rather than left as dead code.
`drawQrCode()` is unaffected (already correct, reused as-is with a
firmware-side scale-selection update to match the new algorithm above).

## Orientation

The new screen always renders portrait, regardless of `settings.rotation` — a
deliberate product decision (not just a demo artifact): call whatever rotation
value makes `epaper.width() < epaper.height()` before drawing this screen, then
restore the configured rotation (`applyOrientation()`) afterward so photo
display is unaffected. `PANEL_NATIVE_LANDSCAPE` (already in `display.h`) is
enough to determine which rotation value that is per board.

## Files touched

- `src/logic/layout_math.h` — replace `computeLayout()`; new pure-logic helpers
(`fitSize`, shrink pass, QR scale, reduction cascade)
- `src/logic/status_content.h` (new) — pure-logic content-line building per
state (mirrors the atlas's `buildLines`/`statLines`/`legendLines`)
- `src/icons.h` (new) — icon bitmap data
- `src/display.h` / `display.cpp` — remove the three vector icon functions; add
a bitmap-icon draw helper; QR scale selection updated to the new algorithm
- `src/ui.cpp` — new `drawFrameScreen()`; `drawStatusScreen()` becomes a wrapper
- `src/net.cpp` — `showProvisioningScreen()` becomes a wrapper
- `src/display.cpp` — `showError()` becomes a wrapper
- `test/test_layout_math/` (existing, extended) and new `test/test_*` targets
for the content-line building and QR scale logic

## Open questions carried over from the atlas (not yet resolved)

- 2px/module as the QR scannability floor is a starting assumption, not
measured against a real phone camera at each panel's actual DPI.
- EE03 is driven as 16-level gray in the firmware; the product description
calls it monochrome. Worth confirming against the panel's real spec.
- Onboarding's legend wording (`"KEY1 Show Screen"`, KEY2/KEY3 blanked) is a
first guess with no settled real behavior for buttons before Wi-Fi is set up.
118 changes: 29 additions & 89 deletions src/display.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,35 @@
#include "config.h"
#include "settings.h"
#include "state.h"
#include "ui.h"
#include <JPEGDecoder.h>
#include <qrcode.h>

EPaper epaper;

void applyOrientation() { epaper.setRotation(settings.rotation); }

PortraitScope::PortraitScope() {
// Rotation 0 is landscape-shaped on a native-landscape panel, so
// rotation 1 (90 deg) is the portrait one there; on a native-portrait
// panel rotation 0 already is portrait.
epaper.setRotation(PANEL_NATIVE_LANDSCAPE ? 1 : 0);
}
PortraitScope::~PortraitScope() { applyOrientation(); }

void drawStatusIcon(const uint8_t *bitmap, int x, int y, uint32_t fgColor) {
epaper.drawBitmap(x, y, bitmap, ICON_W, ICON_H, fgColor);
}

void drawKeycap(const char *digit, int cx, int cy, int sizePx, uint32_t fgColor) {
const int half = sizePx / 2;
epaper.drawRoundRect(cx - half, cy - half, sizePx, sizePx, sizePx / 6, fgColor);
epaper.setTextDatum(MC_DATUM);
epaper.setTextColor(fgColor, TFT_WHITE);
epaper.setTextSize(1);
epaper.drawString(digit, cx, cy, 2); // classic Font2, legible at any legend size
}

void initPanelColorMode() {
#ifdef USE_MUTIGRAY_EPAPER
epaper.initGrayMode(16); // must be 4 or 16 literally; GRAY_LEVEL16 == 16
Expand Down Expand Up @@ -45,81 +67,6 @@ uint32_t batteryColorForLevel(BatteryLevel level) {
#endif
}

uint32_t chargingBoltColor() {
#if defined(USE_COLORFULL_EPAPER)
return TFT_BLUE;
#else
return TFT_BLACK;
#endif
}

// Classic battery glyph: rounded-rect outline + right-side nub, inner fill
// proportional to pct. Outline is always black regardless of fill color
// (readability); fillColor should already be board-appropriate (see
// batteryColorForLevel above).
void drawBatteryIcon(int cx, int cy, int r, int pct, uint32_t fillColor, bool charging) {
const int bodyW = r * 3, bodyH = r * 2;
const int nubW = r / 2, nubH = r;
const int x0 = cx - (bodyW + nubW) / 2;
const int y0 = cy - bodyH / 2;
epaper.drawRoundRect(x0, y0, bodyW, bodyH, r / 4, TFT_BLACK);
epaper.fillRect(x0 + bodyW, cy - nubH / 2, nubW, nubH, TFT_BLACK);
const int pad = r / 5;
const int innerX = x0 + pad, innerY = y0 + pad;
const int innerW = bodyW - 2 * pad, innerH = bodyH - 2 * pad;
epaper.fillRect(innerX, innerY, innerW, innerH, TFT_WHITE);
const int clamped = pct < 0 ? 0 : (pct > 100 ? 100 : pct);
const int filledW = innerW * clamped / 100;
if (filledW > 0) epaper.fillRect(innerX, innerY, filledW, innerH, fillColor);
if (charging) {
const uint32_t bolt = chargingBoltColor();
const int bx = cx + bodyW / 2 - r / 4;
epaper.fillTriangle(bx, cy - r, bx - r / 2, cy, bx, cy, bolt);
epaper.fillTriangle(bx, cy, bx + r / 2, cy, bx, cy + r, bolt);
}
}

// Classic Wi-Fi glyph (dot + up to 2 upward-opening arcs). Built from
// fillCircle "donut" cuts (draw a filled disc, then a smaller white disc
// inside it to leave a ring) instead of drawArc(), which avoids depending
// on this library's arc-angle convention -- masking the lower half of each
// ring with one final white fillRect converts the full rings into the
// classic upward-opening arcs. Rings are drawn outermost-first so the
// always-drawn center dot is painted last and never gets erased by a
// later "cut".
void drawWifiIcon(int cx, int baseY, int r, WifiStrength level) {
const int dotR = r / 5;
const int ring1 = r * 3 / 5, ring1In = ring1 - r / 6;
const int ring2 = r, ring2In = ring2 - r / 6;
if (level == WifiStrength::Strong) {
epaper.fillCircle(cx, baseY, ring2, TFT_BLACK);
epaper.fillCircle(cx, baseY, ring2In, TFT_WHITE);
}
if (level != WifiStrength::Weak) {
epaper.fillCircle(cx, baseY, ring1, TFT_BLACK);
epaper.fillCircle(cx, baseY, ring1In, TFT_WHITE);
}
epaper.fillCircle(cx, baseY, dotR, TFT_BLACK);
epaper.fillRect(cx - r - 1, baseY, 2 * (r + 1), r + 2, TFT_WHITE);
}

// Clock face (next scheduled fetch), or a map-pin marker when the current
// picture is pinned (KEY3) -- neither hand position is a real clock, both
// are purely decorative glyphs paired with the tile's own time/"pinned"
// text.
void drawNextPhotoIcon(int cx, int cy, int r, bool pinned) {
if (pinned) {
const int headR = r * 2 / 3;
epaper.fillCircle(cx, cy - r / 3, headR, TFT_BLACK);
epaper.fillCircle(cx, cy - r / 3, headR - r / 4, TFT_WHITE);
epaper.fillTriangle(cx - headR / 2, cy, cx + headR / 2, cy, cx, cy + r, TFT_BLACK);
return;
}
epaper.drawCircle(cx, cy, r, TFT_BLACK);
epaper.drawLine(cx, cy, cx, cy - r * 3 / 5, TFT_BLACK);
epaper.drawLine(cx, cy, cx + r * 2 / 5, cy + r / 5, TFT_BLACK);
}

// Panel color/gray index (drawPixel stores it directly, 1 or 4 bpp
// depending on panel) + sRGB approximation used as the dithering target.
// idx is uint32_t (not uint8_t) because the mono fallback below uses the
Expand Down Expand Up @@ -282,21 +229,14 @@ bool renderJpeg(uint8_t *buf, size_t len) {
return ok;
}

// Full-panel error screen (calls update()). Only drawn when someone is
// watching (button-initiated actions) — unattended wakes keep the photo.
// Full-panel error screen. Only drawn when someone is watching
// (button-initiated actions) — unattended wakes keep the photo.
void showError(const String &msg) {
const int cx = epaper.width() / 2, cy = epaper.height() / 2;
ScreenContent content = gatherLiveContent(lastVbatMv, 0, false);
strncpy(content.wifiBase, "failed", sizeof(content.wifiBase) - 1);
strncpy(content.errorMsg, msg.c_str(), sizeof(content.errorMsg) - 1);
snapshotPrevious();
epaper.fillScreen(TFT_WHITE);
epaper.setTextDatum(MC_DATUM);
epaper.setTextSize(2);
epaper.setTextColor(TFT_RED, TFT_WHITE);
epaper.drawString("Something went wrong", cx, cy - 100, 4);
epaper.setTextColor(TFT_BLACK, TFT_WHITE);
epaper.drawString(msg, cx, cy, 4);
epaper.setTextSize(1);
epaper.drawString("Check your Wi-Fi, then press KEY2 to try again.",
cx, cy + 90, 4);
epaper.setTextDatum(TL_DATUM);
PortraitScope portrait;
drawFrameScreen(ScreenState::Error, content);
epaper.update();
}
Loading
Loading