Compare commits
6 Commits
8dc6ee362f
...
9d6daf7afd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d6daf7afd | ||
|
|
c206fc28e4 | ||
|
|
ad023843e9 | ||
|
|
3386969655 | ||
|
|
b50043020d | ||
|
|
e57709a9ee |
@@ -10,3 +10,4 @@
|
||||
| --- | --- |
|
||||
| [`ctrl-g-perceived-latency.md`](ctrl-g-perceived-latency.md) | Durability before delivery — surfacing "commit landed" at ~0.2 s makes the 5–10 s `Ctrl-G` push feel instant. |
|
||||
| [`git-sync-images-and-repo-size.md`](git-sync-images-and-repo-size.md) | Why we don't shrink the notes repo — its 153 MB of media is remanso's image CDN, so rewriting history to slim the on-device clone breaks the web app. |
|
||||
| [`boot-time-budget.md`](boot-time-budget.md) | Where the ~4.3 s to cursor goes — and why the ≤ 3 s v1.0 target is hard: one ~1.9 s full refresh is unavoidable at cold boot, so the splash is nearly free. |
|
||||
|
||||
73
docs/notes/boot-time-budget.md
Normal file
73
docs/notes/boot-time-budget.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# Boot-time budget — where the ~4.3 s to cursor goes
|
||||
|
||||
> **Measured 2026-07-11:** cold boot is **4258 ms** power-on → cursor (the
|
||||
> `boot: cursor ready` log prefix). That clears the **≤ 5 s v0.1 gate** with
|
||||
> ~742 ms to spare. This note breaks the number down, and argues that the
|
||||
> **≤ 3 s v1.0 target** is hard on this panel because one ~1.9 s full refresh is
|
||||
> architecturally unavoidable at cold boot.
|
||||
>
|
||||
> Notes index: [`README.md`](README.md). Docs index:
|
||||
> [`../README.md`](../README.md). Backs the boot-time acceptance criterion in
|
||||
> [`../v0.1-mvp-product.md`](../v0.1-mvp-product.md#acceptance-criteria) and the
|
||||
> v1.0 goal in [`../roadmap.md`](../roadmap.md). Refresh cost model:
|
||||
> [`../tradeoff-curves/epd-refresh-latency.md`](../tradeoff-curves/epd-refresh-latency.md).
|
||||
|
||||
## The waterfall
|
||||
|
||||
From the boot serial log (power-on → first editor frame + input loop live):
|
||||
|
||||
| Phase | ~ms | Lever |
|
||||
| --- | ---: | --- |
|
||||
| ROM + 2nd-stage bootloader + app image load | ~550 | flash speed (DIO now; QIO / 80 MHz ≈ −200 ms, speculative) |
|
||||
| PSRAM init + **memtest** + heap | ~920 | `CONFIG_SPIRAM_MEMTEST=n` → **−730 ms** (kept **on**: a real HW sanity check on a hand-wired board) |
|
||||
| EPD reset + init | ~130 | fixed panel bring-up |
|
||||
| **Splash full refresh** | ~1850 | e-ink floor — see below |
|
||||
| SD mount + note load | ~70 | quick on the genuine 32 GB SDHC |
|
||||
| USB host install + git thread spawn | ~60 | background |
|
||||
| **First editor render** (full-area partial) | ~680 | already fixed from ~1870 ms (was a *second* full refresh) |
|
||||
| **Total** | **~4260** | |
|
||||
|
||||
Two lines carry the weight: the **splash full refresh (~1.85 s)** and the
|
||||
**first editor render (~0.68 s)**. Everything else is ≤ ~0.9 s combined, and the
|
||||
biggest of *those* — the ~0.73 s PSRAM memtest — is a deliberate keep.
|
||||
|
||||
## The insight: one full refresh is unavoidable, so the splash is nearly free
|
||||
|
||||
After power-on the panel controller's `0x26` "previous" RAM bank holds garbage. A
|
||||
partial refresh *diffs the new image against that bank*
|
||||
([`../tradeoff-curves/epd-refresh-latency.md`](../tradeoff-curves/epd-refresh-latency.md)),
|
||||
so the **first clean paint must be a full refresh** (~1.9 s) to establish a known
|
||||
image. There is no way around this on this panel short of a different waveform.
|
||||
|
||||
That reframes two things:
|
||||
|
||||
- **The splash costs almost nothing.** Boot needs one full refresh regardless; the
|
||||
splash simply *is* that refresh, turned into a "boot is happening" affordance.
|
||||
Dropping the splash would **not** save the 1.9 s — the editor's first frame would
|
||||
then have to be the full refresh instead. (This is exactly what the old boot did
|
||||
and why it paid *two* full refreshes.)
|
||||
- **The v0.1 win was removing the second full refresh, not the first.** Once the
|
||||
splash has seeded a clean baseline, the editor rides in on a full-area *partial*
|
||||
(~0.63 s) instead of a second full refresh (~1.9 s) — the ~1.25 s saving that
|
||||
took cold boot from ~5.5 s to ~4.26 s. Verified clean on-panel (no splash
|
||||
ghost behind the editor text).
|
||||
|
||||
## Is ≤ 3 s (v1.0) reachable?
|
||||
|
||||
To go from ~4.26 s to ≤ 3 s needs ~1.26 s cut. The honest lever list:
|
||||
|
||||
- **PSRAM memtest off:** −0.73 s → ~3.5 s. Costs the boot-time hardware check;
|
||||
reasonable once the board is no longer hand-wired.
|
||||
- **Faster flash boot (QIO / 80 MHz):** ~−0.2 s, speculative, needs a bench check.
|
||||
- **Overlap cheap init under the splash busy-wait:** SD mount + note load + USB
|
||||
install (~0.13 s total) currently run *after* the splash refresh returns, but
|
||||
the refresh is a `wait_while_busy` spin — those could be kicked off before it.
|
||||
Saves ~0.1 s at most.
|
||||
|
||||
Even stacked, that lands around **~3.2 s** — still over. The ~1.9 s full-refresh
|
||||
floor is the wall, and it can't be cut without dropping the clean first image or
|
||||
moving to a faster panel/waveform. **Conclusion:** ≤ 3 s is marginal-to-unreachable
|
||||
on the GDEY0579T93 as driven today. When v1.0 comes, either revisit the target
|
||||
(≤ 3.5 s is achievable with the memtest off), or accept the splash as the
|
||||
deliberate cover for the one refresh e-ink makes us pay. Recorded here so the v1.0
|
||||
boot-time item is scoped against physics, not optimism.
|
||||
33
docs/qfd.md
33
docs/qfd.md
@@ -81,8 +81,8 @@ inside HOW names: **Render** (buffer → e-ink frame, inside Type),
|
||||
| --- | -------------------------------------------------- | :-: | ------------------------ | ------------------- |
|
||||
| H1 | Type latency (keypress → glyph) | ↓ | ≤ 200 ms | ≤ 150 ms |
|
||||
| H2 | Partial-refresh region area per keystroke | ↓ | ≤ 1 text line (~22 px h) | same |
|
||||
| H3 | Full-refresh cadence (clears ghosting) | → | 1 per 20 partials | tuned by panel temp |
|
||||
| H4 | Boot latency (cold) | ↓ | ≤ 5 s | ≤ 3 s |
|
||||
| H3 | Full-refresh cadence (clears ghosting) | → | 1 per 64 partials | tuned by panel temp |
|
||||
| H4 | Boot latency (cold) | ↓ | ≤ 5 s | ≤ 3 s † |
|
||||
| H5 | Continuous-typing endurance (no drop, no leak) | ↑ | ≥ 1 h | ≥ 8 h |
|
||||
| H6 | Publish reliability (network up) | ↑ | ≥ 95 % | ≥ 99 % |
|
||||
| H7 | Publish latency (one file) | ↓ | ≤ 30 s | ≤ 10 s |
|
||||
@@ -94,6 +94,12 @@ inside HOW names: **Render** (buffer → e-ink frame, inside Type),
|
||||
| H13 | Idle / typing / Publish current draw | ↓ | measured only | sized for >2 days |
|
||||
| H15 | Build time (clean, release) | ↓ | ≤ 7 min | ≤ 5 min |
|
||||
|
||||
† **Boot latency, measured 2026-07-11:** cold boot is **4258 ms**, so the ≤ 5 s
|
||||
v0.1 target is met. The ≤ 3 s v1.0 target is assessed **marginal-to-unreachable** —
|
||||
one ~1.9 s full refresh is unavoidable at cold boot (the `0x26` "previous" bank is
|
||||
garbage until the first full paint), an e-ink floor rather than a tuning knob.
|
||||
Breakdown + levers: [`notes/boot-time-budget.md`](notes/boot-time-budget.md).
|
||||
|
||||
---
|
||||
|
||||
## 3. House of Quality — WHATs × HOWs
|
||||
@@ -172,7 +178,10 @@ actually shape the design are called out below.
|
||||
visible flashes that hurt H8 perception and H1 burst behaviour. The
|
||||
[ADR-003] strip aspect is the structural answer: a small framebuffer makes
|
||||
_both_ cheaper, not one at the expense of the other. The runtime answer
|
||||
is render §H3: schedule full refreshes on idle ≥ 1 s (v0.1 tech doc).
|
||||
is render §H3: schedule full refreshes on idle ≥ 1 s (v0.1 tech doc). The
|
||||
rows-vs-latency cost model behind this tradeoff — full / full-area-partial /
|
||||
windowed-Y — is in
|
||||
[`tradeoff-curves/epd-refresh-latency.md`](tradeoff-curves/epd-refresh-latency.md).
|
||||
- **H9 heap ↔ H10 binary size** (strong). std + gitoxide + mbedtls inflate
|
||||
both. We chose to spend on these ([ADR-001], [ADR-004]) because 16 MB flash
|
||||
and 8 MB PSRAM make them affordable; the kill-switch is spike 7. If
|
||||
@@ -290,8 +299,8 @@ numbers spikes 2–7 must validate before integration starts.
|
||||
| 3 | H8 durability | 100 % (post-confirm power loss) | bench HIL | Re-evaluate [ADR-007] (move config to internal NVS only) |
|
||||
| 4 | H1 Type latency | ≤ 200 ms (keypress → glyph) | spike 5 | Larger partial-refresh region; render multi-char bursts |
|
||||
| 5 | H6 Publish reliability | ≥ 95 % (network up) | spike 6 + spike 7 | TLS cipher trim; reconnect backoff tuning |
|
||||
| 6 | H3 cadence | full every ~20 partials | spike 2 | Adjust per panel temperature; defer flash to idle ≥ 1 s |
|
||||
| 7 | H4 Boot latency | ≤ 5 s (cold, to cursor) | integration smoke | Trim startup logging; lazy-mount SD after splash |
|
||||
| 6 | H3 cadence | full every ~64 partials | spike 2 | Adjust per panel temperature; defer flash to idle ≥ 1 s |
|
||||
| 7 | H4 Boot latency | ≤ 5 s (cold, to cursor) | 4258 ms 2026-07-11 ✓ | Editor rides a full-area partial over the splash (done, −1.25 s); PSRAM memtest off (−0.74 s) — [boot-time-budget](notes/boot-time-budget.md) |
|
||||
| 8 | H5 soak | 1 h no leak / no drop | 1 h bench soak | Glyph-cache eviction; PSRAM heap-fragmentation review |
|
||||
|
||||
The two not-in-MVP rows but already-shaped-by-design:
|
||||
@@ -473,6 +482,20 @@ These are the live tensions we are watching, not deciding harder:
|
||||
context where it belongs once the function name carries the
|
||||
transformation; H4's "to cursor" is implicit in Boot's definition.
|
||||
Matrix cell strengths held; no Σ recompute.
|
||||
- **H4 boot measured; H3 cadence corrected; boot-time docs added
|
||||
(2026-07-11).** Cold boot instrumented at **4258 ms** — the ≤ 5 s v0.1 target
|
||||
is met; §6's H4 row now carries that measured result and the real mitigation
|
||||
(editor rides a full-area partial over the splash, −1.25 s) in place of the
|
||||
pre-integration guesses (trim logging / lazy-mount SD). §2's ≤ 3 s v1.0 target
|
||||
gained a footnote flagging it **marginal-to-unreachable** — one ~1.9 s full
|
||||
refresh is an unavoidable e-ink cold-boot floor. Separately, §2 + §6 H3
|
||||
full-refresh cadence corrected from "1 per 20 partials" to **1 per 64**: the
|
||||
firmware stretched it (`FULL_REFRESH_EVERY = 64`) once windowed-Y refresh made
|
||||
ghosting rare — a drift that predated this pass. New supporting docs:
|
||||
[`notes/boot-time-budget.md`](notes/boot-time-budget.md) (waterfall + v1.0
|
||||
feasibility) and
|
||||
[`tradeoff-curves/epd-refresh-latency.md`](tradeoff-curves/epd-refresh-latency.md)
|
||||
(rows-vs-latency model), cross-linked from §4's H1↔H3 bullet.
|
||||
|
||||
The earlier variance between README's "~12 lines" and product/[ADR-003]'s
|
||||
"~11 lines" of "edit area" is now superseded: the side-panel redesign removed
|
||||
|
||||
@@ -15,15 +15,13 @@ baseline): SD storage, save, and **git publish are all wired into the app binary
|
||||
and hardware-verified** (`:sync` commits on the SD `/sd/repo` and pushes to a
|
||||
test repo), and the **boot splash (Spike 9) is confirmed on the panel** — a
|
||||
vector `typoena`-in-a-circle shown at startup while the SD mounts, then the
|
||||
editor comes up. **Cold boot measured ~5.5 s** (power-on → cursor, instrumented
|
||||
2026-07-11 — the 3.5 s eyeball undercounted; `esp_timer` sees only the ~4.1 s
|
||||
app-side slice, missing the bootloader + ~0.74 s PSRAM memtest). **Over the ≤ 5 s
|
||||
gate**; **fix implemented 2026-07-11** — the first editor render is now a
|
||||
full-area partial (~630 ms) rather than a second full refresh (~1.9 s), expected
|
||||
~4.2 s, pending a reflash to re-measure + check ghosting (PSRAM memtest kept on).
|
||||
The 1-hour soak is attested from real use; boot time joins the
|
||||
remaining post-ship acceptance checks (power-pull recovery, 1000-word no-drop,
|
||||
and `Ctrl-G`'s not-yet-built pull-then-retry → v0.9). Beyond v0.1, v0.2 navigation, **v0.2.5 international input (hardware-verified
|
||||
editor comes up. **Cold boot verified at 4258 ms** (power-on → cursor,
|
||||
2026-07-11; 742 ms under the ≤ 5 s gate). It first measured ~5.5 s; the fix was
|
||||
to bring the editor up with a full-area partial (~630 ms) instead of a second
|
||||
full refresh (~1.9 s) — panel confirmed clean, no ghosting. The 1-hour soak is
|
||||
attested from real use; the remaining post-ship acceptance checks are power-pull
|
||||
recovery, 1000-word no-drop, and `Ctrl-G`'s not-yet-built pull-then-retry
|
||||
(→ v0.9). Beyond v0.1, v0.2 navigation, **v0.2.5 international input (hardware-verified
|
||||
2026-07-11)**, and most of v0.6 Markdown already run. Version numbers are
|
||||
unchanged — they track shippable device releases, not core progress.
|
||||
|
||||
@@ -127,14 +125,13 @@ per ADR-012); **git publish is wired** (`:sync` → commit + fast-forward push o
|
||||
the SD `/sd/repo`, hardware-verified against a test repo); and the **boot splash
|
||||
(Spike 9) is confirmed on the panel** — [`Frame::splash`](../display/src/lib.rs)
|
||||
shows a vector `typoena`-in-a-circle at startup while the SD mounts, then the
|
||||
editor comes up. Cold boot measured ~5.5 s (power-on → cursor, 2026-07-11) — **over the ≤ 5 s
|
||||
gate**: the 3.5 s eyeball undercounted, and `esp_timer` catches only the ~4.1 s
|
||||
app-side slice (it starts after the bootloader + the ~0.74 s PSRAM memtest). Fix
|
||||
implemented 2026-07-11: first editor render is now a full-area partial (~4.2 s
|
||||
expected, pending a reflash to verify); PSRAM memtest kept on. The 1-hour soak
|
||||
is attested from real use; boot time joins the remaining post-ship acceptance
|
||||
checks (power-pull recovery, 1000-word no-drop, `Ctrl-G` pull-then-retry → v0.9) —
|
||||
see [product → acceptance](v0.1-mvp-product.md#acceptance-criteria).
|
||||
editor comes up. Cold boot **verified at 4258 ms** (power-on → cursor, 2026-07-11; 742 ms under
|
||||
the ≤ 5 s gate). It first measured ~5.5 s; the fix was to bring the editor up
|
||||
with a full-area partial (~630 ms) instead of a second full refresh (~1.9 s) —
|
||||
panel confirmed clean. The 1-hour soak is attested from real use; the remaining
|
||||
post-ship acceptance checks are power-pull recovery, 1000-word no-drop, and
|
||||
`Ctrl-G` pull-then-retry (→ v0.9) — see
|
||||
[product → acceptance](v0.1-mvp-product.md#acceptance-criteria).
|
||||
|
||||
- [x] ESP32-S3 boots (✓); e-ink shows Typoena splash (✓ Spike 9, confirmed on
|
||||
panel 2026-07-11); boot status surfaces via the panel snackbar (no serial on device)
|
||||
@@ -374,7 +371,9 @@ engine remain (snippets are net-new scope, added 2026-07-08).
|
||||
|
||||
## v1.0 — Polish — [ ]
|
||||
|
||||
- [ ] Boot time ≤ 3 s to usable cursor
|
||||
- [ ] Boot time ≤ 3 s to usable cursor — currently ~4.26 s; the ~1.9 s cold-boot
|
||||
full refresh is a hard e-ink floor, so ≤ 3 s is marginal (see
|
||||
[`notes/boot-time-budget.md`](notes/boot-time-budget.md))
|
||||
- [ ] Font selection (at least one serif + one mono) with adjustable font
|
||||
size, switchable at runtime and persisted across reboots
|
||||
- [ ] Theme: light / dark (inverted e-ink), switchable at runtime and
|
||||
|
||||
@@ -10,3 +10,4 @@
|
||||
| Curve | What it decides |
|
||||
| --- | --- |
|
||||
| [`wifi-auto-sync.md`](wifi-auto-sync.md) | `auto_sync` interval vs Wi-Fi energy (a `1/T` hyperbola) — why the default is 10 min and opportunistic, not a wall-clock timer. |
|
||||
| [`epd-refresh-latency.md`](epd-refresh-latency.md) | E-ink refresh latency vs rows driven — the full / full-area-partial / windowed-Y cost model behind typing responsiveness and the boot splash→editor swap. |
|
||||
|
||||
117
docs/tradeoff-curves/epd-refresh-latency.md
Normal file
117
docs/tradeoff-curves/epd-refresh-latency.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# E-ink refresh latency vs rows driven
|
||||
|
||||
> **Model:** on this GDEY0579T93 (SSD1683 dual-controller) panel, refresh time is
|
||||
> set by **how many gate lines (rows, the Y axis) are driven** and **which
|
||||
> waveform LUT runs** — *not* by the pixel column width. Three refresh modes fall
|
||||
> out of that: full refresh (~1870 ms), full-area partial (~630 ms), and
|
||||
> windowed-Y partial (~100–130 ms for one text line). This is the cost model
|
||||
> behind per-keystroke typing, the boot splash→editor swap
|
||||
> ([`../notes/boot-time-budget.md`](../notes/boot-time-budget.md)), and the
|
||||
> scroll/gutter spikes.
|
||||
>
|
||||
> Tradeoff-curves index: [`README.md`](README.md). Docs index:
|
||||
> [`../README.md`](../README.md). Driver:
|
||||
> [`../../firmware/src/epd.rs`](../../firmware/src/epd.rs). Bench origin:
|
||||
> [`../spikes.md`](../spikes.md) (Spikes 5 + 8).
|
||||
|
||||
## The model
|
||||
|
||||
A refresh is three serial costs:
|
||||
|
||||
```
|
||||
set RAM window → clock the pixels out over SPI → run the update waveform
|
||||
(fixed) (scales with bytes = rows) (scales with gate lines
|
||||
AND with which LUT)
|
||||
```
|
||||
|
||||
Two things do **not** help, and one thing does:
|
||||
|
||||
- **Column width (X) is free to keep full.** The panel is a master (`0x00`) +
|
||||
slave (`0x80`) pair with the framebuffer split at the seam; every refresh
|
||||
drives *both* controllers full width so the seam/mirror math stays intact
|
||||
(`update_part` / `write_frame_bank` in
|
||||
[`epd.rs`](../../firmware/src/epd.rs)). Narrowing the *column* saves nothing —
|
||||
"the waveform time dominates, not the data clock-out."
|
||||
- **Row count (Y) is the knob.** E-paper drive time scales with the number of
|
||||
gate lines transitioned, so restricting the refresh to a horizontal band of
|
||||
rows is the real win — a one-line band is far cheaper than the whole panel.
|
||||
- **The LUT sets the tier.** A *partial* update (`0x22`←`0xFF`) runs a short
|
||||
waveform that only nudges pixels that changed. A *full* update (`0x22`←`0xD7`,
|
||||
the fast-full LUT) runs ~3× as many frames per pixel to fully clear-and-set —
|
||||
slower, but it erases ghosting and re-establishes a known image.
|
||||
|
||||
Within partial mode the latency is roughly linear in rows:
|
||||
|
||||
```
|
||||
t_partial(rows) ≈ 90 ms + 2 ms · rows
|
||||
└ floor ┘ └ per-gate-line slope ┘
|
||||
```
|
||||
|
||||
Floor ≈ fixed SPI setup + border/VCOM commands + waveform ramp; slope ≈ the
|
||||
per-row waveform drive. Full refresh sits in its own flat tier (~1870 ms, all 272
|
||||
rows, full-clear LUT) and **cannot be windowed** — the clear waveform needs the
|
||||
whole panel.
|
||||
|
||||
## The curve
|
||||
|
||||
```
|
||||
Partial-refresh latency vs rows driven (Y-band) t ≈ 90 ms + 2 ms · rows
|
||||
|
||||
ms
|
||||
1870 |========================================= FULL refresh — separate tier:
|
||||
| all 272 rows, full-clear LUT,
|
||||
| un-windowable. ~3× the partial
|
||||
| waveform. De-ghosts + seeds a
|
||||
| known image.
|
||||
|
|
||||
630 | * ← full-area partial (272 rows)
|
||||
| . ·
|
||||
| . ·
|
||||
| . · slope ≈ 2 ms / gate line
|
||||
300 | . ·
|
||||
| . ·
|
||||
110 | * ← one text line (~10 rows): the per-keystroke path
|
||||
90 | * ← floor (SPI setup + border/VCOM + waveform ramp)
|
||||
+----+----+----+----+----+----+----+----+----+----+----+---
|
||||
0 25 50 75 100 125 150 175 200 225 250 272 rows
|
||||
```
|
||||
|
||||
| Mode | Rows driven | Latency | LUT | Used for |
|
||||
| --- | ---: | ---: | --- | --- |
|
||||
| Full refresh | 272 (all) | **~1870 ms** (measured) | full-clear | first cold-boot image; periodic de-ghost (every `FULL_REFRESH_EVERY` = 64 updates) |
|
||||
| Full-area partial | 272 | **~630 ms** (measured; 680 ms at boot) | partial | deletes, caret moves, mode switches, the snackbar, and the boot splash→editor swap |
|
||||
| Windowed-Y partial | ~10 (1 line) | **~100–130 ms** (estimated¹) | partial | additive per-keystroke typing |
|
||||
|
||||
¹ The single-line windowed figure is projected from the floor+slope model and the
|
||||
Spike 5 full-area measurement; the exact bench number is still to be confirmed
|
||||
from the on-device refresh log (`{mode} refresh #N … {ms} ms` in
|
||||
[`main.rs`](../../firmware/src/main.rs)). The 1870 ms full and 630 ms full-area
|
||||
figures are measured.
|
||||
|
||||
## Two things that bound it
|
||||
|
||||
**Ghosting caps the partial streak.** Partial updates leave faint residue, so a
|
||||
full refresh every 64 updates resets clarity and panel state. You can't "always
|
||||
partial" — the ~1870 ms tier is a periodic tax paid for longevity, not a mode you
|
||||
can retire.
|
||||
|
||||
**The first cold-boot image must be a full refresh.** After power-on the `0x26`
|
||||
"previous" bank holds garbage, and a partial refresh *diffs against it* — so the
|
||||
very first clean paint has to be the full tier. This is why boot pays exactly one
|
||||
unavoidable ~1.9 s full refresh, and why the splash (which rides it) is nearly
|
||||
free while the *editor's* first frame can be a cheap partial on top. Full
|
||||
derivation: [`../notes/boot-time-budget.md`](../notes/boot-time-budget.md).
|
||||
|
||||
## What it decides
|
||||
|
||||
- **Per-keystroke typing → windowed-Y partial.** Only the touched line's band is
|
||||
driven (~100–130 ms), so typing keeps up with the keyboard; the panel never
|
||||
repaints per keystroke off that line.
|
||||
- **Boot splash→editor → full-area partial (~630 ms), not a second full refresh
|
||||
(~1870 ms).** The splash already seeded the baseline, so the editor rides in on
|
||||
a partial — the ~1.25 s cold-boot win recorded in the boot-time budget.
|
||||
- **Deletes, caret moves, mode flips, snackbar → full-area partial.** These erase
|
||||
ink or change the panel off the cursor line, which a windowed band would ghost,
|
||||
so they take the whole-panel partial rather than a windowed one.
|
||||
- **Splash + periodic → full refresh.** The unavoidable first image and the
|
||||
every-64 de-ghost.
|
||||
@@ -207,23 +207,21 @@ place to check before declaring an item done.
|
||||
|
||||
> **Status 2026-07-11:** v0.1 was declared **delivered**, with acceptance
|
||||
> criteria run as **post-ship hardening**. The 1-hour soak is attested from
|
||||
> sustained real use (below). Two criteria are known **not** to pass yet, tracked
|
||||
> as follow-ups rather than silent passes: cold boot measured **~5.5 s**
|
||||
> (instrumented 2026-07-11, over the 5 s gate — fix identified below), and
|
||||
> `Ctrl-G`'s pull-then-retry path is **not yet implemented** (deferred to v0.9).
|
||||
> sustained real use, and cold boot is now **verified at 4258 ms** (under the 5 s
|
||||
> gate, after a boot-refresh fix — see below). One criterion remains known **not**
|
||||
> to pass: `Ctrl-G`'s pull-then-retry path is **not yet implemented** (deferred
|
||||
> to v0.9).
|
||||
|
||||
- [ ] After a cold boot with valid pre-flashed config, cursor is ready in ≤ 5 s.
|
||||
— **MEASURED 2026-07-11: ~5.5 s power-on → cursor**, over the gate (the
|
||||
`boot: cursor ready` log prefix, 5508 ms, from `esp_log_timestamp` ≈ since
|
||||
power-on; the 4091 ms my first instrument printed was only the app-side
|
||||
slice — `esp_timer` doesn't start until ~1.4 s in, after the 2nd-stage
|
||||
bootloader + the ~0.74 s PSRAM memtest, so it excludes them). **Fix
|
||||
implemented 2026-07-11** (pending a reflash to re-measure + confirm no
|
||||
ghosting): the first editor render is now a full-area partial (~630 ms)
|
||||
instead of a second full refresh (~1.9 s), which should bring boot to
|
||||
~4.2 s. The two boot full refreshes (~3.9 s) were the structural cost; the
|
||||
PSRAM memtest (~0.74 s, left on — a real HW sanity check on a hand-wired
|
||||
board) is a further lever, and both feed the v1.0 ≤ 3 s target.
|
||||
- [x] After a cold boot with valid pre-flashed config, cursor is ready in ≤ 5 s.
|
||||
— **VERIFIED 2026-07-11: 4258 ms power-on → cursor** (`boot: cursor ready`
|
||||
log prefix; 742 ms under the gate). First measured ~5.5 s (over the gate);
|
||||
the fix was to bring the editor up with a full-area partial (~630 ms) instead
|
||||
of a second full refresh (~1.9 s) — panel confirmed clean, no splash
|
||||
ghosting. (Instrument note: `esp_timer_get_time` reads only the ~2.85 s
|
||||
app-side slice — it starts ~1.4 s in, after the bootloader + the ~0.74 s
|
||||
PSRAM memtest; the log prefix / `esp_log_timestamp` is the real
|
||||
from-power-on number.) Remaining boot levers for the v1.0 ≤ 3 s target: the
|
||||
~1.9 s splash full refresh and the ~0.74 s PSRAM memtest.
|
||||
- [ ] Typing a 1000-word paragraph never drops a keystroke and never lags
|
||||
more than 300 ms behind the keyboard.
|
||||
- [ ] `Ctrl-S` durably writes the file (verified by power-cycling immediately
|
||||
|
||||
@@ -72,7 +72,10 @@ read snapshot (render diff).
|
||||
9. STEADY STATE
|
||||
```
|
||||
|
||||
Target boot time: ≤ 5 s to cursor (v0.1). The 3 s target is v1.0.
|
||||
Target boot time: ≤ 5 s to cursor (v0.1). The 3 s target is v1.0. Measured cold
|
||||
boot is **4258 ms** (2026-07-11) — under the v0.1 gate; the full breakdown and why
|
||||
≤ 3 s is hard (one ~1.9 s full refresh is unavoidable at cold boot) is in
|
||||
[`notes/boot-time-budget.md`](notes/boot-time-budget.md).
|
||||
|
||||
## Hardware bring-up order
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
//! baked at build time (`TW_*`, ADR-007: v0.1 device config is compiled in).
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::path::Path;
|
||||
use std::rc::Rc;
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
@@ -147,8 +148,15 @@ fn publish_cycle(
|
||||
bail!("git config missing — set TW_WIFI_SSID / TW_REMOTE_URL / TW_GH_USER / TW_PAT in firmware/.env and rebuild");
|
||||
}
|
||||
|
||||
// Phases are timed so a cold :sync reports where the seconds go. Wi-Fi, clock
|
||||
// and TLS run only on the first sync of a session; a warm sync skips them, so
|
||||
// they read 0 ms and the total collapses to just publish(fetch+commit+push).
|
||||
let t_total = Instant::now();
|
||||
|
||||
// Bring Wi-Fi up once (on-demand: the radio stays off until the first :sync).
|
||||
let mut wifi_ms = 0u128;
|
||||
if wifi.is_none() {
|
||||
let t = Instant::now();
|
||||
log::info!("first :sync — bringing Wi-Fi up; free heap {}", free_heap());
|
||||
let m = modem.take().expect("modem taken once");
|
||||
let n = nvs.take().expect("nvs taken once");
|
||||
@@ -160,62 +168,78 @@ fn publish_cycle(
|
||||
let ip = w.wifi().sta_netif().get_ip_info()?;
|
||||
log::info!("Wi-Fi up — IP {}", ip.ip);
|
||||
*wifi = Some(w);
|
||||
wifi_ms = t.elapsed().as_millis();
|
||||
}
|
||||
let mut clock_ms = 0u128;
|
||||
if !*clock_synced {
|
||||
let t = Instant::now();
|
||||
sync_clock()?;
|
||||
*clock_synced = true;
|
||||
clock_ms = t.elapsed().as_millis();
|
||||
}
|
||||
let mut tls_ms = 0u128;
|
||||
if !*tls_ready {
|
||||
let t = Instant::now();
|
||||
install_tls_trust_store()?;
|
||||
*tls_ready = true;
|
||||
tls_ms = t.elapsed().as_millis();
|
||||
}
|
||||
|
||||
publish_once()
|
||||
let t_publish = Instant::now();
|
||||
let outcome = publish_once()?;
|
||||
log::info!(
|
||||
":sync timing — wifi {wifi_ms}ms, clock {clock_ms}ms, tls {tls_ms}ms, publish(commit+push) {}ms, total {}ms",
|
||||
t_publish.elapsed().as_millis(),
|
||||
t_total.elapsed().as_millis(),
|
||||
);
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// Open `/sd/repo`, stage the working tree, commit on top of the current branch,
|
||||
/// and fast-forward push. Never clones or wipes: a `/sd/repo` that isn't a valid
|
||||
/// repo is a provisioning error, surfaced as such.
|
||||
/// Open `/sd/repo`, commit the working tree on the current branch, and push.
|
||||
///
|
||||
/// Optimistic: it pushes onto the current tip *without* a pre-fetch, so the
|
||||
/// common case (nothing else touched the remote) costs a single TLS handshake.
|
||||
/// If the remote has moved under us — a foreign push, e.g. maintenance — the push
|
||||
/// is rejected non-fast-forward; we then reconcile onto origin, replay our note on
|
||||
/// the new tip, and retry once.
|
||||
///
|
||||
/// Never clones or wipes: a `/sd/repo` that isn't a valid repo is a provisioning
|
||||
/// error, surfaced as such.
|
||||
fn publish_once() -> Result<PublishOutcome> {
|
||||
log::info!("publish started — free heap {}", free_heap());
|
||||
let repo = Repository::open(REPO_DIR).with_context(|| {
|
||||
format!("opening git repo at {REPO_DIR} — provision the card with a clone (just init) whose origin is your remote")
|
||||
})?;
|
||||
|
||||
// Stage everything (add --all also stages deletions, for a future note-delete)
|
||||
// and build the tree from what the editor saved.
|
||||
let mut index = repo.index().context("opening index")?;
|
||||
index
|
||||
.add_all(["*"], IndexAddOption::DEFAULT, None)
|
||||
.context("staging (add --all)")?;
|
||||
index.write().context("writing index")?;
|
||||
let tree = repo.find_tree(index.write_tree().context("writing tree")?)?;
|
||||
|
||||
// Commit on top of the current branch tip (None on an empty/unborn remote).
|
||||
let parent = repo.head().ok().and_then(|h| h.peel_to_commit().ok());
|
||||
|
||||
// Nothing-to-publish short-circuit: staged tree identical to the parent's.
|
||||
if let Some(p) = &parent {
|
||||
if p.tree_id() == tree.id() {
|
||||
log::info!("nothing to publish — tree unchanged @ {}", short(p.id()));
|
||||
return Ok(PublishOutcome::UpToDate);
|
||||
}
|
||||
}
|
||||
|
||||
let sig = Signature::now(AUTHOR_NAME, AUTHOR_EMAIL).context("building signature")?;
|
||||
let message = format!("Typoena publish — unix {}", now_unix());
|
||||
let parents: Vec<&Commit> = parent.iter().collect();
|
||||
let oid = repo
|
||||
.commit(Some("HEAD"), &sig, &sig, &message, &tree, &parents)
|
||||
.context("creating commit")?;
|
||||
let Some(mut oid) = stage_and_commit(&repo)? else {
|
||||
return Ok(PublishOutcome::UpToDate);
|
||||
};
|
||||
let branch = repo
|
||||
.head()?
|
||||
.shorthand()
|
||||
.context("HEAD has no branch shorthand")?
|
||||
.to_string();
|
||||
log::info!("committed {} to {branch} — free heap {}", short(oid), free_heap());
|
||||
let refspec = format!("refs/heads/{branch}:refs/heads/{branch}");
|
||||
|
||||
push_with_retry(&repo, &branch)?;
|
||||
// Optimistic push. A non-fast-forward rejection means the remote moved under
|
||||
// us: reconcile onto origin and replay the note on the new tip, then retry
|
||||
// once. reconcile_onto_origin mixed-resets, so the just-saved note survives in
|
||||
// the working tree and stage_and_commit lands it on top of origin.
|
||||
if let Err(first) = try_push(&repo, &refspec) {
|
||||
log::warn!("push rejected ({first}); reconciling onto origin and replaying the note");
|
||||
reconcile_onto_origin(&repo, &branch).context("reconciling after a rejected push")?;
|
||||
match stage_and_commit(&repo)? {
|
||||
Some(replayed) => {
|
||||
oid = replayed;
|
||||
try_push(&repo, &refspec).context("push after reconcile")?;
|
||||
}
|
||||
// The note was already on origin (nothing to replay) — treat as done.
|
||||
None => {
|
||||
log::info!("nothing to replay after reconcile — already up to date");
|
||||
return Ok(PublishOutcome::UpToDate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"push done — free heap {}, min-ever {}",
|
||||
@@ -225,21 +249,49 @@ fn publish_once() -> Result<PublishOutcome> {
|
||||
Ok(PublishOutcome::Pushed(short(oid)))
|
||||
}
|
||||
|
||||
/// Push `branch` to origin; on a rejected push, fetch origin and reconcile, then
|
||||
/// retry once. A true divergence (two writers) needs a merge commit and is
|
||||
/// deferred to increment B — `fetch_and_integrate` bails there. A single-writer
|
||||
/// appliance always fast-forwards, so the happy path never hits it.
|
||||
fn push_with_retry(repo: &Repository, branch: &str) -> Result<()> {
|
||||
let refspec = format!("refs/heads/{branch}:refs/heads/{branch}");
|
||||
match try_push(repo, &refspec) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(first) => {
|
||||
log::warn!("push rejected ({first}); fetching origin to reconcile");
|
||||
fetch_and_integrate(repo, branch).context("reconciling after a rejected push")?;
|
||||
log::info!("reconciled with origin; retrying push");
|
||||
try_push(repo, &refspec).context("push after reconcile")
|
||||
/// Stage the working tree and commit it on top of the current branch tip.
|
||||
/// Returns the new commit id, or `None` when the tree already matches the parent
|
||||
/// (nothing to publish). Called on the first attempt and again to replay the note
|
||||
/// after a reconcile.
|
||||
///
|
||||
/// `add_all` runs a per-path filter that drops macOS AppleDouble sidecars
|
||||
/// (`._name`) and `.DS_Store` that Finder/Spotlight sprinkle onto the FAT card
|
||||
/// whenever it's mounted on a Mac — without it, a blind add --all sweeps them into
|
||||
/// the commit (it did once: 07d87772 shipped `._.git`, `._README.md`,
|
||||
/// `._notes.md`). Filtering here fixes it for *every* repo at the device level, so
|
||||
/// no per-repo `.gitignore` is needed. (add --all also stages deletions, for a
|
||||
/// future note-delete.)
|
||||
fn stage_and_commit(repo: &Repository) -> Result<Option<git2::Oid>> {
|
||||
let mut index = repo.index().context("opening index")?;
|
||||
let mut skip_macos_cruft = |path: &Path, _matched: &[u8]| -> i32 {
|
||||
match path.file_name().and_then(|n| n.to_str()) {
|
||||
Some(name) if name.starts_with("._") || name == ".DS_Store" => 1, // skip
|
||||
_ => 0, // add
|
||||
}
|
||||
};
|
||||
index
|
||||
.add_all(["*"], IndexAddOption::DEFAULT, Some(&mut skip_macos_cruft))
|
||||
.context("staging (add --all)")?;
|
||||
index.write().context("writing index")?;
|
||||
let tree = repo.find_tree(index.write_tree().context("writing tree")?)?;
|
||||
|
||||
// Commit on top of the current branch tip (None on an empty/unborn remote).
|
||||
let parent = repo.head().ok().and_then(|h| h.peel_to_commit().ok());
|
||||
if let Some(p) = &parent {
|
||||
if p.tree_id() == tree.id() {
|
||||
log::info!("nothing to publish — tree unchanged @ {}", short(p.id()));
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
|
||||
let sig = Signature::now(AUTHOR_NAME, AUTHOR_EMAIL).context("building signature")?;
|
||||
let message = format!("Typoena publish — unix {}", now_unix());
|
||||
let parents: Vec<&Commit> = parent.iter().collect();
|
||||
let oid = repo
|
||||
.commit(Some("HEAD"), &sig, &sig, &message, &tree, &parents)
|
||||
.context("creating commit")?;
|
||||
log::info!("committed {} — free heap {}", short(oid), free_heap());
|
||||
Ok(Some(oid))
|
||||
}
|
||||
|
||||
/// One push attempt over HTTPS. Binds the PAT credential + the cert-verify
|
||||
@@ -273,10 +325,19 @@ fn try_push(repo: &Repository, refspec: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch origin and integrate `branch` into the local branch. Handles up-to-date
|
||||
/// and fast-forward (the common single-writer cases). A real divergence needs a
|
||||
/// merge commit written to FATFS — deferred to increment B — so it bails.
|
||||
fn fetch_and_integrate(repo: &Repository, branch: &str) -> Result<()> {
|
||||
/// Fetch origin and mixed-reset the local branch onto it, so our just-made commit
|
||||
/// can be replayed on the current tip. Only runs after a non-fast-forward push
|
||||
/// rejection — i.e. the remote moved under us.
|
||||
///
|
||||
/// **MIXED**, deliberately not a force checkout: the note we're publishing lives
|
||||
/// in the working tree, and a force checkout would clobber it. Mixed moves the
|
||||
/// branch ref + index onto origin but leaves the working tree, so the note
|
||||
/// survives and `stage_and_commit` replays it on top. For a single-writer
|
||||
/// appliance this resolves last-writer-wins — a concurrent remote *edit* to the
|
||||
/// same note loses to ours, and a remote-only *added* file the card doesn't have
|
||||
/// is dropped by the replay's add --all. Both need a real merge (increment B) and
|
||||
/// don't arise from this device's own use.
|
||||
fn reconcile_onto_origin(repo: &Repository, branch: &str) -> Result<()> {
|
||||
let mut remote = repo.find_remote("origin")?;
|
||||
let mut fo = FetchOptions::new();
|
||||
fo.remote_callbacks(auth_callbacks());
|
||||
@@ -284,24 +345,18 @@ fn fetch_and_integrate(repo: &Repository, branch: &str) -> Result<()> {
|
||||
.fetch(&[branch], Some(&mut fo), None)
|
||||
.context("fetch origin")?;
|
||||
|
||||
let fetch_head = repo.find_reference("FETCH_HEAD")?;
|
||||
let fetch_head = repo
|
||||
.find_reference("FETCH_HEAD")
|
||||
.context("no FETCH_HEAD after fetch")?;
|
||||
let theirs = repo.reference_to_annotated_commit(&fetch_head)?;
|
||||
let (analysis, _) = repo.merge_analysis(&[&theirs])?;
|
||||
|
||||
if analysis.is_up_to_date() {
|
||||
log::info!("already up to date with origin/{branch}");
|
||||
return Ok(());
|
||||
}
|
||||
if analysis.is_fast_forward() {
|
||||
log::info!("fast-forwarding local {branch} to origin");
|
||||
let refname = format!("refs/heads/{branch}");
|
||||
repo.find_reference(&refname)?
|
||||
.set_target(theirs.id(), "fast-forward to origin")?;
|
||||
repo.set_head(&refname)?;
|
||||
repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force()))?;
|
||||
return Ok(());
|
||||
}
|
||||
bail!("origin/{branch} diverged from local — a real merge commit is needed (increment B, deferred)")
|
||||
log::info!(
|
||||
"reconcile: resetting local {branch} onto origin @ {} (mixed, keeps the note)",
|
||||
short(theirs.id())
|
||||
);
|
||||
let their_obj = repo.find_object(theirs.id(), None)?;
|
||||
repo.reset(&their_obj, git2::ResetType::Mixed, None)
|
||||
.context("mixed reset onto origin")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Auth + cert callbacks shared by fetch and push. Captures only the baked
|
||||
|
||||
Reference in New Issue
Block a user