Compare commits

...

179 Commits

Author SHA1 Message Date
Julien Calixte
5f50cf1b45 chore: remove bar 2026-07-13 11:35:08 +02:00
Julien Calixte
5c97e019ec docs(kaizen): drop the after-kaizen bar until step 6 measures it
Step 1 is baseline + target only; the target line stays, the bar would
fake a measurement that doesn't exist yet.
2026-07-13 11:07:25 +02:00
Julien Calixte
7712de6153 docs(kaizen): size the xychart so its labels stop clipping
Explicit 900x500 canvas, tighter x-axis labels, unit-only y-axis title
(the long vertical title collided with the tick numbers in Remanso).
2026-07-13 11:06:44 +02:00
Julien Calixte
1df25f75d6 docs(kaizen): chart the improvement potential as a mermaid xychart
Before/after bars with the target as a line series; the caption keeps
the nuance the chart can't draw (true before value is ∞, not 611 s).
2026-07-13 11:03:43 +02:00
Julien Calixte
acbafa3d6b docs(kaizen): retell the real-repo sync fix as a six-step kaizen
The tradeoff-curves doc stays the chronological data layer; the kaizen
is the story layer — value model, 8-factor hypothesis table, ideas
ranked by verified cause, and an evaluation left PENDING until the
end-to-end on-device :sync lands.
2026-07-13 10:56:05 +02:00
Julien Calixte
e801d2d480 docs(palette): note the two on-device boot-log measurements pending
Walk time after the d_type fix, and the file-list internal-DRAM cost
with the decision rule for interning paths into one PSRAM buffer.
2026-07-13 10:26:25 +02:00
Julien Calixte
a771c3f6de chore(diag): log internal DRAM next to PSRAM-dominated heap totals
"free heap 5.8MB" in the crash log was PSRAM and masked the actual
internal-RAM exhaustion. git_sync's publish/commit/push lines now
carry the MALLOC_CAP_INTERNAL reading, and boot brackets the palette
file-list build with it — small Strings are forced internal by the
16 KB SPIRAM malloc threshold, so the 1098-path list is a suspected
~60-70 KB DRAM consumer competing with Wi-Fi/TLS; the log decides
whether interning into one PSRAM buffer is worth it.
2026-07-13 10:25:25 +02:00
Julien Calixte
4c92b0dddc fix(sync): move mbedTLS allocations to PSRAM
esp-idf's default is internal-RAM-only, and a TLS connection needs
~33 KB of it (two ~17 KB I/O buffers + contexts) at the exact moment
:sync pushes — with Wi-Fi, USB host, the editor and libgit2 resident,
mbedtls_ssl_setup failed there on the first real-repo push (and its
failure path then hit the stream double-free fixed in the previous
commit). TLS buffers are CPU-only data, so PSRAM is safe; the
handshake is network-bound.
2026-07-13 10:25:25 +02:00
Julien Calixte
af5b41a104 fix(sync): patch the mbedtls stream double-free that reset the chip
Upstream libgit2 v1.9.4 bug: mbedtls_stream_wrap's ssl_setup error
path closes and frees the caller's socket stream, then
git_mbedtls_stream_new frees it again — and wrap's other error paths
don't free it at all, so callers can't compensate either way. When
ssl_setup failed on the device (internal-RAM exhaustion during the
first real-repo push), the double git__free tripped tlsf ("block
already marked as free") and reset the chip mid-:sync.

Fixed the way esp_map.c replaces map.c: the vendored streams/mbedtls.c
is excluded from the component and esp_mbedtls_stream.c (verbatim copy
+ one-hunk fix: out_err leaves st->io to the caller, and frees the
leaked ssl struct) takes its place. Keep the copy in lockstep on
submodule bumps; worth reporting upstream.
2026-07-13 10:25:12 +02:00
Julien Calixte
2660a3e9dd perf(palette): trust dirent d_type instead of a per-entry stat
The per-entry metadata() call makes FatFS re-walk the directory by
path every time — ~32ms/file on the card, 35s for a 1098-file tree.
esp-idf's FAT VFS always fills d_type (DT_DIR/DT_REG from the FILINFO
readdir already holds, never unknown) and Rust std maps file_type()
onto it stat-free, so the walk is now one readdir pass per directory.
2026-07-13 01:05:10 +02:00
Julien Calixte
79fad4689c docs(palette): amend v0.5 with the recursive walk and search threshold 2026-07-13 00:55:34 +02:00
Julien Calixte
98fc817b3f feat(palette): show recents only until the query reaches two chars
With the file list now a recursive walk of the whole card, an unranked
short query pages through too much. Below PALETTE_MIN_QUERY (2) the
result list is the MRU only, keeping Cmd-P + Enter quick-switch one
keystroke away; two chars reveal the full fuzzy-ranked list. Commands
(>) and snippets ($) are short curated lists and keep matching from
the first char.
2026-07-13 00:55:25 +02:00
Julien Calixte
d306caacf7 feat(palette): walk the card recursively for the file list
A nested repo (jcalixte/notes) showed only its top-level files in the
palette. Dot entries are skipped at every level so .git is never
descended; each directory is read fully before recursing so a single
FatFS dir handle is open at a time; depth capped at 8. The boot walk
logs its file count and duration to keep the FAT dir-IO cost visible.
2026-07-13 00:55:16 +02:00
Julien Calixte
2166b932b6 docs(sync): update the wiring section to how-it-landed
The merged plan section now records the shipped design: single path
set (working tree as truth) instead of {changed, deleted}, the dirty
journal, stranded-commit recovery, radio-free up-to-date, and the
soft-reset reconcile with its carried-files side win.
2026-07-13 00:53:50 +02:00
Julien Calixte
02b7ed88b6 fix(provisioning): rewrite the card origin to HTTPS at load
The device's libgit2 has no SSH transport (HTTPS+PAT over mbedTLS
only), but _load-repo copied the desktop clone's origin verbatim — an
SSH-shaped URL fails every on-device push/fetch with "unsupported URL
protocol" (first real-repo :sync, 2026-07-13). Derive the HTTPS
equivalent for the card copy (git@host:path and ssh://git@host/path);
the source clone keeps its own URL. Warns on non-github hosts, since
the embedded trust store carries GitHub roots only.
2026-07-13 00:53:50 +02:00
Julien Calixte
a5edaed810 feat(sync): commit by splicing journaled dirty paths onto HEAD
The index pipeline (add_all → index.write → write_tree) is O(N_tree)
and cannot commit the real 1179-file / 570 MB-pack clone (index.write
measured up to 611 s on FAT's racy-clean re-hash). stage_and_commit is
now an O(depth) TreeBuilder splice of exactly the paths the editor
saved or deleted — ~2-2.8 s on the real clone — with the working tree
as source of truth (existing file → insert, missing → remove).

Storage records those repo-relative paths on every save/delete and
journals them to /sd/.typoena-dirty (atomic, only on growth), so a
power pull can't strand a saved-but-unpublished note now that nothing
walks the tree. take_dirty → publish_succeeded/publish_failed settles
each publish's snapshot from the UI outcome handler.

Also required by / discovered with the splice:
- mwindow opts at git-service start (32-bit defaults would OOM PSRAM
  on the first real-pack access; bench-proven 256 KB / 4 MB).
- 16-FD mount for git builds (libgit2 holds pack+idx descriptors open;
  the editor's 4-FD budget overruns).
- reconcile is a soft reset (no index to reset anymore); side win: a
  remote-only added file is carried by the replay instead of dropped.
- stranded-commit recovery: tree-unchanged now pushes anyway when
  origin/<branch> lacks HEAD (a commit whose push failed used to be
  silently never retried).
- radio-free up-to-date: empty dirty set + origin at HEAD answers
  without bringing Wi-Fi up.
- try_push splits ref rejection (reconcilable) from transport failure
  (surfaced directly) — the first on-device run burned a doomed
  reconcile on "unsupported URL protocol" and hid the cause.

Deliberate behavior change: files changed on the card outside the
editor are never committed anymore (also retires the macOS-cruft
filter). Trail: docs/tradeoff-curves/sync-commit-staging.md.
2026-07-13 00:53:39 +02:00
Julien Calixte
e86a3b8254 docs(sync): merge the commit handoff into the staging tradeoff curve
The bench phase the handoff was written for is closed (splice benched,
fast-seek landed, cache removed, decision made), so the note's live half —
the firmware plumbing plan — moves into sync-commit-staging.md as "The fix —
wiring the O(depth) splice into the firmware", reconciled to the run-5 state.
The duplicated TL;DR/measurement summaries are dropped in favor of the
curve's own trail; inbound references (notes index, git_bench comments)
now point at the curve.
2026-07-13 00:29:07 +02:00
Julien Calixte
9309f3f239 docs(sync): record the esp_map v2 verdict and cache removal
Run 4: memory discipline verified (1833 KB flat, no OOM) but 0 hits
with the small maps demonstrably retained — window-repetition theory
refuted, sub-second bar failed (splice 2.83 s cold / ~2 s steady).
Decision: wire the splice in anyway (~9-10 s cold real-repo :sync vs
611 s/OOM for every alternative). Run 5: cache removal confirmed
free; the ~1.85 MB "resident" was mwindow's live window set, which
makes the mwindow opts in shipping git_sync.rs load-bearing.
2026-07-13 00:20:12 +02:00
Julien Calixte
1a5e1736f5 refactor(sync): remove the never-hit mmap window cache
Four instrumented real-repo bench runs scored 0 cache hits: libgit2's
mwindow layer reuses its open windows, so only genuinely new
(offset, len) ranges ever reach p_mmap — there is no repetition to
cache. The 7.4 MB OOM the v2 discipline fixed was caused by the cache
itself holding buffers past p_munmap; plain free-at-munmap is honest
with MWINDOW_MAPPED_LIMIT by construction. Run 5 confirmed removal is
I/O-neutral on device (byte-identical read pattern, warm splice 1953
vs 1949 ms) and even 15 reads better — v2's low-water eviction was
fighting mwindow. Stats counters kept to spot any future workload
that does repeat ranges.
2026-07-13 00:20:02 +02:00
Julien Calixte
ce3204a350 docs(sync): record fast-seek root cause and splice bench trail
Splice bench (6.5 s, O(depth) shape confirmed), FatFS cluster-chain
root cause with elm-chan/ESP-IDF references, on-device fast-seek
verification, second localization round (strict-creation refuted,
read_header 470 ms), esp_map v2 design, and the two firmware plumbing
gaps (mwindow opts + 16-FD mount).
2026-07-13 00:02:55 +02:00
Julien Calixte
dd3d70cbec perf(sync): cache small pack windows and evict mmap cache on munmap
Admission keyed on file size (>= 1 MB) instead of map length: the hot
set is the small repeated maps (pack trailer, idx fanout, delta bases)
that the 64 KB floor excluded — 0 hits across three real-repo runs.
Small mutable working-tree files stay uncacheable. p_munmap now evicts
unreferenced entries to a 2 MB low-water mark so released windows are
actually returned to git__malloc (fixes the 7.4 MB resident / 508 KB
heap zlib OOM and keeps MWINDOW_MAPPED_LIMIT honest).
2026-07-13 00:02:54 +02:00
Julien Calixte
2c1c590c9e perf(sync): enable FatFS fast seek for pack reads
Without CONFIG_FATFS_USE_FASTSEEK every long/backward lseek in the
263 MB pack walks the FAT cluster chain over SPI (~190 ms), and libgit2
pays ~8 such seeks per loose-object write. The CLMT makes lseek O(1)
for read-mode files; verified on device: far seek 198.7 -> 20.4 ms,
splice commit 6.5 -> 2.8 s.
2026-07-13 00:02:42 +02:00
Julien Calixte
2c24ece3a5 feat(bench): add packfile long-seek op to sd_bench
Reads 4 KB at the start vs the end of the repo's largest pack (skipping
macOS ._ sidecars). Proved the ~1.5 s/loose-object cost was FatFS
walking the FAT cluster chain on every long/backward lseek: 5.8 ms at
offset 0 vs 198.7 ms at the end of the 263 MB pack.
2026-07-13 00:02:42 +02:00
Julien Calixte
da8ca7d8d2 feat(bench): bench the O(depth) TreeBuilder splice and odb probes
Adds the splice prototype (patch one path onto HEAD's tree, O(depth))
as git_bench's headline op, run first so its cold number survives the
index ops' OOM, plus odb.read_header/odb.exists probes and strict-off
re-benches that localized the residual cost and refuted the
strict-object-creation theory.
2026-07-13 00:02:29 +02:00
Julien Calixte
dade5e6bb3 docs(sync): record real-repo commit bench and TreeBuilder handoff
The real jcalixte/notes clone (570 MB pack, 1179 files) proved both index
strategies are O(N_tree) and unshippable: index.write re-hashes the tree
(611 s), and the index-free read_tree is 77 s cold and OOMs the mmap cache
(zlib crash). Record the run in the tradeoff curve, revise the verdict to an
O(depth) TreeBuilder walk, and add a handoff note with the design, firmware
call sites, and bench steps for the next session.
2026-07-12 15:04:59 +02:00
Julien Calixte
a6f52df8b6 perf(sync): bench the index-free commit path and mmap cache
Replace the index.write()/write_tree ops (which re-hash the whole working
tree on a fresh FAT clone -- up to 611 s on the real repo) with the
index-free candidate: Index::new + read_tree(HEAD) + blob + write_tree_to.
Add per-op mmap-cache + free-heap logging (via esp_map_stats), announce each
op before it runs so a hang reveals the culprit, mount_for_git for the FD
budget, tune the mwindow limits, and drop N 10 -> 3 for the slow real clone.
2026-07-12 15:04:52 +02:00
Julien Calixte
6c5e666f4b feat(persistence): add mount_for_git with a larger open-file budget
libgit2 keeps the pack, .idx and commit-graph descriptors open for the
repo's lifetime and opens loose objects on top, so a read_tree walk overruns
the editor's tight 4-FD budget with "no free file descriptors". Add
mount_for_git (16 FDs, matching the flash-FAT git binaries) alongside the
editor's mount; both route through a shared mount_with_max_files.
2026-07-12 15:04:44 +02:00
Julien Calixte
c5e119f6be perf(git): cache emulated pack mmap to avoid re-reading the pack
p_mmap emulated a mapping by malloc+read()ing the range from SD on every
call. libgit2 re-hits the pack idx/windows on every object write (via
git_odb__freshen -> git_odb_refresh), so a commit re-read pack bytes over
SPI repeatedly -- ~500ms-1.1s/op on a small repo, far worse on the real
570 MB pack.

Cache read-only mappings >= 64 KB (pack idx/windows, commit-graph, midx,
packed-refs -- all immutable on this device) in a refcounted, PSRAM-backed
slot table keyed by (dev, ino, size, mtime, offset, len), LRU-evicted under
a soft cap. Small mutable working-tree maps (diff_file.c) fall below the
floor and are never served stale.
2026-07-12 15:04:37 +02:00
Julien Calixte
456c4c43e7 perf(sync): instrument and benchmark commit-staging latency
Break the stage+commit window into sub-phases (FAT working-tree walk vs
object writes) via `commit split —` log lines, and add two micro-benchmarks
(sd_bench for SD/FAT primitive ops, git_bench for libgit2 object overhead)
with justfile recipes. Documents the walk-vs-writes cost model in
tradeoff-curves/sync-commit-staging.md to decide whether explicit-path
staging over the editor's dirty set is worth replacing add_all(["*"]).
2026-07-12 12:24:50 +02:00
Julien Calixte
beb11eda5e chore(firmware): skip legacy-I2C conflict check
esp-idf-hal always compiles its i2c.rs, which binds the old driver/i2c.h
API, so ESP-IDF links the legacy driver's constructor into every image and
prints its deprecation warning at boot. Typoena uses no I2C (EPD/SD are SPI,
keyboard is USB), so the warning is pure noise; this flag compiles the
constructor (and its conflict-abort check) out.
2026-07-12 12:23:10 +02:00
Julien Calixte
88bb2b99cf feat(provisioning): pull source clone before copying to the card
`just load` / `just init` now fast-forward the source repo from its
origin before rsyncing to /sd/repo, so the card picks up notes the
device already pushed. Fast-forward-only and best-effort: dirty tree,
diverged history, detached HEAD, or offline warns and copies the
current on-disk state rather than aborting. TW_NO_PULL=1 skips it.
2026-07-12 10:59:05 +02:00
Julien Calixte
69843086f9 chore(firmware): flash at 921600 baud
Default espflash baud (115200) made a full flash take ~2-3 min of
serial transfer. Bump the cargo runner and the two direct-espflash
recipes (flash-git-push, flash-git-sync) to 921600. Monitor recipes
keep 115200 to match the device UART log rate.
2026-07-12 10:49:16 +02:00
Julien Calixte
45664fc956 feat: add glyphs 2026-07-12 10:43:41 +02:00
Julien Calixte
56f5d18bd8 docs(macroplan): record v0.6 markdown complete in core (0.6.0)
Mark v0.6 delivered (core, host-tested; on-device gate pending): the
snippet engine, the Cmd-P/>/$ palette split retiring :e, and the just
init catalog. Note the →/≠ ISO-8859-15 caveat.
2026-07-12 10:42:56 +02:00
Julien Calixte
767742ba12 docs(v0.6): mark boot-read + catalog done, finalise the catalog list
Record the firmware boot-read (serde_json builds for xtensa) and the
just-init catalog as delivered, and replace the proposed French catalog
with the finalised 17-snippet English set (three groups). Note the
→/≠ base-font caveat and the no-clobber seeding guard.
2026-07-12 10:36:58 +02:00
Julien Calixte
85cbeceea9 feat(provisioning): seed a snippet catalog + starter prefs on just init
Add a curated snippet catalog (firmware/snippets-catalog/, three opt-in
groups: Symbols, Structure, Prose — 17 snippets, English prefixes,
translated from the source Zed set) and a _seed-configs step in `just
init` that jq-merges the chosen groups into repo/.typoena.snippets.json
and writes a starter repo/.typoena.toml. Seeds only files that are
absent, so re-running init never clobbers a synced library.
2026-07-12 10:36:46 +02:00
Julien Calixte
8527f75bf8 feat(firmware): read .typoena.snippets.json at boot into set_snippets
Mirror the prefs boot-read: load the git-tracked snippet library from the
SD repo, parse it with Snippets::parse, and hand it to the editor before
the first render. Missing/unreadable/malformed is non-fatal (no snippets,
editor runs). Verified serde_json builds for xtensa (cargo check, 0.6.0).
2026-07-12 10:30:02 +02:00
Julien Calixte
5a076e3226 docs(v0.6): mark the palette generalisation delivered in core
Record the > command registry, one-shot/two-step dispatch, and the :e
retirement as done in core; note the ISO-8859-15 label constraints
(ASCII `...`, `»` hint marker).
2026-07-12 10:22:24 +02:00
Julien Calixte
c969d3e051 feat(editor): generalise the > palette into a command registry
Turn the > palette from a settings-only list into an action registry
dispatched by PaletteCmd::kind: toggles stay open, one-shots (format,
publish) run and close, and the parameterised `new file...` morphs the
palette into a filename input step. Share run_publish between :sync and
the publish command, and retire :e (bare Cmd-P opens files).
2026-07-12 10:22:17 +02:00
Julien Calixte
baf9715570 feat(editor): hint the matching snippet in the panel on typing pause
Snapshot the snippet name inline Tab would expand into a panel field in
refresh_stats, so it rides the typing-pause throttle rather than
repainting per keystroke. Draw it as `» name` on the row above the mode
line (Latin-9 has no tab glyph), sharing the slot with the NO KBD flag,
which can't co-occur since the hint means you're typing.
2026-07-12 10:09:43 +02:00
Julien Calixte
4aaf89d977 feat(editor): add snippet library, tab-stop engine, and $ palette
Parse the Zed-compatible .typoena.snippets.json (serde_json), strip
${n:label} stops to bare $n, and drive a forward-only tab-stop session.
Expand a prefix inline on Tab in Insert; browse and insert from the
Cmd-P palette via a $ sigil (parallel to the > command mode), landing
on $1 as one undo group.
2026-07-12 10:03:45 +02:00
Julien Calixte
221135cd9b docs(v0.6): specify the snippet library and $/> palette split
Reshape v0.6 snippets from a hard-coded table into a git-synced,
Zed-compatible JSON library (.typoena.snippets.json) with an inline Tab
path and a $ palette launcher. Add the file-format reference and
document the Cmd-P/>/$ verb split and the just init catalog.
2026-07-12 10:03:38 +02:00
Julien Calixte
93f3a43b34 docs(prefs): document theme + auto_sync presets, override v0.5 deferral
Reference doc gains a theme key, an Options column, and the e-paper dark-mode
ghosting caveat. The v0.5 spec gets an amendment note superseding the two
reversed "decide-before-build" calls (auto_sync now a set-ahead palette
command; theme shipped early) without rewriting the verified-on-device
history; macroplan updated to match.
2026-07-12 09:02:21 +02:00
Julien Calixte
f37da21462 feat(editor): add theme and auto_sync preset prefs with rotate-on-Enter
Generalise the palette so Enter advances any pref to its next value and
wraps; a boolean toggle is the two-option case. Both string prefs share a
next_option(current, &OPTIONS) helper (off-list values snap to the head).

- theme (light/dark): rendered as a single whole-frame Frame::invert at the
  end of draw(), so text, selection, caret, panel and palette flip together.
- auto_sync: cycles 2m/5m/10m/15m/30m. Set-ahead only — still read by
  nothing until the v0.7 periodic push.

toggle_pref -> cycle_pref; palette hint "Enter toggle" -> "Enter change".
2026-07-12 09:02:15 +02:00
Julien Calixte
fdd59093db docs(v0.5): document the trailing-newline display model
Record the d14d9e7 behaviour the doc didn't cover: format-on-save keeps at
most one trailing blank line, and load-verbatim + guarded-save render a
file's POSIX terminator as a visible trailing empty line.
2026-07-12 02:25:24 +02:00
Julien Calixte
55a8d21e0f docs(macroplan): split each version into its own page
Move each version's scope checklist and status out of the monolithic
macroplan into a dedicated docs/vX.Y-<slug>.md page; macroplan keeps the
source block, the rollup status, and a one-line summary + link per
version. v0.1 reuses its existing product/technical pages.

Also drop the "Optional column ruler at 80" requirement from v0.6.
2026-07-12 02:10:29 +02:00
Julien Calixte
d14d9e77a5 fix(persistence): show a saved note's trailing newline as an empty line
Read notes verbatim and insert the final newline only when it is missing,
instead of stripping the terminator on load and appending it unconditionally
on save. The editor's `rows = #\n + 1` model then renders a file's POSIX
terminator as a visible trailing blank line — what a writer expects: open a
note and see (and land the caret on) the empty line the newline stands for.

Supersedes the strip-on-load / unconditional-append handling that shipped
with the prefs work (c535864), which kept the buffer newline-free and hid the
terminator. Load + save are now an identity round-trip for any device-written
file (all end in '\n'); files stay git-clean (exactly one terminator); and a
trailing blank line the writer leaves is mirrored, never doubled.

- load_path: read verbatim (drop the strip)
- save_path: guarded final-newline (drop the unconditional append)
- Prefs::to_toml: ends in a newline again — the guarded save leaves exactly
  one, so the prefs file is byte-identical to before and its device-verified
  round-trip still holds
- sd_fat spike: payload ends in '\n' so its exact-equality round-trip holds
2026-07-12 02:02:46 +02:00
Julien Calixte
d46cdb7e7f docs(macroplan): mark v0.5 delivered, close slice-4 on-device gate
All four v0.5 slices are done and device-verified: the prefs round-trip is
confirmed both directions (boot-read + on-device palette edit -> push flipped
line_numbers on origin), and the slice-3 delete fix is recorded as confirmed on
device. Descoped items (buffer close, grey-Publish-in-Local cue, multi-file
publish count) noted, not blocking delivery.
2026-07-12 01:57:00 +02:00
Julien Calixte
c535864ee7 feat(editor): add .typoena.toml prefs and palette settings (v0.5 slice 4)
Git-tracked editor preferences read at boot and toggled live on-device:

- Prefs type (line-based TOML parse/serialize, no crate on xtensa) held on
  Editor; firmware reads /sd/repo/.typoena.toml before the first render and
  falls back to per-key defaults. Keys: save_on_idle, format_on_save,
  line_numbers (bool) + auto_sync (string, schema/default only until v0.7).
- line_numbers applied live (gutter_cols -> 0 when off).
- Palette > command mode toggles the three bools; the list stays open so
  several flip in one visit, and :settings opens it directly. Each toggle
  applies live and queues Effect::SavePrefs (host atomic-writes the file,
  which rides the next :sync).
- save_on_idle honoured host-side as a silent, unformatted idle auto-save.
- to_toml is newline-free; save_path now appends exactly one terminator
  unconditionally so buffers round-trip byte-for-byte (trailing blanks kept).
- Firmware 0.4.0 -> 0.5.0; new docs/typoena-toml.md reference. Also refreshes
  the slice-3 macroplan status (delete fix was confirmed on device).
2026-07-12 01:48:10 +02:00
Julien Calixte
82f305cea6 feat(persistence): end saved files with a trailing newline
Add the POSIX line terminator on save and strip it on load, so files
written by the editor no longer trip git's "No newline at end of file".
Done at the persistence choke point, not in :fmt: the editor buffer is
newline-free by design (rows = #\n + 1), so a trailing '\n' in the buffer
would render a phantom blank last line. save_path adds exactly one '\n'
(guarded against doubling); load_path strips one back off so the buffer
stays newline-free and round-trips byte-stable.

Update the sd_fat spike payload to be newline-free so its byte-identity
round-trip assertion holds under the new normalizing contract.
2026-07-12 01:11:28 +02:00
Julien Calixte
c9c07165e0 feat(editor): add :enew and :delete with real git-staging (v0.5 slice 3)
:enew <name> creates a new file (empty, dirty, added to the palette list);
:delete unlinks the current file via a new Effect::Delete and switches to a
parked buffer or scratch. Scope is read from the path (local/x, repo/x) rather
than a modal prompt, and the /sd prefix is now optional in resolve_path.

On-device testing showed deletions never reached the remote: add_all(["*"])
alone does not stage a removal on this libgit2, so the tree came back unchanged
and the push was a silent no-op. stage_and_commit now runs add_all then
update_all(["*"]) (git add -u) — together git add -A. The :delete snackbar now
confirms the scoped file and flags that a Tracked file is local until :sync.
2026-07-12 00:44:39 +02:00
Julien Calixte
e967773bd6 feat(editor): add v0.5 file palette (Cmd-P) with fuzzy match
Cmd-P opens a modal transient palette over the writing column: a bare
fuzzy-search input, the ranked file list, and the selected row in reverse
video. A pure host-testable fuzzy_score (subsequence + word-boundary and
consecutive-run bonuses) ranks results; an in-core MRU floats recently
opened files first and is shared with :e (both route through open_path).
The host feeds the file list once at boot (enumerate_files over /sd/repo
and /sd/local). Ctrl-n/Ctrl-p navigate the list; Enter opens via the same
park/evict path as :e; Esc closes.

Ctrl-n/Ctrl-p also become down/up line motions in Normal and View (vim
CTRL-N/CTRL-P, count-aware), which is why the palette opener is Cmd-P
alone. No `>` prefix on the file input — `>` is reserved for the command
palette (slice 4).

112 editor + 28 keymap tests; the no-git firmware binary builds clean.
2026-07-12 00:11:33 +02:00
Julien Calixte
2215da939d feat: add multi-file buffer foundation (v0.5 slice 1)
Rework the single Effect return into a drained effect queue
(Save{path,scope,contents} / Load / Publish / Pull) so one action can
ask the host for several ordered steps: opening a non-resident file
queues a Save of the outgoing dirty buffer then a Load of the target.

Keep the active buffer's fields inline on Editor and park inactive
buffers in a small LRU (<=3 resident = active + 2); switching back to a
resident buffer restores its caret/scroll/undo without touching the SD.
A dirty parked buffer is saved before eviction, so nothing leaves RAM
unsaved. `:e <path>` opens by prefix (/sd/repo -> Tracked, /sd/local ->
Local); `:sync` is refused in-core for a Local buffer.

Firmware drains the queue to empty each batch (a Load can cascade an
eviction Save) and persistence generalises the atomic save off the
hard-coded notes.md into load_path/save_path.

Also bump the side panel to FONT_9X15 and the `:` command line to
FONT_10X20 for legibility, word-wrapping the snackbar so a long notice
keeps its actionable tail.
2026-07-11 22:26:37 +02:00
Julien Calixte
fa0ea56e1a feat(editor): add Visual mode (v/V) with y/d/c, move View to gr
Charwise `v` and linewise `V` selection with yank/delete/change on the
span; motions and counts extend it. Read-only View moves off v/V to
`gr` (go-read). Selection renders reverse-video on the 1-bit panel.
Normal motions factored into a shared move_by. Firmware -> 0.4.0.
2026-07-11 20:50:16 +02:00
Julien Calixte
470a9d25d0 docs(macroplan): note v0.3 on-device smoke-test and paste-scroll fix 2026-07-11 20:26:15 +02:00
Julien Calixte
937868dd85 fix(editor): reveal the pasted block when it runs past the fold
A multi-line paste near the bottom of the screen left its later lines below
the viewport — adjust_scroll only kept the caret's (first) pasted line
visible. Add reveal(), which scrolls the end of the pasted block into view
while the caret stays on its first line (vim semantics unchanged).
2026-07-11 20:26:15 +02:00
Julien Calixte
884c8f2e48 docs(postmortems): record the SPI-DMA OOM editor freeze
Root cause, shipped safety net, and the specced eradication (a
persistent internal DMA scratch buffer in Epd so paints never allocate
mid-sync). Tracks hardware re-test and the eradication as follow-ups.
2026-07-11 20:21:49 +02:00
Julien Calixte
32b9bc6a15 fix(firmware): keep the editor alive when a panel paint fails
A screen refresh that ran while :sync had Wi-Fi + TLS up could fail to
allocate an internal DMA bounce buffer (ESP_ERR_NO_MEM); the error
propagated through ? out of main(), main_task returned from app_main(),
and the editor loop died while the USB and git threads kept running —
keys logged, panel frozen forever.

A paint is idempotent and retryable (the buffer is the source of truth),
so every editor-loop paint now logs and drops the frame instead of
propagating, leaves shown untouched so the next paint repaints the diff,
and forces a full refresh next to re-sync both RAM banks. Same contract
as save_note. The boot-time first render stays fatal on purpose.
2026-07-11 20:21:49 +02:00
Julien Calixte
f8ef9c821c Merge remote-tracking branch 'origin/main' 2026-07-11 20:04:37 +02:00
Julien Calixte
f950abdc4a docs(macroplan): mark v0.3 editing complete
Records v0.3 as delivered in core (host-tested, on-device smoke-test pending)
and notes the known dot-repeat limits. Also reorders the plan so the Status
block follows the macroplan source.
2026-07-11 20:03:59 +02:00
Julien Calixte
137a51eb76 chore(firmware): bump version to 0.3.0
v0.3 editing (register/yank/paste, undo/redo, dot-repeat) is complete, so the
device release version tracks the shipped feature set.
2026-07-11 20:03:59 +02:00
Julien Calixte
ba8f4e9a92 feat(editor): add v0.3 editing — register, undo/redo, and dot-repeat
Yank/paste via one unnamed register: `y` operator (yy/yw/yiw/y$), `p`/`P`,
with `x`/`d`/`c` also filling it so `dd`…`p` moves a line. Undo/redo (`u`,
`Ctrl-r`) as bounded full-buffer snapshots — one Insert session is one undo
group. `.` replays the last change from its recorded keystrokes, so it
repeats insert sessions like `ciwfoo<Esc>`.
2026-07-11 20:03:52 +02:00
Julien Calixte
8e4e3a7586 feat(keymap): add Ctrl-r redo intent
Decodes Ctrl+R (HID 0x15) to a new Key::Redo, the inverse of the editor's
`u`. Meaningful in Normal mode; ignored elsewhere.
2026-07-11 20:03:42 +02:00
657aba3cf3 Updating docs/qfd.md from Remanso 2026-07-11 18:46:27 +01:00
Julien Calixte
229c259e7c docs: mark v0.2 complete and align delivered-release bookkeeping
Spike 13 (line-number gutter) verified on the panel closes the last v0.2
gate. Record v0.2 as delivered, and give v0.2.5 a delivered date + learning
note to match v0.1/v0.2 (its source-block entry still read on-track).
2026-07-11 19:41:17 +02:00
Julien Calixte
f5a0b45f6f feat(editor): format the buffer on save/sync (format_on_save)
Run :fmt in-core before :w/:sync emit their effect, so :sync is
fmt -> save -> commit -> push and :w saves formatted. Gated on the
format_on_save field (default on); the v0.5 .typoena.toml key will drive it.
2026-07-11 19:32:16 +02:00
Julien Calixte
cb3160541d feat(editor,firmware): add :gl fast-forward pull command
Add Effect::Pull and the `:gl` command (fetch + fast-forward only, refuse on
divergence). The editor side is host-tested; the firmware arm is a stub posting
"pull: not wired yet (v0.7)" — the on-device fetch/fast-forward in git_sync is
v0.7 work (only push is wired today).
2026-07-11 19:32:02 +02:00
Julien Calixte
1d7448ba75 feat(editor): edit the : command line with Ctrl-W / Cmd-Backspace
Handle DeleteWord (Ctrl-W) and DeleteLine (Cmd-Backspace) in Command mode:
Ctrl-W drops the previous word, Cmd-Backspace clears the line. Neither exits to
Normal on empty (unlike Backspace), so command editing stays on the line.
2026-07-11 19:30:39 +02:00
Julien Calixte
5d9591e5ea docs: rename roadmap.md to macroplan.md and refresh the plan
The file is the macroplan (plus per-version scope), so rename it to match and
retitle to "Macroplan"; update all inbound links and friendly labels across the
docs. Refresh the plan while here: v0.2 gutter built, :gl pull recorded (v0.7),
command-line editing (v0.4), and the format_on_save pref (v0.5).
2026-07-11 19:27:27 +02:00
Julien Calixte
97216db0c0 docs: record line-number gutter and v0.5 line_numbers pref
Mark the v0.2 gutter built + host-tested (Spike 13 bench check still pending),
add the v0.5 .typoena.toml line_numbers toggle, and update CONTEXT.md screen
regions for the rebalanced 63-col writing region / 25-col side panel.
2026-07-11 18:59:52 +02:00
Julien Calixte
8b30658275 feat(editor): absolute line-number gutter
Reserve a left gutter for absolute line numbers, drawn on each logical line's
first display row and blank on wrapped continuation rows. Gutter width tracks
the buffer's line count (2 digits + separator, widening past 99 lines) and
steals its columns from the soft-wrap. Widen the writing region 60->63 cols so
the gutter doesn't narrow the text: a file up to 99 lines keeps a full 60-col
text column, at the cost of the side panel shrinking 30->25 cols.
2026-07-11 18:59:46 +02:00
Julien Calixte
54dc7a31d3 Merge remote-tracking branch 'origin/main' 2026-07-11 18:34:05 +02:00
Julien Calixte
fc83306a81 feat(keymap,editor): add Ctrl-d/u half-page scroll
Decode Ctrl+D/Ctrl+U as HalfPageDown/HalfPageUp intents (matching the existing
DeleteWord/DeleteLine chord pattern, so the editor stays ignorant of Ctrl).
They step display (soft-wrapped) rows, not logical lines, so half a page is
half the visible window regardless of how prose wraps: Normal moves the caret
and the viewport follows, View scrolls the viewport, Insert/Command are no-ops.
2026-07-11 18:32:05 +02:00
Julien Calixte
491dc57144 docs: drop relative line numbering from the v0.2 gutter
Relative numbering renumbers the whole gutter on every j/k, a tall partial
refresh per cursor move that eats the e-ink ghosting budget for no
proportionate gain. Gutter is now absolute-only; Spike 13 downgrades from a
genuine e-ink risk to a layout decision.
2026-07-11 18:31:33 +02:00
9f797d814c Updating docs/qfd.md from Remanso 2026-07-11 17:00:42 +01:00
Julien Calixte
a817229ae1 docs(qfd): rebase perception and H1 targets on measured v0.1
Update the House now that v0.1 is delivered and hardware-verified:

- Perception zone: Typoena column moves from v0.1 target to measured.
  W6 3->4 (1 h soak attested), W1 4->2 (type latency measured ~630 ms,
  a visible lag). Typoena total 52->51, lead over Pomera down to one.
- H1 target: v0.1 relaxed 200->400 ms and v1.0 150->300 ms; ~630 ms
  still exceeds 400 ms, recorded as the open latency gap, not a pass.
- Fix pre-existing drift: TikZ W14 disagreed with the table/totals
  (Pomera/Smart 2/5 vs 5/1); "thirteen rows" -> "fourteen".
- Hoist the House diagram above section 1 so the picture leads.
- Trim AI writing tells and thin em-dash density in the new prose.
2026-07-11 17:58:38 +02:00
Julien Calixte
17b6631310 docs(qfd): annotate H7 Publish latency with measured :sync data
Adds a ‡ footnote on H7 (mirroring the H4 boot-latency †): cold :sync ~16 s /
warm ~10 s measured 2026-07-11 — within the ≤30 s v0.1 target, marginal against
the ≤10 s v1.0 target (cold's one-time Wi-Fi+SNTP push it over). Links the
breakdown in notes/sync-latency.md.
2026-07-11 17:21:26 +02:00
Julien Calixte
fe544ccdd3 refactor(docs): merge quality-house into qfd §3
quality-house.md mirrored qfd.md's §1/§2 catalogues in a separate file,
which silently drifted (H3 cadence read 20 there vs 64 in qfd). Fold the
House diagram, perception scores, and regen notes into qfd.md §3 with no
section renumbering, so inbound #3/#6/#7 anchors still resolve. Delete
quality-house.md; re-point quality-house-empty.md and the docs index.
2026-07-11 17:17:18 +02:00
Julien Calixte
a700412773 docs: record :sync latency budget from hardware measurement
New docs/notes/sync-latency.md breaks down the measured ~16 s cold :sync
(Wi-Fi + SNTP + one TLS push), explains the optimistic-retry handshake saving,
the reconcile/last-writer-wins semantics, and why the rest is near the protocol
floor. Linked from the notes index, the docs index, and the Git row of the root
README.
2026-07-11 17:04:02 +02:00
Julien Calixte
92068f1cce docs(quality-house): sync H3 target to 1:64 after qfd change 2026-07-11 17:00:45 +02:00
Julien Calixte
9d6daf7afd docs(qfd): sync boot latency and refresh cadence to measured values 2026-07-11 16:47:12 +02:00
Julien Calixte
c206fc28e4 docs: record verified 4258 ms cold boot after refresh fix 2026-07-11 16:46:56 +02:00
Julien Calixte
ad023843e9 docs: add e-ink refresh-latency curve and boot-time budget notes 2026-07-11 16:46:50 +02:00
Julien Calixte
3386969655 perf(firmware): optimistic :sync — push first, reconcile only on reject
The pre-commit fetch cost ~6s on the measured cold sync (it did real work
absorbing a foreign push; ~3s + a full TLS handshake even when the remote is
unchanged). Drop it: push onto the current tip first, and only when the remote
rejects the push non-fast-forward do we fetch, mixed-reset onto origin, replay
the note, and retry. The happy path is now a single handshake.

- stage_and_commit extracted (used on the first attempt and the replay).
- reconcile_onto_origin replaces fast_forward_before_commit + fetch_and_integrate.
  Mixed reset keeps the just-saved note; the replay lands it on origin's tip.
- Single-writer semantics: a foreign push resolves last-writer-wins instead of
  bailing on divergence, so the device never gets stuck. A remote-only added file
  it doesn't have would be dropped by the replay — needs the real merge path
  (increment B), doesn't arise from this device's own use.
2026-07-11 16:46:49 +02:00
Julien Calixte
b50043020d feat(firmware): log per-phase :sync timing breakdown
publish_cycle now times wifi assoc, SNTP, TLS setup and publish separately
and logs a one-line total. Cold sync shows all phases; a warm sync reads 0ms
for the first three. Makes the fetch added to publish measurable.
2026-07-11 16:11:30 +02:00
Julien Calixte
e57709a9ee fix(firmware): harden :sync publish — skip macOS sidecars, fast-forward first
Two robustness fixes surfaced by commit 07d87772 (the first editor-driven
:sync), which shipped three macOS AppleDouble files to typoena-test:

- Staging filter: add_all now runs a per-path callback that skips ._* and
  .DS_Store, so Finder/Spotlight cruft on the FAT card never lands in a
  commit. Device-level, so it protects every repo without a per-repo
  .gitignore.

- Pre-commit fast-forward: publish_once fetches origin and, if the local
  branch is behind, fast-forwards to it via a MIXED reset before committing.
  This lets the device absorb a foreign push (e.g. the sidecar cleanup) and
  fast-forward cleanly instead of stacking a commit on a stale base and
  diverging at push time. The mixed reset moves the ref+index but leaves the
  working tree, so the just-saved unsynced note isn't lost.
2026-07-11 16:09:41 +02:00
Julien Calixte
8dc6ee362f feat(firmware): wire SD persistence + git publish into the editor
Land the v0.1 editor integration: the git_sync module (libgit2 on the SD
/sd/repo, dedicated 96KB git thread, lazy Wi-Fi, :sync push with
synced/up-to-date/failed snackbars), the boot splash (Spike 9) plus its bin
and justfile recipes, and a power-on→cursor boot-timing log. Also re-syncs
the roadmap/spikes/v0.1-product status and adds the SD hardware reference
photo.
2026-07-11 15:30:43 +02:00
Julien Calixte
98a9d1dffe docs: flag that v0.1 keeps wifi up, link git_sync.rs
Ground the off-between-syncs assumption in the code: the shipped firmware
runs the stay-associated strategy the section argues against. Link
run_git_service so the claim is verifiable and mark teardown as v0.8 work.
2026-07-11 15:25:33 +02:00
Julien Calixte
2673a3377a docs: note wifi is off (not modem-sleep) between syncs
Make explicit that the per-sync burst assumes a full radio de-init between
pushes, and that staying associated to skip handshakes loses on energy until
~150 syncs/hr — a regime a writing appliance never reaches.
2026-07-11 15:19:18 +02:00
Julien Calixte
6f7f05baf2 docs: link tradeoff-curves from the root README 2026-07-11 14:57:53 +02:00
Julien Calixte
f4af38d8ef docs: add .typoena.toml config and auto-sync energy tradeoff
Record the git-tracked .typoena.toml preferences file (save_on_idle,
auto_sync) and the palette `>` command mode that edits it in the v0.5
roadmap. Add a tradeoff-curves note deriving Wi-Fi sync energy as a 1/T
curve, which sets auto_sync's default to 10m (opportunistic, min-clamped)
rather than a 5m wall-clock timer.
2026-07-11 14:38:07 +02:00
Julien Calixte
14967a06c6 docs: retire resolved SD risk, re-sync v0.1 status
Spike 3 (SD) is resolved (genuine 32 GB card mounts, verified on its own
SPI3 host per ADR-012) and the mount/save path is now wired into main.rs.

- README: move the SD/CMD59 open question to the retired-risks line; drop
  the stale "blocked on SD (waiting on a compatible card)" from the status
  blurb. Remaining v0.1 gate is the boot splash + wiring git publish.
- roadmap: flip the boot-load and save-to-SD v0.1 items to done, re-sync
  the status blurb and macroplan note, re-point the config-at-boot TODO
  from the (landed) SD wiring to the git-publish wiring.
2026-07-11 14:14:08 +02:00
Julien Calixte
18ed6aa599 docs(roadmap): record the panel snackbar and fix panel status
The side-panel split (writing column + panel) is already built in the
editor crate; correct the stale "not built yet" note and add the
snackbar under v0.1.
2026-07-11 14:01:02 +02:00
Julien Calixte
8e3e6e25a4 feat(firmware): post loaded/saved snackbars to the panel
Boot posts "loaded <name>" (the note's filename without suffix); :w and
:sync post "saved" / "save FAILED - retry :w" via Editor::set_notice, so
save state is visible on-device, where there is no serial console.
2026-07-11 14:00:55 +02:00
Julien Calixte
e797381da6 feat(editor): boot in Normal mode and add a panel snackbar
Two boot/panel UX changes that share the editor's constructor, so they
land in one commit:

- Power-on mode is now Normal (was Insert). with_text() opens a loaded
  note in Normal with the caret on the last char (the resume point);
  draw(false) now suppresses the caret in every mode, so the boot-error
  screen renders as pure text rather than sprouting a block caret.
- notice/set_notice(): a transient side-panel message ("snackbar")
  drawn under the word count and cleared on the next keystroke — no
  timed repaint, which on e-ink would cost a ~630 ms flash to erase.
2026-07-11 14:00:45 +02:00
Julien Calixte
ee00fecdc8 docs(firmware): flip build-mode docs to git-default 2026-07-11 12:47:57 +02:00
Julien Calixte
9101d61045 build(firmware): default to the git build, add build-light
Make `just build`/`just flash` the nominal product build (--features git
+ LIBGIT2_SRC), and move the fast editor-only build to `build-light`/
`flash-light`. Verified the git default links libgit2 into the firmware
ELF (1.2 MB) cleanly.
2026-07-11 12:47:57 +02:00
Julien Calixte
830701e670 docs(firmware): document light vs git build modes 2026-07-11 12:39:24 +02:00
Julien Calixte
235c7e4d24 build(firmware): add git-enabled firmware just recipes 2026-07-11 12:39:24 +02:00
Julien Calixte
4c8c557e7b feat(firmware): gate :sync publish behind the git feature
Route :sync through publish(), whose git-push path is #[cfg(feature =
"git")]. Git code in the firmware binary is now only ever compiled with
--features git, so the default build stays a light editor build with no
git2 crate and (since the justfile only sets LIBGIT2_SRC for git recipes)
no libgit2 component. Feature is off by default; :sync saves locally.
2026-07-11 12:39:24 +02:00
Julien Calixte
acecf9998f Merge GitHub main (empty case-README commit from Remanso)
Reconciles a split-brain: fd62fac was pushed to GitHub only (not apoena)
and is an empty commit. Merging lets both remotes fast-forward to a single
head without rewriting the already-pushed persistence-wiring commits.
2026-07-11 12:20:09 +02:00
Julien Calixte
81d5f818cc docs(persistence): note boot-load and :w/:sync save are wired 2026-07-11 12:17:58 +02:00
Julien Calixte
637fe4a4f4 feat(firmware): boot-load and save the note via persistence
Seed the editor from Storage::load at boot and persist on :w/:sync
through Storage::save. A missing card/repo/unreadable note halts boot
with the reason on the panel rather than starting empty and clobbering
the note on the next save. Save errors are logged, buffer kept in RAM.

The git-push half of :sync stays a TODO — it needs git_sync graduated
into a module on the dedicated git thread; :sync saves for now.
2026-07-11 12:17:58 +02:00
Julien Calixte
a61e0a2196 feat(editor): add text() getter and with_text seed constructor
The host had no way to read the buffer out or seed it from a loaded
file. with_text lands the caret at the end so boot-load resumes writing
where the user left off; text() exposes the buffer for :w/:sync.
2026-07-11 12:17:51 +02:00
fd62fac10e Updating hardware/case/README.md from Remanso 2026-07-11 11:07:07 +01:00
Julien Calixte
7a1f25f5d1 docs(persistence): document module and refine FAT recovery to two cases
Record that persistence is now implemented and hardware-verified, and correct
the crash-recovery rule everywhere it appeared as "just promote the tmp": a
crash during the tmp write leaves a partial tmp, so recovery keeps the
committed target when both files are present and only promotes the tmp when
the target was already unlinked. Also fix the stale README heading that still
said the SD was on shared SPI2.
2026-07-11 12:02:30 +02:00
Julien Calixte
1e3220a95f refactor(sd): drive Spike 3 harness through persistence module
sd_fat is now a thin on-device harness over firmware::persistence instead of
duplicating the mount FFI. It mounts, reports FAT usage + negotiated clock,
loads notes.md (non-destructive), and only runs the write round-trip when
notes.md is empty so a provisioned card is never clobbered. Verified on
hardware 2026-07-11: mount at 10 MHz, 91-byte round-trip byte-identical.
2026-07-11 12:02:22 +02:00
Julien Calixte
9c338571f0 feat(persistence): add SD mount + atomic save/load module
Graduate the proven Spike 3 storage stack into firmware::persistence so the
editor and the spike share one implementation. Storage::mount brings up the
dedicated SPI3 bus (ADR-012) and mounts FAT with format_if_mount_failed=false
(never reformat the user's card). save() does the FAT-safe atomic write
(tmp -> fsync -> unlink -> rename); recover() reconciles a leftover *.tmp at
boot, keeping the committed file when the crash point is ambiguous and only
promoting the tmp when the target was already unlinked. load() caps reads at
256 KiB.
2026-07-11 12:02:15 +02:00
Julien Calixte
5540f901de docs(sd): mark SD-on-SPI3 verified on hardware
Re-ran the spike on the dedicated SPI3 wiring (CLK 14 / MOSI 15) with
the same clean mount + atomic round-trip result. Flip the docs from
"pending a re-run" to verified.
2026-07-11 11:43:34 +02:00
Julien Calixte
1b9987c4ee fix(sd): correct stale shared-bus log strings to SPI3
The boot banner, success line, and round-trip payload still said
"shared SPI2" / "shared bus" after the move to SPI3. Cosmetic only —
serial output now matches the dedicated-bus wiring.
2026-07-11 11:43:34 +02:00
Julien Calixte
4bc9236bd7 docs(adr): record ADR-012 — SD on its own SPI3 host
Document the shared-bus arbitration decision: rather than rework the
proven EPD SPI layer and add a cross-thread mutex on the save path, the
SD moves to a dedicated SPI3. Update the boot sequence, risk table,
Spike 3 postmortem follow-up, hardware overview, and firmware README.
2026-07-11 11:28:22 +02:00
Julien Calixte
9129f04d0e feat(sd): move the SD spike to its own SPI3 host
The EPD's SpiBusDriver holds an exclusive SPI2 lock for its lifetime, so
an SD on SPI2 is locked out while the panel driver is alive. Give the SD
its own SPI3 (SCK 14, MOSI 15; MISO 13 / CS 10 unchanged) and drop the
now-pointless EPD-CS deselect. See ADR-012.
2026-07-11 11:28:16 +02:00
Julien Calixte
54a9e31ee5 docs(firmware): document the SD card provisioning workflow
Add a "Provisioning an SD card" section covering the just init/load/provision
entry points, the config-resolution ladder (env → derive → prompt), the macOS
Keychain Wi-Fi lookup, and the plaintext-secret threat model for the card.
2026-07-11 11:14:53 +02:00
Julien Calixte
fc4ce4d017 feat(provision): fill card config from env, git, keychain, or prompts
typoena.conf no longer requires a hand-written firmware/.env. _write-conf now
resolves each value through a ladder: .env if set, else derived from tools
already on the machine (git config, gh, the active Wi-Fi network + its System
keychain password), else an interactive prompt with the derived value as the
default. The PAT is never derived (a broad gh token on a plaintext card would
defeat the scoped-token model) and is always typed by hand. Missing required
values now abort before touching the card instead of ejecting a blank config.
2026-07-11 11:14:48 +02:00
Julien Calixte
5335751323 docs(sd): record Spike 3 hardware verification and findings
Mark Spike 3 resolved (genuine 32 GB SDHC mounts, round-trips clean on the
shared SPI2 bus; the 133 GB SDXC's CMD59 rejection was the sole fault).
Capture the FatFS f_rename caveat and the ≤32 GB card-compatibility note in
ADR-007, the resolution in the postmortem, and the verified writeup in the
firmware README.
2026-07-11 11:14:31 +02:00
Julien Calixte
b899286639 feat(sd): reformat a fresh card on mount failure in the spike
Flip format_if_mount_failed to true so a fresh exFAT/unformatted bench card is
reformatted to FAT on the device instead of failing — no Mac-side prep for the
spike. Fires only on a filesystem mount failure, not the CMD59 protocol
rejection. The real persistence module must keep this false.
2026-07-11 11:14:15 +02:00
Julien Calixte
698c10c8c0 fix(sd): unlink target before f_rename to overwrite on FAT
FatFS's f_rename returns FR_EXIST on an existing destination — unlike POSIX
rename(2) it won't replace it, so re-running the round-trip failed at rename.
Unlink the target first (tolerating a missing one for the first save). This
opens a crash window the real persistence module must close with *.tmp boot
recovery (ADR-007).
2026-07-11 11:14:04 +02:00
Julien Calixte
bef9587a34 chore(sd): drop Spike 3 CMD59 diagnostic logging
The per-command sdmmc/sdspi DEBUG logs only existed to diagnose the CMD59
init failure, now resolved (verified 2026-07-11). Remove the build-time log
ceiling and the runtime esp_log_level_set block.
2026-07-11 11:13:42 +02:00
Julien Calixte
195311a395 feat(case): add a reset button hole in the back wall
Momentary switch wired to the board's EN/GND, mounted past the µSD so
it's never hit while typing. BOOT is omitted on purpose: on the S3,
auto-download makes it recovery-only. Positions are << MEASURE >>
placeholders — a custom carrier PCB will re-fix them later.
2026-07-11 10:01:21 +02:00
Julien Calixte
3faff25fba chore(case): pin $fn in the render recipe for stable previews
Preview quality no longer depends on whatever $fn the model happens to be
left at for fast editing.
2026-07-11 09:45:00 +02:00
Julien Calixte
208c929650 feat(case): relieve the bracket and foam for the FPC U-turn
The flex leaves the glass's back plane on the left and must fold ~180 deg
to dive into the cavity toward the breakout. A safe bend radius makes
that loop ~4 mm deep, too deep for the 1 mm foam gap, so it fouled the
rigid bracket. Open the bracket's left frame member and the foam's left
border over the FPC span, aligned with the body's existing slot, to give
the U-turn room. Re-adds the relief dropped in c4320e2, correctly scoped.
2026-07-11 09:44:43 +02:00
Julien Calixte
32e9623411 chore(case): expand just render to regenerate every preview
Was only rendering assembled + body, which let bracket.png drift stale.
Now covers all 11 views. Recovered the two bespoke cameras that were
never recorded: nameplate (straight-on body) and front34 (low back 3/4).
2026-07-11 09:34:34 +02:00
Julien Calixte
c4320e2239 revert(case): keep the bracket a solid frame (no FPC notch)
Reverts the notch added in 6af959f. The flex U-turns off the glass's
left edge and returns inboard through the ~1 mm foam gap above the
bracket, then drops into the cavity through the aperture — it never
crosses the bracket plane, so the relief in the body's pocket wall is
enough. A solid frame also clamps that edge more evenly. Comment added
so the notch doesn't get reintroduced.
2026-07-11 09:34:26 +02:00
Julien Calixte
6af959f92d feat(case): cut an FPC ribbon relief into the bracket frame
Gap in the left frame member aligned with the body's FPC clearance so
the flex folds back through the bracket plane into the cavity instead
of being pinched. Refresh renders and the bracket alt-text.
2026-07-11 09:27:25 +02:00
Julien Calixte
757dd492b9 docs: trim AI writing tells from READMEs and docs prose
Drop magic adverbs (silently, arguably), a stakes-inflation flourish,
and the bold-first Snippets bullets; de-duplicate the "usable artifact,
not a checkpoint" line shared by the README and roadmap.
2026-07-11 09:19:58 +02:00
Julien Calixte
91287899a4 style(case): apply markdown formatter to the enclosure README 2026-07-11 09:08:40 +02:00
Julien Calixte
314fefc9b6 docs: link the enclosure to its README, not the folder 2026-07-11 09:08:40 +02:00
Julien Calixte
e8482e4e2d style(case): recolor the body to soft sage, refresh renders 2026-07-11 09:03:22 +02:00
Julien Calixte
d47de98c22 docs(case): document plan_up/plan_down and add their renders 2026-07-11 08:51:34 +02:00
Julien Calixte
896ca8073b feat(case): split the plan section into plan_up and plan_down
Factor the two halves of the horizontal cut into shared modules so each
can be viewed alone (`plan_up` = deck/screen, `plan_down` = cavity),
while `plan` keeps the exploded combo. Cut height and gap are now the
`plan_z` / `plan_explode` params.
2026-07-11 08:51:33 +02:00
Julien Calixte
d63a66f4cf docs(case): document the plan section and embed its render 2026-07-11 08:46:47 +02:00
Julien Calixte
e28a6a6d19 feat(case): add exploded horizontal plan section
New `show="plan"` lifts the deck/screen half off the cavity half so the
board layout reads at a glance — standoffs, corner posts, ports, and the
internal FPC clearance. Tunable via `plan_z` and `explode`.
2026-07-11 08:46:47 +02:00
Julien Calixte
c521d68ad7 docs(case): describe the FPC clearance as internal, refresh renders 2026-07-11 03:04:48 +02:00
Julien Calixte
8fa727702e fix(case): keep the FPC clearance internal, unbroken bezel
The ribbon accommodation must not be visible from outside. Drop the cut
below the bezel lip so the flex folds back into the cavity with no notch
breaking the external face.
2026-07-11 03:04:48 +02:00
Julien Calixte
0c3b5b50b1 docs(case): refresh renders and document the FPC bay + section
Regenerate all previews for the current model, embed the nameplate and
section views, and correct the screen/FPC captions (ribbon exits the
left, breakout on the left, wiring to the ESP32).
2026-07-11 03:01:26 +02:00
Julien Calixte
ff0ff0b5ae feat(case): section view, foam gasket, and left-side FPC bay
The screen's flex leaves the LEFT short edge (user's left), so route it
there: an open ribbon bay in the left bezel, the DESPI-C579 breakout in
the cavity below it, and SPI wiring across to the ESP32. Add a modeled
foam gasket, a `show="section"` cutaway of the screen clamp, and
`active_off_x/y` for the panel's off-centre active area.
2026-07-11 03:01:19 +02:00
Julien Calixte
704fd743a0 docs: link the enclosure concept from README and hardware.md 2026-07-11 02:31:50 +02:00
Julien Calixte
66595c7767 feat(case): add parametric 3D-printed enclosure concept
Typewriter-body OpenSCAD model for the GDEY0579T93 panel and
ESP32-S3-DevKitC-1: glueless screen retention (bezel lip + foam gasket
+ screwed bracket), baseplate-as-chassis board mounting, and a recessed
deck nameplate. Ships render previews, a dimensioned concept drawing,
and just recipes to open/render/export.
2026-07-11 02:31:44 +02:00
Julien Calixte
13d49930cc docs: document the SD card provisioning recipes
Record init/load/provision in the roadmap (the config-on-SD migration
and the firmware env!()->file read TODO) and in the git-sync note as the
implementation of the pre-seed-from-a-computer decision.
2026-07-11 01:44:44 +02:00
Julien Calixte
78958d9de6 feat(justfile): split SD provisioning into init/load/provision
Replace the single seed-sd recipe with three entry points sharing
private helpers: init (repo + config), load (repo copy only), provision
(config only). Each excludes gitignored paths via git's own resolution
(so node_modules and firmware/.env never reach the card), writes
/sd/typoena.conf from firmware/.env (Wi-Fi + PAT + git identity, PAT
never printed), and ejects the card so it goes straight into Typoena.
2026-07-11 01:44:38 +02:00
Julien Calixte
e92f9c15d3 chore: gitignore Cargo.lock in display and editor crates
Match the workspace convention (firmware, keymap already ignore their
lockfiles); these two library crates just never got a .gitignore when
they were extracted.
2026-07-11 01:25:15 +02:00
Julien Calixte
9ea8e74394 chore(justfile): add seed-sd recipe to pre-seed the notes repo
The device never cold-clones the 566 MB notes repo over Wi-Fi + mbedTLS;
a laptop copies the clone onto the SD card via a reader so the device
only ever takes the open + fast-forward path. Excludes are driven off
git's own ignore resolution (collapsing node_modules, .env, etc.), and
the target card is auto-detected but refused on 0 or >1 match so rsync
--delete can't wipe the wrong disk.
2026-07-11 01:24:56 +02:00
Julien Calixte
da23b95cfd feat(editor): add :w/:sync commands as host save/publish effects
The pure, no-IO editor core can't persist or push, so `handle()` now
returns an `Effect` (None/Save/Publish) that the firmware actions. `:w`
signals Save, `:sync` signals Publish (save then git push); `:wq`/`:x`
alias Save with vim's "quit" half dropped, and the `:q` family stays
absent — an always-on appliance has nothing to quit to. `:fmt` remains
in-core and yields no effect.

The main loop keeps the batch's last effect and matches on it. Actual
SD-write and git-push are the v0.1 gate and still stubbed as log lines.
2026-07-11 01:24:46 +02:00
Julien Calixte
e6498cdcb3 docs(roadmap): mark v0.2.5 + the v0.2 UTF-8 buffer done
International input is hardware-verified (dead-key composer, UTF-8-correct
editor, physical Esc -> backtick/tilde). Records the editor's extraction
into host-testable crates and the deliberately-dropped side-panel accent
marker.
2026-07-11 00:54:55 +02:00
Julien Calixte
182526cdc8 feat(keymap): repurpose the physical Esc key to type backtick/tilde
Escape already comes from a Caps tap, so the physical Esc key (HID 0x29)
is redundant. Map it to `/~ instead, which also reaches the grave/tilde
dead-key accents and Markdown code fences without a Fn layer on 60%
boards. Esc is now the Caps tap only.
2026-07-11 00:40:34 +02:00
Julien Calixte
bc13c5b065 feat(usb-kbd): compose dead-key accents before enqueuing keys
Route decoded keys through keymap::Composer, so on device `'`+e yields é
(grave/acute/circumflex/diaeresis/tilde, plus ç), and reset it on
keyboard detach. Enabling it is now safe because the editor buffer is
UTF-8-correct. Not verified on the xtensa build in this environment.
2026-07-11 00:30:38 +02:00
Julien Calixte
bdabfcf935 fix(editor): make the buffer UTF-8-correct for accented input
Step whole characters everywhere instead of assuming 1 byte = 1 char, so
the dead-key composer's accented Latin-9 output (é, ç, …) no longer traps
the caret mid-character and panics on the next edit. Covers h/l/a and the
Escape step-back, j/k column math, backspace and x, word-end (e) with
de/ce, and layout/caret_rc (byte offsets vs display columns). Adds 7
accented-input tests (15 total).
2026-07-11 00:29:04 +02:00
Julien Calixte
e8063a2b13 refactor(editor): extract editor + display into host-testable crates
Move the editor core out of the firmware crate (pinned to the xtensa
target, so it can't run `cargo test`) into a standalone `editor` crate,
with the panel framebuffer + geometry split into a `display` crate. Both
depend only on embedded-graphics + keymap, so the editor is now
host-buildable and unit-tested (8 characterization tests). Firmware links
them and re-exports the geometry from epd.rs; behaviour is unchanged.

The xtensa firmware build was not verified in this environment.
2026-07-11 00:28:37 +02:00
Julien Calixte
875658a661 feat(wifi): retry association with backoff in shared connect helper
The single-shot connect_wifi aborted boot on any transient association or
DHCP miss — common right after a reset, when the AP still holds the stale
pre-reset association. Add a bounded retry (5 attempts, 500ms→4s backoff,
disconnect between tries) so those transient failures recover instead of
failing the boot.

Extract the logic into firmware::net (new lib target) and rewire the
wifi_tls / git_push / git_sync spikes to it, removing three duplicated
copies. main.rs reuses this when Wi-Fi lands there instead of adding a
fourth copy.
2026-07-10 23:44:02 +02:00
Julien Calixte
4460475fb0 feat(keymap): add US-International dead-key accent composer
Folds a dead key (' ` ^ " ~) plus the following letter into one Latin-9
Key::Char, with the roadmap's `'`+c -> ç case and `'`+space -> literal
apostrophe. Pure and host-tested (12 new tests, 25 total pass).

Not wired into the live decode path yet: it emits non-ASCII and the
editor buffer still assumes byte==char, so wiring waits on the v0.2
UTF-8-correct buffer.
2026-07-10 23:43:15 +02:00
Julien Calixte
6c284a94db docs: record memory-audit remediation status (#1, #3, decode tests) 2026-07-10 10:36:52 +01:00
Julien Calixte
8edd3badfc fix(usb-kbd): quiesce in-flight transfer before free; guard hot-plug leaks
Adopt the keymap crate (drops the PREV_KEYS/CAPS_USED statics for one
owned Decoder) and harden device teardown:

- #1 UAF: track REPORT_INFLIGHT (set on submit, cleared first thing in
  report_cb). Teardown moves to close_device, which pumps client events
  until the transfer quiesces before freeing it, and leaks rather than
  frees if it never does — a leak is recoverable, a use-after-free is
  not. usb_host_transfer_free's return is now checked.
- #3 leaks: a new attach while a device is still open tears the old one
  down first; control_request and start_report_polling free the transfer
  they allocated on a submit error.

Decode correctness is covered by the keymap tests; the teardown paths
are FFI and still need an on-device hot-plug run to confirm.
2026-07-10 10:36:47 +01:00
Julien Calixte
2cd3bba98d feat(keymap): extract pure HID decode into host-testable crate
The Key type, US-QWERTY translate, and the edge-detecting boot-report
parser lived in usb_kbd.rs, unreachable by cargo test (the firmware
crate is pinned to the xtensa target). Move them to a dependency-free
#![no_std] + #![forbid(unsafe_code)] crate so the one path that parses
untrusted device bytes can be exercised on the host.

14 tests, including an ASCII-invariant sweep over all 256 usage IDs
(pins the guarantee the editor's byte==char indexing relies on) and a
never-panics fuzz over arbitrary-length/content reports.
2026-07-10 10:36:37 +01:00
Julien Calixte
f373892870 docs: add memory-safety audit of the Rust unsafe/FFI surface 2026-07-10 10:22:21 +01:00
Julien Calixte
371b542d44 docs(roadmap): plan Markdown snippets for v0.6
Trigger-driven expansion (no popup — e-ink refresh + distraction-free),
numbered empty tab stops + $0, no placeholder text, no dynamic values,
pause-triggered panel indicator. Records the why so scope isn't relitigated.
2026-07-08 22:44:33 +02:00
Julien Calixte
f197df6dbe style(editor): drop the mode line to the panel's bottom edge
Move the mode indicator (and the NO KBD flag above it) down so the
mode sits flush at the bottom-left with a 2 px margin, reclaiming the
dead space that was left below it.
2026-07-08 22:01:10 +02:00
Julien Calixte
ad047cc717 docs: reconcile screen-layout docs with the panel changes
Mode now sits at the side panel's bottom-left (not its top), and the
words-this-session field is gone. Update the CONTEXT.md field list and
the product-doc mock + prose to match; note the keyboard flag renders
as NO KBD (Latin-9 has no glyph for the mock's symbols).
2026-07-08 21:48:32 +02:00
Julien Calixte
ba4a8bfad8 feat(editor): put the mode indicator at the side-panel bottom-left
Move the mode + pending-command echo from the panel's top to its
bottom-left (vim `-- INSERT --` style); word count takes the top slot
and the NO KBD flag sits just above the mode. Also drop the
words-this-session field (not wanted) and its session_base_words state.
2026-07-08 21:48:21 +02:00
Julien Calixte
50f650fef6 feat(editor): split display into writing column and side panel
Carve the panel into a ~60-col writing column and a right-hand side
panel (per CONTEXT.md screen regions); the divider sits right of the
x=396 driver seam. The mode indicator and pending-command echo move
into the panel, while the ':' command line stays in the bottom strip.

The panel also shows word count and words-this-session (a snapshot
refreshed on a typing pause / non-Insert action, never per keystroke,
so ordinary typing keeps the fast windowed refresh) and a "NO KBD"
flag while the keyboard is dropped.
2026-07-08 21:33:41 +02:00
Julien Calixte
ddd9c387d3 feat(usb): track keyboard connection state
Add a persistent KBD_PRESENT atomic (set on setup, cleared on unplug)
and a keyboard_present() accessor. DEV_GONE is a one-shot detach event
the client loop consumes, not a state the panel can read.
2026-07-08 21:33:32 +02:00
Julien Calixte
d6b9e46ec1 docs: add index READMEs for docs/ and docs/notes/
Give the two linked-but-unindexed folders a browsable entry point so
directory links land on a real index instead of a bare listing.
2026-07-08 14:45:58 +02:00
Julien Calixte
dbe7720a27 docs: point directory links at their folder's README
Bare-folder links (e.g. docs/postmortems/) only resolve on GitHub's
auto-render; retarget them to the folder's README.md so they open a
real file in any markdown viewer.
2026-07-08 14:45:52 +02:00
Julien Calixte
fcf5815468 docs(roadmap): track the writing column + side panel split in v0.1
The side panel was referenced from v0.2.5/v0.5/v0.8 but never defined or
scheduled. Add it as a v0.1 build item, mark it unbuilt, and link the
CONTEXT.md and product-design definitions.
2026-07-08 11:02:41 +02:00
Julien Calixte
aa068147ab docs(roadmap): replace the mermaid Gantt with Macroplan TOML
The macroplan app parses this source directly; the baseline originals
stay fixed and v0.1's slip is expressed as an at-risk status instead of
a re-drawn bar.
2026-07-08 00:19:28 +02:00
Julien Calixte
c4326421fd docs(readme): link the gix postmortem where it is mentioned 2026-07-08 00:06:31 +02:00
Julien Calixte
fd6ccb729c Merge remote-tracking branch 'origin/main' 2026-07-08 00:05:48 +02:00
Julien Calixte
3128bca4ad docs(readme): sync to bench reality and slim via links
Fix stale claims: hardware IS on bench; git is libgit2/git2 as an
esp-idf component (ADR-004 kill-switch fired), not gix; display is the
in-tree SSD1683 driver; TLS handshake heap is the measured ~35 KB; v0.1
auth is build-time TW_* env vars (ADR-011 open). Replace the hardware
table, Gantt, and "why not" sections with links (docs/hardware.md,
docs/roadmap.md, ADR-001/002); update repo layout from planned to
actual; prune resolved open questions and link postmortems.
2026-07-08 00:04:49 +02:00
Julien Calixte
c82130a616 docs(roadmap): move the macro-plan Gantt in from the README
Kept as the original June 2026 baseline with a note pointing to the
Status block for actuals.
2026-07-08 00:04:32 +02:00
Julien Calixte
571c5ed825 docs(hardware): add part table, rationale, and bench status page
Extracted from the README so it can slim down to a summary + link.
2026-07-08 00:04:25 +02:00
b401ea2906 Updating docs/roadmap.md from Remanso 2026-07-07 23:52:38 +02:00
Julien Calixte
77768cc85f docs(roadmap): sync marks to actual core progress
The editor core runs 2-3 versions ahead of shippable device releases (no
release has shipped; v0.1's hardware gate is still open). Mark done/partial
items across v0.1-v0.7 with [x]/[~], record what landed early (View mode,
d/c operators + text objects, :fmt), and the v0.4 decision to move View off
v/V so Visual can take them.
2026-07-07 23:44:46 +02:00
Julien Calixte
b746d6e3f9 feat(editor): add Markdown affordances and a :fmt formatter
v0.6 affordances plus the v0.4 command-line mechanism, all in editor.rs:

- soft-wrap display lines at word boundaries (layout() no longer cuts
  mid-word; a single word wider than the panel still hard-breaks)
- Enter continues Markdown lists: -/*/+ repeat, N. increments, an empty
  item exits the list; only at end-of-line, indentation preserved
- heading lines (# .. ######) render faux-bold via a 1px double-strike
  (no bold Latin-9 font); checks the logical line so wrapped headings stay bold
- new `:` command mode (Command variant + status-strip echo, Esc/Backspace
  cancel) with one command, `:fmt`: align pipe tables (honoring :--: colons),
  collapse runs of blank lines to one, strip trailing whitespace; caret lands
  on roughly the same line afterwards
2026-07-07 23:44:46 +02:00
Julien Calixte
2aa4032e1f docs(notes): record why the notes repo stays full-size (pre-seed the SD)
The 150 MB of images are remanso's image CDN (it reads image bytes straight
out of git as blobs), not offloadable bloat — LFS/filter-repo/git-rm all
break remanso. libgit2 also lacks blobless partial clone. Decision: leave
the repo untouched and pre-seed the SD with a full clone from a computer, so
the device only ever takes the open + incremental fetch/push path. Also
notes the increment-C constraint: stage specific paths (not add_all) so a
media-excluding sparse checkout is safe.
2026-07-07 22:55:59 +02:00
Julien Calixte
ef755bac81 docs(postmortems): correct the FATFS delete finding (std, not read-only)
The read-only-attribute theory (and the predicted AM_RDO unlink shim) was
wrong. A diagnostic recursive walk showed every entry writable (ro=false)
and succeeded via path-based deletion, so the real blocker was
std::fs::remove_dir_all's openat/unlinkat/fdopendir usage against esp-idf's
path-based FATFS VFS. Update the increment-A/B checklist accordingly.
2026-07-07 22:49:18 +02:00
Julien Calixte
8dece735d1 fix(firmware): delete repo dir via path-based recursion, not remove_dir_all
std::fs::remove_dir_all deletes race-free via the openat/unlinkat/fdopendir
family; esp-idf's path-based FATFS VFS doesn't implement those, so it fails
with EACCES on the first entry. That looked like a read-only-attribute
problem but wasn't. A plain read_dir -> remove_file/remove_dir walk
(remove_tree) uses only what FATFS supports.

Hardware-verified: a diagnostic recursive delete removed the whole
/spiflash/repo (every entry reported writable, ro=false) then re-cloned +
pushed, twice. Wire remove_tree into both the RECLONE_EACH_BOOT reset and
the stale-clone recovery.

The p_open/p_creat shim (39e1155) stays: it keeps libgit2's objects writable
(ro=false), so neither this delete nor libgit2's own gc/repack/fetch-prune
is ever blocked by a read-only file on FAT.
2026-07-07 22:48:17 +02:00
Julien Calixte
8e848894fa feat(firmware): add RECLONE_EACH_BOOT toggle to git_sync
Debug/recovery knob (ships false): when true, wipe /spiflash/repo and re-clone
every boot instead of opening the persistent clone. Doubles as the validation
harness for the FAT read-only-delete fix — the wipe deletes libgit2-created
objects, which only succeeds once they are writable.
2026-07-07 09:33:08 +02:00
Julien Calixte
39e1155bf9 fix(libgit2): create objects writable so FAT can delete them
libgit2 creates loose objects and packfiles with mode 0444 (read-only). FATFS
turns that into the AM_RDO attribute and then refuses to f_unlink them (EACCES),
and esp-idf's FATFS VFS chmod cannot clear AM_RDO — so a written object can
never be deleted, blocking re-clone recovery and (later) fetch/repack.

Shim p_open/p_creat in esp_stubs.c to force owner-write into every create mode
(compile posix.c's originals under throwaway names, same mechanism as p_rename),
so nothing is ever created read-only. Immutability is only a safety hint on an
appliance where nothing but libgit2 touches these files.

Non-regressive on hardware: git_push, which links the same component, still
commits + pushes cleanly with the shim. The delete path itself is validated via
git_sync's RECLONE_EACH_BOOT knob (pending a dedicated flash).
2026-07-07 09:33:08 +02:00
Julien Calixte
4153cae9c4 docs(postmortems): record milestone #2A verified, FAT read-only-delete finding
Increment A (persistent-clone publish cycle) is hardware-verified: clone +
persistent open + fast-forward push. Mark the follow-up checklist: git module
fold is in progress (A done; B = divergence + AM_RDO unlink shim; C = editor
wiring), tech-doc revision done, PAT-in-flash tracked as open ADR-011. Note
the FAT read-only-delete limitation and the real-notes-repo sizing caveat.
2026-07-07 08:35:11 +02:00
Julien Calixte
afa61deaa6 fix(firmware): recover git_sync from a leftover clone dir
859c478 aborted if /spiflash/repo already existed — libgit2 refuses to
clone into a non-empty dir (code=Exists). Remove a writable stale/partial
clone and re-clone.

Milestone #2A now hardware-verified end to end: clone (boot 1) + persistent
open + fast-forward push (boot 2) against origin/main, on the 96 KB git
thread, min heap 6.8 MB — clone/checkout fit the stack, no bump needed.

Known limit: the remove only works on a *writable* leftover. libgit2 marks
objects/packs read-only and FATFS won't f_unlink a read-only file (EACCES);
POSIX chmod does not clear AM_RDO on esp-idf's FATFS VFS (verified). So
re-cloning over a run that wrote objects needs an AM_RDO-clearing unlink
shim in esp_stubs.c (increment B, also needed for fetch/repack); until then
the fallback is a one-time `espflash erase-region 0x310000 0x400000`.
2026-07-07 08:34:09 +02:00
95 changed files with 16579 additions and 2426 deletions

View File

@@ -15,7 +15,7 @@ by user-facing weight. References the terms in this file as canonical.
surface, expressed in this vocabulary.
[`docs/v0.1-mvp-technical.md`](docs/v0.1-mvp-technical.md) — how v0.1 is
built.
[`docs/roadmap.md`](docs/roadmap.md) — per-version scope, where new terms
[`docs/macroplan.md`](docs/macroplan.md) — per-version scope, where new terms
(e.g. multi-file **Buffer** concepts at v0.5) will enter this glossary.
## Language
@@ -34,6 +34,25 @@ stay Local for their lifetime. Lives under `/sd/local/`.
_Avoid_: draft, private, untracked, scratch (these all imply impermanence or
promotability, which is not the model).
### Editing model
**Buffer**:
A **File** loaded into memory for editing, with its own caret, scroll position,
and undo history. Opening a file makes it the **active buffer** — the one the
**Writing column** shows. Up to three buffers stay **resident** at once (the
active one plus two parked in the background); switching back to a resident
buffer restores its caret and undo without re-reading the card. A fourth open
**evicts** the least-recently-used resident buffer — saved first if it has
unsaved edits, so nothing is lost.
_Avoid_: tab, window, document (a buffer is not a UI chrome element); "the file"
when you mean the in-memory copy rather than the bytes on the card.
**Open**:
Bringing a **File** into the **active buffer**, via `Cmd-P` (the file palette)
or `:e`. Scope is read from where the file lives (`/sd/repo`**Tracked**,
`/sd/local`**Local**), never chosen at open time.
_Avoid_: load (implementation talk for the disk read behind an Open).
### User-facing actions
**Save**:
@@ -60,15 +79,19 @@ into user-facing language).
**Writing column**:
The left region of the panel showing the text being edited — the _only_ region
that repaints per keystroke. ~60 columns, full panel height; straddles the
driver's `x = 396` seam invisibly.
that repaints per keystroke. A 63-col region split into a **line-number gutter**
(absolute numbers, 24 cols wide, sized to the buffer's line count) and the text
column it steals from (~60 cols for a file ≤ 99 lines). Full panel height;
straddles the driver's `x = 396` seam invisibly.
_Avoid_: edit area, text area, main pane (superseded — they named the old
full-width text region before the side panel carved out its right edge).
**Side panel**:
The right region (~150 px / ~20 cols, full height) holding all metadata:
filename + dirty dot, mode, word count, session, clock, Wi-Fi,
keyboard-disconnect flag, publish state. Sits entirely in the master half
The right region (~160 px / ~17 cols at its FONT_9X15 metadata font, full
height) holding all metadata:
filename + dirty dot, word count, elapsed time, clock, Wi-Fi,
keyboard-disconnect flag, publish state, and the mode indicator at its
bottom-left. Sits entirely in the master half
(right of the `x = 396` seam). Every field is static, event-driven, or
throttled — never per-keystroke.
_Avoid_: header, status line, status bar (retired — the old top header band and

244
MEMORY_AUDIT.md Normal file
View File

@@ -0,0 +1,244 @@
# Memory-safety audit — firmware Rust
> **Snapshot.** Audited at commit `371b542` on 2026-07-10. This is a
> point-in-time review of the `unsafe`/FFI surface; line numbers and the
> soundness arguments below are only valid against that tree. Re-run it after
> any change to `usb_kbd.rs`, the SD/git FFI, or an `esp-idf-sys` bump.
>
> **Remediation (2026-07-10).** Findings #1 and #3 are addressed in follow-up
> commits, and the decode logic is now the host-tested `keymap` crate (14 tests,
> including an ASCII-invariant sweep over all 256 usage IDs and a never-panics
> fuzz). The `usb_kbd.rs` line numbers below predate that refactor. The fixes are
> verified by inspection plus the `keymap` tests, but still need an on-device
> hot-plug run to confirm — #1/#3 live in FFI that can't run on the host.
> Per-finding status is inline.
## Scope & method
Memory-unsafety in Rust can only originate in `unsafe`, so the audit focuses
there. The entire `unsafe` surface is FFI into ESP-IDF / libgit2, concentrated
in:
- `firmware/src/usb_kbd.rs` — by far the largest, and the only place with C
callbacks, raw transfer buffers, and `slice::from_raw_parts`. **Highest
risk.**
- `firmware/src/bin/{sd_fat,git_push,git_sync,wifi_tls}.rs` — descriptor-struct
zeroing plus simple FFI calls.
- `firmware/src/epd.rs`, `firmware/src/editor.rs`,
`spikes/spike7-git-push/`**100% safe Rust, zero `unsafe`.** They cannot
cause UB; their failure mode is panic→abort, not corruption.
Bottom line: the FFI code is careful, with real SAFETY reasoning and one
genuinely good defensive clamp. The audit found **one plausible true-UB path**
(conditional, ordering-dependent) plus a set of latent footguns and non-UB
robustness gaps. Nothing looks like a slam-dunk exploitable bug in normal
operation.
## Score: 8 / 10 (memory safety only)
- The design leans on safe Rust — only ~40 lines of genuine `unsafe`, all thin
FFI wrappers, and the safe core (editor, framebuffer, layout) can't produce
UB at all. Right architecture; most of the score.
- Every `unsafe` site carries real SAFETY reasoning, and the one place
untrusted device data sizes a raw slice (`report_cb`) is correctly clamped.
- Not a 910 because finding #1 is closed by an *assumed* event ordering rather
than by construction, and findings #2#3 are latent, dependency-sensitive
risks. Real memory safety means the invariant is *enforced*, not hoped for.
- Closing #1 so the in-flight invariant is explicit → 9. A 10 on FFI this heavy
needs the structural guards in the "Regression testing" section.
**Update (2026-07-10):** #1 and #3 are now addressed in code and the decode path
is host-tested (see per-finding status). Once the hot-plug bench run confirms #1
on hardware this becomes a 9; #2 and #4 are the remaining gap to 10.
This is a *memory-safety* score. Robustness (leaks on hot-plug) and correctness
would score separately and slightly lower.
## Findings
### 1. Possible use-after-free freeing the interrupt transfer on unplug — `usb_kbd.rs:176-182` (highest)
On `DEV_GONE`, `client_loop` frees `report_xfer` and closes the device:
```rust
if !report_xfer.is_null() {
unsafe { usb_host_transfer_free(report_xfer) }; // line 177
report_xfer = ptr::null_mut();
}
```
The interrupt-IN transfer is resubmitted on every completion (`report_cb`,
line 224), so it is **in-flight** most of the time. `report_cb` only fires from
inside `usb_host_client_handle_events` (line 159); the free happens *after* that
call returns. The code implicitly relies on the transfer's final
canceled-completion callback having already run in the same `handle_events`
batch that delivered `DEV_GONE`.
If the library delivers the `DEV_GONE` client event **before** the transfer's
cancellation callback, then either:
- `usb_host_transfer_free` refuses an in-flight transfer
(`ESP_ERR_INVALID_STATE` — its return value is **ignored** here → silent
leak), or
- a later `usb_host_client_handle_events` iteration invokes `report_cb` on the
freed transfer → `let t = unsafe { &mut *transfer };` (line 219) is a
**use-after-free**.
Ordering-dependent, so medium confidence rather than a definite always-fires
bug — but it's the one path in the codebase that can reach real UB, and it's
exactly the teardown race ESP-IDF's USB Host contract warns about (free only
when not in-flight). Verify against the library semantics rather than assuming
the batch ordering holds.
**Fix.** Halt/dequeue the endpoint and wait for the last completion callback
before freeing — or track an in-flight flag set on submit and cleared in
`report_cb`, and only free once it's clear (loop `handle_events` until then). At
minimum, check the return value of `usb_host_transfer_free` and don't null the
pointer / proceed to `device_close` while it reports the transfer busy.
**Status: fixed in code.** A `REPORT_INFLIGHT` flag is set on submit and cleared
first thing in every `report_cb`. The teardown moved into a `close_device`
helper that pumps client events (bounded) until the transfer quiesces before
freeing it — and *leaks it rather than frees* if it never does, since a leak is
recoverable and a UAF is not. `usb_host_transfer_free`'s return value is now
checked. Needs the hot-plug bench run below to confirm on hardware.
### 2. `mem::zeroed()` / `MaybeUninit::zeroed().assume_init()` on bindgen structs is a latent footgun — `usb_kbd.rs:110,143`, `sd_fat.rs:138,173,192`
**Sound today**: every field of the zeroed descriptors is valid at all-zero (C
fn pointers are `Option<extern fn>``None`; floats → `0.0`; enums are `u32`
aliases; bools → `false`).
The risk is that this soundness is **invisible and unenforced**. `esp-idf-sys`
is pinned to a git branch (`[patch.crates-io]` in `Cargo.toml`), so a bindgen
regen that introduces a field where zero is an invalid bit pattern (a reference,
`NonNull`, or a niche enum) turns `assume_init()` into instant UB with no
compiler warning — the classic `zeroed`-on-FFI trap.
**Fix.** These structs are fully overwritten in their meaningful fields anyway.
Prefer keeping them as `MaybeUninit` and writing fields via `addr_of_mut!`, or
at least add a static/compile-time assertion (or a test) that pins the
zero-is-valid assumption so a dependency bump fails loudly.
**Status: left as-is deliberately** (sound today, low severity). There is no
clean *stable* compile-time "all-zero is a valid bit pattern" assertion, and a
runtime canary can't fire before the UB it would guard (`assume_init` is the UB
site). The `addr_of_mut!` rewrite is the real fix but churns five call sites for
a latent-only risk; deferred until an `esp-idf-sys` bump makes it worth it.
### 3. Resource leaks on re-attach and on submit error — not UB — `usb_kbd.rs:163-168, 417/436, 449`
- A second keyboard attaching while one is open makes `setup_keyboard` overwrite
`open_dev`/`report_xfer` (lines 164-167) without freeing/closing the previous
ones → leaked transfer + device handle.
- `control_request`: if `usb_host_transfer_submit_control` errors, the `?` at
line 430 returns before `usb_host_transfer_free(xfer)` (line 436) → leaked
64-byte transfer. A submit failure in `start_report_polling` (line 458) leaks
similarly.
Not memory-unsafe — worst case is heap exhaustion over many hot-plug cycles,
which matters for an always-powered appliance. Guard the re-attach case
(`if !open_dev.is_null()` → tear down first) and free-on-error in
`control_request`.
**Status: fixed in code.** A new attach while a device is still open now runs
`close_device` on the old one first. `control_request` and `start_report_polling`
free the transfer they allocated on a submit error before returning.
### 4. USB thread stack sizing is unverified — `usb_kbd.rs:121,132`
Daemon thread = 4096 B, client thread = 8192 B. The client thread runs
`report_cb → handle_report → enqueue → log::info!`, and formatting/logging is
stack-hungry; a FreeRTOS stack overflow is silent memory corruption unless the
canary/MPU check catches it. `git_push.rs` already reasons carefully about this
(96 KB, with a comment block on why); the USB threads deserve the same
measured-headroom treatment. Low confidence it's actually too small — measure
the high-water mark, don't change blindly.
### 5. `report_cb` bounds clamp — done right (noted, not a defect) — `usb_kbd.rs:221-222`
```rust
let n = (t.actual_num_bytes as usize).min(BOOT_REPORT_LEN);
let report = unsafe { core::slice::from_raw_parts(t.data_buffer, n) };
```
The one place device-controlled data sizes a raw slice. `.min(BOOT_REPORT_LEN)`
correctly clamps even a negative/garbage `actual_num_bytes` (the `i32 as usize`
blows up huge, `.min(8)` reins it back), and `handle_report` re-guards with
`report.len() < 3`. A malicious/broken keyboard can't overread the 8-byte
buffer here.
## Safe modules — no UB possible by construction
`editor.rs`, `epd.rs`, and the spike are safe Rust. Two invariants confirmed
rather than assumed:
- **`editor.rs` byte-indexing invariant holds.** It slices the buffer by byte
offset treating it as a char index (`self.text[..self.caret]`,
`text.as_bytes()[..]`). Valid only because the buffer is pure ASCII — and it
is: the only source of `Key::Char` is `translate()` (`usb_kbd.rs:298`), which
emits ASCII exclusively, and every internal insert (TAB, list markers, table
formatting) is ASCII too. So byte == char holds and those slices can't hit a
char-boundary panic. **When the v0.2 UTF-8 work lands, this invariant breaks
into panics** — add a `debug_assert`/comment at the insert boundary then.
- **`epd.rs` slicing is bounded by its asserts.** `display_frame*` assert
`fb.len() == FB_BYTES` and `y0 + h <= HEIGHT`, the row math stays within
`FB_BYTES`, and the u16 arithmetic (`x+w-1`, `y0+h`) doesn't overflow given
those bounds.
`git_push.rs`/`git_sync.rs`: the `RemoteCallbacks` closures capture
`Rc<RefCell<…>>` and run synchronously on the git thread during `remote.push`
never sent across threads, so no `Send`/aliasing hazard.
`git2::opts::set_ssl_cert_file` (line 267) is `unsafe` because it sets a
process-global; called once in single-threaded setup before the git thread
spawns — sound.
## Regression testing
The honest constraint first: **the on-target binary can't be run under
Miri/ASAN** (`target_os = "espidf"`, all `unsafe` is FFI). So the strategy is
split by what's reachable where, ranked by leverage.
1. **Make the pure logic host-testable (highest leverage).** The functions that
take untrusted input or do the slicing are FFI-free: `translate`,
`handle_report`'s decode, the editor text ops, `changed_rows` /
`only_adds_ink` in `main.rs`, the `epd` row math. Pull them into a
no-esp-deps module/crate (workspace member or `#[cfg]`-gated) so `cargo test`
runs on host.
- **Done for the keyboard decode.** `translate` + the report edge-detection
are now the `../keymap` crate (`#![no_std]`, `#![forbid(unsafe_code)]`, zero
deps), with 14 host tests including a `translate` **ASCII-invariant sweep**
over all 256 usage IDs × modifiers and a **never-panics fuzz** feeding
arbitrary-length/content byte streams. The slice itself is bounds-clamped in
the FFI layer (finding #5), and once it's a safe `&[u8]` the decode is
`forbid(unsafe_code)` — so Miri adds nothing over the panic-freedom the fuzz
test already proves; the plain `cargo test` run is the guard.
- **Still TODO:** the editor text ops and `changed_rows`/`only_adds_ink` are
also FFI-free and worth the same treatment, but stay coupled to
`embedded-graphics` / `epd` constants for now.
2. **Compile-time guards for the `zeroed()` assumption (#2).** Static assertions
(or a test constructing the struct and checking a sentinel field is
`None`/`0`) so an `esp-idf-sys` bump fails loudly instead of going silently
UB.
3. **`clippy` as a ratchet.** `#![warn(clippy::undocumented_unsafe_blocks)]` +
`clippy::multiple_unsafe_ops_per_block`, deny-warnings in CI. Forces every
new `unsafe` to carry a SAFETY comment — keeps the existing discipline from
eroding. **Deferred:** turning it on crate-wide today floods warnings on the
many existing bare-FFI `unsafe` one-liners, which trains people to ignore it.
Worth doing *after* a pass that adds `// SAFETY:` to the existing blocks.
4. **On-device tests for what only exists on device (#1, #3, #4).**
- **Hot-plug stress loop**: attach/detach ~100× on a bench script, log
`esp_get_free_heap_size` each cycle. A downward trend proves the leaks
(#3); a crash/`LoadProhibited` on the freed transfer proves the UAF (#1).
- **Stack high-water mark**: `uxTaskGetStackHighWaterMark` on the USB
threads, asserted in a debug build, guards #4.
5. **Fix #1 by making it impossible, not just tested.** The best defense is an
in-flight flag set on submit and cleared in `report_cb`, with a
`debug_assert!(!in_flight)` before the free. Any future change that
reintroduces the race trips the assert in the hot-plug loop instead of
corrupting memory in the field.
Priority if you only do some: **1 (fuzz `handle_report` under Miri) + 5
(in-flight flag)** cover the two real memory-safety concerns; 2 and 3 are cheap
insurance against dependency bumps; 4 is the only way to regression-test the
on-device races and is worth it for an always-powered appliance.

210
README.md
View File

@@ -4,9 +4,13 @@ A distraction-free, hackable, DIY writing machine. ESP32-S3 + e-ink + a real
mechanical keyboard. You write Markdown, you commit, you push. Nothing else
runs on it.
> Status: pre-MVP. Hardware not yet on bench. Bring-up in progress.
How each decision is weighted against the user-facing requirements — and the critical performance budget that falls out — lives in [`docs/qfd.md`](docs/qfd.md). The ontology layers those docs use (WHAT / Function / Characteristic / Metric / Target) are defined in [`GLOSSARY.md`](GLOSSARY.md).
> **Status: pre-MVP, hardware on bench.** Display, USB keyboard, live typing
> with partial refresh, Wi-Fi + TLS, SD storage, and on-device git push are all
> verified in spikes; SD mount and save are now wired into the app binary. No
> release has shipped yet — v0.1's remaining gate is the boot splash and wiring
> git publish (`Ctrl-G` → push) into the app binary. Live per-item status:
> [`docs/macroplan.md`](docs/macroplan.md) · failure write-ups:
> [`docs/postmortems/`](docs/postmortems/README.md).
---
@@ -33,70 +37,44 @@ the remote) is offered.
## Hardware
| Part | Choice | Why |
| --------- | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| MCU | **ESP32-S3-N16R8** (16 MB flash, 8 MB octal PSRAM) | USB OTG host (for the keyboard), Wi-Fi, BLE, dual core @ 240 MHz, plenty of PSRAM for git pack data and screen buffer. Best-supported Rust target in the ESP family. |
| Display | **GDEY0579T93 + DESPI-c579 breakout** (5.79", 792×272, 1-bit) | Good Display panel matched with its own FPC breakout. Strip aspect (~2.9:1) — Freewrite-coded: ~13 lines, ~79 cols at the editor's 10px font. Tiny framebuffer (~27 KB) leaves PSRAM headroom. The DESPI-c579 is a passive level-shifter / FPC-to-header board, not an active controller — driven over plain SPI like any other epd. |
| Keyboard | **Nuphy Air60/Halo65 wired USB-C** | ESP32-S3 acts as USB host via TinyUSB. BLE-HID is a fallback but contends with Wi-Fi for radio time during push. |
| Storage | microSD over SPI | Holds both the git working copy (`/sd/repo/`) **and** the local-only scratch space (`/sd/local/`). Internal flash is for firmware + config only. |
| Power | **USB-C wall power for MVP**, 18650 + IP5306 in Phase 3 | Measure power profile on real hardware before sizing the battery. E-ink + sleep should give multi-day battery life but battery introduces charging, safety, and BMS complexity we don't need on day one. |
| Enclosure | 3D-printed, hinged lid | Phase 4 concern. |
**ESP32-S3-N16R8** (16 MB flash, 8 MB PSRAM) · **GDEY0579T93** 5.79″ e-ink
strip (792×272, ~2.9:1 — biases the UX toward "current line + recent context",
the writing posture we want) · **Nuphy wired USB keyboard** with the S3 as USB
host · **microSD over SPI** · **USB-C wall power** for the MVP, battery in
v0.8.
**Why the strip aspect:** the ~2.9:1 long-narrow shape biases the UX
toward "current line + recent context" rather than "full page" — the
writing posture we want. The renderer stays resolution-agnostic so a
10.3" e-ink upgrade (v1.x) is a swap, not a rewrite. Medium choice
(e-ink over LCD / memory LCD / OLED) and panel rationale:
[ADR-003](docs/adr.md#adr-003-display-medium--e-ink-gdey0579t93-panel).
Full part table, rationale, and bench status:
[`docs/hardware.md`](docs/hardware.md). Enclosure — a parametric,
3D-printable typewriter-body case (OpenSCAD): [`hardware/case/`](hardware/case/README.md).
---
## Software stack
**Language: Rust on `esp-idf-rs` (std).** Decision is load-bearing — see the
rejected alternatives below, and [`docs/adr.md`](docs/adr.md) for the full
decision log covering language, UI strategy, display, git lib, auth,
concurrency, storage, power, and keyboard transport. How each decision is
weighted against the user-facing requirements — and the critical performance
budget that falls out — lives in [`docs/qfd.md`](docs/qfd.md). The ontology
layers those docs use (WHAT / Function / Characteristic / Metric / Target)
are defined in [`GLOSSARY.md`](GLOSSARY.md).
**Language: Rust on `esp-idf-rs` (std).** Every stack decision — language, UI
strategy, display, git lib, auth, concurrency, storage, power, keyboard
transport — has an ADR in [`docs/adr.md`](docs/adr.md), including the rejected
alternatives (Ratatui, Gleam + Shore on AtomVM, C/Arduino — ADR-001/002). How
each decision is weighted against the user-facing requirements lives in
[`docs/qfd.md`](docs/qfd.md); the ontology those docs use is defined in
[`GLOSSARY.md`](GLOSSARY.md). Where a default traces to a cost curve rather than
a discrete pick — energy, latency, or memory bending against an interval or size
— the curve and its knee live in
[`docs/tradeoff-curves/README.md`](docs/tradeoff-curves/README.md). A
memory-safety review of the Rust `unsafe`/FFI
surface (mostly `usb_kbd.rs`) is in [`MEMORY_AUDIT.md`](MEMORY_AUDIT.md).
| Layer | Crate / Component | Notes |
| ---------------- | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| HAL / runtime | `esp-idf-svc`, `esp-idf-hal` | std build, gives us heap, threads, VFS, mbedtls, Wi-Fi stack. |
| Display | `embedded-graphics` + `epd-waveshare` (or custom driver) | Pixel framebuffer with partial-refresh regions. We track dirty rects ourselves. The GDEY0579T93 uses an SSD1683-class controller; if it's not already in `epd-waveshare`, we write a small driver against `embedded-hal` SPI — ~300 LoC, low risk. |
| Editor core | Custom, in-tree | Rope buffer (`ropey`), mode state machine, Vim keymap table. |
| TUI-style layout | Custom thin layer (~500 LoC) | API inspired by Ratatui (`Layout`, `Block`, `Paragraph`) but renders directly to `embedded-graphics`. See below. |
| USB host | `esp-idf` TinyUSB bindings | Boot-protocol HID is enough for the keyboard. |
| Git | `gitoxide` (`gix`) | Pure-Rust, modular. We only need add / commit / push (smart HTTP). No libgit2, no mbedtls glue beyond what `esp-idf` already gives us. |
| TLS | `mbedtls` via `esp-idf` | Used for GitHub HTTPS. ~120 KB heap during handshake — fits in PSRAM. |
| Auth | GitHub Personal Access Token in encrypted NVS | SSH on embedded is painful; HTTPS+PAT is the pragmatic path. |
| Filesystem | FAT on SD (`esp_vfs_fat`) | Working copy lives here. Internal LittleFS holds config. |
### Why not Ratatui
Ratatui assumes a **character-grid terminal** with an ANSI backend. E-ink is a
**pixel framebuffer with partial-refresh windows**. The right primitive for
e-ink is dirty-rectangle tracking aligned to the panel's refresh regions —
Ratatui's per-cell diff model fights this. We can borrow its widget _API
shape_ (it's a good one) without dragging in the terminal abstraction. Net
saving: probably 200 KB of binary and a lot of pretending the screen is a
VT100.
### Why not Gleam + Shore
BEAM doesn't run on ESP32. AtomVM does, but: memory budget is tight, Gleam-on-
AtomVM is bleeding-edge, and there are no bindings for USB host / e-ink / SD /
TLS / git in that ecosystem. Shore is also terminal-oriented, so the same
impedance mismatch as Ratatui applies. Building this on Gleam would be a
research project stacked on a research project. Revisit in 2-3 years.
### Why not C / Arduino
Workable, well-trodden, fastest path to a blinking screen. But this is a
project I want to keep evolving — Rust's refactoring leverage and type safety
pay off the moment we start adding modes, palette, search, etc.
| Layer | Choice | Notes |
| ------------- | --------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| HAL / runtime | `esp-idf-svc`, `esp-idf-hal` | std build: heap, threads, VFS, mbedtls, Wi-Fi stack. |
| Display | Custom SSD1683 driver (`src/epd.rs`) + `embedded-graphics` | Dual-controller 792×272 panel; dirty-rect partial refresh (~630 ms measured). |
| UI layer | Custom thin widget layer | Ratatui's API _shape_ without its char-grid terminal model ([ADR-002](docs/adr.md#adr-002-ui-strategy--custom-widgets-on-embedded-graphics-not-ratatui)). |
| Editor core | Custom, in-tree (`src/editor.rs`) | Modal (Normal / Insert / Visual / VisualLine / View / Command), motions, operators + text objects. Plain-ASCII buffer until the v0.2 UTF-8 work. |
| USB host | `esp-idf` TinyUSB bindings | Boot-protocol HID; verified on hardware (Spike 4). |
| Git | **libgit2 via `git2`**, built as an esp-idf component with mbedTLS (`firmware/components/libgit2/`) | `gix` was the original pick but can't push over HTTPS — the [ADR-004](docs/adr.md#adr-004-git-implementation--gitoxide-gix) kill-switch fired ([postmortem](docs/postmortems/2026-07-05-spike7-gix-https-push.md)). On-device add commit push verified; ~16 s cold-`:sync` [latency breakdown](docs/notes/sync-latency.md). |
| TLS | `mbedtls` via `esp-idf` | GitHub HTTPS with the chain checked against embedded roots; ≈35 KB heap measured during handshake (Spike 6). |
| Auth | HTTPS + GitHub PAT | v0.1 bakes credentials in at build time via `TW_*` env vars; provisioning + at-rest protection is [ADR-011](docs/adr.md#adr-011-credential-provisioning--how-the-pat-reaches-the-device-and-is-protected-at-rest) (open), on-device settings land in v0.9. |
| Filesystem | FAT on SD (`esp_vfs_fat`) | Working copy lives here. Internal LittleFS holds config. |
---
@@ -118,81 +96,67 @@ around, not against:
## Roadmap
Frequent releases. Each version is a usable artifact, not a checkpoint.
Macro-plan below; per-version scope lives in [`docs/roadmap.md`](docs/roadmap.md).
Releases are frequent, and every version is a usable artifact rather than a
checkpoint. Per-version scope, current `[x]`/`[~]` marks, and the Macroplan
source live in [`docs/macroplan.md`](docs/macroplan.md).
```mermaid
gantt
title Typoena — macro plan
dateFormat YYYY-MM-DD
axisFormat %b %Y
section MVP
v0.1 it writes, it pushes :v01, 2026-06-01, 4w
section Vim
v0.2 navigation :v02, after v01, 3w
v0.2.5 international input :v025, after v02, 2w
v0.3 editing :v03, after v025, 3w
v0.4 visual + ex :v04, after v03, 2w
section Files
v0.5 palette + multi-file :v05, after v04, 3w
v0.6 markdown :v06, after v05, 2w
v0.7 search + git :v07, after v06, 3w
section Hardware polish
v0.8 battery + sleep :v08, after v07, 4w
v0.9 robustness :v09, after v08, 4w
v1.0 polish :v10, after v09, 4w
```
| Version | Theme | One-liner |
| ------------------------------------------------------- | ------------ | ------------------------------------------ |
| [v0.1](docs/roadmap.md#v01--mvp-it-writes-it-pushes--) | MVP | Boots, edits one file, `Ctrl-G` pushes. |
| [v0.2](docs/roadmap.md#v02--vim-navigation--) | Vim nav | Normal/Insert, motions, line numbers. |
| [v0.2.5](docs/roadmap.md#v025--international-input--) | Intl input | US-Intl dead keys: à é ê ç, `'`+space = `'`. |
| [v0.3](docs/roadmap.md#v03--vim-editing--) | Vim edit | `dd yy p`, undo/redo, counts. |
| [v0.4](docs/roadmap.md#v04--visual-mode--ex-commands--) | Visual + ex | `v V`, `:w :q :e` command line. |
| [v0.5](docs/roadmap.md#v05--file-palette--multi-file--) | Files | `Ctrl-P` over `/repo` + `/local`, buffers. |
| [v0.6](docs/roadmap.md#v06--markdown-affordances--) | Markdown | Headings, list continuation, soft-wrap. |
| [v0.7](docs/roadmap.md#v07--search--better-git--) | Search + git | `/`, `:Gpull`, `:Gbranch`. |
| [v0.8](docs/roadmap.md#v08--power-battery--sleep--) | Power | 18650 + sleep + lid switch. |
| [v0.9](docs/roadmap.md#v09--robustness--) | Robustness | Crash-safe writes, reconnect, settings. |
| [v1.0](docs/roadmap.md#v10--polish--) | Polish | Boot ≤ 3 s, fonts, enclosure, guide. |
| [v1.x](docs/roadmap.md#v1x--stretch--nice-to-have) | Stretch | 10.3" panel, spell-check, themes, BLE. |
| Version | Theme | One-liner |
| ------------------------------------------------------- | ------------ | -------------------------------------------- |
| [v0.1](docs/macroplan.md#v01--mvp-it-writes-it-pushes--) | MVP | Boots, edits one file, `Ctrl-G` pushes. |
| [v0.2](docs/macroplan.md#v02--vim-navigation--) | Vim nav | Normal/Insert, motions, line numbers. |
| [v0.2.5](docs/macroplan.md#v025--international-input--) | Intl input | US-Intl dead keys: à é ê ç, `'`+space = `'`. |
| [v0.3](docs/macroplan.md#v03--vim-editing--) | Vim edit | `dd yy p`, undo/redo, counts. |
| [v0.4](docs/macroplan.md#v04--visual-mode--ex-commands--) | Visual + ex | `v`/`V` select + `y d c`, `gr` read, `:` ex. |
| [v0.5](docs/macroplan.md#v05--file-palette--multi-file--) | Files | `Ctrl-P` over `/repo` + `/local`, buffers. |
| [v0.6](docs/macroplan.md#v06--markdown-affordances--) | Markdown | Headings, list continuation, soft-wrap. |
| [v0.7](docs/macroplan.md#v07--search--better-git--) | Search + git | `/` search, `:Gpull`. |
| [v0.8](docs/macroplan.md#v08--power-battery--sleep--) | Power | 18650 + sleep + lid switch. |
| [v0.9](docs/macroplan.md#v09--robustness--) | Robustness | Crash-safe writes, reconnect, settings. |
| [v1.0](docs/macroplan.md#v10--polish--) | Polish | Boot ≤ 3 s, fonts, themes, enclosure, guide. |
| [v1.x](docs/macroplan.md#v1x--stretch--nice-to-have) | Stretch | 10.3″ panel, multi-remote, stats, BLE. |
---
## Repo layout (planned)
## Repo layout
```
/firmware Rust crate, esp-idf-rs target
(SD card mounted at runtime contains /repo and /local)
/firmware Rust crate, esp-idf-rs target
(SD card mounted at runtime contains /repo and /local)
/src
editor/ rope buffer, modes, keymap
render/ embedded-graphics + dirty rects
git/ gitoxide wrapper, auth
usb/ TinyUSB host glue
fs/ SD + NVS
build.rs reads TW_* env vars (Wi-Fi, PAT, author) — v0.1 config path
sdkconfig.defaults
/hardware BOM, schematic, enclosure (later)
/docs ADRs, QFD, roadmap, per-version product + technical specs,
rendering/UX spike log (spikes.md)
CONTEXT.md project glossary — Tracked / Local / Save / Publish, and the
principles that fall out of them
GLOSSARY.md methodology glossary — the WHAT / Function / Characteristic /
Metric / Target ontology layers used across docs
package.json pnpm + oxfmt — formatting toolchain for docs/JSON
(companions: pnpm-lock.yaml, .oxfmtrc.json, .node-version)
main.rs app binary — editor + display + USB (SD/git not wired yet)
editor.rs modal editor core: buffer, modes, keymap, :commands
epd.rs SSD1683 dual-controller e-ink driver
usb_kbd.rs TinyUSB host glue, HID → key events
/bin on-device spike binaries (sd_fat, wifi_tls, git_push,
git_sync, git_smoke)
/components/libgit2 libgit2 as an esp-idf CMake component (mbedTLS);
source vendored as a git submodule
build.rs bakes TW_* env vars (Wi-Fi, PAT, author) — v0.1 config path
/spikes desktop spikes (spike7 git push proof, pre-device)
/docs ADRs, QFD, hardware, roadmap, per-version specs,
spikes.md, postmortems/, notes/
/hardware enclosure — parametric OpenSCAD case (case/) + renders
CONTEXT.md project glossary — Tracked / Local / Save / Publish, and
the principles that fall out of them
GLOSSARY.md methodology glossary — the WHAT / Function /
Characteristic / Metric / Target ontology layers
package.json pnpm + oxfmt — formatting toolchain for docs/JSON
```
---
## Open questions / risks (tracked, not yet resolved)
- [ ] `gix-clone` + `gix-pack` smart-HTTP push working on `esp-idf-rs` with
mbedtls — needs an early spike before locking the stack.
- [ ] TinyUSB host stability with arbitrary HID descriptors (Nuphy reports
consumer-control keys we may need to ignore).
- [ ] Heap fragmentation over a long writing session with PSRAM allocator.
- [ ] Real-world e-ink ghosting with current partial-refresh cadence.
- [ ] Heap fragmentation over a long writing session with the PSRAM allocator.
- [ ] Real-world e-ink ghosting with the current partial-refresh cadence.
- [~] Use-after-free freeing the in-flight USB transfer on keyboard unplug —
fixed in code, pending an on-device hot-plug run to confirm
([`MEMORY_AUDIT.md`](MEMORY_AUDIT.md) finding #1).
Retired risks ([gix push](docs/postmortems/2026-07-05-spike7-gix-https-push.md),
[SD CMD59 rejection](docs/postmortems/2026-07-05-spike3-sd-cmd59.md), TinyUSB HID
stability, TLS heap, libgit2-on-xtensa) and how they died:
[`docs/spikes.md`](docs/spikes.md) and
[`docs/postmortems/`](docs/postmortems/README.md).
These get resolved by writing code, not by deciding harder.

2
display/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/target
/Cargo.lock

10
display/Cargo.toml Normal file
View File

@@ -0,0 +1,10 @@
[package]
name = "display"
version = "0.1.0"
edition = "2021"
description = "The GDEY0579T93 panel's geometry plus an in-memory drawable Frame (an embedded-graphics DrawTarget), split out of the hardware driver so the driver and the host-testable editor crate share one framebuffer definition without pulling in esp-idf."
# Pure embedded-graphics — no esp/std hardware deps — so the editor crate that
# renders onto Frame stays host-buildable and testable off the xtensa target.
[dependencies]
embedded-graphics = "0.8"

341
display/src/glyphs.rs Normal file
View File

@@ -0,0 +1,341 @@
//! Extra glyphs the ISO-8859-15 render font lacks.
//!
//! The buffer is UTF-8, so it can hold codepoints outside Latin-9. The
//! `embedded-graphics` `iso_8859_15` font draws those as a fallback box. This
//! module carries hand-authored 10×20 bitmaps for a curated set of common
//! single-width characters — typographic punctuation that arrives in imported
//! prose (curly quotes, en/em dashes, ellipsis, bullet) and the math symbols the
//! user's Markdown snippets insert (→ ≠ Σ). [`Editor::draw`] overlays these over
//! the fallback box after the font has laid out the line.
//!
//! Every glyph is exactly one 10×20 cell (matching the body `FONT_10X20`, i.e.
//! `editor::CW`×`editor::CH`), so the editor's 1-char = 1-cell layout invariant
//! is untouched. A row is a 10-bit pattern: the leftmost column (x = 0) is the
//! high bit, so a binary literal reads left-to-right as the pixels of that row.
//! Rows are top (y = 0) to bottom (y = 19); 1 = ink.
use crate::Frame;
use embedded_graphics::pixelcolor::BinaryColor;
use embedded_graphics::prelude::*;
const GLYPH_W: usize = 10;
const GLYPH_H: usize = 20;
/// A 10×20 1-bit glyph: one `u16` per row, low `GLYPH_W` bits used, high bit =
/// leftmost pixel.
pub type Glyph = [u16; GLYPH_H];
// → U+2192 RIGHTWARDS ARROW: a horizontal shaft with a `>` head at the right.
#[rustfmt::skip]
const ARROW_RIGHT: Glyph = [
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000010000,
0b0000001000,
0b0000000100,
0b0000000010,
0b0111111111,
0b0111111111,
0b0000000010,
0b0000000100,
0b0000001000,
0b0000010000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
];
// ≠ U+2260 NOT EQUAL TO: two equals bars with a slash through them.
#[rustfmt::skip]
const NOT_EQUAL: Glyph = [
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000010,
0b0000000100,
0b0000000100,
0b0000001000,
0b0111111110,
0b0000010000,
0b0000010000,
0b0000100000,
0b0111111110,
0b0001000000,
0b0001000000,
0b0010000000,
0b0010000000,
0b0000000000,
0b0000000000,
0b0000000000,
];
// Σ U+03A3 GREEK CAPITAL LETTER SIGMA: top & bottom bars with the diagonals
// meeting at a vertex on the centre-right.
#[rustfmt::skip]
const SIGMA: Glyph = [
0b0000000000,
0b0000000000,
0b0000000000,
0b0111111110,
0b0100000000,
0b0010000000,
0b0001000000,
0b0000100000,
0b0000010000,
0b0000100000,
0b0001000000,
0b0010000000,
0b0100000000,
0b0100000000,
0b0111111110,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
];
// • U+2022 BULLET: a filled dot at mid-height.
#[rustfmt::skip]
const BULLET: Glyph = [
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0001111000,
0b0011111100,
0b0011111100,
0b0011111100,
0b0001111000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
];
// … U+2026 HORIZONTAL ELLIPSIS: three dots on the baseline.
#[rustfmt::skip]
const ELLIPSIS: Glyph = [
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0110110110,
0b0110110110,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
];
// U+2013 EN DASH: a short mid-height bar.
#[rustfmt::skip]
const EN_DASH: Glyph = [
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0011111100,
0b0011111100,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
];
// — U+2014 EM DASH: a full-width mid-height bar.
#[rustfmt::skip]
const EM_DASH: Glyph = [
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b1111111111,
0b1111111111,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
];
// ' U+2018 LEFT SINGLE QUOTATION MARK: raised, tail up (opening).
#[rustfmt::skip]
const LEFT_SINGLE_QUOTE: Glyph = [
0b0000000000,
0b0000000000,
0b0000100000,
0b0001100000,
0b0001100000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
];
// ' U+2019 RIGHT SINGLE QUOTATION MARK / apostrophe: raised, tail down (closing).
#[rustfmt::skip]
const RIGHT_SINGLE_QUOTE: Glyph = [
0b0000000000,
0b0000000000,
0b0001100000,
0b0001100000,
0b0000100000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
];
// " U+201C LEFT DOUBLE QUOTATION MARK: two opening quotes.
#[rustfmt::skip]
const LEFT_DOUBLE_QUOTE: Glyph = [
0b0000000000,
0b0000000000,
0b0010010000,
0b0011011000,
0b0011011000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
];
// " U+201D RIGHT DOUBLE QUOTATION MARK: two closing quotes.
#[rustfmt::skip]
const RIGHT_DOUBLE_QUOTE: Glyph = [
0b0000000000,
0b0000000000,
0b0011011000,
0b0011011000,
0b0010010000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
0b0000000000,
];
/// The 10×20 bitmap for a character the ISO-8859-15 font can't draw, or `None`
/// if the base font already covers it (all ASCII and Latin-9, including `œ €`).
pub fn extra_glyph(c: char) -> Option<&'static Glyph> {
Some(match c {
'\u{2192}' => &ARROW_RIGHT, // →
'\u{2260}' => &NOT_EQUAL, // ≠
'\u{03A3}' => &SIGMA, // Σ
'\u{2022}' => &BULLET, // •
'\u{2026}' => &ELLIPSIS, // …
'\u{2013}' => &EN_DASH, //
'\u{2014}' => &EM_DASH, // —
'\u{2018}' => &LEFT_SINGLE_QUOTE, // '
'\u{2019}' => &RIGHT_SINGLE_QUOTE, // '
'\u{201C}' => &LEFT_DOUBLE_QUOTE, // "
'\u{201D}' => &RIGHT_DOUBLE_QUOTE, // "
_ => return None,
})
}
/// Paint a glyph over one 10×20 cell at `(x, y)`. Fills the whole cell — set
/// bits in `ink`, unset bits in `ink.invert()` — so it fully overwrites whatever
/// the base font drew there (e.g. the fallback box). Pass `ink = On` for a
/// normal black-on-white cell, `ink = Off` for a reverse-video cell (a white
/// glyph over the black selection/caret fill).
pub fn blit_glyph(f: &mut Frame, x: i32, y: i32, g: &Glyph, ink: BinaryColor) {
let bg = ink.invert();
let pixels = g.iter().enumerate().flat_map(move |(row, &bits)| {
(0..GLYPH_W).map(move |c| {
let on = (bits >> (GLYPH_W - 1 - c)) & 1 == 1;
Pixel(
Point::new(x + c as i32, y + row as i32),
if on { ink } else { bg },
)
})
});
// Frame's DrawTarget error is Infallible.
let _ = f.draw_iter(pixels);
}

120
display/src/lib.rs Normal file
View File

@@ -0,0 +1,120 @@
//! The e-paper panel's geometry and an in-memory drawable frame.
//!
//! Split out of the hardware driver (`firmware/src/epd.rs`) so the driver and
//! the host-testable `editor` crate share one framebuffer definition. `Frame`
//! is a pure `embedded-graphics` [`DrawTarget`]; the `Epd` driver in firmware
//! consumes its raw bytes via [`Frame::bytes`] and never names the type, so
//! nothing here depends on esp-idf and the whole crate builds on the host.
use embedded_graphics::mono_font::iso_8859_15::FONT_10X20;
use embedded_graphics::mono_font::MonoTextStyle;
use embedded_graphics::pixelcolor::BinaryColor;
use embedded_graphics::prelude::*;
use embedded_graphics::primitives::{Circle, PrimitiveStyleBuilder};
use embedded_graphics::text::{Alignment, Baseline, Text, TextStyleBuilder};
mod glyphs;
pub use glyphs::{blit_glyph, extra_glyph, Glyph};
pub const WIDTH: u16 = 792;
pub const HEIGHT: u16 = 272;
/// Full-frame 1-bit framebuffer: 792 px = 99 bytes per row, MSB-first,
/// 1 = white, 0 = black (SSD16xx convention).
pub const FB_BYTES_W: usize = (WIDTH / 8) as usize; // 99
pub const FB_BYTES: usize = FB_BYTES_W * HEIGHT as usize; // 26928
/// In-memory 792×272 1-bit frame, drawable via `embedded-graphics`.
/// `BinaryColor::On` = black ink, `Off` = white paper.
pub struct Frame {
buf: Vec<u8>,
}
impl Frame {
pub fn new_white() -> Self {
Self { buf: vec![0xFF; FB_BYTES] }
}
pub fn new_black() -> Self {
Self { buf: vec![0x00; FB_BYTES] }
}
/// The Typoena boot splash (Spike 9): the wordmark centred inside a stroked
/// circle on a white frame. Pure `embedded-graphics`, so it renders the same
/// on the host (the preview) as it does through the `Epd` driver at boot.
/// `main.rs` shows this once at startup, before the editor opens.
pub fn splash() -> Self {
// Badge sized to leave a comfortable margin inside the 272 px panel
// height (diameter 200 → 36 px clear top and bottom).
const WORDMARK: &str = "typoena";
const CIRCLE_DIAMETER: u32 = 200;
const STROKE_WIDTH: u32 = 4;
let mut f = Self::new_white();
let center = Point::new(WIDTH as i32 / 2, HEIGHT as i32 / 2);
let stroke = PrimitiveStyleBuilder::new()
.stroke_color(BinaryColor::On) // black ink
.stroke_width(STROKE_WIDTH)
.build();
Circle::with_center(center, CIRCLE_DIAMETER)
.into_styled(stroke)
.draw(&mut f)
.unwrap(); // Frame's DrawTarget error is Infallible
// Centre the wordmark on the panel centre in both axes, so it sits in
// the middle of the circle regardless of string length.
let char_style = MonoTextStyle::new(&FONT_10X20, BinaryColor::On);
let text_style = TextStyleBuilder::new()
.alignment(Alignment::Center)
.baseline(Baseline::Middle)
.build();
Text::with_text_style(WORDMARK, center, char_style, text_style)
.draw(&mut f)
.unwrap();
f
}
pub fn bytes(&self) -> &[u8] {
&self.buf
}
/// Flip every pixel black↔white across the whole framebuffer. The editor
/// draws its native black-ink-on-white-paper frame, then calls this once at
/// the end for the dark theme — so text, selection, caret, panel and palette
/// all invert together and each stays legible against the flipped ground.
pub fn invert(&mut self) {
for b in &mut self.buf {
*b = !*b;
}
}
}
impl OriginDimensions for Frame {
fn size(&self) -> Size {
Size::new(WIDTH as u32, HEIGHT as u32)
}
}
impl DrawTarget for Frame {
type Color = BinaryColor;
type Error = core::convert::Infallible;
fn draw_iter<I>(&mut self, pixels: I) -> Result<(), Self::Error>
where
I: IntoIterator<Item = Pixel<Self::Color>>,
{
for Pixel(p, color) in pixels {
if (0..WIDTH as i32).contains(&p.x) && (0..HEIGHT as i32).contains(&p.y) {
let idx = p.y as usize * FB_BYTES_W + p.x as usize / 8;
let bit = 0x80u8 >> (p.x % 8);
match color {
BinaryColor::On => self.buf[idx] &= !bit, // black ink
BinaryColor::Off => self.buf[idx] |= bit, // white paper
}
}
}
Ok(())
}
}

36
docs/README.md Normal file
View File

@@ -0,0 +1,36 @@
# Docs
> The design record for Typoena — the decisions, specs, and bench write-ups
> behind the writing appliance. Start with the [ADRs](adr.md) for the
> load-bearing choices, or the [v0.1 specs](v0.1-mvp-product.md) for what the
> first release actually does.
>
> Project overview: [`../README.md`](../README.md).
## Decisions & specs
| Doc | What's in it |
| --- | --- |
| [`adr.md`](adr.md) | Architecture Decision Records — the load-bearing technical choices and why. |
| [`v0.1-mvp-product.md`](v0.1-mvp-product.md) | v0.1 product design — boot, type one file, `Ctrl-S` to save, `Ctrl-G` to publish. |
| [`v0.1-mvp-technical.md`](v0.1-mvp-technical.md) | v0.1 technical design — single Rust binary on `esp-idf-rs`, modules, threads, bring-up order. |
| [`macroplan.md`](macroplan.md) | Version-by-version plan; each release is a usable artifact, not a checkpoint. |
| [`typoena-toml.md`](typoena-toml.md) | `.typoena.toml` reference — the git-tracked editor preferences (auto-save, format-on-save, line numbers, auto-sync). |
| [`hardware.md`](hardware.md) | Part choices for the bench build and the rationale behind them. |
## Quality method
| Doc | What's in it |
| --- | --- |
| [`qfd.md`](qfd.md) | Quality Function Deployment — turns user-facing requirements into technical HOWs; §3 is the filled 14 WHATs × 14 HOWs House of Quality. |
| [`quality-house-empty.md`](quality-house-empty.md) | The House chassis, blank — for re-scoring from scratch. |
## Bench work
| Area | What's in it |
| --- | --- |
| [`spikes.md`](spikes.md) | Rendering & UX spikes — display/UX risks proved outside the hardware stack. |
| [`postmortems/`](postmortems/README.md) | Bring-up debugging write-ups: what broke, the root cause, and the decisions that came out of it. |
| [`notes/`](notes/README.md) | Longer-form essays on the thinking behind specific choices — e.g. where the ~16 s cold [`:sync`](notes/sync-latency.md) goes. |
| [`tradeoff-curves/`](tradeoff-curves/README.md) | Cost-vs-knob curves behind chosen defaults — energy, latency, memory. |
| [`kaizen/real-repo-sync.md`](kaizen/real-repo-sync.md) | Six-step kaizen write-ups — the problem→analysis→fix story behind an improvement, e.g. the real-repo `:sync` brick. |

View File

@@ -12,7 +12,7 @@ Format inspired by Michael Nygard's ADR template, kept short on purpose.
[`../CONTEXT.md`](../CONTEXT.md) — project glossary: **Tracked**, **Local**,
**Save**, **Publish**, plus the principles ("writing tool, not sync engine")
that constrain [ADR-010] specifically.
[`roadmap.md`](roadmap.md) — per-version scope (v0.1 → v1.x).
[`macroplan.md`](macroplan.md) — per-version scope (v0.1 → v1.x).
[`v0.1-mvp-product.md`](v0.1-mvp-product.md) — what the v0.1 device must do.
[`v0.1-mvp-technical.md`](v0.1-mvp-technical.md) — how v0.1 is built.
[`qfd.md`](qfd.md) — Quality Function Deployment: requirements → functions →
@@ -180,7 +180,7 @@ as the price of those properties.
do not promise "instant feedback."
- Idle power on e-ink is structurally ~0, which makes the v0.8 battery
sizing exercise straightforward — see [ADR-008] and
[roadmap → v0.8](roadmap.md#v08--power-battery--sleep--).
[macroplan → v0.8](macroplan.md#v08--power-battery--sleep--).
- 10.3" e-ink upgrade path is preserved by keeping the renderer
resolution-agnostic. A _non_-e-ink swap (e.g. Sharp Memory LCD) would
invalidate [ADR-002]'s dirty-rect strategy and force a fresh medium ADR.
@@ -234,7 +234,7 @@ over `file://` and `ssh://` — **not HTTP(S)** — so with HTTPS + PAT fixed by
[ADR-005], the smart-HTTP push path this ADR bet on does not exist yet. We
switched to **`libgit2` (`git2` crate)** and proved `add → commit → push`
(incl. `pull --no-edit` + retry) on desktop
([`spikes/spike7-git-push`](../spikes/spike7-git-push/)). The remaining risk is
([`spikes/spike7-git-push`](../spikes/spike7-git-push/README.md)). The remaining risk is
now the on-device **libgit2 → xtensa/mbedtls cross-compile** — the very pain
this ADR chose gix to avoid. Full context:
[postmortem](postmortems/2026-07-05-spike7-gix-https-push.md). Revisit gix if
@@ -277,7 +277,7 @@ card alone is not enough.
[v0.1 product → provisioning](v0.1-mvp-product.md#provisioning-build-time-dev-only).
- PAT is never logged. Validated in code review.
- Rotation in v0.1 = wipe NVS and re-run setup. Proper rotation UI is v0.9
— see [roadmap → v0.9](roadmap.md#v09--robustness--).
— see [macroplan → v0.9](macroplan.md#v09--robustness--).
- Revisit if we ever want to support multiple remotes per device with
different credentials.
@@ -355,6 +355,25 @@ derived key.
atomic-rename writes (see
[v0.1 technical → `persistence`](v0.1-mvp-technical.md#module-breakdown)
and [file layout](v0.1-mvp-technical.md#file-layout)).
- **FatFS caveat (Spike 3, verified 2026-07-11):** FatFS's `f_rename` returns
`FR_EXIST` on an existing destination — it does **not** replace like POSIX
`rename(2)`. So the atomic save must `f_unlink` the target before renaming the
`*.tmp` over it, and pair that with **boot recovery** of a lingering `*.tmp`.
Recovery is *not* simply "promote the tmp": a crash *during* the tmp write
leaves a partial tmp, so the choice depends on whether the target survived —
- **tmp + target both present** → the crash could have been mid-write, so the
tmp is untrustworthy. Keep the committed target, discard the tmp (this is the
documented "you get the previous version" behaviour).
- **tmp only, target absent** → the target was already unlinked, so the tmp is
the newest complete, fsync'd copy and the only one left. Promote it.
Implemented in `firmware::persistence::Storage::{save,recover}`. See the
[Spike 3 postmortem](postmortems/2026-07-05-spike3-sd-cmd59.md#resolution-2026-07-11).
- **SD-card compatibility:** use a genuine card, ideally **≤32 GB (SDHC/FAT32)**.
Large or counterfeit SDXC cards may reject `CMD59` (SPI-mode CRC) and fail to
mount; we keep CRC required rather than run the user's writing over an
unchecked bus. The device reports a swap-the-card message instead of a hex
code.
---
@@ -389,7 +408,7 @@ Sizing a battery before measuring is guessing.
scope is in [v0.1 product → out of scope](v0.1-mvp-product.md#out-of-scope-for-v01).
- We can decide cell capacity from real numbers in v0.8, not specs sheets.
- Lid-close detection / deep sleep slips to v0.8 with the battery — see
[roadmap → v0.8](roadmap.md#v08--power-battery--sleep--).
[macroplan → v0.8](macroplan.md#v08--power-battery--sleep--).
---
@@ -537,6 +556,63 @@ into [ADR-007](#adr-007-storage-split--fat-on-sd-for-working-copy-littlefs-on-fl
---
## ADR-012: SD on its own SPI3 host (not shared with the EPD on SPI2)
**Status:** Accepted — 2026-07-11
**Scope:** v0.1 hardware; whole project.
### Context
The EPD (SSD1683) and the SD card both want SPI. The v0.1 plan (the boot
sequence in [v0.1 technical](v0.1-mvp-technical.md#hardware-bring-up-order) and
the storage context of
[ADR-007](#adr-007-storage-split--fat-on-sd-for-working-copy-littlefs-on-flash-for-config))
assumed **one shared SPI2 bus** with a per-device chip-select. Spike 3 (verified
2026-07-11, [postmortem](postmortems/2026-07-05-spike3-sd-cmd59.md)) proved the
SD works on the SPI2 wiring, but surfaced the integration blocker: the EPD driver
uses esp-idf-hal's `SpiBusDriver`, whose constructor takes an **exclusive
`spi_device_acquire_bus(BLOCK)` and holds it for the driver's whole lifetime**
(it must keep CS asserted across a cmd→data sequence while toggling DC). While
held, no other device on that host can transact — so an SD on SPI2 is locked out
for as long as the panel driver is alive. Compounding it, persistence/git runs on
a **dedicated thread** (Spike 7) while the EPD refreshes on the main task, so SD
and EPD access are genuinely concurrent.
### Options considered
| Option | Pros | Cons |
| --- | --- | --- |
| **Shared SPI2, arbitrate** | One bus; ~2 fewer GPIOs. | Rewrite the proven EPD SPI layer to per-transaction device drivers; add a cross-thread mutex around all SPI2 access; residual "corruption on save during render" risk on the highest-value path. |
| **SD on its own SPI3** | EPD code untouched; no arbitration/mutex; each bus at its own clock; matches the risk-table fallback exactly. | ~2 extra GPIOs + traces. |
### Decision
**SD gets its own SPI3 host.** The EPD keeps SPI2 and its exclusive-lock model,
unchanged. This is the mitigation the technical-doc risk table already names
("move SD to a separate SPI peripheral — ESP32-S3 has two"). SPI3 is free (SPI0/1
are flash + PSRAM; nothing else uses SPI3).
Pins — **SD on SPI3:** SCK 14, MOSI 15, MISO 13, CS 10 (MISO/CS unchanged from
the spike; only SCK/MOSI move off the EPD-shared 12/11). **EPD stays on SPI2:**
SCK 12, MOSI 11, CS 7, DC 6, RST 5, BUSY 4.
### Consequences
- No shared-bus arbitration or mutex — the git thread's SD I/O never contends
with an EPD refresh. Removes the "corruption on save during render" risk for
the device's first value (not losing the user's writing).
- Each bus runs at its own clock (EPD ~4 MHz on jumpers; SD 10 MHz+).
- Costs ~2 extra GPIOs + traces; the pin budget has room (avoids flash 2632,
octal PSRAM 3337, strapping 0/3/45/46, USB 19/20, RGB 38/48, EPD 47/11/12).
- Supersedes the "shared SPI2, different CS" assumption in the boot sequence and
ADR-007's storage context; the `sd_fat` spike is rewired to SPI3 and its
EPD-CS-deselect step (only meaningful on a shared bus) is removed.
- The `SpiBusDriver`-holds-the-lock mechanism was read from the constructor, not
re-verified on silicon; it doesn't affect this decision (SPI3 sidesteps it),
but is the first thing to confirm if a shared bus is ever revisited.
---
## How to add a new ADR
1. Append a new `## ADR-NNN: <title>` section to this file.

34
docs/hardware.md Normal file
View File

@@ -0,0 +1,34 @@
# Hardware — parts and rationale
Part choices for the bench build, moved here from the README. The display
medium decision (e-ink over LCD / memory LCD / OLED) is
[ADR-003](adr.md#adr-003-display-medium--e-ink-gdey0579t93-panel); power is
[ADR-008](adr.md#adr-008-mvp-power--wall-powered-battery-deferred-to-v08);
keyboard transport is
[ADR-009](adr.md#adr-009-keyboard-transport--usb-host-tinyusb).
| Part | Choice | Why |
| --------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| MCU | **ESP32-S3-N16R8** (16 MB flash, 8 MB octal PSRAM) | USB OTG host (for the keyboard), Wi-Fi, BLE, dual core @ 240 MHz, plenty of PSRAM for git pack data and screen buffer. Best-supported Rust target in the ESP family. |
| Display | **GDEY0579T93 + DESPI-C579 breakout** (5.79", 792×272, 1-bit) | Good Display panel matched with its own FPC breakout. Strip aspect (~2.9:1) — Freewrite-coded: ~13 lines, ~79 cols at the editor's 10px font. Tiny framebuffer (~27 KB) leaves PSRAM headroom. The DESPI-C579 is a passive level-shifter / FPC-to-header board, not an active controller — driven over plain SPI like any other epd. |
| Keyboard | **Nuphy Air60/Halo65 wired USB-C** | ESP32-S3 acts as USB host via TinyUSB. BLE-HID is a fallback but contends with Wi-Fi for radio time during push. |
| Storage | microSD over SPI | Holds both the git working copy (`/sd/repo/`) **and** the local-only scratch space (`/sd/local/`). Internal flash is for firmware + config only. |
| Power | **USB-C wall power for MVP**, 18650 + IP5306 in v0.8 | Measure power profile on real hardware before sizing the battery. E-ink + sleep should give multi-day battery life but battery introduces charging, safety, and BMS complexity we don't need on day one. |
| Enclosure | 3D-printed typewriter body — [`hardware/case/`](../hardware/case/README.md) | v1.0 concern. |
## Why the strip aspect
The ~2.9:1 long-narrow shape biases the UX toward "current line + recent
context" rather than "full page" — the writing posture we want. The renderer
stays resolution-agnostic so a 10.3" e-ink upgrade (v1.x) is a swap, not a
rewrite.
## Bench status
The board is on the bench and bring-up is largely done — per-spike results
live in [`spikes.md`](spikes.md) and
[`v0.1-mvp-technical.md`](v0.1-mvp-technical.md#hardware-bring-up-order),
with failure write-ups in [`postmortems/`](postmortems/README.md). Notable: the
keyboard runs bus-powered on the S3's native USB port, and the SD/FAT stack is
verified on a 32 GB card on its own SPI3 host (2026-07-11, ADR-012)
([postmortem](postmortems/2026-07-05-spike3-sd-cmd59.md)).

View File

@@ -0,0 +1,280 @@
# Kaizen — `:sync` never completes on the real notes repo
The writer presses `:sync` on the Typoena appliance and the device freezes for
ten minutes; a power-cycle re-enters the same freeze forever. The real
`jcalixte/notes` clone (1179 files, 263 MB pack) has never been synced from the
device — only the toy test repo has. Full measurement trail:
[`../tradeoff-curves/sync-commit-staging.md`](../tradeoff-curves/sync-commit-staging.md).
## 1) Improvement potential
**Value model for the writer (Julien on the appliance)**
| Want more of | Do less of |
| ----------------------------------------------- | ------------------------------------------------------- |
| + words safely on the remote with one keystroke | waiting on a frozen device |
| + trust that `:sync` completes, every time | power-cycling mid-sync and losing the session's trust |
| + keep writing while it publishes | babysitting git from a desktop to rescue the card |
**Measurement**: seconds from `:sync` to the snackbar, on the real
`jcalixte/notes` clone on the device.
```mermaid
---
config:
xyChart:
width: 900
height: 500
---
xychart-beta
title "Improvement potential — :sync → snackbar"
x-axis ["Before kaizen (~611 s, never completes)", "After kaizen (target ≤ ~10 s)"]
y-axis "seconds" 0 --> 650
bar [611]
line [10, 10]
```
The before bar understates the reality: a reset re-enters the freeze, so the
true value is ∞ — 0 successful syncs ever. The line is the ≤ ~10 s target;
## 2) Current method analysis
```
:sync (current method, real repo)
┌─ local commit ─────────────────────────────────────┐ ┌─ network push ──────┐
│ stage: add_all(["*"]) + update_all(["*"]) │ │ TLS + push │
│ → stat()s 1179 files / 158 dirs over 10 MHz SPI │──▶│ ~6.5 s floor │
│ index.write ⚡ ~611 s │ │ (separate curve) │
│ → FAT's 2 s mtime marks ~every entry "racy" │ └─────────────────────┘
│ → re-hashes ~170 MB (150 MB of images) │
│ write_tree + commit-obj ⚡ seconds each │
│ → loose writes trigger pack reads through │
│ emulated mmap; each far lseek walks the │
│ FAT cluster chain (~190 ms) │
└────────────────────────────────────────────────────┘
⚡ = weak points, measured on device
```
Every factor was benched on the device (`sd_bench` for raw FAT, `git_bench` for
git2 primitives on the real clone) — details and full tables in
[`../tradeoff-curves/sync-commit-staging.md`](../tradeoff-curves/sync-commit-staging.md):
| Factor # | Factor | Hypothesis | Test method | Test result | True or false? |
| -------- | --------------------------- | ------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | -------------------- |
| 1 | SD card raw I/O | The card itself is slow: a loose-object write costs ~700900 ms | `sd_bench`: raw FAT create/write/rename composite, same card, same 10 MHz | complete loose-object composite **86 ms** — 8× under libgit2's number | **FALSE** |
| 2 | `index.write()` racy-clean | FAT's 2 s mtime granularity makes ~all 1179 entries look racy → full ~170 MB re-hash over SPI | `git_bench` on the real clone, 3 consecutive `index.write()` | **611 s → 12.8 s → 360 ms** — the decay is the racy set shrinking as the index mtime advances | **TRUE** |
| 3 | Index-free alternative | Skipping `index.write` (fresh in-memory index) escapes the wall | `git_bench`: `Index::new` + `read_tree(HEAD)` + `write_tree_to` | seed `read_tree` **77 s cold** + mmap grows to 7.4 MB → **zlib OOM crash** — still O(N_tree) | **FALSE** (as a fix) |
| 4 | FatFS `lseek` | Long/backward seeks walk the FAT cluster chain over SPI (~67 KB of FAT reads for a 263 MB file) | `sd_bench`: seek+read 4 KB @offset 0 vs @end of the pack; A/B with `CONFIG_FATFS_USE_FASTSEEK` | @0 = 5.8 ms, @end = **198.7 ms**; fast-seek → **20.4 ms** | **TRUE** |
| 5 | Free-cluster scan | A ~740 MB-full card slows FAT allocation | `sd_bench` re-run on the full card | composite 77 ms — unchanged | **FALSE** |
| 6 | Strict object creation | OID validation on commit/treebuilder causes the ~1.3 s commit premium | strict-off A/B in `git_bench` | 1.80 s vs 1.93 s — no premium removed | **FALSE** |
| 7 | Repeated small mmap windows | A window cache in `esp_map.c` would absorb the ~8 small pack reads per loose write | instrumented cache (v2), 4 real-repo runs | **0 hits / 313 misses** — every read hits a unique (offset, len); `mwindow` already absorbs true repetition | **FALSE** |
| 8 | Cache memory discipline | The OOM in factor 3 is our own cache holding buffers past `p_munmap`, defeating `MWINDOW_MAPPED_LIMIT` | run-4 memory monitor after evict-on-munmap fix, then full removal (run 5) | resident flat at 1833 KB, heap ≥ 6.2 MB, no OOM; removal I/O-neutral | **TRUE** |
### Details on hypothesis #2 (the freeze itself)
`git_index_write` unconditionally runs `truncate_racily_clean`, which re-hashes
every entry whose mtime is not strictly older than the index stamp. On a fresh
FAT clone the 2 s mtime granularity makes the whole tree racy, so a one-line
note edit re-hashes ~170 MB — mostly the ~260 images a text edit never touches —
over the 10 MHz SPI bus. This is why the device freezes ~10 minutes and why a
reset never helps: the index mtime doesn't advance, so it re-hashes forever.
**Selected weak point**: the local commit stage — its staging strategy is
O(N_tree) in files and bytes, on a device where N_tree ≈ 1179 and the bus is
10 MHz SPI. (The ~6.5 s network half is real but is a separate curve,
[`../notes/sync-latency.md`](../notes/sync-latency.md), and it does not brick
the device.)
## 3) Ideas
### Bibliography
- [FatFS `f_lseek` docs (elm-chan.org)](http://elm-chan.org/fsw/ff/doc/lseek.html) — the cluster-chain walk and the CLMT fast-seek mechanism.
- [ESP-IDF FatFS docs](https://docs.espressif.com/projects/esp-idf/en/latest/esp32/api-reference/storage/fatfs.html) — `CONFIG_FATFS_USE_FASTSEEK`, recommended for read-heavy workloads with long backward seeks.
- Vendored libgit2 v1.9.4 source — `index.c` `truncate_racily_clean` (the re-hash wall), `odb.c` `git_odb__freshen`/`git_odb_refresh` (the per-write pack probes), `mwindow.c` (32-bit defaults: 32 MB window / 256 MB mapped limit).
### New ideas
| Name | Estimated gain | Estimated lead time | Cause addressed | Comments |
| ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | --------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **O(depth) TreeBuilder splice** — patch only the edited path's ancestor chain onto HEAD's tree | commit 611 s → ~23 s; **flat in repo size forever** | ~2 days (bench op, then plumbing) | #2 + #3 — never opens the index, never materialises the tree | **Chosen.** Bonus: carries the 150 MB of images forward by OID, so the device doesn't even need them in its working tree |
| `CONFIG_FATFS_USE_FASTSEEK` + 256-word CLMT buffer | 2.3× on the splice (6.5 → 2.8 s); far seek 198.7 → 20.4 ms | hours — config, not code | #4 | Shipped as the companion fix; write-mode files fall back transparently |
| Explicit-path index staging (`add_path` over the editor's dirty set) | removes the O(N) walk term only | ~1 day | #2 partially | Retired — still calls `index.write()`, so it hits the same racy-clean wall |
| Index-free `write_tree_to` on a fresh in-memory index | — | ~1 day | #2 | Refuted by factor 3: seeding `read_tree(HEAD)` is 77 s + OOM — trades one O(N) for another |
| `esp_map.c` window cache (v2: size-keyed admission, evict-on-munmap) | hoped ~150250 ms per loose write | ~1 day | #7 | Built and instrumented → **0 hits in 4 runs** → removed entirely. The one build made on an unverified cause; see learnings |
| Faster card / SD at 20 MHz | none for the commit | PCB respin budget | #1 | Rejected — factor 1 refuted; the card was exonerated twice (86 ms then 77 ms composites) |
| Shrink the repo (images off-card / LFS-style pointers) | shrinks N and the 570 MB clone | unknown | N itself | Rejected for now — the images are load-bearing for another app ([`../notes/git-sync-images-and-repo-size.md`](../notes/git-sync-images-and-repo-size.md)). Composes with the splice later |
**Chosen idea**: the O(depth) TreeBuilder splice, with fast-seek as its config
companion — it is the only candidate whose cost is independent of repo size, so
the problem cannot come back as the notes tree grows. Everything index-shaped
stays O(N_tree) and merely moves the wall.
## 4) Test plan
**What could go wrong?**
| Lens | Anticipated consequence | Mitigation |
| ------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Stable | libgit2's 32-bit mwindow defaults `malloc` a 32 MB window on first pack access → instant OOM on the 8 MB PSRAM heap | `GIT_OPT_SET_MWINDOW_SIZE` 256 KB / `MAPPED_LIMIT` 4 MB set at service start, before any `Repository::open` |
| Stable | Power pull between a save and the next `:sync` loses the dirty set — nothing walks the tree anymore, so an unrecorded file would **never** sync | Dirty set journaled to `/sd/.typoena-dirty` (atomic write), recorded _before_ the file write; over-reporting is a free no-op splice, so the semantics stay simple |
| Stable | Commit lands, push fails → the commit strands forever behind "up to date" | Stranded-commit recovery: compare HEAD against `refs/remotes/origin/<branch>` and push even when there is nothing new to commit |
| Method | Reconcile's `Mixed` reset writes the index → re-enters the racy-clean wall through the back door | `ResetType::Soft` — ref move only; there is no index to reset anymore |
| Method | **Deliberate behavior change:** files edited on the card from a desktop are never committed anymore (`add_all` used to sweep them in) | Accepted and documented as intent — the appliance's editor is the only writer that counts |
| Machine | libgit2 holds the pack + `.idx` descriptors open and opens loose objects on top → blows the editor's 4-FD mount | git builds mount with 16 FDs (`Storage::mount_for_git()`) |
The mechanism was stress-tested as a prototype first, never in production: the
splice ran as a `git_bench` op against a full real-repo clone through four
localization rounds before any firmware plumbing.
**Who must we convince?**
- Upstream libgit2 maintainers — the tlsf double-free in the mbedTLS stream
error path (found during rollout; `wrap`'s error path frees the caller's
socket stream, `new()` frees it again). Needs an upstream report; we ship a
whole-file override meanwhile.
- Desktop-me (and any future contributor) — the card's working tree now shows a
permanent diff against HEAD when inspected on a Mac. That is intent, not a
bug.
**Rollback**: the `git` feature flag gates the whole sync stack (the light
build never links it), and the plumbing is a small commit range to revert. The
journal file is additive — an old build simply ignores it.
**Measurement protocol for step 6**: full `:sync` on the device against the
real clone — `commit split —` log lines plus snackbar-to-snackbar wall time,
cold and steady-state.
## 5) Implementation
**Before**`stage_and_commit` walked and hashed the world through the index
(condensed; `firmware/src/git_sync.rs` at `a5edaed~1`):
```rust
fn stage_and_commit(repo: &Repository) -> Result<Option<Oid>> {
let mut index = repo.index()?;
index.add_all(["*"], IndexAddOption::DEFAULT, Some(&mut skip_macos_cruft))?;
index.update_all(["*"], Some(&mut skip_macos_cruft))?; // stat 1179 files
index.write()?; // ⚡ racy-clean re-hash: ~611 s on FAT
let tree = repo.find_tree(index.write_tree()?)?;
// … commit(Some("HEAD"), …, &tree, &parents)
}
```
**After** — the editor's journaled dirty paths are spliced onto HEAD's tree;
the index never exists (condensed; `firmware/src/git_sync.rs:345`):
```rust
fn stage_and_commit(repo: &Repository, paths: &BTreeSet<String>) -> Result<Option<Oid>> {
let parent = repo.head().ok().and_then(|h| h.peel_to_commit().ok());
let mut tree = parent.as_ref().map(|c| c.tree()).transpose()?;
for path in paths {
let blob = match fs::read(format!("{REPO_DIR}/{path}")) {
Ok(bytes) => Some(repo.blob(&bytes)?), // present → splice in
Err(e) if e.kind() == NotFound => None, // deleted → splice out
Err(e) => return Err(e.into()),
};
let spliced = splice(repo, tree.as_ref(), &parts(path), blob)?;
tree = Some(repo.find_tree(spliced)?);
}
// … same "tree unchanged → nothing to publish" check, same commit call
}
/// Reads ~depth tree objects, writes ~depth new ones; every sibling entry is
/// carried by OID — never opened.
fn splice(repo: &Repository, base: Option<&Tree>, path: &[&str], blob: Option<Oid>) -> Result<Oid>
```
The same pipeline, redrawn with the change applied:
```
:sync (new method, real repo)
┌─ local commit ───────────────────────────────┐ ┌─ network push ──────┐
│ splice: for each journaled dirty path (≈1) │ │ TLS + push │
│ read file → blob → rebuild its ancestor │──▶│ ~6.5 s floor │
│ subtree chain (~depth tree objects) │ │ (untouched — next │
│ commit-obj │ │ curve to attack) │
│ no index · no walk · no re-hash · images │ └─────────────────────┘
│ carried by OID · lseek O(1) via fast-seek │
└──────────────────────────────────────────────┘
```
Around the mechanism, the plumbing that makes it safe day-to-day: the
`/sd/.typoena-dirty` journal with its pending → in-flight → settled lifecycle,
stranded-commit recovery, a radio-free "up to date" answer (~150 ms instead of
a Wi-Fi/TLS round), soft-reset reconcile, the 16-FD git mount, and the mwindow
options at service start. Details:
[the fix — wiring](../tradeoff-curves/sync-commit-staging.md#the-fix--wiring-the-odepth-splice-into-the-firmware).
## 6) Evaluation
**Measurement redone** (seconds, `:sync` → snackbar on the real repo):
∞ (never completed) → **PENDING end-to-end** (target was ≤ ~10 s).
What is measured so far, on device against the real clone (2026-07-13):
- The commit half works for the first time ever: splice **4.2 s** + commit-obj
**3.2 s** with the UI running concurrently (commits `a73bca0e`, then
`8939168f` carrying a 2-path journal across a power cycle).
- Projected full cold `:sync`**910 s** (commit + the ~6.5 s network half).
- The push half is not yet verified: the first attempt hit the card's
SSH-shaped origin (now rewritten to HTTPS at load), the second a tlsf
double-free in libgit2's mbedTLS stream error path (fixed via the
`esp_mbedtls_stream.c` override + `CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC=y`).
The step-1 number lands when the next on-device `:sync` completes
snackbar-to-snackbar — protocol in step 4.
**Learnings**:
- **Bench the real thing.** The toy repo understated everything by ~2 orders
of magnitude; "works on the toy" hid a total brick. The real clone is now
the only valid bench target.
- **Refute before building.** Four theories died to measurement (card speed,
free-cluster scan, strict creation, repeated windows). The one build made on
an unverified cause — the `esp_map.c` window cache — was the one wasted
build: 0 hits in 4 instrumented runs, removed entirely.
- **FAT breaks POSIX assumptions.** 2 s mtime granularity, no inode,
cluster-chain seeks: any POSIX-shaped library (libgit2's racy-clean check,
the mmap emulation) needs its cost model audited on FAT before trusting it.
- **Prefer mechanisms flat in N.** The splice cannot regress as the notes tree
grows — the fix that prevents the problem from ever coming back, which is
the kind kaizen prefers.
**Standard to update**:
- Bench and verify against the **real repo clone**, never the toy (the toy is
~2 orders of magnitude too kind) — recorded in the tradeoff doc's
[how to bench](../tradeoff-curves/sync-commit-staging.md#how-to-bench--flash).
- Any new libgit2 entry point **must set the mwindow options before its first
`Repository::open`** — the 32-bit defaults OOM the 8 MB heap (done in
`git_sync` and `git_bench`; the rule is the standard).
- `CONFIG_FATFS_USE_FASTSEEK=y` + 256-word CLMT buffer stay pinned in
`sdkconfig.defaults`.
**Share with**:
- Upstream libgit2 — file the tlsf double-free report (mbedTLS stream error
path: `wrap`'s error path frees the caller's socket stream, `new()` frees it
again; v1.9.4).
- A write-up for the notes/blog — the four-round localization story (611 s →
~2.8 s commit on a microcontroller, four theories refuted on the way) stands
on its own.
**Next steps**
- Run the end-to-end on-device `:sync` verify and close the step-1
measurement (the stranded `8939168f` should push first).
- File the libgit2 upstream bug report.
- Instrument the residual ~360 ms/loose-write (suspect: FAT directory-op cost
in the freshen/refresh path) — one `sd_bench` + `p_mmap`-miss logging pass.
- Measure the ref/reflog leg of the shipping commit (the bench's
`commit(None)` writes no ref).
- Attack the next curve: the ~6.5 s network floor
([`../notes/sync-latency.md`](../notes/sync-latency.md)); the
images-off-card lever
([`../notes/git-sync-images-and-repo-size.md`](../notes/git-sync-images-and-repo-size.md))
composes with the splice.

228
docs/macroplan.md Normal file
View File

@@ -0,0 +1,228 @@
# Macroplan — version details
Frequent releases. Each version is a usable artifact, not a checkpoint.
This file holds the `macroplan` source block (below), a cross-version status
roll-up, and a one-line summary of each release linking to its dedicated page.
The user-facing requirements and engineering targets each release feeds into are
tracked in [`qfd.md`](qfd.md).
## Macro-plan
Macroplan source — paste into the macroplan app to render the week-by-week
view. `original` dates are the June 2026 baseline and never move; slips get
appended as `reestimates`, per-item actuals live in the Status block below.
```macroplan
title = "Typoena — macro plan"
[[feature]]
name = "v0.1 it writes, it pushes"
start = 2026-06-01
original = 2026-06-29
delivered = 2026-07-11
learning = "Shipped 12 days late. The long pole was hardware bring-up risk, not the editor: SD on a shared SPI bus (resolved by moving it to its own SPI3, ADR-012) and on-device git (gix killed, pivoted to libgit2 as an esp-idf CMake component, ADR-004). Splash landed as a vector wordmark, not the planned 1-bit bitmap — the asset-embed/blit path is deferred to v1.0."
[[feature]]
name = "v0.2 navigation"
start = 2026-06-29
original = 2026-07-20
delivered = 2026-07-11
learning = "Delivered 9 days early. Motions/modes, Ctrl-d/u, the UTF-8 buffer, and the absolute line-number gutter all landed 2026-07-11; the last gate, Spike 13's on-panel gutter refresh check, confirmed a single-line edit repaints only rows at/below it with no extra full refresh. Relative line numbering was dropped as an e-ink ghosting cost with no proportionate gain."
[[feature]]
name = "v0.2.5 international input"
start = 2026-07-20
original = 2026-08-03
delivered = 2026-07-11
learning = "Delivered 23 days early — ahead of its own start window. Dead-key accent composer in the keymap crate (US-International, à é ê ë ñ ç), editor buffer made UTF-8-correct, typed on the bench with no panic. The side-panel pending-accent marker was dropped by decision: at typing speed it is stale before the ~630 ms panel repaint, so it conveyed nothing. Bonus: physical Esc (HID 0x29) remapped to backtick/tilde so code fences + grave/tilde accents work on a 60% board without a Fn layer."
[[feature]]
name = "v0.3 editing"
start = 2026-08-03
original = 2026-08-24
delivered = 2026-07-11
learning = "Core complete 44 days early, host-tested and partially smoke-tested on the panel. Register + yank/paste (yy/p/P), snapshot undo/redo (u/Ctrl-r, bounded 100 groups in PSRAM), and keystroke-recorded `.` repeat all landed 2026-07-11; the d/c operator grammar + text objects were already done ahead of schedule. Firmware bumped to 0.3.0. On device dd/yy/Ctrl-r confirmed; the one bug found was a multi-line paste leaving its later lines below the fold (adjust_scroll only tracked the caret) — fixed with a reveal() that scrolls the block end into view."
[[feature]]
name = "v0.4 visual + ex"
start = 2026-08-24
original = 2026-09-07
delivered = 2026-07-11
learning = "Core complete 58 days early, host-tested. Visual (v) and VisualLine (V) selection with y/d/c landed 2026-07-11 (charwise vim-inclusive of the char under the caret; linewise spans whole lines and pastes like yy/dd), plus the recorded v/V→Visual reassignment: the read-only View mode moved to `gr` (go-read). Selection is drawn as reverse-video cells on the 1-bit panel with the caret punched back to normal video so the active end stands out; 18 new editor tests (83 total). The `:` command mechanism and :fmt were already done; `:e <path>` was deliberately deferred to v0.5 where its multi-file/buffer-lifecycle machinery (Spikes 11/14) lives, rather than half-building file-open here. Firmware bumped to 0.4.0. On-device smoke-test of Visual still pending (pure editor-core, low risk)."
[[feature]]
name = "v0.5 palette + multi-file"
start = 2026-09-07
original = 2026-09-28
delivered = 2026-07-12
learning = "Delivered 2026-07-12, well ahead of the 2026-09-28 baseline, and fully on-device confirmed. Four slices: the drained Effect queue + parked-buffer LRU foundation; the Cmd-P fuzzy file palette (Spike 11 — no ghosting on the transient panel); :enew + file delete (Spike 14 caught that add_all alone doesn't stage a deletion on this libgit2 — fixed with update_all, i.e. git add -A); and the git-tracked .typoena.toml prefs with a stay-open palette `>` command mode + :settings. Both directions of the prefs loop are proven on hardware — boot-read (byte-exact parse) and on-device palette edit (a device publish flipped line_numbers on origin). Three decide-before-build calls: the idle auto-save is unformatted, and both the per-device auto_sync override and the `> auto sync` command are deferred to v0.7 where auto_sync gains behaviour. Amended 2026-07-12: a light/dark `theme` key and a set-ahead `> auto sync` preset command (2m/5m/10m/15m/30m) were added on top — the palette generalised so Enter rotates any pref to its next value (a bool is the two-option case); auto_sync is still read by nothing until v0.7. Descoped from v0.5 (not the four slices): explicit buffer close, the grey-Publish-in-Local panel cue, and the multi-file publish count."
[[feature]]
name = "v0.6 markdown"
start = 2026-09-28
original = 2026-10-12
delivered = 2026-07-12
learning = "Core complete 2026-07-12, ~92 days ahead of the 2026-10-12 baseline, host-tested (187 editor tests). The snippet feature was reshaped 2026-07-08→07-12 from a hard-coded table into a git-synced, Zed-compatible .typoena.snippets.json library: a forward-only tab-stop session ($1..$n/$0, ${n:label} stripped to $n) driven by two surfaces — inline Tab-expansion in Insert and a $ palette launcher — plus a quiet pause hint in the side panel. The Cmd-P palette generalised into a verb split: bare = files, > = a real command registry (toggles stay open, one-shots format/publish close, the parameterised `new file` two-step), $ = snippets — retiring :e. Firmware bumped 0.5.0→0.6.0; the boot-read of the library was confirmed to build for xtensa (serde_json, the one new dep — cargo check passes). `just init` now seeds a curated 17-snippet catalog (three opt-in groups). On-device smoke-test still pending (pure editor-core + a mirror of the proven prefs boot-read, low risk). Known caveat: two symbols the catalog inserts (arrow →, neq ≠) are outside ISO-8859-15, so they store/sync correctly but need a display-layer glyph overlay (in flight) to draw on the panel; the other 15 render on the stock font."
[[feature]]
name = "v0.7 search + git"
start = 2026-10-12
original = 2026-11-02
[[feature]]
name = "v0.8 battery + sleep"
start = 2026-11-02
original = 2026-11-30
[[feature]]
name = "v0.9 robustness"
start = 2026-11-30
original = 2026-12-28
[[feature]]
name = "v1.0 polish"
start = 2026-12-28
original = 2027-01-25
[[milestone]]
name = "MVP ships"
week = 2026-06-29
requires = ["v0.1 it writes, it pushes"]
```
## Status — synced 2026-07-12
The editor **core** has been built 23 versions ahead of the device
**releases**, and is now **extracted into a host-testable `editor` crate** (plus
a `display` crate for the panel framebuffer) so `cargo test` exercises it off the
xtensa target. **v0.1 shipped 2026-07-11** (late against the 2026-06-29
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 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). **v0.2 navigation is COMPLETE 2026-07-11** — Spike 13's on-panel gutter
refresh check passed (single-line edit repaints only rows at/below it, no extra
full refresh), closing the last gate. **v0.2.5 international input** is
hardware-verified (2026-07-11), and **v0.3 editing is complete in core** the same
day (register + yank/paste, snapshot undo/redo, `.` repeat — host-tested, and
partially smoke-tested on the panel: `dd`/`yy`/`Ctrl-r` good, a multi-line-paste
scroll bug found + fixed). **v0.4 visual + ex is complete in core** the same day
too — charwise/linewise **Visual** selection (`v`/`V` with `y`/`d`/`c`), the
read-only View mode moved to `gr`, and the selection drawn as reverse-video on
the panel; `:e` was deferred to v0.5. Host-tested (83 editor tests); on-device
smoke-test pending. The firmware crate is bumped to **0.4.0**. Most of v0.6
Markdown also already runs. Version numbers track shippable device releases, not
raw core progress — the 0.4.0 bump reflects the v0.4 feature set being met.
**v0.5 palette + multi-file is DELIVERED 2026-07-12** (firmware **0.5.0**), fully
on-device confirmed: the Cmd-P fuzzy palette, `:e`/`:enew`/delete across the
`/sd/repo` + `/sd/local` scopes, and the git-tracked `.typoena.toml` prefs
(boot-read plus a stay-open palette `>` command mode + `:settings` that edits them
live and syncs the change). Descoped to later: explicit buffer close, the
grey-Publish-in-Local panel cue, and the multi-file publish count.
**v0.6 Markdown is COMPLETE in core 2026-07-12** (firmware **0.6.0**), host-tested
(187 editor tests), on-device smoke-test pending. The render affordances (heading
bold, list continuation, soft-wrap) were done early; the headline is the
**snippet engine** — a forward-only tab-stop session reached both inline (type a
prefix + Tab in Insert) and from a **`$` palette** launcher, fed by a git-synced,
Zed-compatible `.typoena.snippets.json` read at boot (serde_json, confirmed to
build for xtensa). The `Cmd-P` palette **generalised** into a verb split — bare =
files, `>` = a command registry (toggles stay open, `format`/`publish` one-shots
close, a two-step `new file`), `$` = snippets — which **retired `:e`**. `just init`
seeds a curated 17-snippet catalog (Symbols · Structure · Prose, opt-in). One
caveat: `→`/`≠` sit outside ISO-8859-15 and need a display-layer glyph overlay
(in flight) to render on the panel; the other 15 draw on the stock font.
Marks: `[x]` done in core · `[~]` partially done · `[ ]` not started. An
inline `(✓)` marks the done half of a split item.
Each version below links to its dedicated page, which carries the full scope
checklist and status.
---
## v0.1 — MVP: "it writes, it pushes" — [x]
The minimum thing that justifies the hardware existing — boot, type one file,
`:w` to save, `:sync` to publish to GitHub. **SHIPPED 2026-07-11** (late vs the
2026-06-29 baseline); cold boot verified at 4258 ms.
**Design:** [product](v0.1-mvp-product.md) · [technical](v0.1-mvp-technical.md).
## v0.2 — Vim navigation — [x]
Modal Normal/Insert/View, `h j k l`/`w b e`/`0 $`/`gg G` motions, `Ctrl-d/u`
half-page scroll, the UTF-8-correct buffer, and the absolute line-number gutter.
**COMPLETE 2026-07-11.** Detail: [v0.2-navigation.md](v0.2-navigation.md).
## v0.2.5 — International input — [x]
US-International dead-key accent composition (à é ê ë ñ ç) in the `keymap`
crate, plus the Esc→backtick/tilde remap for a 60% board.
**Hardware-verified 2026-07-11.**
Detail: [v0.2.5-international-input.md](v0.2.5-international-input.md).
## v0.3 — Vim editing — [x]
Register + yank/paste (`yy`/`p`/`P`), snapshot undo/redo (`u`/`Ctrl-r`), `.`
repeat, and the `d`/`c` operator grammar + text objects.
**COMPLETE in core 2026-07-11**, partially smoke-tested on the panel.
Detail: [v0.3-editing.md](v0.3-editing.md).
## v0.4 — Visual mode + ex commands — [x]
Charwise `v` / linewise `V` selection with `y`/`d`/`c`, the `:` command line
(`:w`/`:fmt`/`:sync`/`:gl`), and View mode moved to `gr`.
**COMPLETE in core 2026-07-11**, on-device smoke-test pending.
Detail: [v0.4-visual-and-ex.md](v0.4-visual-and-ex.md).
## v0.5 — File palette + multi-file — [x]
The `Cmd-P` fuzzy file palette, `:e`/`:enew`/delete across `/sd/repo` +
`/sd/local`, the parked-buffer LRU, and the git-tracked `.typoena.toml` prefs
with a palette `>` command mode + `:settings`.
**DELIVERED 2026-07-12** (firmware 0.5.0), fully on-device confirmed.
Detail: [v0.5-palette-and-multi-file.md](v0.5-palette-and-multi-file.md).
## v0.6 — Markdown affordances — [x]
Heading bolding, list continuation, and soft-wrap, plus the trigger-driven
snippet engine (net-new scope, added 2026-07-08): a tab-stop session reached
inline (prefix + Tab) and from the `$` palette, fed by a git-synced,
Zed-compatible `.typoena.snippets.json`; the `Cmd-P` palette generalised into a
`files`/`>` commands/`$` snippets split (retiring `:e`); and a `just init`
catalog. **COMPLETE in core 2026-07-12** (firmware 0.6.0), on-device smoke-test
pending. Detail: [v0.6-markdown.md](v0.6-markdown.md).
## v0.7 — Search + better git — [~]
`/` forward search (`n`/`N`) and `:gl` pull (fetch + fast-forward only). The
`:gl` editor command landed 2026-07-11; the on-device fetch and search are still
to build. Detail: [v0.7-search-and-git.md](v0.7-search-and-git.md).
## v0.8 — Power: battery + sleep — [ ]
Bench current-draw measurement, 18650 + charge board, light/deep sleep, and a
battery indicator. **Not started.**
Detail: [v0.8-battery-and-sleep.md](v0.8-battery-and-sleep.md).
## v0.9 — Robustness — [ ]
Crash-safe writes, interrupted-push recovery, SD removal handling, Wi-Fi
reconnect, and on-device provisioning (the first release usable by a non-author).
**Not started.** Detail: [v0.9-robustness.md](v0.9-robustness.md).
## v1.0 — Polish — [ ]
≤ 3 s boot, runtime-switchable fonts, enclosure files, and a user guide
(light/dark theme landed early, in v0.5). **Not started.** Detail:
[v1.0-polish.md](v1.0-polish.md).
## v1.x — Stretch / nice-to-have
Post-1.0 ideas, not committed to any release (10.3" panel, multiple remotes,
writing stats, BLE-HID fallback). Detail: [v1.x-stretch.md](v1.x-stretch.md).

14
docs/notes/README.md Normal file
View File

@@ -0,0 +1,14 @@
# Notes
> Longer-form essays on the thinking behind specific Typoena choices — the
> arguments too big for an ADR and too durable for a commit message.
>
> Docs index: [`../README.md`](../README.md). Project overview:
> [`../../README.md`](../../README.md).
| Note | What it argues |
| --- | --- |
| [`ctrl-g-perceived-latency.md`](ctrl-g-perceived-latency.md) | Durability before delivery — surfacing "commit landed" at ~0.2 s makes the 510 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. |
| [`sync-latency.md`](sync-latency.md) | Where the ~16 s cold `:sync` goes — Wi-Fi + SNTP + one TLS push; why optimistic-retry cut a whole handshake, and why the rest is close to the protocol floor. |

View 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 [`../macroplan.md`](../macroplan.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.

View File

@@ -17,7 +17,7 @@ Sitting with that, here's the concern I couldn't shake:
The reframing question: **what is the user actually waiting for?** For `Ctrl-S`, the moment that matters is "my work is saved" — and the SD card completes the write in 50200 ms. Save = safe. Same instant.
For `Ctrl-G`, the equivalent moment isn't "push complete." It's "commit landed locally" — which happens at ~0.2 seconds, well before the push even starts. From that moment on, your work is preserved across power loss, SD removal, the apocalypse — everything except remote delivery. The remaining 510 seconds is _transport of an already-safe thing_.
For `Ctrl-G`, the equivalent moment isn't "push complete." It's "commit landed locally" — which happens at ~0.2 seconds, well before the push even starts. From that moment on, your work is preserved across power loss and SD removal — everything except remote delivery. The remaining 510 seconds is _transport of an already-safe thing_.
Surface that moment in the side panel at ~0.2 seconds (`✓ committed abc1234 · pushing…`) and the perceived latency of `Ctrl-G` collapses from 10 seconds to roughly 200 milliseconds. The gap with `Ctrl-S` disappears.

View File

@@ -0,0 +1,148 @@
# Why we don't shrink the notes repo
> The 150 MB of images in my notes repo isn't bloat to offload — it's the
> image CDN for the web app that reads the same repo. Shrinking it for
> Typoena's sake breaks remanso. So we don't.
The question that started this: **Typoena** keeps a persistent clone of my
notes repo (`github.com/jcalixte/notes`) on its SD card and fast-forwards it
on every `Ctrl-G`. The most likely first failure is a cold clone that's too
big — so the instinct was "clone the least data possible; ignore
`node_modules`; maybe strip the media." That instinct is mostly wrong, for
reasons worth writing down before anyone force-pushes a rewritten history.
## The measured reality
| Metric (target repo, measured 2026-07-07) | Value |
| --- | --- |
| Working tree | 3.9 GB (dominated by `node_modules`) |
| `.git` (what a clone actually transfers) | **566 MB** |
| Commits / objects | 13,852 / 63,252 |
| Depth-1 snapshot (HEAD tree + blobs) | **154.7 MB** |
| — of which markdown (the notes) | **1.4 MB** |
| — of which media (png/jpg/pdf/gif/bmp) | **153 MB** |
| Media across *all* history (dedup) | 566 MB (715 PNG objects = 463 MB) |
Two early assumptions corrected:
- **`node_modules` is a non-issue.** It's gitignored and was never committed,
so a clone never touches it. There's nothing to filter. The 3.9 GB working
tree is a red herring; a clone transfers the 566 MB `.git`.
- **The risk isn't stack overflow, it's transfer size.** 566 MB over Wi-Fi +
mbedTLS to an SPI SD card, with no resume, is 30+ minutes and one dropout
away from failure. Stack/memory pressure is a symptom of asking the device
to cold-clone half a gigabyte.
## Why shrinking is off the table
The clean fix for "device only needs 1.4 MB of text" would be a blobless
partial clone (`--filter=blob:none`) — **libgit2 does not support it**. The
fallbacks are LFS migration, a filter-repo history purge, or `git rm` of media
at HEAD. All three remove the image *blobs* from what a git client sees.
That breaks **remanso**, the web app that reads the same repo. remanso is a
frontend with no server-side git; it displays a note image by reading the
image straight out of git as a blob and inlining it as a data URI
(`useImages.hook.ts` + `repo.ts`):
1. Markdown `<img src="relative/path.png">`
2. resolve path → find the file's `sha` in the GitHub tree
3. `GET /repos/{owner}/{repo}/git/blobs/{sha}`**base64 of the git blob**
4. `img.src = "data:image/jpeg;base64," + thatContent`
The image *is* the git blob. GitHub's Git Blobs / Trees / Contents APIs do
**not** resolve LFS (only `media.githubusercontent.com` does, which remanso
doesn't use). So after an LFS migration those endpoints return the ~130-byte
**pointer text**, remanso wraps it in `data:image/jpeg;base64,…`, and every
image renders broken. Uploads break too: remanso writes images via
`PUT /contents`, which ignores `.gitattributes`/LFS and commits a plain blob.
And it's not specific to LFS — `git rm` at HEAD or a filter-repo purge remove
the blobs from the tree, so remanso can't find them either. **Any approach
that takes the images out of the git repo breaks remanso**, because those
150 MB are load-bearing infrastructure for the web app, not offloadable bloat.
| Consumer | How it reads images | If we shrink the repo |
| --- | --- | --- |
| Typoena (libgit2) | doesn't render images; needs valid git objects | fine — would get tiny pointers |
| remanso (Blobs API → data URI) | reads image bytes straight out of git | **broken** — pointer/missing bytes render as a dead image; uploads bypass LFS |
## Decision
**Leave the notes repo untouched. Pre-seed the device SD card with a full
`git clone` from a computer.**
Repo size is only a *device* constraint when the *device* does the cold clone.
A laptop clones 566 MB in ~2 minutes onto the SD via a card reader; the SD has
GB to spare. The device then only ever takes the `open` + incremental
fetch/commit/push path (`open_or_clone` already splits on this). A *full*
pre-seed (not depth-1) also sidesteps the shallow-push sharp edge. remanso
keeps working, the device gets everything, and repo size stops being anyone's
problem.
Implemented in firmware/justfile as three recipes, each ejecting the card when
done so it goes straight into Typoena:
- `just init <repo-path>` — full prep of a fresh card: copies a full clone to
the card's `repo/`, excluding everything the repo's `.gitignore` ignores (so
`node_modules` and local secrets like `firmware/.env` never land on the card),
then writes `/sd/typoena.conf` — Wi-Fi creds + PAT + git identity — from the
TW_* vars already in `firmware/.env` (no re-typing, no prompts).
- `just load <repo-path>` — the repo copy on its own (refresh after big upstream
changes).
- `just provision` — the config on its own (rotate the PAT / switch networks
without re-copying the repo).
The firmware still reads those values via `env!()` today; reading
`/sd/typoena.conf` at boot is a TODO that rides with the SD wiring into
`main.rs`.
## What happens on an ongoing pull
In the single-writer model the device usually doesn't pull at all:
`fetch_and_integrate` runs only from the rejected-push arm of
`push_with_retry`, and a sole writer always fast-forwards. "Images to pull"
only arises when a **second writer** (remanso or the desktop) pushed them.
When it does, today the device fetches (downloading the image blobs) and then
hits the divergence bail (`increment B, deferred`) — no data loss, but no
integration until the merge path exists.
Once integration lands, the costs of carrying media the device never renders:
1. **Bandwidth for unusable bytes.** No partial fetch in libgit2, so a fetch
pulls the full new image blobs. 20 MB of pasted screenshots = a 20 MB fetch
before a one-line note can publish.
2. **~2× SD storage.** Each image lives in `.git` *and* in the working-tree
checkout that `checkout_head(force)` writes.
3. **Memory — the real edge.** libgit2 tends to materialize a whole blob in
RAM for checkout. History already has a 38 MB mp3 and 16 MB PNGs. Against
**8 MB PSRAM**, a single large image arriving in a pull is a genuine OOM
risk on checkout. Verify on hardware (push a 20 MB image from remanso, watch
`min_free_heap`) rather than trusting a read of libgit2 internals.
**The trap:** `publish()` stages with `add_all(["*"])`. A sparse checkout that
omitted images would make `add_all` see them as deleted → the device would
commit "delete all images" and push it → remanso loses every image. So with
the current staging, a full checkout is mandatory — which is what feeds costs
2 and 3.
## Fix for increment C (git module in the editor)
Change two things together:
- **Stage specific paths, not `add_all(["*"])`.** The editor knows which note
file it wrote — commit that path explicitly.
- Then a **sparse checkout that excludes media** is safe: the device never
writes images to its working tree, killing the checkout OOM and the 2×
storage. The bytes still transit `.git` on fetch (no partial clone), but
writing objects to disk is far lower memory risk than a checkout that
materializes them in RAM.
## Related
- `firmware/src/bin/git_sync.rs` — the persistent-clone publish cycle analysed
here (milestone #2A).
- ADR-010 — "writing tool, not sync engine": the principle this decision
serves.
- remanso: `src/hooks/useImages.hook.ts`, `src/modules/repo/services/repo.ts`
(`queryFileContent`) — the image-as-blob pipeline.

View File

@@ -0,0 +1,90 @@
# Sync latency — where the ~16 s cold `:sync` goes
> **Measured 2026-07-11** on hardware, via the `:sync timing —` log line in
> [`firmware::git_sync`](../../firmware/src/git_sync.rs) (`publish_cycle`). A
> **cold** `:sync` (first of a power cycle) is **~16.0 s** power-on of Wi-Fi →
> `push done`; a **warm** one skips the one-time setup and is just the ~10 s
> publish. This note breaks the number down and records why most of it is a
> floor, not a bug.
>
> Notes index: [`README.md`](README.md). Docs index:
> [`../README.md`](../README.md). Why the raw number matters less than it looks:
> [`ctrl-g-perceived-latency.md`](ctrl-g-perceived-latency.md). Energy/keep-Wi-Fi-up
> tradeoff: [`../tradeoff-curves/wifi-auto-sync.md`](../tradeoff-curves/wifi-auto-sync.md).
> Sibling timing note: [`boot-time-budget.md`](boot-time-budget.md).
## The waterfall (cold sync)
From the serial log, first `:sync` after a cold boot
(`… wifi 3654ms, clock 2108ms, tls 304ms, publish(commit+push) 9944ms, total 16012ms`):
| Phase | ~ms | One-time? | Lever |
| --- | ---: | --- | --- |
| Wi-Fi assoc + DHCP | ~3650 | yes (per power cycle) | radio off until first `:sync`; association floor |
| SNTP first sync | ~2100 | yes | varies with NTP RTT (4.2 s the prior run); needed before TLS + commit time |
| TLS trust store install | ~300 | yes | write ~6 KB CA bundle to SD + set libgit2 option |
| **publish** = stage+commit + push | **~9900** | **every sync** | see below |
| **Total** | **~16000** | | |
The three one-time phases (~6.1 s) only pay on the *first* sync of a power cycle —
Wi-Fi, the clock, and the trust store are set up once and reused, so a **warm sync
is just the ~10 s publish**. Publish splits as:
| Sub-phase | ~ms | Note |
| --- | ---: | --- |
| stage + commit | ~3150 | `add_all(["*"])` walking the SD/FAT working tree, then commit to FAT |
| push: TLS handshake | ~2400 | one mbedTLS handshake to github.com |
| push: pack negotiate + upload | ~4400 | tiny delta — cost is negotiation/round-trips, not payload |
## The win: one TLS handshake, not two
The first hardware run (2026-07-11) measured **23.7 s** because it did a
**pre-commit fetch** — a second full TLS handshake plus a ref exchange — on every
sync, to absorb a foreign push before committing. That's ~3 s wasted on a normal
sync (remote unchanged), and it did ~6 s of real work the one time it absorbed a
maintenance commit.
The optimistic-retry rewrite (commit `3386969`) drops it: **push onto the current
tip first**; only if the remote *rejects* the push non-fast-forward do we fetch,
reconcile, and retry. The happy path — what runs ~99 % of the time — is now a
**single** handshake. That took the true normal-cold baseline from ~19 s to
**16.0 s** (and the inflated 23.7 s figure will never recur, since it was the
one-time reconcile).
## Foreign pushes: reconcile-and-replay, last-writer-wins
On a rejected push, `reconcile_onto_origin` fetches origin and does a **mixed**
reset onto it — moving the branch ref + index but leaving the working tree, so the
just-saved note survives — then `stage_and_commit` replays the note on the new tip
and retries. For this **single-writer appliance** that 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 would be dropped by the replay's `add --all`.
Both need a real merge (increment B) and don't arise from the device's own use.
This path is **hardware-verified for the happy case** (`9b635c42` fast-forwarded
clean); the reconcile branch itself is compile-verified but not yet exercised on
device.
## Can cold sync go lower?
The big rocks are physics or protocol, not slack:
- **Wi-Fi assoc ~3.6 s** and **SNTP ~24 s** are one-time per power cycle and
mostly out of our hands (association floor, NTP RTT). Keeping Wi-Fi up between
syncs trades battery for latency — see
[`../tradeoff-curves/wifi-auto-sync.md`](../tradeoff-curves/wifi-auto-sync.md).
- **TLS handshake ~2.4 s** and **push negotiate/upload ~4.4 s** are inherent to
libgit2-over-mbedTLS on this part; the payload is tiny, so there's little to
shave.
- **stage + commit ~3.1 s** is the one soft spot: staging over the editor's dirty
set (`add_path`) instead of `add_all(["*"])` would skip the SD/FAT tree walk
(likely → sub-second) *without* losing multi-file — the dirty set is the file
list. Whether the walk actually dominates the ~4 s commit is now being measured
by the `commit split —` log line; the cost model and the rule it decides live in
[`../tradeoff-curves/sync-commit-staging.md`](../tradeoff-curves/sync-commit-staging.md).
**Conclusion:** ~16 s cold / ~10 s warm is close to the floor for "commit to FAT +
one TLS push over Wi-Fi with a fresh clock." It reads as slow only if you wait on
it — and by design you don't: `:sync` is a deliberate action with a snackbar, and
[`ctrl-g-perceived-latency.md`](ctrl-g-perceived-latency.md) argues the perceived
cost is set by *when durability is surfaced*, not by wall-clock. Recorded here so
the number is scoped against the protocol, not treated as a regression.

View File

@@ -1,8 +1,10 @@
# Spike 3 (SD) — blocked on a card that rejects CMD59 (SPI-mode CRC)
> Date: 2026-07-05 · Build at time of failure: `07-05 16:07Z @f77f669-dirty`
> Status: **paused, not failed** — bench card is incompatible; awaiting a
> compliant microSD. Wiring and firmware are proven good.
> Status: **RESOLVED 2026-07-11** — a genuine 32 GB SDHC card mounts, negotiates
> 10 MHz, and round-trips cleanly on the shared SPI2 bus. Wiring and firmware
> were always good; the 133 GB SDXC card's CMD59 rejection was the sole fault.
> See [Resolution](#resolution-2026-07-11) below.
>
> Context: Spike 3 in
> [`../v0.1-mvp-technical.md`](../v0.1-mvp-technical.md#hardware-bring-up-order),
@@ -10,6 +12,47 @@
> firmware notes [`../../firmware/README.md`](../../firmware/README.md).
> Spike program: [`../../firmware/src/bin/sd_fat.rs`](../../firmware/src/bin/sd_fat.rs).
## Resolution (2026-07-11)
Re-ran `sd_fat` with a genuine 32 GB SDHC card (build `07-11 08:53Z
@195311a-dirty`). Everything the paused write predicted held:
- `sdspi_host: data CRC set=1`**CMD59 now succeeds**. Init runs clean through
card identification (`sdmmc_card_init: card type is SD`).
- `card mounted at /sd — max 10000 kHz, negotiated 10000 kHz`.
- `FAT usage — 29806 MiB total, 29802 MiB free` (~29.8 GiB of the 32 GB card).
- `round-trip OK — 92 bytes: create …tmp → fsync → rename → read back identical`.
Wiring, pull-ups, bus sharing, and the hand-rolled FFI mount path all confirmed
correct — the only variable that changed was the card, exactly as diagnosed. The
`cmd=52` / `cmd=5` "command not supported" lines still print and are still
normal (SDIO probes a memory card ignores).
### New finding: FatFS `f_rename` does not overwrite (atomic-save is not POSIX)
The first run created `/sd/spike3.md`; the **second** run failed at `rename tmp
-> final` with `File exists (os error 17)`. Root cause is a FatFS semantics
difference, not a fault: **`f_rename` returns `FR_EXIST` when the destination
already exists** — unlike POSIX `rename(2)`, which atomically replaces it. So the
textbook write-`*.tmp` → rename-over-target idiom from ADR-007 does **not** work
as-is on FAT; the destination must be unlinked first.
That unlink opens a crash window: between `f_unlink(target)` and `f_rename`, the
target is momentarily gone while `*.tmp` holds the complete, fsync'd new content.
The consequence for the real persistence module is a **boot-recovery** rule for a
lingering `*.tmp`. Implementing it (`firmware::persistence::Storage::recover`)
surfaced that "promote the tmp" is too blunt: a crash *during* the tmp write (a
second window, before the fsync) leaves a **partial** tmp, so recovery must look
at whether the target survived —
- **tmp + target both present** → crash point ambiguous, tmp may be partial →
keep the committed target, discard the tmp (the "previous version" guarantee);
- **tmp only, target absent** → target was already unlinked → tmp is the newest
complete copy → promote it.
The spike now does unlink-then-rename and tolerates a missing target, so re-runs
are idempotent; recorded in
[ADR-007](../adr.md#adr-007-storage-split--fat-on-sd-for-working-copy-littlefs-on-flash-for-config).
## Summary
Built the Spike 3 bench program (`sd_fat`): bring up the SD card over the EPD's
@@ -51,7 +94,7 @@ runs **SD-only** (see "EPD bus lock" below).
against marginal signal integrity.
3. Verified the card on the Mac: `Windows_FAT_32 TYPOENA`, 33.6 GB partition on
a 133 GB card, **healthy and readable**. Rules out a dead card / wrong FS.
4. Read the esp-idf SD init source: the CMD8 handler *silently tolerates* the
4. Read the esp-idf SD init source: the CMD8 handler *tolerates* the
same `ESP_ERR_NOT_SUPPORTED` (treats it as "not a v2 card"), while CMD59's
does not. So the failure might be a persistent bad response, not a CMD59
one-off — needed the raw R1 bytes to tell.
@@ -143,16 +186,23 @@ it is explicitly **not recommended** and not applied.
## Follow-ups
- [ ] Re-run with a genuine ≤32 GB card → expect clean mount + round-trip.
- [ ] Strip the debug logging once green: remove `CONFIG_LOG_MAXIMUM_LEVEL_DEBUG`
from `sdkconfig.defaults` and the `esp_log_level_set` block in `sd_fat.rs`.
- [ ] Write up Spike 3 as verified in
[`../../firmware/README.md`](../../firmware/README.md) (wiring, LFN
requirement, card-compatibility note), matching the other spikes.
- [ ] Record a **recommended-SD-card note** for v0.1 (genuine, ≤32 GB;
large/counterfeit SDXC may fail CMD59) — product doc / ADR-007.
- [ ] Settle the **shared-bus arbitration** decision (EPD lock vs. SPI3 for SD).
- [ ] Enable PSRAM, then build Spike 7 (gitoxide push) for the push leg.
- [x] Re-run with a genuine ≤32 GB card → clean mount + round-trip (2026-07-11).
- [x] Strip the debug logging once green: removed `CONFIG_LOG_MAXIMUM_LEVEL_DEBUG`
from `sdkconfig.defaults` and the `esp_log_level_set` block in `sd_fat.rs`
(2026-07-11).
- [x] Write up Spike 3 as verified in
[`../../firmware/README.md`](../../firmware/README.md) (2026-07-11).
- [x] Record a **recommended-SD-card note** for v0.1 (genuine, ≤32 GB;
large/counterfeit SDXC may fail CMD59) — in
[ADR-007](../adr.md#adr-007-storage-split--fat-on-sd-for-working-copy-littlefs-on-flash-for-config)
(2026-07-11).
- [x] Enable PSRAM (done, `CONFIG_SPIRAM`) and build Spike 7 (git push) — both
complete; see [Spike 7 postmortem](2026-07-05-spike7-gix-https-push.md).
- [x] Settle the **shared-bus arbitration** decision → **SD on its own SPI3**
(ADR-012, 2026-07-11); spike rewired to SPI3 (SCK 14 / MOSI 15) and
**re-verified on hardware 2026-07-11** — same mount + round-trip result.
- [ ] **Still open:** implement the FatFS atomic-save (unlink-then-rename +
`*.tmp` boot-recovery) in the real `persistence` module.
## Artifacts (this session)

View File

@@ -10,7 +10,7 @@
> [`../v0.1-mvp-technical.md`](../v0.1-mvp-technical.md#hardware-bring-up-order),
> git impl [ADR-004](../adr.md#adr-004-git-implementation--gitoxide-gix), auth
> [ADR-005](../adr.md#adr-005-auth--https--github-personal-access-token).
> Spike program: [`../../spikes/spike7-git-push/`](../../spikes/spike7-git-push/).
> Spike program: [`../../spikes/spike7-git-push/`](../../spikes/spike7-git-push/README.md).
## Summary
@@ -34,7 +34,7 @@ is met at the library level.
**Decision:** take the fallback the risk table already names — `libgit2` via the
[`git2`](https://docs.rs/git2) crate — keeping ADR-005 (HTTPS + PAT) intact.
Proved the full `add → commit → push` sequence on desktop
([`spikes/spike7-git-push`](../../spikes/spike7-git-push/)).
([`spikes/spike7-git-push`](../../spikes/spike7-git-push/README.md)).
## Why not the alternatives
@@ -80,7 +80,7 @@ Implementation notes that carry into the real module:
## What it does *not* prove — the next gate
The risk moved **with** the kill-switch, and arguably got harder. ADR-004 chose
The risk moved **with** the kill-switch and got harder. ADR-004 chose
gix *specifically to avoid* libgit2's C cross-compile to xtensa; falling back to
libgit2 re-introduces exactly that. The open question is now:
@@ -207,16 +207,30 @@ up.
HTTPS (needs a non-fast-forward against a real remote to trigger it).
- [x] On-device `init → commit → push` over mbedTLS HTTPS — **DONE +
hardware-verified 2026-07-06** (see "On-device push COMPLETE").
- [ ] Revise the `git` module section of the technical doc (it still describes
gix crates/transport) now the device path is confirmed.
- [x] Revise the `git` module section of the technical doc **DONE 2026-07-07**
(commit `2f2f122`): gix → libgit2/git2, transport settled, 96 KB stack,
persistent-clone flow.
- [x] Real cert trust-store (drop the `certificate_check` bypass) — **DONE +
hardware-verified 2026-07-06** (commit `2519ed8`; see "Shortcuts — status").
- [x] Settle the product sync transport — **DECIDED 2026-07-06: HTTPS + PAT**
(on-device libgit2 is HTTPS-only; no libssh2 port).
- [ ] Retire the last shortcut: no PAT-in-flash (ADR-005) — source the token at
provisioning / from secure storage instead of `env!()` into the image.
- [ ] Fold the push into the editor's `git` module (persistent clone +
fast-forward, not a fresh per-boot branch) over the HTTPS+PAT remote.
- [~] Retire the last shortcut: no PAT-in-flash **decision documented, deferred**
as [ADR-011](../adr.md#adr-011-credential-provisioning--how-the-pat-reaches-the-device-and-is-protected-at-rest)
(open): on-device paste → eFuse-encrypted NVS + a per-device fine-grained PAT.
Gates the first non-dev distribution; nothing to implement until then.
- [~] Fold the push into the editor's `git` module (persistent clone +
fast-forward) over HTTPS+PAT — **increment A DONE + hardware-verified
2026-07-07** (`git_sync.rs`): `clone` + persistent `open` + fast-forward push
proven on device (`afa61de`), plus **recursive delete on FATFS solved**
(`8dece73`): the EACCES on wiping a repo dir was `std::fs::remove_dir_all`
(uses `openat`/`unlinkat`/`fdopendir`, unimplemented in esp-idf's path-based
FATFS VFS), NOT read-only — a diagnostic walk showed every entry writable
(`ro=false`). Fix = a path-based `remove_tree`; the `p_open`/`p_creat` shim
(`39e1155`) stays to keep objects writable. Remaining: **B** = divergence/
merge path (merge commit on FATFS; low value for a single writer); **C** =
lift the logic into a reusable `git` module wired to the editor's `Ctrl-G`.
Storage caveat: the real notes repo is 3.9 GB / 562 MiB pack — needs
shallow+sparse or a dedicated small repo (ADR-007), can't clone whole.
- [x] Move git to a dedicated large-stack task so the shared main-task stack (and
the editor build) can drop back — **DONE + hardware-verified 2026-07-06**.
`git_publish` now runs on its own `std::thread` (`GIT_STACK = 96 KB` via

View File

@@ -0,0 +1,164 @@
# Editor freeze — SPI-DMA out-of-memory during a background `:sync`
> Date: 2026-07-11 · Build at time of failure: `07-11 18:02Z @229c259-dirty`
> Status: **Safety net shipped** (paints are non-fatal, the appliance no longer
> bricks) — pending hardware re-test. **Root cause not yet eradicated**: a paint
> that lands during a sync still fails and drops the frame; the permanent fix (a
> persistent internal DMA scratch buffer) is specced below and tracked in the
> follow-ups.
>
> Context: editor loop [`../../firmware/src/main.rs`](../../firmware/src/main.rs),
> EPD driver [`../../firmware/src/epd.rs`](../../firmware/src/epd.rs), git
> transport [`../../firmware/src/git_sync.rs`](../../firmware/src/git_sync.rs).
> Display medium [ADR-003](../adr.md#adr-003-display-medium--e-ink-gdey0579t93-panel);
> concurrency model (dedicated git thread) [ADR-006](../adr.md).
## Summary
First-ever field freeze. After an hour of clean editing, the user ran `:sync`;
the commit and push **succeeded** (`push accepted by remote`, commit
`48a2c0a8`), but partway through the push the panel stopped updating. Keystrokes
kept being logged, so the device wasn't hung — but nothing repainted again.
The editor task had died. A screen refresh that happened to run **while Wi-Fi +
TLS were up for the push** failed to allocate an internal DMA buffer
(`ESP_ERR_NO_MEM`), and that error propagated through a `?` straight out of
`main()`. ESP-IDF's `main_task` returned from `app_main()`; the editor loop lived
on that task, so it stopped. The USB-keyboard and git threads are **separate
FreeRTOS tasks** (ADR-006) and kept running — hence keys still logged while the
panel was frozen. A zombie, not a crash.
## Symptom
```
I (305749) firmware::git_sync: verifying github.com TLS chain against embedded GitHub CA bundle
I (305979) firmware::usb_kbd: key: HalfPageUp ← scroll arrives → editor loop paints
E (306259) spi_master: setup_dma_priv_buffer(1206): Failed to allocate priv TX buffer
Error: ESP_ERR_NO_MEM
I (306259) main_task: Returned from app_main() ← editor task exits
...
I (310119) firmware::git_sync: push accepted by remote ← git thread unaffected; commit landed
I (311069) firmware::usb_kbd: key: Escape ← keys still logged, no refresh ever again
I (312729) firmware::usb_kbd: key: HalfPageDown
```
## Root cause
The EPD is on SPI2 configured `Dma::Auto(4096)` (`main.rs`), and the driver hands
frame data to `spi.write()` as ordinary Rust `Vec<u8>` buffers (`epd.rs`
`write_frame_bank` / `data`). With PSRAM added to the heap allocator, those
`Vec`s can be allocated **from PSRAM**, which is **not DMA-capable**. When a
transfer buffer isn't DMA-capable, esp-idf's `spi_master` bounces it through a
temporary internal buffer it mallocs on the fly (`setup_dma_priv_buffer`, using
`MALLOC_CAP_DMA`**internal RAM only**).
For the whole session that bounce allocation succeeded, because internal RAM was
plentiful. The instant `:sync` brought up **Wi-Fi + TLS**, they consumed the
small internal pool. The next paint's bounce allocation returned
`ESP_ERR_NO_MEM`. The real defect is not the low-memory moment itself — it's that
the paint's `?` made a **transient, retryable** I/O failure **fatal to the whole
appliance**.
Two things made this easy to misread:
- **"8.4 MB free heap" is a red herring.** That figure is dominated by PSRAM.
The starved pools are the tiny internal ones (~265 KB / 21 KB / 32 KB), and DMA
bounce buffers can only come from those.
- **It looked like a git/sync bug, but the push was fine.** The git thread
finished and the remote accepted the commit. Only the UI task died.
## Timeline
1. Hour of normal editing — hundreds of ~630 ms partial refreshes, no issue
(internal RAM never under pressure; Wi-Fi off — the radio is lazy, ADR-006).
2. `:sync` → save → git thread brings up Wi-Fi, SNTP, TLS, commits `48a2c0a8`,
starts the push. Internal RAM now under heavy Wi-Fi/TLS load.
3. User keeps scrolling (`HalfPageUp`) during the push. Each scroll triggers a
full-area partial refresh → SPI-DMA transfer → bounce-buffer malloc.
4. One such malloc fails (`ESP_ERR_NO_MEM`); the `?` in the refresh call
propagates out of `main()`; `main_task` returns from `app_main()`.
5. Push completes normally; Wi-Fi torn down; internal RAM freed — but the editor
task is already gone, so nothing repaints. Keys log into the void.
## What it was *not*
- **Not out of heap** — plenty of PSRAM free; it was internal *DMA-capable* RAM
specifically.
- **Not a git or TLS bug** — the sync succeeded end to end.
- **Not an editor-core bug** — `editor`/`keymap` never ran; the failure is in the
firmware paint path's error handling.
- **Not a panel/wiring fault** — the same paint path worked for an hour and works
again after Wi-Fi is down.
## Remediation shipped — paints are non-fatal
A screen refresh is idempotent and retryable: the editor buffer is the source of
truth, so a dropped frame costs nothing and the next paint recovers. This is the
exact contract already written into `save_note` ("errors are logged, never
propagated"). Every paint site in the editor loop (`main.rs`) now:
- logs the failure and **drops the frame** instead of `?`-propagating it;
- leaves `shown` untouched so the next paint repaints the same diff;
- sets a `force_full` flag so the next paint is a **full refresh**, which
rewrites both RAM banks and recovers from a partial that may have died
mid-transfer and left the `0x24`/`0x26` banks inconsistent.
Effect: a paint that lands during a sync is dropped (panel goes stale for the
~15 s of the push), then self-heals the moment the push finishes and internal RAM
frees. A permanent brick becomes a brief stale window. The boot-time first render
stays `?`-fatal on purpose — it runs before Wi-Fi, can't hit this, and a dead
panel at boot is a legitimate hard fault.
## Root-cause eradication (specced, not yet built)
The safety net stops the brick but not the underlying **contention**: paints
during a sync still fail and drop. To keep the editor fully live while a push
runs — the whole point of the async git thread (ADR-006) — remove the on-the-fly
DMA allocation entirely.
**Fix: a persistent, internal, DMA-capable scratch buffer owned by `Epd`.**
- Allocate it **once at `Epd` construction** (boot, before Wi-Fi is ever up, when
internal RAM is plentiful) via
`heap_caps_malloc(SPI_CHUNK, MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT)`.
- `data()` already chunks writes to `SPI_CHUNK` (4096 B), so a **4 KB** scratch
buffer is sufficient: copy each chunk into it and hand that DMA-capable slice to
`spi.write()`.
- Because the source is now DMA-capable, `spi_master` DMAs directly from it —
`setup_dma_priv_buffer` is never invoked, there is **no per-refresh
allocation**, and paints no longer compete with Wi-Fi/TLS for internal RAM.
Refreshes then succeed during a sync, and `force_full` recovery is only ever
exercised by genuine faults, not by normal syncing.
**Cost:** ~4 KB of internal RAM reserved for the life of the device; one small
`unsafe` block for the alloc + slice; a `memcpy` per 4 KB chunk (negligible
against the ~630 ms panel waveform).
**Alternatives considered and rejected:**
- *Trim Wi-Fi's internal buffer counts* to leave headroom — fragile tuning that
risks Wi-Fi stability/throughput and only widens the margin instead of removing
the failure mode.
- *Force the whole ~13.6 KB frame buffer internal* (custom allocator / full
`heap_caps` buffer) — larger reservation and more churn than the 4 KB
chunk-scratch, for no extra benefit since `data()` already chunks.
- *Serialize paints against sync* (skip painting while a push is in flight) —
defeats the async-git design and freezes the panel for the whole push; the
safety net already makes this unnecessary.
**Verification when built:** reproduce the exact failing scenario — edit,
`:sync`, and keep scrolling/paging through the *entire* push. Expect: every
refresh succeeds (no `refresh … FAILED` warnings, no dropped frames), min-ever
internal heap stays comfortably above zero throughout, and `force_full` is not
triggered by the sync.
## Follow-ups
- [x] Make all editor-loop paints non-fatal + `force_full` recovery (`main.rs`);
release build green.
- [ ] Reflash and hardware-verify the safety net against the repro (edit →
`:sync` → scroll through the push): panel must recover, not freeze.
- [ ] Implement the persistent internal DMA scratch buffer in `Epd` (eradication
above) if the stale-during-sync window proves annoying in real use.
- [ ] After eradication, confirm refreshes succeed *during* a push and drop the
stale window entirely.

View File

@@ -12,3 +12,4 @@
| ---------- | ------------------------------------------------------------------------ | ------ |
| 2026-07-05 | [Spike 3 (SD) — card rejects CMD59 (SPI-mode CRC)](2026-07-05-spike3-sd-cmd59.md) | Paused — awaiting a compliant microSD; wiring + firmware proven |
| 2026-07-05 | [Spike 7 (git push) — ADR-004 kill-switch fired: gix can't push over HTTPS](2026-07-05-spike7-gix-https-push.md) | Turned — pivoted to libgit2; git mechanics proven on desktop, device build next |
| 2026-07-11 | [Editor freeze — SPI-DMA OOM during a background `:sync`](2026-07-11-editor-freeze-spi-dma-oom-during-sync.md) | Safety net shipped (paints non-fatal); root-cause eradication specced, not yet built |

View File

@@ -10,19 +10,578 @@ Scope: v0.1 MVP — see
[`v0.1-mvp-product.md`](v0.1-mvp-product.md) for user-facing scope and
[`v0.1-mvp-technical.md`](v0.1-mvp-technical.md) for implementation —
with the v0.2v1.0 trajectory ([README](../README.md),
[roadmap](roadmap.md)) in mind so we don't paint into a corner. Terminology
[macroplan](macroplan.md)) in mind so we don't paint into a corner. Terminology
(e.g. **Tracked**, **Local**, **Save**, **Publish**) follows the project
glossary at [`../CONTEXT.md`](../CONTEXT.md).
Format inspired by the classic House of Quality, kept compact. Strength
weights: **9** strong, **3** medium, **1** weak, blank none. The §3
matrix and §4 roof — plus a guessed competitor perception zone — live
in [`quality-house.md`](quality-house.md); this file owns the WHAT/HOW
catalogues (§1, §2), the narrative reading of the numbers (§3, §4), and
the downstream sections (§5§8).
weights: **9** strong, **3** medium, **1** weak, blank none. This one file
owns everything: the House diagram itself (matrix, roof, basement Σ, and the
guessed competitor perception zone), hoisted to the top (just below); the
WHAT/HOW catalogues (§1, §2); the narrative reading of the numbers (§3, §4);
and the downstream sections (§5§8). (The House was a separate `quality-house.md`
until 2026-07-11, merged into §3 to end the mirror-drift between the two files;
the diagram was lifted above §1 on 2026-07-11 so the picture leads.)
---
## House of Quality — the diagram
The artifact this whole document builds and reads is shown first: §1's WHATs (rows) × §2's HOWs (columns), scored 9 / 3 / 1 / blank, with the roof correlations (§4), the basement Σ / relative weights, and the right-hand competitive-perception zone. §1§8 define, prioritise, and read it; the sync rules and a blank practice copy travel with the diagram just below.
> **Single source of truth.** The `\foreach` blocks in the diagram restate §1's
> weights and §2's targets — TikZ can't read the tables, so keep them in sync when
> either changes, and **recompute the basement Σ / Rel % here** (see
> [Regenerating](#regenerating)) rather than transcribing them from elsewhere.
> This mirror used to live in a separate `quality-house.md`; it was merged into §3
> on 2026-07-11 so the two can no longer silently drift.
For a blank version of the same chassis (WHATs, HOWs, importance, and v0.1
targets kept; relations + roof + Σ basement left empty for practice), see
[`quality-house-empty.md`](quality-house-empty.md).
```tikz
% =====================================================================
% QFD "House of Quality" preamble
% =====================================================================
\usetikzlibrary{arrows.meta, positioning, shapes.geometric, shapes.misc, calc, fit, backgrounds}
\newif\ifqfdshowroof \qfdshowrooftrue
\newif\ifqfdshowbasement \qfdshowbasementtrue
\newif\ifqfdshowcompetitive \qfdshowcompetitivetrue
\newif\ifqfdshowlegend \qfdshowlegendtrue
\newif\ifqfdshowimportance \qfdshowimportancetrue
\newif\ifqfdshowcorrlegend \qfdshowcorrlegendtrue
\newif\ifqfdshowevallegend \qfdshowevallegendtrue
\def\qfdNW{5}
\def\qfdNH{5}
\def\qfdWhatW{4.0}
\def\qfdImpW{0.9}
\def\qfdCmpW{3}
\def\qfdHdrH{2.6}
\def\qfdBasementN{4}
\def\qfdWhatsTitle{Customer needs}
\def\qfdImpTitle{Imp.\ \%}
\def\qfdPerceptionTitle{Comparative evaluation}
\def\qfdPoorLabel{poor}
\def\qfdExcellentLabel{excellent}
\def\qfdAltOneLabel{Typoena}
\def\qfdAltTwoLabel{Competitor A}
\def\qfdAltThreeLabel{Competitor B}
\def\qfdRelTitle{Relation}
\def\qfdCorrTitle{Correlation}
\def\qfdEvalTitle{Evaluation}
\tikzset{
qfdthin/.style ={line width=0.35pt},
qfdmed/.style ={line width=0.7pt},
qfdstrong/.style={circle, draw, fill=black,
minimum size=7pt, inner sep=0pt},
qfdmod/.style ={circle, draw,
minimum size=7pt, inner sep=0pt, line width=0.8pt},
qfdweak/.style ={regular polygon, regular polygon sides=3, draw,
minimum size=8.5pt, inner sep=0pt, line width=0.7pt},
qfdrel/.is choice,
qfdrel/S/.style={qfdstrong},
qfdrel/M/.style={qfdmod},
qfdrel/W/.style={qfdweak},
qfdalt1mk/.style={circle, draw, fill=black,
minimum size=6pt, inner sep=0pt, line width=1pt},
qfdalt1ln/.style={line width=1.2pt},
qfdalt2mk/.style={regular polygon, regular polygon sides=3, draw,
fill=black, minimum size=6pt, inner sep=0pt,
line width=0.7pt},
qfdalt2ln/.style={line width=0.7pt, dashed},
qfdalt3mk/.style={rectangle, draw, fill=black,
minimum size=5pt, inner sep=0pt, line width=0.7pt},
qfdalt3ln/.style={line width=0.7pt, dotted},
}
\newcommand{\qfdDrawGrid}{%
\foreach \c in {1,...,\qfdNHm} \draw[qfdthin] (\c, 0) -- (\c, -\qfdNW);
\foreach \r in {1,...,\qfdNWm} \draw[qfdthin] (0, -\r) -- (\qfdNH, -\r);
\foreach \r in {1,...,\qfdNWm}
\draw[qfdthin] (\qfdLeftEdge, -\r) -- (0, -\r);
\ifqfdshowroof
\foreach \c in {1,...,\qfdNHm}
\draw[qfdthin] (\c, 0) -- (\c, \qfdHdrH);
\fi
\ifqfdshowcompetitive
\foreach \r in {1,...,\qfdNWm}
\draw[qfdthin] (\qfdNH, -\r) -- (\qfdNH+\qfdCmpW, -\r);
\fi
\ifqfdshowbasement
\foreach \r in {1,...,\qfdBasementN}
\draw[qfdthin] (0, -\qfdNW-\r) -- (\qfdNH, -\qfdNW-\r);
\foreach \c in {1,...,\qfdNHm}
\draw[qfdthin] (\c, -\qfdNW) -- (\c, -\qfdNW-\qfdBasementN);
\fi
}
\newcommand{\qfdDrawRoof}{%
\ifqfdshowroof
\foreach \k in {1,...,\qfdNHm} {%
\pgfmathsetmacro{\rx}{(\k+\qfdNH)/2}
\pgfmathsetmacro{\ry}{\qfdHdrH + (\qfdNH-\k)/2}
\pgfmathsetmacro{\lx}{\k/2}
\pgfmathsetmacro{\ly}{\qfdHdrH + \k/2}
\draw[qfdthin] (\k, \qfdHdrH) -- (\rx, \ry);
\draw[qfdthin] (\k, \qfdHdrH) -- (\lx, \ly);
}%
\draw[qfdmed] (0, \qfdHdrH)
-- (\qfdNH/2, \qfdApexY) -- (\qfdNH, \qfdHdrH);
\foreach \i in {1,...,\qfdNH}
\foreach \k in {1,...,\qfdNH} {%
\pgfmathtruncatemacro{\jj}{\i+\k}
\ifnum\jj>\qfdNH\relax\else
\pgfmathsetmacro{\xx}{\i + \k/2 - 0.5}
\pgfmathsetmacro{\yy}{\qfdHdrH + \k/2}
\coordinate (C-\i-\jj) at (\xx, \yy);
\fi
}%
\fi
}
\newcommand{\qfdDrawScale}{%
\ifqfdshowcompetitive
\foreach \tk in {0,1,2,3,4,5} {%
\pgfmathsetmacro{\tx}{\qfdNH + (\tk+0.5)*\qfdCmpW/6}
\node[anchor=south, font=\scriptsize] at (\tx, 0.02) {\tk};
}%
\node[anchor=south, font=\scriptsize\bfseries, align=center,
text width=\qfdCmpW cm]
at ({\qfdNH + \qfdCmpW/2}, 0.7) {\qfdPerceptionTitle};
\node[anchor=north, font=\scriptsize\itshape]
at ({\qfdNH + 0.45}, -\qfdNW) {\qfdPoorLabel};
\node[anchor=north, font=\scriptsize\itshape]
at ({\qfdNH + \qfdCmpW - 0.45}, -\qfdNW) {\qfdExcellentLabel};
\fi
}
\newcommand{\qfdDrawZoneTitles}{%
\ifqfdshowimportance
\node[rotate=90, anchor=west, font=\footnotesize\bfseries]
at ({-\qfdImpW/2}, 0.12) {\qfdImpTitle};
\fi
\node[font=\scriptsize\bfseries, align=center, text width=\qfdWhatW cm]
at ({\qfdLeftEdge + \qfdWhatW/2},
{\ifqfdshowroof \qfdHdrH/2 \else 0.6 \fi}) {\qfdWhatsTitle};
}
\newcommand{\qfdDrawFrames}{%
\begin{scope}[qfdmed]
\draw (\qfdLeftEdge, 0) rectangle (\qfdNH, -\qfdNW);
\ifqfdshowimportance \draw (-\qfdImpW, 0) -- (-\qfdImpW, -\qfdNW); \fi
\draw (0, 0) -- (0, -\qfdNW);
\ifqfdshowroof
\draw (0, 0) rectangle (\qfdNH, \qfdHdrH); \fi
\ifqfdshowbasement
\draw (0, -\qfdNW) rectangle (\qfdNH, -\qfdNW-\qfdBasementN); \fi
\ifqfdshowcompetitive
\draw (\qfdNH, 0) rectangle (\qfdNH+\qfdCmpW, -\qfdNW); \fi
\end{scope}
}
\newcommand{\qfdDrawLegend}{%
\ifqfdshowlegend
\pgfmathsetmacro{\qfdLegX}{%
\qfdNH + \ifqfdshowcompetitive \qfdCmpW + 0.7 \else 0.7 \fi}
\pgfmathsetmacro{\qfdLegBottom}{%
-2.05
\ifqfdshowroof \ifqfdshowcorrlegend - 2.55 \fi \fi
\ifqfdshowcompetitive \ifqfdshowevallegend - 2.20 \fi \fi}
\pgfmathsetmacro{\qfdLegY}{\qfdHdrH - 0.4}
\begin{scope}[shift={(\qfdLegX, \qfdLegY)}]
\draw[qfdmed, rounded corners=2pt]
(-0.15, 0.4) rectangle (4.5, \qfdLegBottom);
\node[anchor=west, font=\footnotesize\bfseries] at (0, 0.1)
{\qfdRelTitle};
\draw[qfdthin] (0, -0.15) -- (4.35, -0.15);
\node[qfdstrong] at (0.22, -0.5) {};
\node[anchor=west] at (0.5, -0.5) {Strong (9)};
\node[qfdmod] at (0.22, -0.95) {};
\node[anchor=west] at (0.5, -0.95) {Medium (3)};
\node[qfdweak] at (0.22, -1.4) {};
\node[anchor=west] at (0.5, -1.4) {Weak (1)};
\ifqfdshowroof \ifqfdshowcorrlegend
\node[anchor=west, font=\footnotesize\bfseries] at (0, -2.10)
{\qfdCorrTitle};
\draw[qfdthin] (0, -2.35) -- (4.35, -2.35);
\node[anchor=west] at (0, -2.70) {{$+\!+$}\quad very positive};
\node[anchor=west] at (0, -3.05) {{$+$\phantom{$+$}}\quad positive};
\node[anchor=west] at (0, -3.40) {{$-$\phantom{$-$}}\quad negative};
\node[anchor=west] at (0, -3.75) {{$-\!-$}\quad very negative};
\fi \fi
\ifqfdshowcompetitive \ifqfdshowevallegend
\pgfmathsetmacro{\qfdEvalTop}{%
-2.10 \ifqfdshowroof\ifqfdshowcorrlegend - 2.55 \fi\fi}
\node[anchor=west, font=\footnotesize\bfseries]
at (0, \qfdEvalTop) {\qfdEvalTitle};
\pgfmathsetmacro{\qfdEvalSep}{\qfdEvalTop - 0.25}
\draw[qfdthin] (0, \qfdEvalSep) -- (4.35, \qfdEvalSep);
\pgfmathsetmacro{\qfdLegA}{\qfdEvalTop - 0.55}
\draw[qfdalt1ln] (0.05, \qfdLegA) -- (0.45, \qfdLegA);
\node[qfdalt1mk] at (0.25, \qfdLegA) {};
\node[anchor=west, font=\bfseries] at (0.55, \qfdLegA)
{\qfdAltOneLabel};
\pgfmathsetmacro{\qfdLegB}{\qfdEvalTop - 0.95}
\draw[qfdalt2ln] (0.05, \qfdLegB) -- (0.45, \qfdLegB);
\node[qfdalt2mk] at (0.25, \qfdLegB) {};
\node[anchor=west] at (0.55, \qfdLegB) {\qfdAltTwoLabel};
\pgfmathsetmacro{\qfdLegC}{\qfdEvalTop - 1.35}
\draw[qfdalt3ln] (0.05, \qfdLegC) -- (0.45, \qfdLegC);
\node[qfdalt3mk] at (0.25, \qfdLegC) {};
\node[anchor=west] at (0.55, \qfdLegC) {\qfdAltThreeLabel};
\fi \fi
\end{scope}
\fi
}
\newenvironment{qfdhouse}{%
\begin{tikzpicture}[x=1cm, y=1cm, font=\scriptsize,
line cap=round, line join=round]
\ifqfdshowimportance
\pgfmathsetmacro{\qfdLeftEdge}{-\qfdWhatW-\qfdImpW}
\else
\pgfmathsetmacro{\qfdLeftEdge}{-\qfdWhatW}
\fi
\pgfmathsetmacro{\qfdApexY}{\qfdHdrH + \qfdNH/2}
\pgfmathtruncatemacro{\qfdNHm}{\qfdNH - 1}
\pgfmathtruncatemacro{\qfdNWm}{\qfdNW - 1}
\qfdDrawGrid
\qfdDrawRoof
\qfdDrawScale
\qfdDrawZoneTitles
}{%
\qfdDrawFrames
\qfdDrawLegend
\end{tikzpicture}%
}
% --- Dimensions tuned for the typewriter QFD (14 W x 15 H) ---
\def\qfdNW{14}
\def\qfdNH{14}
\def\qfdWhatW{4.6}
\def\qfdImpW{0.7}
\def\qfdHdrH{5.0}
\def\qfdBasementN{3}
\def\qfdCmpW{3.4}
\qfdshowlegendfalse % we draw a 4-alternative legend manually
\def\qfdWhatsTitle{User-facing requirements (W)}
\def\qfdImpTitle{Weight}
\def\qfdPerceptionTitle{Competitive perception\\(05, guessed)}
\def\qfdPoorLabel{poor}
\def\qfdExcellentLabel{excellent}
% Perception-zone markers: shape + colour-blind-safe colour per product.
% Palette is Okabe-Ito (blue, vermillion, bluish green, reddish purple).
% Light fills + saturated outlines keep stacked markers legible.
\definecolor{qfdcTypoena}{RGB}{0,114,178}
\definecolor{qfdcRem}{RGB}{213,94,0}
\definecolor{qfdcFrw}{RGB}{0,158,115}
\definecolor{qfdcPom}{RGB}{204,121,167}
\definecolor{qfdcFrwS}{RGB}{86,180,233}
\tikzset{
qfdalt1mk/.style={circle, draw=qfdcTypoena, fill=qfdcTypoena!55!white,
minimum size=6.5pt, inner sep=0pt, line width=1.1pt},
qfdalt1ln/.style={line width=1.2pt, qfdcTypoena},
qfdalt2mk/.style={regular polygon, regular polygon sides=3,
draw=qfdcRem, fill=qfdcRem!55!white,
minimum size=7pt, inner sep=0pt, line width=0.9pt},
qfdalt2ln/.style={line width=0.8pt, dashed, qfdcRem},
qfdalt3mk/.style={rectangle, draw=qfdcFrw, fill=qfdcFrw!55!white,
minimum size=5.5pt, inner sep=0pt, line width=0.9pt},
qfdalt3ln/.style={line width=0.8pt, dotted, qfdcFrw},
qfdalt4mk/.style={diamond, aspect=1, draw=qfdcPom,
fill=qfdcPom!50!white,
minimum size=7pt, inner sep=0pt, line width=1.0pt},
qfdalt4ln/.style={line width=0.8pt, dash dot, qfdcPom},
qfdalt5mk/.style={regular polygon, regular polygon sides=5,
draw=qfdcFrwS, fill=qfdcFrwS!40!white,
minimum size=5.5pt, inner sep=0pt, line width=0.8pt},
qfdalt5ln/.style={line width=0.7pt, dash dot dot, qfdcFrwS},
}
\begin{document}
\begin{qfdhouse}
% ---------- WHATs (left column) ----------
% Box width is 0.2 cm narrower than the column so labels have 0.1 cm
% clearance on each side and don't bleed into the Weight column.
\pgfmathsetmacro{\qfdWhatTextW}{\qfdWhatW - 0.2}
\foreach \r/\t in {%
1/{W1 Sub-second visible response to typing},
2/{W2 Publishing is one deliberate action away},
3/{W3 Pulling power never corrupts the file},
4/{W4 Provisioning never interrupts a writing session},
5/{W5 Quick boot to a writing cursor},
6/{W6 Long sessions without crash, lag, drift},
7/{W7 Nothing on the device competes with prose},
8/{W8 The UI never moves except when I move it},
9/{W9 Codebase absorbs the planned roadmap},
10/{W10 I can repair or fork it with hobbyist tools},
11/{W11 Multi-day battery life (v0.8 onward)},
12/{W12 Local-only files coexist with git scope (v0.5+)},
13/{W13 Typography sets a writing-tool tone},
14/{W14 I can carry the device and write away from a desk}%
}
\node[anchor=west, font=\scriptsize,
text width=\qfdWhatTextW cm, align=left]
at ({\qfdLeftEdge + 0.1}, {-\r + 0.5}) {\t};
% ---------- Importance (raw 1-10 weight) ----------
\foreach \r/\w in {1/10, 2/9, 3/10, 4/7, 5/6, 6/9, 7/8, 8/7,
9/8, 10/5, 11/4, 12/5, 13/7, 14/8}
\node[font=\scriptsize] at ({-\qfdImpW/2}, {-\r + 0.5}) {\w};
% ---------- HOWs (rotated column titles) ----------
\foreach \c/\t in {%
1/{H1 Type latency},
2/{H2 Refresh area per keystroke},
3/{H3 Full-refresh cadence},
4/{H4 Boot latency (cold)},
5/{H5 Continuous-typing endurance},
6/{H6 Publish reliability},
7/{H7 Publish latency},
8/{H8 Save durability},
9/{H9 PSRAM heap headroom},
10/{H10 Firmware binary size},
11/{H11 Total stack budget},
12/{H12 Network reconnect time},
13/{H13 Idle / typing / push current},
14/{H15 Clean release build time}%
}
\node[rotate=90, anchor=west, font=\scriptsize]
at ({\c - 0.5}, 0.15) {\t};
% ---------- Relation matrix (S=9, M=3, W=1) ----------
% W1 row 1: H1S H2S H3M H5M H9W H11W
\node[qfdrel/S] at ({1 - 0.5}, {-1 + 0.5}) {};
\node[qfdrel/S] at ({2 - 0.5}, {-1 + 0.5}) {};
\node[qfdrel/M] at ({3 - 0.5}, {-1 + 0.5}) {};
\node[qfdrel/M] at ({5 - 0.5}, {-1 + 0.5}) {};
\node[qfdrel/W] at ({9 - 0.5}, {-1 + 0.5}) {};
\node[qfdrel/W] at ({11 - 0.5}, {-1 + 0.5}) {};
% W2 row 2: H6S H7M H9S H12S
\node[qfdrel/S] at ({6 - 0.5}, {-2 + 0.5}) {};
\node[qfdrel/M] at ({7 - 0.5}, {-2 + 0.5}) {};
\node[qfdrel/S] at ({9 - 0.5}, {-2 + 0.5}) {};
\node[qfdrel/S] at ({12 - 0.5}, {-2 + 0.5}) {};
% W3 row 3: H8S
\node[qfdrel/S] at ({8 - 0.5}, {-3 + 0.5}) {};
% W4 row 4: H6M H12M
\node[qfdrel/M] at ({6 - 0.5}, {-4 + 0.5}) {};
\node[qfdrel/M] at ({12 - 0.5}, {-4 + 0.5}) {};
% W5 row 5: H4S H10M
\node[qfdrel/S] at ({4 - 0.5}, {-5 + 0.5}) {};
\node[qfdrel/M] at ({10 - 0.5}, {-5 + 0.5}) {};
% W6 row 6: H1M H3M H5S H6M H8M H9S H11M H12M
\node[qfdrel/M] at ({1 - 0.5}, {-6 + 0.5}) {};
\node[qfdrel/M] at ({3 - 0.5}, {-6 + 0.5}) {};
\node[qfdrel/S] at ({5 - 0.5}, {-6 + 0.5}) {};
\node[qfdrel/M] at ({6 - 0.5}, {-6 + 0.5}) {};
\node[qfdrel/M] at ({8 - 0.5}, {-6 + 0.5}) {};
\node[qfdrel/S] at ({9 - 0.5}, {-6 + 0.5}) {};
\node[qfdrel/M] at ({11 - 0.5}, {-6 + 0.5}) {};
\node[qfdrel/M] at ({12 - 0.5}, {-6 + 0.5}) {};
% W7 row 7: H1M H2M H3M H13M
\node[qfdrel/M] at ({1 - 0.5}, {-7 + 0.5}) {};
\node[qfdrel/M] at ({2 - 0.5}, {-7 + 0.5}) {};
\node[qfdrel/M] at ({3 - 0.5}, {-7 + 0.5}) {};
\node[qfdrel/M] at ({13 - 0.5}, {-7 + 0.5}) {};
% W8 row 8: H1W H2S H3S
\node[qfdrel/W] at ({1 - 0.5}, {-8 + 0.5}) {};
\node[qfdrel/S] at ({2 - 0.5}, {-8 + 0.5}) {};
\node[qfdrel/S] at ({3 - 0.5}, {-8 + 0.5}) {};
% W9 row 9: H10W H11W H15M
\node[qfdrel/W] at ({10 - 0.5}, {-9 + 0.5}) {};
\node[qfdrel/W] at ({11 - 0.5}, {-9 + 0.5}) {};
\node[qfdrel/M] at ({14 - 0.5}, {-9 + 0.5}) {};
% W10 row 10: H10M H13W H15W
\node[qfdrel/M] at ({10 - 0.5}, {-10 + 0.5}) {};
\node[qfdrel/W] at ({13 - 0.5}, {-10 + 0.5}) {};
\node[qfdrel/W] at ({14 - 0.5}, {-10 + 0.5}) {};
% W11 row 11: H13S
\node[qfdrel/S] at ({13 - 0.5}, {-11 + 0.5}) {};
% W12 row 12: H6W H8M
\node[qfdrel/W] at ({6 - 0.5}, {-12 + 0.5}) {};
\node[qfdrel/M] at ({8 - 0.5}, {-12 + 0.5}) {};
% W13 row 13: H9M
\node[qfdrel/M] at ({9 - 0.5}, {-13 + 0.5}) {};
% W14 row 14: H4W H8M H12M H13S
\node[qfdrel/W] at ({4 - 0.5}, {-14 + 0.5}) {};
\node[qfdrel/M] at ({8 - 0.5}, {-14 + 0.5}) {};
\node[qfdrel/M] at ({12 - 0.5}, {-14 + 0.5}) {};
\node[qfdrel/S] at ({13 - 0.5}, {-14 + 0.5}) {};
% ---------- Roof correlations ----------
\node[font=\scriptsize] at (C-1-2) {$+\!+$}; % H1-H2 strong reinforce
\node[font=\scriptsize] at (C-1-3) {$-$}; % H1-H3 mild conflict
\node[font=\scriptsize] at (C-1-5) {$+$}; % H1-H5 mild reinforce
\node[font=\scriptsize] at (C-1-13) {$-$}; % H1-H13 mild conflict
\node[font=\scriptsize] at (C-2-3) {$+\!+$}; % H2-H3 strong reinforce
\node[font=\scriptsize] at (C-2-13) {$+$}; % H2-H13
\node[font=\scriptsize] at (C-3-13) {$+$}; % H3-H13
\node[font=\scriptsize] at (C-4-10) {$-$}; % H4-H10 boot vs binary
\node[font=\scriptsize] at (C-5-6) {$+$}; % H5-H6
\node[font=\scriptsize] at (C-5-8) {$+$}; % H5-H8
\node[font=\scriptsize] at (C-5-9) {$-\!-$}; % H5-H9 soak vs heap
\node[font=\scriptsize] at (C-6-7) {$+$}; % H6-H7
\node[font=\scriptsize] at (C-6-9) {$-\!-$}; % H6-H9 push vs heap
\node[font=\scriptsize] at (C-6-12) {$+\!+$}; % H6-H12
\node[font=\scriptsize] at (C-7-9) {$-$}; % H7-H9
\node[font=\scriptsize] at (C-7-12) {$+\!+$}; % H7-H12
\node[font=\scriptsize] at (C-9-10) {$-\!-$}; % H9-H10 heap vs binary
\node[font=\scriptsize] at (C-10-14) {$-\!-$}; % H10-H15 binary vs build
\node[font=\scriptsize] at (C-11-13) {$-$}; % H11-H13
% ---------- Basement: target / abs weight / rel weight % ----------
\foreach \c/\tgt/\abs/\rel in {%
1/{$\leq$400\,ms}/148/10,
2/{$\leq$1 line}/177/11,
3/{1 : 64}/144/9,
4/{$\leq$5\,s}/62/4,
5/{$\geq$1\,h}/111/7,
6/{$\geq$95\,\%}/134/9,
7/{$\leq$30\,s}/27/2,
8/{100\,\%}/156/10,
9/{$\geq$1\,MB}/193/12,
10/{$\leq$2\,MB}/41/3,
11/{$\leq$80\,KB}/45/3,
12/{$\leq$30\,s}/153/10,
13/{obs.}/137/9,
14/{$\leq$7\,min}/29/2%
} {
\node[font=\scriptsize] at ({\c - 0.5}, {-\qfdNW - 0.5}) {\tgt};
\node[font=\scriptsize] at ({\c - 0.5}, {-\qfdNW - 1.5}) {\abs};
\node[font=\scriptsize\bfseries]
at ({\c - 0.5}, {-\qfdNW - 2.5}) {\rel};
}
% ---------- Basement row labels (in the margin below WHATs) ----------
\foreach \k/\lbl in {1/{Target (v0.1)}, 2/{$\Sigma$ abs}, 3/{Rel.\ \%}}
\node[anchor=east, font=\scriptsize\itshape]
at ({-0.1}, {-\qfdNW - \k + 0.5}) {\lbl};
% ---------- Perception zone: 5 products x 14 WHATs (0-5 scores) ----------
% Columns: \so=Typoena v0.1 (measured 2026-07-11), \st=reMarkable 2 + Type Folio,
% \sf=Freewrite Traveler, \sg=Pomera DM250,
% \sh=Freewrite Smart Typewriter.
% Pass 1: stash each score as a named coordinate so the profile lines
% below can reuse it without recomputing.
\foreach \r/\so/\st/\sf/\sg/\sh in {%
1/2/1/4/5/3,
2/5/4/4/2/4,
3/4/4/2/2/2,
4/5/2/2/5/2,
5/4/3/4/5/4,
6/4/3/4/5/4,
7/5/2/5/5/5,
8/4/3/4/5/4,
9/4/3/2/1/2,
10/5/4/2/1/2,
11/1/5/5/4/5,
12/3/1/2/3/2,
13/3/5/2/2/2,
14/2/4/5/5/1%
} {
\pgfmathsetmacro{\xo}{\qfdNH + (\so + 0.5)*\qfdCmpW/6}
\pgfmathsetmacro{\xt}{\qfdNH + (\st + 0.5)*\qfdCmpW/6}
\pgfmathsetmacro{\xf}{\qfdNH + (\sf + 0.5)*\qfdCmpW/6}
\pgfmathsetmacro{\xg}{\qfdNH + (\sg + 0.5)*\qfdCmpW/6}
\pgfmathsetmacro{\xs}{\qfdNH + (\sh + 0.5)*\qfdCmpW/6}
\coordinate (po-\r) at (\xo, {-\r + 0.5});
\coordinate (pr-\r) at (\xt, {-\r + 0.5});
\coordinate (pf-\r) at (\xf, {-\r + 0.5});
\coordinate (pp-\r) at (\xg, {-\r + 0.5});
\coordinate (ps-\r) at (\xs, {-\r + 0.5});
}
% Pass 2: profile lines per alternative. Drawn before markers so the
% dots sit on top of (not under) the line endpoints.
\draw[qfdalt1ln] (po-1) \foreach \r in {2,...,\qfdNW} { -- (po-\r) };
\draw[qfdalt2ln] (pr-1) \foreach \r in {2,...,\qfdNW} { -- (pr-\r) };
\draw[qfdalt3ln] (pf-1) \foreach \r in {2,...,\qfdNW} { -- (pf-\r) };
\draw[qfdalt4ln] (pp-1) \foreach \r in {2,...,\qfdNW} { -- (pp-\r) };
\draw[qfdalt5ln] (ps-1) \foreach \r in {2,...,\qfdNW} { -- (ps-\r) };
% Pass 3: markers on top of the lines.
\foreach \r in {1,...,\qfdNW} {
\node[qfdalt1mk] at (po-\r) {};
\node[qfdalt2mk] at (pr-\r) {};
\node[qfdalt3mk] at (pf-\r) {};
\node[qfdalt4mk] at (pp-\r) {};
\node[qfdalt5mk] at (ps-\r) {};
}
% ---------- Manual legend (5 alternatives, placed right of zones) ----------
\pgfmathsetmacro{\qfdLegX}{\qfdNH + \qfdCmpW + 0.7}
\begin{scope}[shift={(\qfdLegX, \qfdHdrH - 0.4)}]
\draw[qfdmed, rounded corners=2pt]
(-0.15, 0.4) rectangle (5.1, -7.50);
% Relations
\node[anchor=west, font=\footnotesize\bfseries] at (0, 0.1)
{Relation};
\draw[qfdthin] (0, -0.15) -- (4.95, -0.15);
\node[qfdstrong] at (0.22, -0.5) {};
\node[anchor=west] at (0.5, -0.5) {Strong (9)};
\node[qfdmod] at (0.22, -0.95) {};
\node[anchor=west] at (0.5, -0.95) {Medium (3)};
\node[qfdweak] at (0.22, -1.4) {};
\node[anchor=west] at (0.5, -1.4) {Weak (1)};
% Correlation
\node[anchor=west, font=\footnotesize\bfseries] at (0, -2.10)
{Correlation};
\draw[qfdthin] (0, -2.35) -- (4.95, -2.35);
\node[anchor=west] at (0, -2.70) {{$+\!+$}\quad very positive};
\node[anchor=west] at (0, -3.05) {{$+$\phantom{$+$}}\quad positive};
\node[anchor=west] at (0, -3.40) {{$-$\phantom{$-$}}\quad negative};
\node[anchor=west] at (0, -3.75) {{$-\!-$}\quad very negative};
% Perception
\node[anchor=west, font=\footnotesize\bfseries] at (0, -4.20)
{Perception};
\draw[qfdthin] (0, -4.45) -- (4.95, -4.45);
\draw[qfdalt1ln] (0.05, -4.80) -- (0.45, -4.80);
\node[qfdalt1mk] at (0.25, -4.80) {};
\node[anchor=west, font=\bfseries] at (0.55, -4.80)
{Typoena (v0.1 measured)};
\draw[qfdalt2ln] (0.05, -5.25) -- (0.45, -5.25);
\node[qfdalt2mk] at (0.25, -5.25) {};
\node[anchor=west] at (0.55, -5.25) {reMarkable 2 + Type Folio};
\draw[qfdalt3ln] (0.05, -5.70) -- (0.45, -5.70);
\node[qfdalt3mk] at (0.25, -5.70) {};
\node[anchor=west] at (0.55, -5.70) {Freewrite Traveler};
\draw[qfdalt5ln] (0.05, -6.15) -- (0.45, -6.15);
\node[qfdalt5mk] at (0.25, -6.15) {};
\node[anchor=west] at (0.55, -6.15) {Freewrite Smart Typewriter};
\draw[qfdalt4ln] (0.05, -6.60) -- (0.45, -6.60);
\node[qfdalt4mk] at (0.25, -6.60) {};
\node[anchor=west] at (0.55, -6.60) {Pomera DM250};
\node[anchor=west, font=\scriptsize\itshape] at (0, -7.15)
{0 = poor, 5 = excellent};
\end{scope}
\end{qfdhouse}
\end{document}
```
## 1. Customer requirements (the WHATs)
What a user (= me) values about the device, with importance weights on a
@@ -33,23 +592,23 @@ What a user (= me) values about the device, with importance weights on a
| W1 | Sub-second visible response to typing | 10 | [product → Write](v0.1-mvp-product.md#user-stories), [README → UX](../README.md#ux-boundaries-set-by-the-medium) |
| W2 | **Publishing** is one deliberate action away | 9 | [product → Publish](v0.1-mvp-product.md#user-stories), [CONTEXT → Publish](../CONTEXT.md#user-facing-actions) |
| W3 | Pulling power never corrupts the file | 10 | [product → Recover](v0.1-mvp-product.md#user-stories), [acceptance](v0.1-mvp-product.md#acceptance-criteria) |
| W4 | Provisioning never interrupts a writing session | 7 | [product → Provisioning](v0.1-mvp-product.md#provisioning-build-time-dev-only), [roadmap → v0.9](roadmap.md#v09--robustness--) |
| W4 | Provisioning never interrupts a writing session | 7 | [product → Provisioning](v0.1-mvp-product.md#provisioning-build-time-dev-only), [macroplan → v0.9](macroplan.md#v09--robustness--) |
| W5 | Quick boot to a writing cursor | 6 | [product → acceptance](v0.1-mvp-product.md#acceptance-criteria) (≤ 5 s) |
| W6 | Long sessions without crash / lag / drift | 9 | [product → acceptance](v0.1-mvp-product.md#acceptance-criteria) (1 h soak) |
| W7 | Nothing on the device competes with prose | 8 | [README → vision](../README.md#vision) |
| W8 | The UI never moves except when I move it | 7 | [README → UX](../README.md#ux-boundaries-set-by-the-medium) |
| W9 | Codebase absorbs the planned roadmap without rewrite | 8 | [roadmap](roadmap.md) |
| W9 | Codebase absorbs the planned roadmap without rewrite | 8 | [macroplan](macroplan.md) |
| W10 | I can repair or fork it with hobbyist tools | 5 | [README → vision](../README.md#vision) |
| W11 | Multi-day battery life (v0.8 onward) | 4 | [roadmap → v0.8](roadmap.md#v08--power-battery--sleep--) |
| W12 | Local-only file scope coexists with git scope (v0.5+) | 5 | [README → scopes](../README.md#vision), [roadmap → v0.5](roadmap.md#v05--file-palette--multi-file--) |
| W13 | Typography sets a writing-tool tone — typewriter or developer editor, never gadget | 7 | [roadmap → v1.0](roadmap.md), [README → UX](../README.md#ux-boundaries-set-by-the-medium) |
| W14 | I can carry the device and write away from a desk | 8 | [roadmap → v0.8](roadmap.md#v08--power-battery--sleep--), [README → hardware](../README.md#hardware) |
| W11 | Multi-day battery life (v0.8 onward) | 4 | [macroplan → v0.8](macroplan.md#v08--power-battery--sleep--) |
| W12 | Local-only file scope coexists with git scope (v0.5+) | 5 | [README → scopes](../README.md#vision), [macroplan → v0.5](macroplan.md#v05--file-palette--multi-file--) |
| W13 | Typography sets a writing-tool tone — typewriter or developer editor, never gadget | 7 | [macroplan → v1.0](macroplan.md), [README → UX](../README.md#ux-boundaries-set-by-the-medium) |
| W14 | I can carry the device and write away from a desk | 8 | [macroplan → v0.8](macroplan.md#v08--power-battery--sleep--), [README → hardware](../README.md#hardware) |
---
## 2. Engineering characteristics (the HOWs)
Measurable attributes performance metrics of the device's functions
Measurable attributes: performance metrics of the device's functions
(below), or properties of its firmware artifact, memory layout, and build
process. See [`../GLOSSARY.md`](../GLOSSARY.md) for the ontology layers
(WHAT / Function / Characteristic / Metric / Target). Targets are v0.1
@@ -79,13 +638,13 @@ inside HOW names: **Render** (buffer → e-ink frame, inside Type),
| ID | Characteristic | Dir | v0.1 target | v1.0 target |
| --- | -------------------------------------------------- | :-: | ------------------------ | ------------------- |
| H1 | Type latency (keypress → glyph) | ↓ | ≤ 200 ms | ≤ 150 ms |
| H1 | Type latency (keypress → glyph) | ↓ | ≤ 400 ms § | ≤ 300 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 |
| H7 | Publish latency (one file) | ↓ | ≤ 30 s | ≤ 10 s |
| H8 | Save durability (post-confirm power loss) | → | 100 % | 100 % |
| H9 | PSRAM heap headroom during Publish | ↑ | ≥ 1 MB free at peak | same |
| H10 | Firmware binary size | ↓ | ≤ 2 MB | ≤ 1.5 MB |
@@ -94,15 +653,56 @@ 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).
**Publish latency, measured 2026-07-11:** a cold `:sync` is **~16 s** (warm
**~10 s**), comfortably inside the ≤ 30 s v0.1 target. The ≤ 10 s v1.0 target is
**marginal** — the warm path meets it, but a cold sync's one-time Wi-Fi assoc
(~3.6 s) + SNTP (~24 s) push it over, and the transport itself (one TLS handshake
+ commit + push) is near its floor. Optimistic-retry (push onto the tip first,
reconcile only on a rejected push) already cut a whole second handshake. Breakdown
+ levers: [`notes/sync-latency.md`](notes/sync-latency.md).
§ **Type latency — revised target, measured 2026-07-11.** Cold per-keystroke
render (keypress → glyph settled) measures **~630 ms**, so the v0.1 target is
**relaxed from ≤ 200 ms to ≤ 400 ms**: the original ≤ 200 ms was tighter than
[ADR-003]'s own accepted "~200300 ms" e-ink cost and never realistic for this
panel. Even ≤ 400 ms is unmet (~630 ms exceeds it), so it stays the open v0.1
latency item; a longer wait is acceptable for now, and usage in the next
version will settle whether ≤ 400 ms holds. The v1.0 target is reset from
≤ 150 ms to ≤ 300 ms (the top of [ADR-003]'s accepted ~200300 ms e-ink cost),
since ≤ 150 ms sat below what the panel can deliver.
---
## 3. House of Quality — WHATs × HOWs
The matrix (row × column = how strongly characteristic H advances requirement
W) and its Σ row — the weighted vote `Σ(weight × strength)` on which
characteristics deserve the most engineering attention — live in
[`quality-house.md`](quality-house.md). The Σ totals quoted below are
from its basement row.
This section reads the House (the diagram is at the top of this document): §1's
WHATs (rows) × §2's HOWs (columns), each cell scoring how strongly a
characteristic advances a requirement (9 / 3 / 1 / blank). The roof carries the §4 HOW-vs-HOW correlations; the basement carries the
v0.1 targets (from §2), the weighted-vote sums `Σ = Σ(W weight × cell strength)`,
and rounded relative weights. The right-hand zone scores five products against
the WHATs (05): the four competitors are **guessed, not measured**, while the
Typoena column is its **measured v0.1** profile (see
[Perception scores](#perception-scores-guessed)). The Σ totals quoted in the
priority list below come from the basement.
### Reading the house
- **Importance (left column)** is the raw 110 weight from §1, not a normalised
%, so adding stays cheap when a WHAT shifts. Sum of weights is 103; treat each
unit as ~0.97 % if you want a percentage view.
- **Roof** carries the §4 symbols translated into classical QFD glyphs:
`++` strong reinforcement (`◎`), `+` mild reinforcement (`○`), `` mild
conflict (`×`), `` strong conflict (`⊗`).
- **Basement rows** are: v0.1 target → column sum (`Σ` of `weight × strength`) →
relative weight as integer % of total (1557). Relative weights round to 100.
- **H7, H10, H15** (Publish latency, binary size, build time) sit at the bottom
of the basement, knowingly-paid costs per §7, not signals to optimise harder.
### Top engineering priorities (from importance)
@@ -126,7 +726,7 @@ from its basement row.
6. **H3 — full-refresh cadence** (144). The ghosting/flash tradeoff; lives
in the render layer.
H13 (current draw, 137) sits at #7 close to the top-six cutoff because
H13 (current draw, 137) sits at #7, close to the top-six cutoff because
W14 promotes the "wall-power for v0.1, measure first" stance from
acknowledged tradeoff to watched metric. The v0.1 "measured only" target
(§2) is still right; what changes is that bench multimeter readings (§6)
@@ -154,6 +754,104 @@ user preference for faster iteration, not matrix-derived priority; if it
pushes back against [ADR-001]'s "+510 min" pricing, the target moves
before the runtime decision does.
### Perception scores (guessed)
Five products on the 05 scale, scored against each WHAT. Reference
configurations: **reMarkable 2 + Type Folio**, **Freewrite Traveler**,
**Freewrite Smart Typewriter**, **Pomera DM250** (DM250 has a reflective
monochrome LCD, not e-ink — flagged in W1 / W8). The Typoena column is the
shipped v0.1 profile, rebased on measured hardware results and lived use
(v0.1 delivered 2026-07-11), not the §2 target it was before; the four
competitors remain single-rater guesses. W1's type latency is now measured at
~630 ms (2026-07-11), over the revised ≤400 ms H1 target (was ≤200 ms), so its
score drops 4→2, still sub-second but a visible per-keystroke lag.
Freewrite Traveler scores assume the
[Sailfish firmware](https://getfreewrite.com/blogs/writing-success/freewrite-sailfish-firmware)
(released 2025-11-19), which rewrote the OS in Rust, cut keystroke latency
40100 %, and trimmed power draw 30 % typing / 50 % idle on both
Traveler and Smart Typewriter Gen 3. Three rows rescored upward as a
result: W1 Traveler 3→4 / Smart 2→3 (Smart's larger panel still trails
Traveler by one notch), W5 both 3→4 (boot accelerated, no published
number), W9 both 1→2 (Rust rewrite explicitly unblocked features that
JS could not carry; still closed so neither reaches reMarkable's
hackable-Linux 3).
| ID | WHAT (truncated) | Typoena | reM. | Frw.T | Frw.S | Pom. | Rationale (shortest defensible) |
| --- | ------------------------------------------------- | :-----: | :--: | :---: | :---: | :--: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| W1 | Sub-second response to typing | 2 | 1 | 4 | 3 | 5 | Typoena type latency measured ~630 ms (2026-07-11) — a visible per-keystroke lag, sub-second but well behind the Freewrites' ~200 ms (v0.1 H1 target relaxed ≤200→≤400 ms, still unmet), so 2 not 4; reMarkable e-ink visibly laggy on a typing-focused device — tested less responsive than Smart Typewriter, and latency is so load-bearing for W1 that it earns a 1 not a 2; both Freewrites post-Sailfish trimmed latency 40100 % (Frw.T plausibly inside 200 ms; Frw.S still trails by one notch on larger panel); Pomera LCD ~zero. |
| W2 | Publishing is one deliberate action away | 5 | 4 | 4 | 4 | 2 | Ctrl-G atomic; reMarkable + Freewrite cloud-sync is one-tap but not git; Pomera = USB/SD copy or QR transfer. |
| W3 | Pulling power never corrupts the file | 4 | 4 | 2 | 2 | 2 | Typoena: atomic-rename + fsync (round-trip verified 2026-07-11; power-pull test deferred to v0.9). reMarkable journals. Freewrite + Pomera: forum reports of corruption on yank. |
| W4 | Provisioning never interrupts writing | 5 | 2 | 2 | 2 | 5 | Typoena v0.1: build-time config (dev-only). reM/Frw need Wi-Fi + account. Pomera: literally none. |
| W5 | Quick boot to a writing cursor | 4 | 3 | 4 | 4 | 5 | Typoena measured 4.26 s cold (2026-07-11). reMarkable cold-boots ~20 s (great from sleep). Both Freewrites accelerated post-Sailfish (no published number; were ~1015 s e-ink wake). Pomera ~3 s. |
| W6 | Long sessions without crash / lag / drift | 4 | 3 | 4 | 4 | 5 | Typoena: 1 h soak attested 2026-07-11 (real use, no crash / lag / leak) — one proven hour vs rivals' years, so 4 not 5. Freewrite famously stable (both variants). Pomera firmware is decades-mature. |
| W7 | Nothing on the device competes with prose | 5 | 2 | 5 | 5 | 5 | reMarkable has apps, menus, drawing, PDFs. Freewrite + Pomera are single-purpose; Typoena by design. |
| W8 | The UI never moves except when I move it | 4 | 3 | 4 | 4 | 5 | reMarkable animates more; Typoena uses dirty-rects; Freewrites minimal motion; Pomera near-static LCD. |
| W9 | Codebase absorbs the planned roadmap | 4 | 3 | 2 | 2 | 1 | Modular Rust Typoena; reMarkable is hackable Linux; both Freewrites carry Sailfish (Rust rewrite explicitly unblocked features JS could not carry) but closed; Pomera closed firmware. |
| W10 | I can repair or fork it with hobbyist tools | 5 | 4 | 2 | 2 | 1 | Typoena: open BOM + ESP32. reMarkable: rooted Linux + community ROMs. Freewrite + Pomera: closed. |
| W11 | Multi-day battery life (v0.8 onward) | 1 | 5 | 5 | 5 | 4 | Typoena v0.1 = wall-powered (battery deferred). reMarkable + both Freewrites legendary (~4 weeks; Sailfish trimmed 30 % typing / 50 % idle). Pomera ~24 h. |
| W12 | Local-only files coexist with git scope | 3 | 1 | 2 | 2 | 3 | Typoena v0.5+ design. reMarkable cloud-only. Freewrites have local + Postbox but no VCS. Pomera = pure local. |
| W13 | Typography sets a writing-tool tone | 3 | 5 | 2 | 2 | 2 | Typoena v0.1: single mono (serif option in v1.0). reMarkable: rich type rendering. Freewrite + Pomera: utilitarian. |
| W14 | I can carry the device and write away from a desk | 2 | 4 | 5 | 1 | 5 | Typoena v0.1 wall-powered (ADR-008), no enclosure spec yet — desk-bound by design. reMarkable + Type Folio bag-friendly with bulk. Freewrite Traveler is the form-factor reference (~1.6 lb, folds). Smart Typewriter ~5 lb, desk-bound. Pomera DM250 pocketable foldable. |
**Totals** (sum across 14 WHATs, no weighting): Typoena 51, Pomera 50,
Freewrite Traveler 47, reMarkable 44, Freewrite Smart Typewriter 42
(Typoena netted 52→51 on measurement: W6 +1 on the attested 1 h soak, W1 2
once type latency measured at ~630 ms, over the revised ≤400 ms target (was ≤200 ms); Traveler
pre-Sailfish 44; Smart pre-Sailfish 39; reMarkable W1 dropped
3→2→1 across two rounds of author testing — first to 2 after firsthand
typing, then to 1 once latency was recognised as the dominant W1
signal). Typoena's lead over Pomera is now a single point: W14 (portability)
and the measured W1 latency are the two dimensions on which v0.1's tethered,
e-ink MVP loses ground; v0.8 (battery) and a faster refresh path are what
recover it. The "Pomera + Wi-Fi + git + hackable BOM" framing from
`README.md` still holds, but reads as a closer contest until those land.
Weighted totals (Σ score × W weight) tell the same story with more
contrast — left as exercise; the unweighted view is enough to read the
picture.
#### Caveats
- **Single-rater bias.** All fourteen rows are scored from the project
author's POV. A reMarkable buyer would weight W11 (battery) at 10 and
W12 (git) at 1, flipping the totals.
- **Configuration matters.** Freewrite Smart Typewriter and Traveler are
both tracked; they diverge most on W1 / W5 because of display tech
(Smart's larger panel is slower to refresh). Traveler is still the
more direct competitor on form factor.
- **W3 / W6 Freewrite scores are anecdotal.** Forum reports, not bench
data. Treat the 2 / 4 as "we'd need to test this" rather than fact.
- **No price column.** Typoena-as-BOM is materially cheaper than the
competitors but cost is not a WHAT in §1, so it's absent here.
Worth a row if a v0.x WHAT ever calls it out.
### Regenerating
The matrix cells (`\node[qfdrel/{S,M,W}]`), roof symbols (`C-i-j` slots),
and basement Σ + Rel% are native to this file — re-score directly in the
TikZ source, then update the §3 priority list and §4 conflict list in
the narrative above to match the new picture.
When §1 or §2 changes:
1. Importance column → §1 weight column.
2. HOW titles + v0.1 targets → §2 target column.
3. Recompute basement Σ for any HOW whose column changed: per-cell
contribution = `(W weight) × (cell strength: 9 / 3 / 1 / 0)`.
4. Recompute relative weight: each Σ ÷ total Σ × 100, rounded to integer
percent.
Perception scores are **not** derived from §1/§2 — they live only in
this file. Update them when (a) a competitor ships a relevant change,
(b) measurement replaces a guess, or (c) a WHAT is added/removed in §1.
Each score keeps its one-line rationale in the table above.
If a renderer rejects the `tikz` fence, the file is still readable as
source — the placement comments name each WHAT, HOW, and cell. The
perception-scores table above is the human-readable fallback for the
right-hand zone of the diagram.
---
## 4. Roof — HOW-vs-HOW tradeoffs
@@ -161,9 +859,9 @@ before the runtime decision does.
The roof shows where pushing one characteristic pushes another the wrong way.
ASCII glyphs (with classical QFD equivalents): **`++`** strong
reinforcement (`◎`), **`+`** mild reinforcement (`○`), **``** mild
conflict (`×`), **``** strong conflict (`⊗`). The 14×14 roof matrix
lives in [`quality-house.md`](quality-house.md); the cells that
actually shape the design are called out below.
conflict (`×`), **``** strong conflict (`⊗`). The 14×14 roof matrix is
in the §3 diagram; the cells that actually shape the design are called out
below.
### Conflicts that actually shape the design
@@ -172,7 +870,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
@@ -263,7 +964,7 @@ HOW-to-component matrix (9 strong / 3 medium / 1 weak):
is just the library that implements it. Changing [ADR-010] doesn't change
C12's column, but changing C12 (the kill-switch) does not change
[ADR-010]'s user contract.
- **C11** (LittleFS) is unused in v0.1 config is build-time. Its non-zero
- **C11** (LittleFS) is unused in v0.1: config is build-time. Its non-zero
cells in the matrix describe the v0.9+ shape per [ADR-007], not v0.1
reality.
- **C2** (std runtime) sits underneath almost everything, but it's the
@@ -288,10 +989,10 @@ numbers spikes 27 must validate before integration starts.
| 1 | H2 region area | ≤ 1 line per keypress | spike 2 + spike 5 | Increase font size to shrink per-glyph dirty rect ([ADR-003] consequence) |
| 2 | H9 PSRAM heap | ≥ 1 MB free at push peak | spike 7 | [ADR-004] kill-switch → `libgit2-sys`; cap rope at 128 KB |
| 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 |
| 4 | H1 Type latency | ≤ 400 ms (revised from ≤ 200 ms) | ~630 ms 2026-07-11 ✗ | Still over target — windowed-Y refresh already in; batch multi-char bursts; open v0.1 gap |
| 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:
@@ -356,7 +1057,7 @@ These are the live tensions we are watching, not deciding harder:
Updated [ADR-006]'s Consequences section to reflect the actual budget
and cross-reference the tech doc. The 76 KB figure still fits
comfortably in the ESP32-S3's 512 KB internal SRAM, so no design
change just documentation accuracy.
change, just documentation accuracy.
- **Commit-message format triple-mismatch.** README said `git commit -m
"wip"`, the v0.1 product doc said `"wip <timestamp>"`, and the user's
actual shell alias (`gct` / `git-commit-timestamp`) uses a pure ISO-8601
@@ -386,7 +1087,7 @@ These are the live tensions we are watching, not deciding harder:
- **W13 reframed, W14 removed.** Earlier W13/W14 rows named solutions
("beautiful monospace", "beautiful serif") inside the requirements
column, conflating _what the user values_ with _which asset delivers it_.
Replaced with one outcome WHAT typography sets a writing-tool tone
Replaced with one outcome WHAT (typography sets a writing-tool tone),
and moved the mono+serif option to §7 as a v1.0 unresolved tension.
Σ shifted (H9 205→193, H2 198→177, H1 155→148) because the prior
W13/W14 cells were scoring solution-fit rather than outcome-fit.
@@ -428,8 +1129,8 @@ These are the live tensions we are watching, not deciding harder:
honest reading that "codebase absorbs the planned roadmap" is
delivered by ADRs, not by a measurable characteristic. ID "H14" left
as a gap (cross-doc HOW references survive without renumbering H15).
Total basement Σ drops 1674 → 1557, so rel% recomputed in
[`quality-house.md`](quality-house.md).
Total basement Σ drops 1674 → 1557, so rel% recomputed in the §3
basement.
- **HOWs renamed "characteristics," not "functions."** A function is a
transformation (input → output); HOWs like H6 "success rate" and
H10 "binary size" are *measures* of functions or properties of
@@ -473,6 +1174,42 @@ 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.
- **Typoena perception column rebased target → measured (2026-07-11).**
With v0.1 delivered and hardware-verified, the §3 right-hand zone's Typoena
profile is the shipped v0.1 result, not a §2 target projection: legend +
caption relabelled "v0.1 measured", W5 rationale now cites the 4.26 s cold
boot, W3 notes the verified atomic round-trip (power-pull test still deferred
to v0.9), **W6 rose 3→4** on the attested 1 h soak, and **W1 dropped 4→2**
once type latency was measured at ~630 ms (over the revised ≤400 ms target).
Net Typoena total 52→51, trimming its lead over Pomera to a single point.
Competitor scores untouched (no new external release). Two drifts caught in the same pass: the
TikZ W14 row scored Pomera/Smart 2/5 while the authoritative table and totals
use 5/1 (Smart ~5 lb desk-bound = 1, Pomera pocketable = 5), TikZ corrected
to match; and the Caveats "thirteen rows" corrected to "fourteen" (§1 has 14
WHATs).
- **H1 type-latency target relaxed ≤200 → ≤400 ms; v1.0 reset to ≤300 ms
(2026-07-11).** Cold per-keystroke render measures ~630 ms, so §2's v0.1 H1
target moved from ≤ 200 ms to ≤ 400 ms and gained a footnote; §3's basement
target text and §6's rank-4 row followed. The relaxed target is unmet: ~630 ms
still exceeds ≤ 400 ms (the open v0.1 latency gap), though a longer wait is
acceptable for now; next-version usage will settle it. The perception W1
score dropped 4→2 to match. The v1.0 figure was reset from ≤ 150 ms to
≤ 300 ms ([ADR-003]'s ~200300 ms floor); ≤ 150 ms sat below what the panel
can deliver.
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
@@ -493,8 +1230,8 @@ README, the product/technical docs, and [ADR-003] are all updated to ~13 lines
reality drifts from estimates.
- The WHATs (§1) change rarely; the HOWs (§2) change with each release.
When either changes, re-score the matrix and recompute the basement Σ
in [`quality-house.md`](quality-house.md); then check the §3 priority
list and §4 conflict list here still match the new picture.
in the §3 diagram; then check the §3 priority list and §4 conflict list
here still match the new picture.
[ADR-001]: adr.md#adr-001-language-and-runtime--rust-on-esp-idf-rs-std
[ADR-002]: adr.md#adr-002-ui-strategy--custom-widgets-on-embedded-graphics-not-ratatui

View File

@@ -1,9 +1,9 @@
# Quality House — empty (training)
Same 14 WHATs × 14 HOWs chassis as [`quality-house.md`](quality-house.md),
with the **relations**, **roof correlations**, and basement Σ / Rel %
**deliberately blank**. Fill them in yourself, then compare against the
populated house to check your reading.
Same 14 WHATs × 14 HOWs chassis as the filled House in
[`qfd.md` §3](qfd.md#3-house-of-quality--whats--hows), with the **relations**,
**roof correlations**, and basement Σ / Rel % **deliberately blank**. Fill them
in yourself, then compare against the populated house to check your reading.
Kept (definitional inputs from `qfd.md`):
@@ -368,9 +368,10 @@ Blank (training surface):
4. **Basement Rel %.** Each Σ ÷ total Σ × 100, rounded to integer percent
(should sum to ~100 across HOWs).
Once filled, diff your numbers against [`quality-house.md`](quality-house.md)
basement and roof to see where your reading agrees or differs — divergences
are usually the most interesting part.
Once filled, diff your numbers against the filled House in
[`qfd.md` §3](qfd.md#3-house-of-quality--whats--hows) (basement and roof) to see
where your reading agrees or differs — divergences are usually the most
interesting part.
## Gotchas

View File

@@ -1,675 +0,0 @@
# Quality House
The 14 WHATs × 14 HOWs House of Quality. The roof carries function-vs-
function correlations; the basement carries v0.1 targets (mirrored from
[`qfd.md`](qfd.md) §2) plus the weighted-vote sums
(`Σ = Σ(W weight × cell strength)`) and rounded relative weights. The
narrative reading of these numbers lives in `qfd.md` §3 (priority list)
and §4 (conflict list).
This file is the authoritative source for the matrix cells, the roof
correlations, and the basement Σ + relative weights. WHATs (§1) and HOWs
(§2) are mirrored here from `qfd.md`; if those diverge, trust `qfd.md`.
Out of scope here: §5 component mapping (would need a second house),
§6 critical performance budget (already a curated rank), §7 tradeoffs
(narrative), §8 inconsistencies (history).
For a blank version of the same chassis (WHATs, HOWs, importance, and
v0.1 targets kept; relations + roof + Σ basement left empty for practice),
see [`quality-house-empty.md`](quality-house-empty.md).
The perception zone scores five products against the WHATs on a 05 scale.
**These are educated guesses, not measurements** — see the
[scoring rationale](#perception-scores-guessed) below for what each cell
is based on. Useful for self-positioning ("where do we land vs the
market"), not as a fair head-to-head buyer's guide.
```tikz
% =====================================================================
% QFD "House of Quality" preamble
% =====================================================================
\usetikzlibrary{arrows.meta, positioning, shapes.geometric, shapes.misc, calc, fit, backgrounds}
\newif\ifqfdshowroof \qfdshowrooftrue
\newif\ifqfdshowbasement \qfdshowbasementtrue
\newif\ifqfdshowcompetitive \qfdshowcompetitivetrue
\newif\ifqfdshowlegend \qfdshowlegendtrue
\newif\ifqfdshowimportance \qfdshowimportancetrue
\newif\ifqfdshowcorrlegend \qfdshowcorrlegendtrue
\newif\ifqfdshowevallegend \qfdshowevallegendtrue
\def\qfdNW{5}
\def\qfdNH{5}
\def\qfdWhatW{4.0}
\def\qfdImpW{0.9}
\def\qfdCmpW{3}
\def\qfdHdrH{2.6}
\def\qfdBasementN{4}
\def\qfdWhatsTitle{Customer needs}
\def\qfdImpTitle{Imp.\ \%}
\def\qfdPerceptionTitle{Comparative evaluation}
\def\qfdPoorLabel{poor}
\def\qfdExcellentLabel{excellent}
\def\qfdAltOneLabel{Typoena}
\def\qfdAltTwoLabel{Competitor A}
\def\qfdAltThreeLabel{Competitor B}
\def\qfdRelTitle{Relation}
\def\qfdCorrTitle{Correlation}
\def\qfdEvalTitle{Evaluation}
\tikzset{
qfdthin/.style ={line width=0.35pt},
qfdmed/.style ={line width=0.7pt},
qfdstrong/.style={circle, draw, fill=black,
minimum size=7pt, inner sep=0pt},
qfdmod/.style ={circle, draw,
minimum size=7pt, inner sep=0pt, line width=0.8pt},
qfdweak/.style ={regular polygon, regular polygon sides=3, draw,
minimum size=8.5pt, inner sep=0pt, line width=0.7pt},
qfdrel/.is choice,
qfdrel/S/.style={qfdstrong},
qfdrel/M/.style={qfdmod},
qfdrel/W/.style={qfdweak},
qfdalt1mk/.style={circle, draw, fill=black,
minimum size=6pt, inner sep=0pt, line width=1pt},
qfdalt1ln/.style={line width=1.2pt},
qfdalt2mk/.style={regular polygon, regular polygon sides=3, draw,
fill=black, minimum size=6pt, inner sep=0pt,
line width=0.7pt},
qfdalt2ln/.style={line width=0.7pt, dashed},
qfdalt3mk/.style={rectangle, draw, fill=black,
minimum size=5pt, inner sep=0pt, line width=0.7pt},
qfdalt3ln/.style={line width=0.7pt, dotted},
}
\newcommand{\qfdDrawGrid}{%
\foreach \c in {1,...,\qfdNHm} \draw[qfdthin] (\c, 0) -- (\c, -\qfdNW);
\foreach \r in {1,...,\qfdNWm} \draw[qfdthin] (0, -\r) -- (\qfdNH, -\r);
\foreach \r in {1,...,\qfdNWm}
\draw[qfdthin] (\qfdLeftEdge, -\r) -- (0, -\r);
\ifqfdshowroof
\foreach \c in {1,...,\qfdNHm}
\draw[qfdthin] (\c, 0) -- (\c, \qfdHdrH);
\fi
\ifqfdshowcompetitive
\foreach \r in {1,...,\qfdNWm}
\draw[qfdthin] (\qfdNH, -\r) -- (\qfdNH+\qfdCmpW, -\r);
\fi
\ifqfdshowbasement
\foreach \r in {1,...,\qfdBasementN}
\draw[qfdthin] (0, -\qfdNW-\r) -- (\qfdNH, -\qfdNW-\r);
\foreach \c in {1,...,\qfdNHm}
\draw[qfdthin] (\c, -\qfdNW) -- (\c, -\qfdNW-\qfdBasementN);
\fi
}
\newcommand{\qfdDrawRoof}{%
\ifqfdshowroof
\foreach \k in {1,...,\qfdNHm} {%
\pgfmathsetmacro{\rx}{(\k+\qfdNH)/2}
\pgfmathsetmacro{\ry}{\qfdHdrH + (\qfdNH-\k)/2}
\pgfmathsetmacro{\lx}{\k/2}
\pgfmathsetmacro{\ly}{\qfdHdrH + \k/2}
\draw[qfdthin] (\k, \qfdHdrH) -- (\rx, \ry);
\draw[qfdthin] (\k, \qfdHdrH) -- (\lx, \ly);
}%
\draw[qfdmed] (0, \qfdHdrH)
-- (\qfdNH/2, \qfdApexY) -- (\qfdNH, \qfdHdrH);
\foreach \i in {1,...,\qfdNH}
\foreach \k in {1,...,\qfdNH} {%
\pgfmathtruncatemacro{\jj}{\i+\k}
\ifnum\jj>\qfdNH\relax\else
\pgfmathsetmacro{\xx}{\i + \k/2 - 0.5}
\pgfmathsetmacro{\yy}{\qfdHdrH + \k/2}
\coordinate (C-\i-\jj) at (\xx, \yy);
\fi
}%
\fi
}
\newcommand{\qfdDrawScale}{%
\ifqfdshowcompetitive
\foreach \tk in {0,1,2,3,4,5} {%
\pgfmathsetmacro{\tx}{\qfdNH + (\tk+0.5)*\qfdCmpW/6}
\node[anchor=south, font=\scriptsize] at (\tx, 0.02) {\tk};
}%
\node[anchor=south, font=\scriptsize\bfseries, align=center,
text width=\qfdCmpW cm]
at ({\qfdNH + \qfdCmpW/2}, 0.7) {\qfdPerceptionTitle};
\node[anchor=north, font=\scriptsize\itshape]
at ({\qfdNH + 0.45}, -\qfdNW) {\qfdPoorLabel};
\node[anchor=north, font=\scriptsize\itshape]
at ({\qfdNH + \qfdCmpW - 0.45}, -\qfdNW) {\qfdExcellentLabel};
\fi
}
\newcommand{\qfdDrawZoneTitles}{%
\ifqfdshowimportance
\node[rotate=90, anchor=west, font=\footnotesize\bfseries]
at ({-\qfdImpW/2}, 0.12) {\qfdImpTitle};
\fi
\node[font=\scriptsize\bfseries, align=center, text width=\qfdWhatW cm]
at ({\qfdLeftEdge + \qfdWhatW/2},
{\ifqfdshowroof \qfdHdrH/2 \else 0.6 \fi}) {\qfdWhatsTitle};
}
\newcommand{\qfdDrawFrames}{%
\begin{scope}[qfdmed]
\draw (\qfdLeftEdge, 0) rectangle (\qfdNH, -\qfdNW);
\ifqfdshowimportance \draw (-\qfdImpW, 0) -- (-\qfdImpW, -\qfdNW); \fi
\draw (0, 0) -- (0, -\qfdNW);
\ifqfdshowroof
\draw (0, 0) rectangle (\qfdNH, \qfdHdrH); \fi
\ifqfdshowbasement
\draw (0, -\qfdNW) rectangle (\qfdNH, -\qfdNW-\qfdBasementN); \fi
\ifqfdshowcompetitive
\draw (\qfdNH, 0) rectangle (\qfdNH+\qfdCmpW, -\qfdNW); \fi
\end{scope}
}
\newcommand{\qfdDrawLegend}{%
\ifqfdshowlegend
\pgfmathsetmacro{\qfdLegX}{%
\qfdNH + \ifqfdshowcompetitive \qfdCmpW + 0.7 \else 0.7 \fi}
\pgfmathsetmacro{\qfdLegBottom}{%
-2.05
\ifqfdshowroof \ifqfdshowcorrlegend - 2.55 \fi \fi
\ifqfdshowcompetitive \ifqfdshowevallegend - 2.20 \fi \fi}
\pgfmathsetmacro{\qfdLegY}{\qfdHdrH - 0.4}
\begin{scope}[shift={(\qfdLegX, \qfdLegY)}]
\draw[qfdmed, rounded corners=2pt]
(-0.15, 0.4) rectangle (4.5, \qfdLegBottom);
\node[anchor=west, font=\footnotesize\bfseries] at (0, 0.1)
{\qfdRelTitle};
\draw[qfdthin] (0, -0.15) -- (4.35, -0.15);
\node[qfdstrong] at (0.22, -0.5) {};
\node[anchor=west] at (0.5, -0.5) {Strong (9)};
\node[qfdmod] at (0.22, -0.95) {};
\node[anchor=west] at (0.5, -0.95) {Medium (3)};
\node[qfdweak] at (0.22, -1.4) {};
\node[anchor=west] at (0.5, -1.4) {Weak (1)};
\ifqfdshowroof \ifqfdshowcorrlegend
\node[anchor=west, font=\footnotesize\bfseries] at (0, -2.10)
{\qfdCorrTitle};
\draw[qfdthin] (0, -2.35) -- (4.35, -2.35);
\node[anchor=west] at (0, -2.70) {{$+\!+$}\quad very positive};
\node[anchor=west] at (0, -3.05) {{$+$\phantom{$+$}}\quad positive};
\node[anchor=west] at (0, -3.40) {{$-$\phantom{$-$}}\quad negative};
\node[anchor=west] at (0, -3.75) {{$-\!-$}\quad very negative};
\fi \fi
\ifqfdshowcompetitive \ifqfdshowevallegend
\pgfmathsetmacro{\qfdEvalTop}{%
-2.10 \ifqfdshowroof\ifqfdshowcorrlegend - 2.55 \fi\fi}
\node[anchor=west, font=\footnotesize\bfseries]
at (0, \qfdEvalTop) {\qfdEvalTitle};
\pgfmathsetmacro{\qfdEvalSep}{\qfdEvalTop - 0.25}
\draw[qfdthin] (0, \qfdEvalSep) -- (4.35, \qfdEvalSep);
\pgfmathsetmacro{\qfdLegA}{\qfdEvalTop - 0.55}
\draw[qfdalt1ln] (0.05, \qfdLegA) -- (0.45, \qfdLegA);
\node[qfdalt1mk] at (0.25, \qfdLegA) {};
\node[anchor=west, font=\bfseries] at (0.55, \qfdLegA)
{\qfdAltOneLabel};
\pgfmathsetmacro{\qfdLegB}{\qfdEvalTop - 0.95}
\draw[qfdalt2ln] (0.05, \qfdLegB) -- (0.45, \qfdLegB);
\node[qfdalt2mk] at (0.25, \qfdLegB) {};
\node[anchor=west] at (0.55, \qfdLegB) {\qfdAltTwoLabel};
\pgfmathsetmacro{\qfdLegC}{\qfdEvalTop - 1.35}
\draw[qfdalt3ln] (0.05, \qfdLegC) -- (0.45, \qfdLegC);
\node[qfdalt3mk] at (0.25, \qfdLegC) {};
\node[anchor=west] at (0.55, \qfdLegC) {\qfdAltThreeLabel};
\fi \fi
\end{scope}
\fi
}
\newenvironment{qfdhouse}{%
\begin{tikzpicture}[x=1cm, y=1cm, font=\scriptsize,
line cap=round, line join=round]
\ifqfdshowimportance
\pgfmathsetmacro{\qfdLeftEdge}{-\qfdWhatW-\qfdImpW}
\else
\pgfmathsetmacro{\qfdLeftEdge}{-\qfdWhatW}
\fi
\pgfmathsetmacro{\qfdApexY}{\qfdHdrH + \qfdNH/2}
\pgfmathtruncatemacro{\qfdNHm}{\qfdNH - 1}
\pgfmathtruncatemacro{\qfdNWm}{\qfdNW - 1}
\qfdDrawGrid
\qfdDrawRoof
\qfdDrawScale
\qfdDrawZoneTitles
}{%
\qfdDrawFrames
\qfdDrawLegend
\end{tikzpicture}%
}
% --- Dimensions tuned for the typewriter QFD (14 W x 15 H) ---
\def\qfdNW{14}
\def\qfdNH{14}
\def\qfdWhatW{4.6}
\def\qfdImpW{0.7}
\def\qfdHdrH{5.0}
\def\qfdBasementN{3}
\def\qfdCmpW{3.4}
\qfdshowlegendfalse % we draw a 4-alternative legend manually
\def\qfdWhatsTitle{User-facing requirements (W)}
\def\qfdImpTitle{Weight}
\def\qfdPerceptionTitle{Competitive perception\\(05, guessed)}
\def\qfdPoorLabel{poor}
\def\qfdExcellentLabel{excellent}
% Perception-zone markers: shape + colour-blind-safe colour per product.
% Palette is Okabe-Ito (blue, vermillion, bluish green, reddish purple).
% Light fills + saturated outlines keep stacked markers legible.
\definecolor{qfdcTypoena}{RGB}{0,114,178}
\definecolor{qfdcRem}{RGB}{213,94,0}
\definecolor{qfdcFrw}{RGB}{0,158,115}
\definecolor{qfdcPom}{RGB}{204,121,167}
\definecolor{qfdcFrwS}{RGB}{86,180,233}
\tikzset{
qfdalt1mk/.style={circle, draw=qfdcTypoena, fill=qfdcTypoena!55!white,
minimum size=6.5pt, inner sep=0pt, line width=1.1pt},
qfdalt1ln/.style={line width=1.2pt, qfdcTypoena},
qfdalt2mk/.style={regular polygon, regular polygon sides=3,
draw=qfdcRem, fill=qfdcRem!55!white,
minimum size=7pt, inner sep=0pt, line width=0.9pt},
qfdalt2ln/.style={line width=0.8pt, dashed, qfdcRem},
qfdalt3mk/.style={rectangle, draw=qfdcFrw, fill=qfdcFrw!55!white,
minimum size=5.5pt, inner sep=0pt, line width=0.9pt},
qfdalt3ln/.style={line width=0.8pt, dotted, qfdcFrw},
qfdalt4mk/.style={diamond, aspect=1, draw=qfdcPom,
fill=qfdcPom!50!white,
minimum size=7pt, inner sep=0pt, line width=1.0pt},
qfdalt4ln/.style={line width=0.8pt, dash dot, qfdcPom},
qfdalt5mk/.style={regular polygon, regular polygon sides=5,
draw=qfdcFrwS, fill=qfdcFrwS!40!white,
minimum size=5.5pt, inner sep=0pt, line width=0.8pt},
qfdalt5ln/.style={line width=0.7pt, dash dot dot, qfdcFrwS},
}
\begin{document}
\begin{qfdhouse}
% ---------- WHATs (left column) ----------
% Box width is 0.2 cm narrower than the column so labels have 0.1 cm
% clearance on each side and don't bleed into the Weight column.
\pgfmathsetmacro{\qfdWhatTextW}{\qfdWhatW - 0.2}
\foreach \r/\t in {%
1/{W1 Sub-second visible response to typing},
2/{W2 Publishing is one deliberate action away},
3/{W3 Pulling power never corrupts the file},
4/{W4 Provisioning never interrupts a writing session},
5/{W5 Quick boot to a writing cursor},
6/{W6 Long sessions without crash, lag, drift},
7/{W7 Nothing on the device competes with prose},
8/{W8 The UI never moves except when I move it},
9/{W9 Codebase absorbs the planned roadmap},
10/{W10 I can repair or fork it with hobbyist tools},
11/{W11 Multi-day battery life (v0.8 onward)},
12/{W12 Local-only files coexist with git scope (v0.5+)},
13/{W13 Typography sets a writing-tool tone},
14/{W14 I can carry the device and write away from a desk}%
}
\node[anchor=west, font=\scriptsize,
text width=\qfdWhatTextW cm, align=left]
at ({\qfdLeftEdge + 0.1}, {-\r + 0.5}) {\t};
% ---------- Importance (raw 1-10 weight) ----------
\foreach \r/\w in {1/10, 2/9, 3/10, 4/7, 5/6, 6/9, 7/8, 8/7,
9/8, 10/5, 11/4, 12/5, 13/7, 14/8}
\node[font=\scriptsize] at ({-\qfdImpW/2}, {-\r + 0.5}) {\w};
% ---------- HOWs (rotated column titles) ----------
\foreach \c/\t in {%
1/{H1 Type latency},
2/{H2 Refresh area per keystroke},
3/{H3 Full-refresh cadence},
4/{H4 Boot latency (cold)},
5/{H5 Continuous-typing endurance},
6/{H6 Publish reliability},
7/{H7 Publish latency},
8/{H8 Save durability},
9/{H9 PSRAM heap headroom},
10/{H10 Firmware binary size},
11/{H11 Total stack budget},
12/{H12 Network reconnect time},
13/{H13 Idle / typing / push current},
14/{H15 Clean release build time}%
}
\node[rotate=90, anchor=west, font=\scriptsize]
at ({\c - 0.5}, 0.15) {\t};
% ---------- Relation matrix (S=9, M=3, W=1) ----------
% W1 row 1: H1S H2S H3M H5M H9W H11W
\node[qfdrel/S] at ({1 - 0.5}, {-1 + 0.5}) {};
\node[qfdrel/S] at ({2 - 0.5}, {-1 + 0.5}) {};
\node[qfdrel/M] at ({3 - 0.5}, {-1 + 0.5}) {};
\node[qfdrel/M] at ({5 - 0.5}, {-1 + 0.5}) {};
\node[qfdrel/W] at ({9 - 0.5}, {-1 + 0.5}) {};
\node[qfdrel/W] at ({11 - 0.5}, {-1 + 0.5}) {};
% W2 row 2: H6S H7M H9S H12S
\node[qfdrel/S] at ({6 - 0.5}, {-2 + 0.5}) {};
\node[qfdrel/M] at ({7 - 0.5}, {-2 + 0.5}) {};
\node[qfdrel/S] at ({9 - 0.5}, {-2 + 0.5}) {};
\node[qfdrel/S] at ({12 - 0.5}, {-2 + 0.5}) {};
% W3 row 3: H8S
\node[qfdrel/S] at ({8 - 0.5}, {-3 + 0.5}) {};
% W4 row 4: H6M H12M
\node[qfdrel/M] at ({6 - 0.5}, {-4 + 0.5}) {};
\node[qfdrel/M] at ({12 - 0.5}, {-4 + 0.5}) {};
% W5 row 5: H4S H10M
\node[qfdrel/S] at ({4 - 0.5}, {-5 + 0.5}) {};
\node[qfdrel/M] at ({10 - 0.5}, {-5 + 0.5}) {};
% W6 row 6: H1M H3M H5S H6M H8M H9S H11M H12M
\node[qfdrel/M] at ({1 - 0.5}, {-6 + 0.5}) {};
\node[qfdrel/M] at ({3 - 0.5}, {-6 + 0.5}) {};
\node[qfdrel/S] at ({5 - 0.5}, {-6 + 0.5}) {};
\node[qfdrel/M] at ({6 - 0.5}, {-6 + 0.5}) {};
\node[qfdrel/M] at ({8 - 0.5}, {-6 + 0.5}) {};
\node[qfdrel/S] at ({9 - 0.5}, {-6 + 0.5}) {};
\node[qfdrel/M] at ({11 - 0.5}, {-6 + 0.5}) {};
\node[qfdrel/M] at ({12 - 0.5}, {-6 + 0.5}) {};
% W7 row 7: H1M H2M H3M H13M
\node[qfdrel/M] at ({1 - 0.5}, {-7 + 0.5}) {};
\node[qfdrel/M] at ({2 - 0.5}, {-7 + 0.5}) {};
\node[qfdrel/M] at ({3 - 0.5}, {-7 + 0.5}) {};
\node[qfdrel/M] at ({13 - 0.5}, {-7 + 0.5}) {};
% W8 row 8: H1W H2S H3S
\node[qfdrel/W] at ({1 - 0.5}, {-8 + 0.5}) {};
\node[qfdrel/S] at ({2 - 0.5}, {-8 + 0.5}) {};
\node[qfdrel/S] at ({3 - 0.5}, {-8 + 0.5}) {};
% W9 row 9: H10W H11W H15M
\node[qfdrel/W] at ({10 - 0.5}, {-9 + 0.5}) {};
\node[qfdrel/W] at ({11 - 0.5}, {-9 + 0.5}) {};
\node[qfdrel/M] at ({14 - 0.5}, {-9 + 0.5}) {};
% W10 row 10: H10M H13W H15W
\node[qfdrel/M] at ({10 - 0.5}, {-10 + 0.5}) {};
\node[qfdrel/W] at ({13 - 0.5}, {-10 + 0.5}) {};
\node[qfdrel/W] at ({14 - 0.5}, {-10 + 0.5}) {};
% W11 row 11: H13S
\node[qfdrel/S] at ({13 - 0.5}, {-11 + 0.5}) {};
% W12 row 12: H6W H8M
\node[qfdrel/W] at ({6 - 0.5}, {-12 + 0.5}) {};
\node[qfdrel/M] at ({8 - 0.5}, {-12 + 0.5}) {};
% W13 row 13: H9M
\node[qfdrel/M] at ({9 - 0.5}, {-13 + 0.5}) {};
% W14 row 14: H4W H8M H12M H13S
\node[qfdrel/W] at ({4 - 0.5}, {-14 + 0.5}) {};
\node[qfdrel/M] at ({8 - 0.5}, {-14 + 0.5}) {};
\node[qfdrel/M] at ({12 - 0.5}, {-14 + 0.5}) {};
\node[qfdrel/S] at ({13 - 0.5}, {-14 + 0.5}) {};
% ---------- Roof correlations ----------
\node[font=\scriptsize] at (C-1-2) {$+\!+$}; % H1-H2 strong reinforce
\node[font=\scriptsize] at (C-1-3) {$-$}; % H1-H3 mild conflict
\node[font=\scriptsize] at (C-1-5) {$+$}; % H1-H5 mild reinforce
\node[font=\scriptsize] at (C-1-13) {$-$}; % H1-H13 mild conflict
\node[font=\scriptsize] at (C-2-3) {$+\!+$}; % H2-H3 strong reinforce
\node[font=\scriptsize] at (C-2-13) {$+$}; % H2-H13
\node[font=\scriptsize] at (C-3-13) {$+$}; % H3-H13
\node[font=\scriptsize] at (C-4-10) {$-$}; % H4-H10 boot vs binary
\node[font=\scriptsize] at (C-5-6) {$+$}; % H5-H6
\node[font=\scriptsize] at (C-5-8) {$+$}; % H5-H8
\node[font=\scriptsize] at (C-5-9) {$-\!-$}; % H5-H9 soak vs heap
\node[font=\scriptsize] at (C-6-7) {$+$}; % H6-H7
\node[font=\scriptsize] at (C-6-9) {$-\!-$}; % H6-H9 push vs heap
\node[font=\scriptsize] at (C-6-12) {$+\!+$}; % H6-H12
\node[font=\scriptsize] at (C-7-9) {$-$}; % H7-H9
\node[font=\scriptsize] at (C-7-12) {$+\!+$}; % H7-H12
\node[font=\scriptsize] at (C-9-10) {$-\!-$}; % H9-H10 heap vs binary
\node[font=\scriptsize] at (C-10-14) {$-\!-$}; % H10-H15 binary vs build
\node[font=\scriptsize] at (C-11-13) {$-$}; % H11-H13
% ---------- Basement: target / abs weight / rel weight % ----------
\foreach \c/\tgt/\abs/\rel in {%
1/{$\leq$200\,ms}/148/10,
2/{$\leq$1 line}/177/11,
3/{1 : 20}/144/9,
4/{$\leq$5\,s}/62/4,
5/{$\geq$1\,h}/111/7,
6/{$\geq$95\,\%}/134/9,
7/{$\leq$30\,s}/27/2,
8/{100\,\%}/156/10,
9/{$\geq$1\,MB}/193/12,
10/{$\leq$2\,MB}/41/3,
11/{$\leq$80\,KB}/45/3,
12/{$\leq$30\,s}/153/10,
13/{obs.}/137/9,
14/{$\leq$7\,min}/29/2%
} {
\node[font=\scriptsize] at ({\c - 0.5}, {-\qfdNW - 0.5}) {\tgt};
\node[font=\scriptsize] at ({\c - 0.5}, {-\qfdNW - 1.5}) {\abs};
\node[font=\scriptsize\bfseries]
at ({\c - 0.5}, {-\qfdNW - 2.5}) {\rel};
}
% ---------- Basement row labels (in the margin below WHATs) ----------
\foreach \k/\lbl in {1/{Target (v0.1)}, 2/{$\Sigma$ abs}, 3/{Rel.\ \%}}
\node[anchor=east, font=\scriptsize\itshape]
at ({-0.1}, {-\qfdNW - \k + 0.5}) {\lbl};
% ---------- Perception zone: 5 products x 14 WHATs (0-5 scores) ----------
% Columns: \so=Typoena target, \st=reMarkable 2 + Type Folio,
% \sf=Freewrite Traveler, \sg=Pomera DM250,
% \sh=Freewrite Smart Typewriter.
% Pass 1: stash each score as a named coordinate so the profile lines
% below can reuse it without recomputing.
\foreach \r/\so/\st/\sf/\sg/\sh in {%
1/4/1/4/5/3,
2/5/4/4/2/4,
3/4/4/2/2/2,
4/5/2/2/5/2,
5/4/3/4/5/4,
6/3/3/4/5/4,
7/5/2/5/5/5,
8/4/3/4/5/4,
9/4/3/2/1/2,
10/5/4/2/1/2,
11/1/5/5/4/5,
12/3/1/2/3/2,
13/3/5/2/2/2,
14/2/4/5/2/5%
} {
\pgfmathsetmacro{\xo}{\qfdNH + (\so + 0.5)*\qfdCmpW/6}
\pgfmathsetmacro{\xt}{\qfdNH + (\st + 0.5)*\qfdCmpW/6}
\pgfmathsetmacro{\xf}{\qfdNH + (\sf + 0.5)*\qfdCmpW/6}
\pgfmathsetmacro{\xg}{\qfdNH + (\sg + 0.5)*\qfdCmpW/6}
\pgfmathsetmacro{\xs}{\qfdNH + (\sh + 0.5)*\qfdCmpW/6}
\coordinate (po-\r) at (\xo, {-\r + 0.5});
\coordinate (pr-\r) at (\xt, {-\r + 0.5});
\coordinate (pf-\r) at (\xf, {-\r + 0.5});
\coordinate (pp-\r) at (\xg, {-\r + 0.5});
\coordinate (ps-\r) at (\xs, {-\r + 0.5});
}
% Pass 2: profile lines per alternative. Drawn before markers so the
% dots sit on top of (not under) the line endpoints.
\draw[qfdalt1ln] (po-1) \foreach \r in {2,...,\qfdNW} { -- (po-\r) };
\draw[qfdalt2ln] (pr-1) \foreach \r in {2,...,\qfdNW} { -- (pr-\r) };
\draw[qfdalt3ln] (pf-1) \foreach \r in {2,...,\qfdNW} { -- (pf-\r) };
\draw[qfdalt4ln] (pp-1) \foreach \r in {2,...,\qfdNW} { -- (pp-\r) };
\draw[qfdalt5ln] (ps-1) \foreach \r in {2,...,\qfdNW} { -- (ps-\r) };
% Pass 3: markers on top of the lines.
\foreach \r in {1,...,\qfdNW} {
\node[qfdalt1mk] at (po-\r) {};
\node[qfdalt2mk] at (pr-\r) {};
\node[qfdalt3mk] at (pf-\r) {};
\node[qfdalt4mk] at (pp-\r) {};
\node[qfdalt5mk] at (ps-\r) {};
}
% ---------- Manual legend (5 alternatives, placed right of zones) ----------
\pgfmathsetmacro{\qfdLegX}{\qfdNH + \qfdCmpW + 0.7}
\begin{scope}[shift={(\qfdLegX, \qfdHdrH - 0.4)}]
\draw[qfdmed, rounded corners=2pt]
(-0.15, 0.4) rectangle (5.1, -7.50);
% Relations
\node[anchor=west, font=\footnotesize\bfseries] at (0, 0.1)
{Relation};
\draw[qfdthin] (0, -0.15) -- (4.95, -0.15);
\node[qfdstrong] at (0.22, -0.5) {};
\node[anchor=west] at (0.5, -0.5) {Strong (9)};
\node[qfdmod] at (0.22, -0.95) {};
\node[anchor=west] at (0.5, -0.95) {Medium (3)};
\node[qfdweak] at (0.22, -1.4) {};
\node[anchor=west] at (0.5, -1.4) {Weak (1)};
% Correlation
\node[anchor=west, font=\footnotesize\bfseries] at (0, -2.10)
{Correlation};
\draw[qfdthin] (0, -2.35) -- (4.95, -2.35);
\node[anchor=west] at (0, -2.70) {{$+\!+$}\quad very positive};
\node[anchor=west] at (0, -3.05) {{$+$\phantom{$+$}}\quad positive};
\node[anchor=west] at (0, -3.40) {{$-$\phantom{$-$}}\quad negative};
\node[anchor=west] at (0, -3.75) {{$-\!-$}\quad very negative};
% Perception
\node[anchor=west, font=\footnotesize\bfseries] at (0, -4.20)
{Perception};
\draw[qfdthin] (0, -4.45) -- (4.95, -4.45);
\draw[qfdalt1ln] (0.05, -4.80) -- (0.45, -4.80);
\node[qfdalt1mk] at (0.25, -4.80) {};
\node[anchor=west, font=\bfseries] at (0.55, -4.80)
{Typoena (v0.1 target)};
\draw[qfdalt2ln] (0.05, -5.25) -- (0.45, -5.25);
\node[qfdalt2mk] at (0.25, -5.25) {};
\node[anchor=west] at (0.55, -5.25) {reMarkable 2 + Type Folio};
\draw[qfdalt3ln] (0.05, -5.70) -- (0.45, -5.70);
\node[qfdalt3mk] at (0.25, -5.70) {};
\node[anchor=west] at (0.55, -5.70) {Freewrite Traveler};
\draw[qfdalt5ln] (0.05, -6.15) -- (0.45, -6.15);
\node[qfdalt5mk] at (0.25, -6.15) {};
\node[anchor=west] at (0.55, -6.15) {Freewrite Smart Typewriter};
\draw[qfdalt4ln] (0.05, -6.60) -- (0.45, -6.60);
\node[qfdalt4mk] at (0.25, -6.60) {};
\node[anchor=west] at (0.55, -6.60) {Pomera DM250};
\node[anchor=west, font=\scriptsize\itshape] at (0, -7.15)
{0 = poor, 5 = excellent};
\end{scope}
\end{qfdhouse}
\end{document}
```
## Perception scores (guessed)
Five products on the 05 scale, scored against each WHAT. Reference
configurations: **reMarkable 2 + Type Folio**, **Freewrite Traveler**,
**Freewrite Smart Typewriter**, **Pomera DM250** (DM250 has a reflective
monochrome LCD, not e-ink — flagged in W1 / W8). "Typoena" is the v0.1
target from `qfd.md` §2, not measured yet.
Freewrite Traveler scores assume the
[Sailfish firmware](https://getfreewrite.com/blogs/writing-success/freewrite-sailfish-firmware)
(released 2025-11-19), which rewrote the OS in Rust, cut keystroke latency
40100 %, and trimmed power draw 30 % typing / 50 % idle on both
Traveler and Smart Typewriter Gen 3. Three rows rescored upward as a
result: W1 Traveler 3→4 / Smart 2→3 (Smart's larger panel still trails
Traveler by one notch), W5 both 3→4 (boot accelerated, no published
number), W9 both 1→2 (Rust rewrite explicitly unblocked features that
JS could not carry; still closed so neither reaches reMarkable's
hackable-Linux 3).
| ID | WHAT (truncated) | Typoena | reM. | Frw.T | Frw.S | Pom. | Rationale (shortest defensible) |
| --- | ------------------------------------------------- | :-----: | :--: | :---: | :---: | :--: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| W1 | Sub-second response to typing | 4 | 1 | 4 | 3 | 5 | Typoena targets ≤200 ms; reMarkable e-ink visibly laggy on a typing-focused device — tested less responsive than Smart Typewriter, and latency is so load-bearing for W1 that it earns a 1 not a 2; both Freewrites post-Sailfish trimmed latency 40100 % (Frw.T plausibly inside 200 ms; Frw.S still trails by one notch on larger panel); Pomera LCD ~zero. |
| W2 | Publishing is one deliberate action away | 5 | 4 | 4 | 4 | 2 | Ctrl-G atomic; reMarkable + Freewrite cloud-sync is one-tap but not git; Pomera = USB/SD copy or QR transfer. |
| W3 | Pulling power never corrupts the file | 4 | 4 | 2 | 2 | 2 | Typoena: atomic-rename + fsync. reMarkable journals. Freewrite + Pomera: forum reports of corruption on yank. |
| W4 | Provisioning never interrupts writing | 5 | 2 | 2 | 2 | 5 | Typoena v0.1: build-time config (dev-only). reM/Frw need Wi-Fi + account. Pomera: literally none. |
| W5 | Quick boot to a writing cursor | 4 | 3 | 4 | 4 | 5 | Typoena target ≤5 s. reMarkable cold-boots ~20 s (great from sleep). Both Freewrites accelerated post-Sailfish (no published number; were ~1015 s e-ink wake). Pomera ~3 s. |
| W6 | Long sessions without crash / lag / drift | 3 | 3 | 4 | 4 | 5 | Typoena unproven (1 h target). Freewrite famously stable (both variants). Pomera firmware is decades-mature. |
| W7 | Nothing on the device competes with prose | 5 | 2 | 5 | 5 | 5 | reMarkable has apps, menus, drawing, PDFs. Freewrite + Pomera are single-purpose; Typoena by design. |
| W8 | The UI never moves except when I move it | 4 | 3 | 4 | 4 | 5 | reMarkable animates more; Typoena uses dirty-rects; Freewrites minimal motion; Pomera near-static LCD. |
| W9 | Codebase absorbs the planned roadmap | 4 | 3 | 2 | 2 | 1 | Modular Rust Typoena; reMarkable is hackable Linux; both Freewrites carry Sailfish (Rust rewrite explicitly unblocked features JS could not carry) but closed; Pomera closed firmware. |
| W10 | I can repair or fork it with hobbyist tools | 5 | 4 | 2 | 2 | 1 | Typoena: open BOM + ESP32. reMarkable: rooted Linux + community ROMs. Freewrite + Pomera: closed. |
| W11 | Multi-day battery life (v0.8 onward) | 1 | 5 | 5 | 5 | 4 | Typoena v0.1 = wall-powered (battery deferred). reMarkable + both Freewrites legendary (~4 weeks; Sailfish trimmed 30 % typing / 50 % idle). Pomera ~24 h. |
| W12 | Local-only files coexist with git scope | 3 | 1 | 2 | 2 | 3 | Typoena v0.5+ design. reMarkable cloud-only. Freewrites have local + Postbox but no VCS. Pomera = pure local. |
| W13 | Typography sets a writing-tool tone | 3 | 5 | 2 | 2 | 2 | Typoena v0.1: single mono (serif option in v1.0). reMarkable: rich type rendering. Freewrite + Pomera: utilitarian. |
| W14 | I can carry the device and write away from a desk | 2 | 4 | 5 | 1 | 5 | Typoena v0.1 wall-powered (ADR-008), no enclosure spec yet — desk-bound by design. reMarkable + Type Folio bag-friendly with bulk. Freewrite Traveler is the form-factor reference (~1.6 lb, folds). Smart Typewriter ~5 lb, desk-bound. Pomera DM250 pocketable foldable. |
**Totals** (sum across 14 WHATs, no weighting): Typoena 52, Pomera 50,
Freewrite Traveler 47, reMarkable 44, Freewrite Smart Typewriter 42
(Traveler pre-Sailfish 44; Smart pre-Sailfish 39; reMarkable W1 dropped
3→2→1 across two rounds of author testing — first to 2 after firsthand
typing, then to 1 once latency was recognised as the dominant W1
signal). Pomera closing to within 2 of
Typoena is W14 doing what W14 should — surfacing the dimension on
which v0.1's tethered MVP loses ground that v0.8 is expected to
recover. The "Pomera + Wi-Fi + git + hackable BOM" framing from
`README.md` still holds and reads stronger.
Weighted totals (Σ score × W weight) tell the same story with more
contrast — left as exercise; the unweighted view is enough to read the
picture.
### Caveats
- **Single-rater bias.** All thirteen rows are scored from the project
author's POV. A reMarkable buyer would weight W11 (battery) at 10 and
W12 (git) at 1, flipping the totals.
- **Configuration matters.** Freewrite Smart Typewriter and Traveler are
both tracked; they diverge most on W1 / W5 because of display tech
(Smart's larger panel is slower to refresh). Traveler is still the
more direct competitor on form factor.
- **W3 / W6 Freewrite scores are anecdotal.** Forum reports, not bench
data. Treat the 2 / 4 as "we'd need to test this" rather than fact.
- **No price column.** Typoena-as-BOM is materially cheaper than the
competitors but cost is not a WHAT in `qfd.md` §1, so it's absent here.
Worth a row if a v0.x WHAT ever calls it out.
## Reading the house
- **Importance (left column)** is the raw 110 weight from `qfd.md` §1, not
a normalised %, so adding stays cheap when a WHAT shifts. Sum of weights
is 103; treat each unit as ~0.97 % if you want a percentage view.
- **Roof** carries the §4 symbols translated into classical QFD glyphs:
`++` strong reinforcement (`◎`), `+` mild reinforcement (`○`), `` mild
conflict (`×`), `` strong conflict (`⊗`).
- **Basement rows** are: v0.1 target → §3 column sum (`Σ` of
`weight × strength`) → relative weight as integer % of total (1557).
Relative weights round to 100.
- **H7, H10, H15** (Publish latency, binary size, build time) sit at the bottom
of the basement — knowingly-paid costs per `qfd.md` §7, not signals to
optimise harder.
## Regenerating
The matrix cells (`\node[qfdrel/{S,M,W}]`), roof symbols (`C-i-j` slots),
and basement Σ + Rel% are native to this file — re-score directly in the
TikZ source, then update the §3 priority list and §4 conflict list in
`qfd.md` to match the new picture.
When `qfd.md` §1 or §2 changes:
1. Importance column → §1 weight column.
2. HOW titles + v0.1 targets → §2 target column.
3. Recompute basement Σ for any HOW whose column changed: per-cell
contribution = `(W weight) × (cell strength: 9 / 3 / 1 / 0)`.
4. Recompute relative weight: each Σ ÷ total Σ × 100, rounded to integer
percent.
Perception scores are **not** derived from `qfd.md` — they live only in
this file. Update them when (a) a competitor ships a relevant change,
(b) measurement replaces a guess, or (c) a WHAT is added/removed in §1.
Each score keeps its one-line rationale in the table above.
If a renderer rejects the `tikz` fence, the file is still readable as
source — the placement comments name each WHAT, HOW, and cell. The
perception-scores table above is the human-readable fallback for the
right-hand zone of the diagram.

View File

@@ -1,132 +0,0 @@
# Roadmap — version details
Frequent releases. Each version is a usable artifact, not a checkpoint.
The macro-plan (Gantt) lives in the [README](../README.md#roadmap); this file
holds the per-version scope. The user-facing requirements and engineering
targets each release feeds into are tracked in [`qfd.md`](qfd.md).
---
## v0.1 — MVP: "it writes, it pushes" — [ ]
The minimum thing that justifies the hardware existing. Full design:
[product](v0.1-mvp-product.md) · [technical](v0.1-mvp-technical.md).
- [ ] ESP32-S3 boots, e-ink shows Typoena splash + boot log
- [ ] USB host enumerates the Nuphy, key events reach the editor
- [ ] One hard-coded file (`/sd/repo/notes.md`) opens on boot
- [ ] Insert-only editing (no modes yet), backspace, enter, arrow keys
- [ ] Line wrap, no line numbers yet
- [ ] Save on `Ctrl-S` → SD
- [ ] Wi-Fi credentials + remote URL + PAT + author baked into the binary at
build time via env vars (no NVS, no on-device provisioning UI in v0.1)
- [ ] `Ctrl-G` runs: `git add .` → commit with an ISO-8601 timestamp message →
`git push`; on push failure, `git pull --no-edit` then retry the push
(no-op short-circuit when nothing is staged). PAT from first-run setup.
- [ ] Partial refresh on edits; full refresh on save
Out of scope: Vim, palette, multiple files, branches, conflict handling.
## v0.2 — Vim navigation — [ ]
- [ ] Mode state machine (Normal / Insert), mode indicator in the side panel
- [ ] Movement: `h j k l`, `w b e`, `0 $`, `gg G`, `Ctrl-d Ctrl-u`
- [ ] `i a o O A` to enter Insert
- [ ] `Esc` returns to Normal
- [ ] Line numbers in the left gutter: relative in Normal mode (current line
shown as its absolute number), absolute in Insert mode
- [ ] Groundwork — UTF-8-correct buffer: caret motions and edits step by
character, not byte (drop the ASCII == byte-offset assumption in
`editor.rs`), so every motion added here and later stays correct once
accented input lands. Done early so it isn't retrofitted across the whole
motion/text-object surface. Render font is already ISO-8859-15 (Latin-9),
so accented glyphs display.
## v0.2.5 — International input — [ ]
A small focused release between navigation and editing. US-International
dead-key accent composition, resolved in the keyboard layer (`usb_kbd.rs`) so
the editor still receives a single `Key::Char`. Builds on the v0.2
UTF-8-correct buffer and the ISO-8859-15 render font.
- [ ] Dead keys — grave, acute, circumflex, diaeresis, tilde — compose with
the next letter: à é ê ë ñ, ç (via `'`+c), both cases
- [ ] `'`+space emits a literal apostrophe (the everyday apostrophe path); a
dead key followed by a non-composing letter emits the accent then the
letter
- [ ] A non-character event (Enter, Backspace, arrows) flushes any pending
accent as its literal first
- [ ] Pending-accent indicator in the side-panel status strip
## v0.3 — Vim editing — [ ]
- [ ] `x dd yy p P`, `dw dd d$`, repeat with `.`
- [ ] Undo / redo (`u`, `Ctrl-r`) — bounded history in PSRAM
- [ ] Numeric prefixes (`3dd`, `5j`)
## v0.4 — Visual mode + ex commands — [ ]
- [ ] Visual char (`v`) and line (`V`) modes, `y d c` on selections
- [ ] `:` command line: `:w :q :wq :e <path>`
## v0.5 — File palette + multi-file — [ ]
- [ ] `Ctrl-P` opens fuzzy file palette over **both** `/sd/repo/` and
`/sd/local/`, with a scope marker (e.g. `[git]` / `[local]`) per result
- [ ] Open, switch, close buffers (keep ≤ 3 in memory)
- [ ] `:e` and palette share the same recent-files list
- [ ] `:enew` creates a new file — prompts for scope (tracked vs local)
- [ ] Delete a file — removes it from the SD card; for a Tracked file the
removal reaches the next `Ctrl-G` Publish's staged set (`git rm` / `add -A`
semantics, not plain `git add .`); a Local file is just unlinked
- [ ] `Ctrl-G` is disabled / hidden when the current buffer is local-scope
- [ ] The side panel briefly shows file count on `Ctrl-G` when the publish bundles
more than one dirty Tracked file (e.g. `"publishing 3 files: abc1234"`),
so workspace-scoped behaviour stays visible to the user
## v0.6 — Markdown affordances — [ ]
- [ ] Heading lines bolded in render
- [ ] List continuation on Enter inside `- ` / `1. `
- [ ] Soft-wrap at word boundaries
- [ ] Optional column ruler at 80
## v0.7 — Search + better git — [ ]
- [ ] `/` forward search, `n N`
- [ ] `:Gpull` (fetch + fast-forward only; refuse on conflict and surface it)
## v0.8 — Power: battery + sleep — [ ]
- [ ] Measure idle / typing / push current draw on bench
- [ ] 18650 + IP5306 charge board, soft power switch
- [ ] Light sleep on idle > 30 s (keyboard interrupt wakes)
- [ ] Deep sleep on lid close (reed switch); restore cursor + buffer
- [ ] Battery indicator in the side panel
## v0.9 — Robustness — [ ]
- [ ] Crash-safe writes (write to `.tmp`, fsync, rename)
- [ ] Recover from interrupted push (re-attempt on next save)
- [ ] SD card removal / reinsert handling
- [ ] Wi-Fi reconnect with backoff
- [ ] On-device provisioning + settings screen: SSID, PAT rotation, default
remote, commit author (replaces the v0.1 dev-only NVS-flashing path —
first release usable by someone who is not the firmware author)
## v1.0 — Polish — [ ]
- [ ] Boot time ≤ 3 s to usable cursor
- [ ] 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
persisted across reboots
- [ ] Enclosure design files in `hardware/`
- [ ] User guide
## v1.x — Stretch / nice-to-have
- 10.3" panel upgrade via IT8951
- Multiple remotes / repos
- Stats: words today, streak
- BLE-HID fallback for wireless keyboards

View File

@@ -8,7 +8,7 @@
>
> They ride on the `epd::Frame` `DrawTarget` and the Spike 5 partial-refresh
> path. Project overview: [`../README.md`](../README.md). Vocabulary:
> [`../CONTEXT.md`](../CONTEXT.md). Release sequence: [`roadmap.md`](roadmap.md).
> [`../CONTEXT.md`](../CONTEXT.md). Release sequence: [`macroplan.md`](macroplan.md).
These prove display/UX risks, not stack risks, so they sit outside the 17
"prove before integration" gate. **Run Spike 8 first:** it partitions the panel
@@ -64,6 +64,22 @@ risk early.
clean full refresh. Mostly a feature — kept as a spike only to prove the
asset path. (Feeds v0.1's "e-ink shows Typoena splash + boot log".)
**Built 2026-07-11 as a *vector* splash**, not a bitmap. The frame is
[`display::Frame::splash`](../display/src/lib.rs) — the `typoena` wordmark
centred inside a stroked `Circle`, drawn with `embedded-graphics`, one clean
full refresh. It is shared by two callers: the
[`splash`](../firmware/src/bin/splash.rs) bench binary (`just flash-splash`)
and **`main.rs`'s boot path**, which shows it right after EPD init — replacing
the old white-clear baseline — while the SD mounts and the note loads, then a
second full refresh brings up the editor. Nothing is embedded, so the
image-asset pipeline named above is deliberately **left unproven** — an
acceptable trade because Spike 2 already covered vector + font rendering.
**Proposition, deferred to end-of-project polish (v1.0):** replace the vector
mark with an embedded 1-bit raster logo, which is when the asset-embed/blit
path would finally be exercised. **Confirmed on the panel 2026-07-11** — the
splash renders cleanly at boot, then the editor comes up. This closed the last
v0.1 display gate; v0.1 shipped the same day.
10. **Spike 10 — Dark / light theme.** Invert the 1-bit framebuffer (white on
black) and refresh. The invert is trivial (XOR at blit); the real unknowns
are (a) ghosting and refresh time on a predominantly-black panel — full-black
@@ -98,23 +114,22 @@ risk early.
a design decision this spike hands data to.)
13. **Spike 13 — Line-number gutter.** Draw a fixed-width digit column left of
the text area. The v0.2 spec is the hard case: **relative** numbers in
Normal mode (current line as its absolute number), **absolute** in Insert.
The naive part — reserving columns and drawing digits — is trivial; the
e-ink risk is churn. Relative numbering renumbers the *entire visible gutter
on every `j`/`k`*, so a single cursor move becomes a partial refresh of a
tall digit column, straight into the Spike 5 windowed-Y path and the "20
partials → forced full refresh" ghosting counter
([`firmware/src/epd.rs`](../firmware/src/epd.rs), render module). Absolute
numbering is cheap by comparison — only the rows below an inserted/deleted
line renumber. Two more interactions: the gutter steals horizontal columns
from the render-time soft-wrap, and wrapped continuation rows have no number
(blank vs. tilde — a layout decision). Prove: measure gutter partial-refresh
cost for (a) a held-`j` scan that renumbers the whole relative gutter vs.
(b) a single-line absolute edit, and confirm neither blows the ghosting
budget or forces extra full refreshes. Decides whether relative numbering is
viable on this panel or must be gated (absolute-only, or a batched/coalesced
gutter repaint). (Feeds v0.2's line-number gutter; genuine new e-ink risk.)
the text area. **Absolute numbering only**relative numbering was dropped
(2026-07-11). It renumbered the *entire visible gutter on every `j`/`k`*, so
a single cursor move became a partial refresh of a tall digit column,
straight into the Spike 5 windowed-Y path and the "20 partials → forced full
refresh" ghosting counter ([`firmware/src/epd.rs`](../firmware/src/epd.rs),
render module) — real e-ink cost for no proportionate gain on a
distraction-first panel. Dropping it removes the genuine e-ink risk this
spike existed to prove: absolute numbering is cheap — only the rows below an
inserted/deleted line renumber. What remains is a **layout decision**, not a
latency one: the gutter steals horizontal columns from the render-time
soft-wrap, and wrapped continuation rows have no number (blank vs. tilde).
Prove: reserve the gutter width, then confirm a single-line edit repaints
only the rows at/below the change and forces no extra full refresh. (Feeds
v0.2's line-number gutter; now low-risk after the relative drop.)
**VERIFIED on the panel 2026-07-11** — a single-line edit repaints only the
rows at/below the change, no extra full refresh; closes the last v0.2 gate.
14. **Spike 14 — Multi-file navigation (open / switch / new / delete).** The
panel *mechanism* is already Spike 11 (which names this v0.5 file palette),
@@ -137,4 +152,4 @@ risk early.
artifact-free full-refresh swap on every buffer change (leaning on Spike
11). Latency is not the risk here; buffer-state and git-staging correctness
are. (Feeds v0.5 multi-file, delete included — now authorized in
[roadmap](roadmap.md#v05--file-palette--multi-file--) v0.5.)
[macroplan](macroplan.md#v05--file-palette--multi-file--) v0.5.)

View File

@@ -0,0 +1,14 @@
# Tradeoff curves
> Where a design knob has a cost that bends — energy, latency, memory — against
> an interval or size, the curve and its knee live here, so the chosen default
> is traceable to a shape rather than a guess.
>
> Docs index: [`../README.md`](../README.md). Project overview:
> [`../../README.md`](../../README.md).
| 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. |
| [`sync-commit-staging.md`](sync-commit-staging.md) | Commit-staging strategy vs working-tree size — RESOLVED: every index-based path is O(N_tree) and fails on the real repo (611 s / OOM); the O(depth) TreeBuilder splice is benched at ~22.8 s and ships. Holds the full measurement trail plus the firmware plumbing plan (merged from the retired handoff note). |

View 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 (~100130 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) | **~100130 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 (~100130 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.

View File

@@ -0,0 +1,640 @@
# Commit-staging cost vs working-tree size
> **Decision (RESOLVED 2026-07-12, real-repo bench):** neither `add_all(["*"])`
> nor an index-free `read_tree`+`write_tree` is viable on the real
> `jcalixte/notes` clone — **both are O(N_tree)** and blow up on the 570 MB pack /
> 1179-file tree (611 s hash one end, 77 s tree-read + OOM the other; see
> [Real-repo run](#real-repo-run-2026-07-12-jcalixtenotes-570-mb-pack--the-index-is-the-wrong-primitive)
> below). The commit must be rebuilt with an **O(depth) TreeBuilder walk** —
> patch only the edited file's ancestor subtree chain onto HEAD's tree, never
> materialise all 1179 entries.
>
> **Final state (2026-07-13, after four localization rounds):** the splice walk
> is benched and the block is lifted. It first measured 6.5 s (each loose-object
> write cost ~1.5 s); the root cause was FatFS's lseek cluster-chain walk, and
> the fast-seek fix cut it to **2.8 s cold / ~2 s steady-state**. The `esp_map.c`
> window cache was **removed entirely** (0 hits across four instrumented runs —
> `mwindow` absorbs any true repetition above `p_mmap`). The sub-second bar
> failed, but ~22.8 s against 611 s/OOM for every alternative ships: a full
> cold real-repo `:sync` lands at ~910 s. **Decision: wire the splice in —
> done 2026-07-13** ([how it landed](#the-fix--wiring-the-odepth-splice-into-the-firmware),
> merged from the retired `notes/sync-commit-handoff.md`; on-device `:sync`
> against the real repo still pending). Shrinking the repo is **not** an option
> ([`../notes/git-sync-images-and-repo-size.md`](../notes/git-sync-images-and-repo-size.md):
> the images are load-bearing for another app), which is exactly why the O(depth)
> mechanism is the only lever left. This note records the cost model and the full
> measurement trail that got here.
>
> Tradeoff-curves index: [`README.md`](README.md). Docs index:
> [`../README.md`](../README.md). The six-step kaizen retelling of this whole
> investigation (problem → factor analysis → chosen fix → evaluation):
> [`../kaizen/real-repo-sync.md`](../kaizen/real-repo-sync.md). Where the whole sync goes:
> [`../notes/sync-latency.md`](../notes/sync-latency.md). Sibling curve on the
> radio cost of *how often* we sync: [`wifi-auto-sync.md`](wifi-auto-sync.md).
## The model
`:sync` commits the working tree on the SD/FAT card before it pushes. The commit
is two kinds of work against the card over SPI (10 MHz today, ADR-012):
```
stage write
─────────────────────────────── ─────────────────────────────
add_all(["*"]) + update_all(["*"]) index.write + write_tree + commit-obj
→ stat() every file in the tree, → serialise the index and three loose
hash the ones whose stat moved objects, each a FAT create+write+fsync
cost ∝ tree size (O(N_tree)) cost ∝ churn (O(N_changed)) + fixed
```
The two have different curves against **N = files in `/sd/repo`**:
- **Walk** rises with N. `add_all(["*"])` visits the whole working tree every
sync regardless of how little changed, and each visit is a FAT `stat` (and a
re-hash when the entry looks dirty) over SPI. This is the term explicit-path
staging removes: the editor already knows which buffers are dirty and which
were `:delete`d, so `index.add_path(p)` / `index.remove_path(p)` over that set
touches `N_changed` files (≈1 for a writing appliance), not `N`.
- **Write** is flat in N. A text commit is the index + a blob + a tree + a commit
object — a handful of small FAT writes whose cost is set by SPI clock and
`fsync`, not by tree size. Explicit-path staging cannot shave this; only a
faster card bus (SD 10 → 20 MHz on a clean PCB, `persistence.rs`) does.
```
Commit latency vs working-tree size two staging strategies
ms
| walk-all: add_all(["*"])
4000 | . * stat()s every file in the
| . * tree each sync → O(N)
| . *
3000 | . *
| . *
2000 | . *
| *
1000 |····································· explicit-path: add_path(dirty)
| FAT object-write floor → O(churn); flat in N. The gap
0 +----+----+----+----+----+----+----+---→ up to walk-all is the avoidable
10 50 100 200 400 800 1179 per-sync tree walk.
└── jcalixte/notes today (N files)
```
The gap between the lines at a given N is exactly what switching buys, and it
**grows without bound** as the notes tree fills.
### The real operating point (measured 2026-07-12, `jcalixte/notes`)
The device syncs into a clone of the actual notes repo, not a `notes.md` toy. Its
working tree is **not small**:
| | count | working-tree bytes |
| --- | ---: | ---: |
| Markdown (`.md`) | 875 | ~1.5 MB |
| Images (png/jpg/webp/bmp/gif) | ~260 | **~150 MB** |
| Other (json/ts/pdf/…) | ~44 | ~20 MB |
| **Total (N)** | **1179 files, 158 dirs** | **~170 MB** |
| `.git` history | | ~570 MB |
So `add_all(["*"])` walks **1179 files across 158 directories every sync** — and
~260 of them are images that a text edit never changes. That does two things the
toy-repo baseline hides:
1. **The walk term is large and paid on every sync** — 1179 `stat`s + 158 dir
reads over SPI, for a one-line note change. This is the O(N) cost the curve
above predicts, at N ≈ 1179 rather than N ≈ 2.
2. **Re-hash risk.** libgit2 decides a file is unchanged from `stat` metadata
(mtime/size). FAT's coarse mtime and lack of a stable inode can make entries
look racy, forcing a content re-hash. If even a slice of the ~150 MB of images
gets re-hashed over a 10 MHz SPI bus, the commit balloons far past 4 s. The
`walk` timer will show it; explicit-path staging sidesteps it entirely by never
visiting the images.
## Measurement (2026-07-12, toy `notes.md` tree, N ≈ 2)
Split from two back-to-back `:sync`es on the small test repo (commits `95ac56ef`
cold, `ab260bde` warm), via the `commit split —` log lines:
| Sub-phase | Kind | Cold (ms) | Warm (ms) |
| --- | --- | ---: | ---: |
| `walk(add_all+update_all)` | scan (O(N)) + likely 1 blob write | 1402 | 1456 |
| `index.write` | FAT write | 204 | 204 |
| `write_tree` | **1 tree object → FAT** | 710 | 715 |
| `parent-load` | FAT read | 102 | 105 |
| `commit-obj` | **1 commit object + ref → FAT** | 914 | 924 |
| **commit total** | | **3332** | **3404** |
### It is not the card — it's libgit2 (`sd_bench`, 2026-07-12)
My first read of the table was "a loose-object write to this SD card costs
~700900 ms." **That was wrong.** `sd_bench` (`firmware/src/bin/sd_bench.rs`) times
the raw FAT primitives on the same card at the same 10 MHz:
| Raw FAT op (200-byte payload) | p50 |
| --- | ---: |
| create + write + close | 21.7 ms |
| rename | 12.8 ms |
| stat (hit / miss) | ~5 ms |
| remove | 14.9 ms |
| **loose-object composite** (stat + create + write + rename) | **86 ms** |
The card does a *complete* loose-object write in **~86 ms**. Yet `write_tree`
(one tree object) took **710 ms** and `commit-obj` **914 ms** — an **~8× gap that
is pure libgit2 overhead, not FAT I/O.** So the earlier "object-write floor / SD
write amplification / better card / SPI-clock" framing is refuted: **the SD card is
not the bottleneck.** fsync is still confirmed off; the extra ~600 ms/op is CPU or
repeated `.git` I/O *inside* libgit2 (candidates: ODB refresh scanning
`objects/`, the treebuilder's per-entry `git_odb_exists`, ref-lock + reflog writes,
config/attributes re-reads). `git_bench` (`firmware/src/bin/git_bench.rs`) localizes
it — see below.
### It's the pack, read through an un-caching emulated mmap (`git_bench`, 2026-07-12)
`git_bench` times the git2 primitives in isolation on `/sd/repo` (git ops on the
same 96 KB thread the real service uses — the main-task stack overflows on
`index.write`, which is itself the reason the service has a dedicated thread):
| git2 op | p50 | note |
| --- | ---: | --- |
| `Repository::open` | 100 ms | one-time |
| `odb.write(blob)` (unique) | **45 ms** | writes a fresh object; touches no existing object |
| `repo.index()` open | ~0 ms | cached |
| `index.write()` | 376 ms | index + `index.lock` rename + tree-cache |
| `write_tree` [unchanged] | ~0 ms | tree exists → freshen-skips the write |
| **`write_tree` [changed]** | **1136 ms** | writes ONE 45 ms object |
| **`commit(None)` orphan obj** | **563 ms** | writes ONE 45 ms object, no ref/reflog |
Writing a fresh object is 45 ms; the ops that wrap one are 825×. The cause, from
the vendored source: `git_odb_write` calls `git_odb__freshen` (odb.c:1011), which
on a not-found object runs **`git_odb_refresh`** (re-reads the pack dir + reloads
pack indexes), and existence checks (`freshen(tree)` in `commit.c:169`, base-object
lookups in `write_tree`) hit the **pack**. Pack access goes through our
`p_mmap` (`esp_map.c`), which **`malloc`s and `read()`s the mapped range from the
card on every call — no cache** — with a 32 MB window on this 32-bit target. So
each write re-reads pack bytes from SD; `odb.write` of a fresh blob is 45 ms only
because it touches no packed object.
**This scales with pack size.** The toy repo's pack is tiny; the real
`jcalixte/notes` clone has a **570 MB pack**, and provisioning rsyncs a full clone
onto the card — so a real-repo commit has **never been benchmarked** and, on this
mechanism, will be far worse than the ~3.3 s toy number. That is the single biggest
open risk in sync.
### Real-repo run (2026-07-12, `jcalixte/notes`, 570 MB pack) — the index is the wrong primitive
`git_bench` was finally run against a full clone of the real repo (1179 files, 158
dirs, 570 MB pack). It settles the design: **any index-based commit is O(N_tree)
and does not fit this device.** Two independent walls:
| op | result | reading |
| --- | ---: | --- |
| `Repository::open` | 88 ms | fine |
| odb open (implicit) | ~6 s cold | maps the 1.7 MB pack `.idx` once (16 miss / 1790 KB) |
| `odb.write(blob)` | **142 ms** p50 | the mmap cache win **holds** (was 862 ms uncached) ✅ |
| `repo.index()` load (1179 entries) | 514 ms max | the on-disk index we were trying to avoid |
| `index.write()` | **min 360 ms / p50 12.8 s / max 611 s** | ⚠️ hangs — see root cause |
| **seed `read_tree(HEAD)` (cold, 1×)** | **~77 s** | ⚠️ reads all ~158 tree objects, 22.7 MB of pack windows |
| `Index::new + read_tree` (warm) | 447 ms p50 | windows still mapped → pure CPU |
| **index-free `stage→tree`** | **💥 crash** | `zlib (5)`: `deflateInit` failed, **508 KB heap left** |
**Wall 1 — `index.write()` hashes the whole working tree (up to 611 s).**
`git_index_write` unconditionally calls `truncate_racily_clean` (index.c:822),
which runs `git_diff_index_to_workdir` over **every** entry flagged "racy" and
re-hashes its file. On a fresh FAT clone the mtime granularity is 2 s and
`index.stamp.mtime <= entry.mtime` for ~all 1179 entries (index.h:117), so the
whole tree looks racy → it re-hashes ~170 MB (mostly the 150 MB of images) over
10 MHz SPI. The 611 s → 12.8 s → 360 ms decay across three iterations is the
signature: each write bumps the index mtime, shrinking the racy set. **Implication:
the shipping `stage_and_commit` calls `index.write()`, so `:sync` on the real repo
effectively bricks on the first commit** — the user sees a 10-minute freeze,
resets, the index mtime never advances, and it re-hashes forever. The real repo has
almost certainly never completed a sync on device (only the toy `typoena-test` has).
**Wall 2 — the index-free path is still O(N_tree), and the mmap cache OOMs.**
Skipping `index.write()` entirely (fresh `Index::new()`, stamp = 0, so
`truncate_racily_clean` can never fire) removes Wall 1. But to seed the in-memory
index, `read_tree(HEAD)` materialises all 1179 entries and reads every tree object
from the 570 MB pack — **77 s cold** (447 ms only once the windows are resident).
`write_tree_to` is O(changed), but you pay O(N_tree) to build the cache it needs, so
the index-free path only trades a 611 s hash for a 77 s tree-read. Worse, that
`read_tree` drove the `esp_map.c` cache to **7.4 MB resident** — past its own 4 MB
soft cap — which left 508 KB of heap and made `repo.blob()`'s zlib `deflateInit`
fail. **The one write we cannot skip crashed.** Root cause of the OOM: our cache
holds pack windows *after* libgit2 `p_munmap`s them (refcount 0, freed only lazily
on the next `p_mmap`), which **defeats `GIT_OPT_SET_MWINDOW_MAPPED_LIMIT`**
libgit2 thinks it released the memory; we didn't.
**Conclusion — use an O(depth) TreeBuilder walk.** "Replace K files in a 1179-file
tree" should touch `O(depth × K)` objects, not `O(N_tree)`. Walk HEAD's tree down
the edited path (`tree.get_name`/`get_path` → read ~depth subtree objects), then
rebuild bottom-up with `repo.treebuilder(Some(&subtree))``insert`/`remove`
`write()`, and `commit` the new root. That never materialises the 1179 entries,
never re-hashes anything, never visits the 150 MB of images, reads only a handful
of tree windows (so the cache stays small and zlib keeps its heap), and — crucially
— **carries the image entries forward untouched from HEAD's tree, so the device
does not even need the images in its working tree.** The `esp_map.c` cache still
needs an evict-on-`munmap` fix (drop the cap, free past a low-water mark) so it can
never again starve a downstream `git__malloc`, but with the TreeBuilder walk the
pressure it was under largely disappears.
### Splice bench (2026-07-12, second real-repo run) — the walk is right, the loose-object write is the new wall
The O(depth) splice op was added to `git_bench` (1 blob + 3 tree writes onto the
depth-3 path `.claude/commands/bsky.md`, run FIRST so its first iteration is
cold; the index ops moved last so their OOM can't cost the new data — it did
crash again, after everything was logged):
| op | result | reading |
| --- | ---: | --- |
| `splice stage→tree` (1 blob + 3 trees) | **6.5 s p50, warm ≈ cold** | O(depth) confirmed — cost is 4 loose writes × ~1.6 s |
| `commit(None)` orphan obj | 1.7 s p50 | one more loose write |
| `odb.write(blob)` | **1.5 s p50** | ⚠️ was 142 ms in the previous run |
| `repo.index()` load | 524 ms max | matches previous run |
| seed `read_tree(HEAD)` cold (now timed) | 81.6 s | reproduces the 77 s |
| `index-free stage→tree` | 💥 crash, 508 KB heap | reproduces the zlib OOM exactly |
Three readings:
1. **The splice mechanism is validated as a mechanism.** Pack reads stayed flat
(~40 KB per write; 6.4 MB heap free through splice + commit + odb.write), so
it really is O(depth) and it cannot OOM. The 6.5 s is not tree-walk cost.
2. **The wall moved to the loose-object write: ~1.5 s each, ×4 per splice.**
The isolated `odb.write(blob)` — one tiny orphan blob — took 1.5 s where the
raw FAT composite is 86 ms. Projected full commit (splice 6.5 s + commit-obj
1.7 s + ref/reflog update) ≈ **89 s**: enormously better than 611 s, still
far off the bar.
3. **The mmap cache scored 0 hits over the entire run** — the documented
862→142 ms `odb.write` win did **not reproduce** (same `esp_map.c`, same
card). Either the earlier run's conditions differed (orphan-object
population? FAT allocation state?) or the win was misattributed. Whatever the
1.5 s is, it is *not* SD data volume: each write moves ~40 KB read + ~1 KB
written.
### ROOT CAUSE FOUND (2026-07-12, `sd_bench` seek op): FatFS lseek walks the cluster chain
Two `sd_bench` re-runs on the ~740 MB-full card settled it:
1. **Free-cluster-scan hypothesis: refuted.** Raw FAT write ops are unchanged on
the full card — loose-object composite **77 ms p50** (was 86 ms), create
20 ms, rename 10 ms. The card is exonerated a second time.
2. **Long seeks are the cost.** A new op opens the repo's largest packfile
(263 MB — the "570 MB pack" was actually the whole `.git`) read-only and does
seek+read(4 KB): **@offset 0 = 5.8 ms; @end = 198.7 ms** — dead constant
across 20 iters. Without `CONFIG_FATFS_USE_FASTSEEK`, FatFS resolves lseek by
walking the file's FAT cluster chain over SPI: forward from the current
position, **from the chain head on any backward seek**. 263 MB ≈ 16.8k
clusters ≈ ~67 KB of FAT reads ≈ ~190 ms per long walk.
**Why FAT behaves this way:** FAT has no extent map or inode — a file is a
singly-linked list of clusters, and the only way to find "byte 260,000,000" is
to follow that list entry by entry through the allocation table. FatFS walks
forward from the current position when it can, but a backward seek restarts
from the chain head ([FatFS `f_lseek` docs, elm-chan.org](http://elm-chan.org/fsw/ff/doc/lseek.html)).
The fast-seek feature fixes exactly this: a pre-computed **cluster link map
table (CLMT)** per file object, "(fragments + 1) × 2" words, after which "no
FAT access is occured in subsequent f_read/f_write/f_lseek" (same page). On
esp-idf it's `CONFIG_FATFS_USE_FASTSEEK` — the official docs recommend it "for
read-heavy workloads with long backward seeks" and note it does not apply to
files opened in write mode
([ESP-IDF FatFS docs](https://docs.espressif.com/projects/esp-idf/en/latest/esp32/api-reference/storage/fatfs.html)).
The budget closes: each loose write does ~810 small (~4 KB) `p_mmap`s (freshen
→ trailer/idx probes) interleaved with low-offset reads, so ~8 of them pay a
fresh ~190 ms walk → **~1.5 s per object**. It also explains everything the
cache couldn't: warm ≈ cold (the walk is paid inside `lseek` before any data
moves, and the maps are below the 64 KB cache floor), the 142 ms vs 1.5 s
run-1/run-2 discrepancy (run 1's `odb.write` bench ran first and hammered only
the trailer — the file position stayed there, so its seeks were forward/no-ops),
and a large slice of the 81.6 s `read_tree` (133 windows × backward seeks ≈ 25 s
of walking on top of the 25 MB of data).
**Fix (config, not code): `CONFIG_FATFS_USE_FASTSEEK=y` +
`CONFIG_FATFS_FAST_SEEK_BUFFER_SIZE=256`** (landed in `sdkconfig.defaults`
2026-07-12). Fast seek builds an in-memory cluster-link map per read-mode file —
exactly how the pack is opened — making lseek O(1); write-mode files fall back
to the walk transparently. 256 words = 1 KB per open read-only file, covering
~127 fragments (default 64 covers ~31; a fragmented pack would silently fall
back to slow seeks, so the headroom matters).
**A/B measured (same evening): a 2.3× partial win, not a full one.**
| op | fast-seek off | fast-seek on |
| --- | ---: | ---: |
| `splice stage→tree` | 6.5 s | **2.81 s** |
| `odb.write(blob)` | 1.5 s | **416 ms** |
| `commit(None)` | 1.7 s | **1.72 s — unchanged** |
`odb.write` dropped by almost exactly the ~6 chain walks the model predicted —
the seek theory holds — but two residuals remain: **~400 ms per loose write**
(vs the 77 ms raw-FAT floor) and **`commit(None)`'s ~1.3 s premium over a plain
write, which was never seek-bound at all**. Prime suspect for the commit
premium: strict object creation makes `git_commit_create` validate its parent +
tree OIDs with pack header resolves, and `git_treebuilder_insert` does the same
per inserted entry — `git_bench` grew `odb.read_header(packed)` /
`odb.exists(missing)` probes and strict-off re-benches to test it.
### Second localization round (2026-07-12, run 3b + sd_bench re-run)
**Fast-seek verified on the metal:** re-running the sd_bench seek op with
`CONFIG_FATFS_USE_FASTSEEK=y` dropped `pack seek+read 4KB @end` from
**198.7 ms → 20.4 ms** (the CLMT fits the pack in the 256-word buffer — the
pack is not too fragmented). A far seek is now ~15 ms, i.e. effectively fixed.
**The strict-creation theory is refuted; the probes found the real unit cost:**
| op | p50 | reading |
| --- | ---: | --- |
| `odb.read_header(packed)` | **470 ms** | ONE pack header resolve costs ~½ s |
| `odb.exists(missing)` | **968 ms** (±0.1 ms) | miss path (scan → refresh → rescan) ≈ 2× |
| `commit(None)` strict OFF | 1.80 s | vs 1.93 s strict on — validation is NOT the premium |
| `splice` strict OFF | 5.7 s | noise-worse; also not validation |
The ±0.1 ms constancy of `exists(missing)` = a fixed, deterministic SD-op
sequence. The map counters identify it: **~78 small (~4 KB) `p_mmap` reads per
op** — pack trailer probes, idx fanout reads and delta-base windows, repeated
at the *same offsets* on every freshen/refresh. Post-fast-seek those cost
~20 ms each (~150 ms/op); the rest of `read_header`'s 470 ms is CPU-side
delta-chain inflation on the 160 MHz core plus repeated re-reads. Two other
observations from run 3b: the loose-object orphan population from bench runs
is creeping costs upward (splice 2.81 s → 3.21 s between consecutive
fast-seek runs — a re-provision resets it), and the mmap cache STILL scored 0
hits — because its 64 KB map-length floor excluded exactly these hot small
maps.
**Fix built (esp_map.c v2):** cache admission re-keyed from
map length to **file size ≥ 1 MB** — the pack/idx's small repeated windows now
cache (RAM hits after first touch) while small mutable working-tree files stay
excluded — plus **evict-on-`p_munmap` down to a 2 MB low-water mark**, fixing
the 7.4 MB OOM from the first real-repo run (released windows are actually
returned to `git__malloc`, so `MWINDOW_MAPPED_LIMIT` stays honest). Expected:
`read_header` collapses toward CPU-only, `odb.write` toward ~150250 ms,
splice at or under the sub-second bar, and no end-of-run zlib OOM.
### Final bench (run 4, esp_map v2) — memory fix works, cache theory dead, bar failed
Run 4 (2026-07-12 evening, same card state as run 3b plus its orphans):
| op | run 3b (fast-seek) | run 4 (+ esp_map v2) |
| --- | ---: | ---: |
| `splice stage→tree` (cold, first op) | 2.81 s | **2.83 s — unchanged** |
| `splice` again (warm, strict-off phase) | 3.21 s | 1.95 s |
| `commit(None)` | 1.72 s | 713 ms |
| `odb.write(blob)` | 416 ms | 366 ms |
| `odb.read_header(packed)` | 470 ms | 412 ms |
| `odb.exists(missing)` | 968 ms | 852 ms |
| mmap-cache hits | 0 | **0** (313 misses) |
| cache resident / heap free | grew to 7.4 MB → zlib OOM | **1833 KB flat / 6.4 MB free all run** |
Three findings:
1. **The memory discipline is verified.** Resident sits at 1833 KB through
every phase (under the 2 MB low-water, so nothing is being churned) and
heap never drops below 6.2 MB. The one uncaptured datum is the index-free
`read_tree` tail (the section that OOM'd runs 13) — the monitor was cut
before it ran. Not blocking: the shipping splice path never calls
`read_tree`; the tail would only re-confirm eviction under burst.
2. **The repeated-small-window theory is REFUTED — theory #3 down** (after
strict-creation and free-cluster-scan). v2 demonstrably admits and retains
the small maps now — the 1833 KB resident *is* them, held below low-water so
nothing is evicted before reuse — and still scored 0 hits in 313 misses.
So the ~8 small reads per loose write hit **unique (offset, len) every
time**: `mwindow` was already absorbing any true repetition above `p_mmap`,
and what reaches the emulation layer is distinct data (different objects,
different delta bases). A window cache cannot help. The residual
~360 ms/loose-write ≈ 8 distinct small SD round-trips × ~45 ms each
(post-fast-seek) — I/O count, not I/O size or seek cost.
3. **Within-run drift cuts both ways, so cross-run tables are mushy.** In this
single run `commit(None)` degraded 713 ms → 1.79 s between the early and
late (strict-off) phases, while splice *improved* 2.83 → 1.95 s. Two
competing effects: first-touch warm-up fading (CLMT build, first pack
reads — helps later ops) and orphan loose objects accumulating in
`.git/objects/xx/` slowing every freshen existence check (FAT directory
lookups are linear scans — hurts later ops). Steady-state on a clean
objects dir ≈ **~2 s per splice+commit**.
**Run 5 (confirmation, cache removed entirely):** esp_map.c stripped back to
plain malloc-read/free-at-munmap (stats counters kept). Read pattern
**byte-identical** to run 4 at every checkpoint (118 maps / 2314 KB after
splice, 148/2434, 163/2494, 208/2674) — except the strict-off phase did **15
fewer reads (~60 KB)** than v2: the low-water eviction had been kicking out
buffers mwindow still wanted, forcing re-reads. The cache was marginally
worse than nothing. Warm splice identical (1953 vs 1949 ms); the cold-op
+1015 % drift (splice 2.83 → 3.26 s) is the known orphan-creep signature,
not the removal. Run 5 also reframes run 4's "resident": **1854 KB `live` is
mwindow's open-window working set** (pinned mappings under the bench's 4 MB
mapped limit), not retained cache buffers — the cache had been retaining
essentially nothing. Removal CONFIRMED free; simpler emulation ships.
**Verdict: the sub-second bar FAILED — wire the splice in anyway.** The bar
was aspirational; measured reality is ~22.8 s to commit on the real
263 MB-pack repo versus 611 s (or a hard OOM) for every alternative benched.
That puts a full real-repo `:sync` at roughly **910 s cold**, which ships.
The remaining ~2 s has survived four localization rounds; the next suspect —
FAT *directory-op* cost in the freshen/refresh path (open/stat/rename by path
walk FAT directories linearly; consistent with the orphan-creep signal) — is
one instrumentation pass for later (log each `p_mmap` miss + bench dir ops in
`sd_bench`), not a prerequisite for the plumbing.
### The walk is ~1.4 s even at N ≈ 2
Mostly fixed cost — the worktree-diff setup and the second (`update_all`) pass —
not per-file `stat` (one raw `stat` is ~5 ms, so N ≈ 2 can't be the 1.4 s). The
O(N) slope only bites on the real `jcalixte/notes` clone (N ≈ 1179), which this run
did **not** exercise. That slope is still unmeasured.
For orientation: `publish(commit+push)` was 9846 ms cold, so the **network half is
~6.5 s** — still the biggest single block of a warm sync (10.1 s total), a separate
floor ([`../notes/sync-latency.md`](../notes/sync-latency.md)).
## The verdict
The real-repo run (above) overturned the earlier ranking. Both index strategies
are O(N_tree) and fail on the 570 MB-pack clone, and the repo cannot be shrunk. The
work, ranked:
1. **Rewrite the commit as an O(depth) TreeBuilder walk (the fix — build this).**
Rebuild only the edited path's ancestor subtree chain onto HEAD's tree; never
materialise the 1179-entry index, never `index.write()`, never `read_tree` the
whole tree. This is the ONLY mechanism that fits: O(depth × dirty) reads/writes,
flat in repo size, small heap, images carried forward untouched. Replaces
`stage_and_commit`'s `add_all`/`update_all`/`index.write`/`write_tree`. Needs the
editor's dirty set (+ deleted set) plumbed to the git service — the editor
already knows both. **Benched to completion 2026-07-12: 6.5 s → 2.8 s cold /
~2 s steady-state after the fast-seek fix (run 4). The sub-second bar failed
but the block is lifted — wire it in** (see the final-bench section above:
the residual is unique small SD round-trips, not something a cache or seek
fix can remove).
2. **Fix the `esp_map.c` cache so it can't OOM — RESOLVED BY REMOVAL (run 5).**
The cache never scored a hit in four instrumented real-repo runs
(`mwindow` absorbs true repetition above `p_mmap`; only new ranges reach
the emulation), and the 7.4 MB OOM it was patched to avoid was caused by
the cache itself holding buffers past `p_munmap`. esp_map.c is now the
plain malloc-read/free-at-munmap emulation: honest with
`MWINDOW_MAPPED_LIMIT` by construction, ~120 lines lighter, and run 5
confirmed removal is I/O-neutral (even 15 reads *better* than v2, whose
low-water eviction fought mwindow). Stats counters kept to spot any future
workload that does repeat ranges.
3. **Retired: `add_all`/explicit-path *index* staging.** Explicit-path `add_path`
still goes through the index and `index.write``truncate_racily_clean`, so it
hits Wall 1 just the same. The TreeBuilder walk supersedes it entirely; the
"explicit-path staging" idea survives only as "the editor's dirty set feeds the
walk."
4. **Retired: SD clock / better card.** The card does a full object write in
~86 ms; raw I/O is not the bottleneck. Do not spend the PCB's 20 MHz budget
expecting a commit-latency win.
5. **Kept: the mmap cache + mwindow tuning** (`GIT_OPT_SET_MWINDOW_*`, 256 KB
window / 4 MB mapped limit). It fixed `odb.write` and the push read path; #2 just
makes it well-behaved under memory pressure.
**Recommendation:** build #1 (the O(depth) TreeBuilder walk) — #2 resolved
itself by removal. The concrete plumbing plan (exact call sites, dirty-set
threading, FD budget) is the
[next section](#the-fix--wiring-the-odepth-splice-into-the-firmware).
## The fix — wiring the O(depth) splice into the firmware
> Merged from `docs/notes/sync-commit-handoff.md` (written 2026-07-12, retired
> 2026-07-13 once the bench phase closed). The handoff's bench half is done —
> the splice op lives in `git_bench` and the numbers above are its output.
> **The firmware plumbing below SHIPPED 2026-07-13** (compile-verified both
> feature flavors; on-device `:sync` against the real repo still pending) —
> each item now records how it landed rather than what to do.
### The splice walk
Rebuild only the edited file's ancestor subtree chain onto HEAD's tree. Never
materialise the 1179-entry index; never `index.write()`; never `read_tree` the
whole tree. Cost is O(depth × dirty_files), flat in repo size, tiny heap, and it
carries every unchanged entry (all 260 images, the other 1176 files) forward
untouched — which means **the device doesn't even need the images in its
working tree.**
Shipped as `git_sync::splice` (git2 0.20: `Repository::treebuilder(Option<&Tree>)`
+ `TreeBuilder::{insert,remove,write}`). Signature:
```rust
fn splice(repo: &Repository, base: Option<&Tree>, path: &[&str], blob: Option<Oid>) -> Result<Oid>
```
`Some(blob)` inserts/replaces, `None` removes; `base: None` synthesizes a
missing intermediate directory on the way down, and a directory emptied by a
remove is pruned on the way up (the empty tree is never re-inserted).
`stage_and_commit` folds every dirty path through it (threading the running
root tree), then `commit(Some("HEAD"), …)` exactly as before — the
`tree unchanged → nothing to publish` check and the `commit split —` timing
log survive. The benched reference is `git_bench`'s `splice stage→tree` op.
### `firmware/src/git_sync.rs` — how it landed
- **mwindow options set at service start** (top of `run_git_service`, before
any `Repository::open`): `set_mwindow_size(256 KB)` +
`set_mwindow_mapped_limit(4 MB)`. Without them the 32-bit defaults (32 MB
window / 256 MB mapped limit, mwindow.c:16) would `git__malloc` a 32 MB
window on the first pack access and die on the 8 MB PSRAM heap. Run 5
sharpened the stakes: the ~1.85 MB bench "resident" **is** mwindow's live
open-window set, so these opts are the only thing bounding it.
- `stage_and_commit(repo, paths)` is the splice walk; `add_all`/`update_all`/
`index.write`/`write_tree` are gone. **The request carries one path set, not
`{changed, deleted}`:** the working tree is the source of truth, so at commit
time a recorded path that exists on the card is spliced in from disk and a
missing one is spliced out. An unchanged path is a no-op — over-reporting is
free, which is what makes the retry/journal semantics below simple.
- **Stranded-commit recovery (new):** when the splice yields the parent's tree
(nothing to commit), the service now compares HEAD against
`refs/remotes/origin/<branch>` and still pushes if origin lacks HEAD — a
previous cycle that committed and then failed mid-push used to strand that
commit forever (the old path reported "up to date" and never retried).
- **Radio-free up-to-date (new):** an empty dirty set + origin already at HEAD
short-circuits before Wi-Fi even comes up — `:sync` with nothing to do
answers in ~150 ms instead of a Wi-Fi/TLS round.
- `reconcile_onto_origin` now `ResetType::Soft` (ref move only) — there is no
index to reset, and a Mixed reset's index write is exactly the racy-clean
wall the splice avoids. Side win: a remote-only added file is now *carried*
by the replay (origin's tree is the splice base) where the old `add --all`
replay dropped it.
- The macOS-cruft filter (`skip_macos_cruft`) is gone with the walk — the
splice only touches paths the editor recorded, so `._*`/`.DS_Store` can't
sneak in the way they once did (07d87772, the Spike-14-era lesson).
- **Deliberate behavior change (now in effect):** only paths the editor
saved/deleted are ever committed. Files changed on the card outside the
editor (card mounted on a Mac) were swept in by `add_all` before; they will
now never be committed, and the working tree will show a permanent diff
against HEAD if inspected on a desktop. Correct for the appliance; recorded
here so it reads as intent, not accident.
### Dirty-set source — `firmware/src/persistence.rs` + `main.rs` (how it landed)
- `Storage` owns the record: `save_path`/`delete_path` note their repo-relative
path in a `RefCell<Dirty>` (paths outside `/sd/repo` are skipped), and the
set is **journaled to `/sd/.typoena-dirty`** — atomic write, rewritten only
when the set actually grows. Without the journal a power pull would strand
every file saved-but-not-published that session: nothing walks the tree
anymore, so an unrecorded change would never reach the remote. The journal
is loaded at mount and its paths ride the next `:sync`.
- Lifecycle: `take_dirty()` snapshots pending → in-flight for one publish
(journal keeps carrying the union); the outcome settles it —
`publish_succeeded()` forgets the snapshot and shrinks the journal,
`publish_failed()` returns it to pending for the next `:sync`. A save landing
*while* a publish runs re-enters pending and rides the next one. Recording
happens *before* the file write, so a crash between the two only
over-approximates (a no-op splice), never under-records.
- `Effect::Publish` in `main.rs` sends `PublishRequest { paths: take_dirty() }`;
the outcome handler in the idle branch calls the matching settle method.
- **FD budget:** a git build now mounts `Storage::mount_for_git()` (16 FDs) in
`boot_storage` — libgit2 keeps the pack + `.idx` descriptors open and opens
loose objects on top, which overruns the editor's 4-FD budget. The light
build keeps the editor's own budget.
### `esp_map.c` — nothing left to do
The handoff's third work item (an evict-on-`p_munmap` cache fix) is superseded:
run 5 removed the cache outright and `esp_map.c` is already the plain
malloc-read/free-at-munmap emulation (see verdict item 2). One surviving
operational note: editing `components/libgit2/*.c` won't rebuild via plain
`cargo build` — first
`rm -rf firmware/target/xtensa-esp32s3-espidf/release/.fingerprint/esp-idf-sys-*`
(the `esp-idf-component-rebuild` lesson).
### How to bench / flash
`git_bench.rs` runs git ops on the 96 KB `GIT_STACK` thread (the main task stack
overflows on these ops — that's why the real service has a dedicated thread). It's
Rust-only, so a plain rebuild picks it up (no fingerprint dance unless you also
touched `esp_map.c`).
```
just flash-gitbench
# = . ~/export-esp.sh && LIBGIT2_SRC=<repo>/firmware/components/libgit2/vendor \
# LIBGIT2_NO_VENDOR=1 PKG_CONFIG_ALLOW_CROSS=1 \
# PKG_CONFIG_LIBDIR=<repo>/firmware/pkgconfig \
# cargo run --release --bin git_bench --features git
```
Bench on the **real repo** clone (`/sd/repo` = full `jcalixte/notes`), not the
toy — the toy pack understates everything by ~2 orders of magnitude.
### Still open (none block the plumbing)
- The residual ~360 ms/loose-write ≈ 8 unique small SD round-trips; next
suspect is FAT **directory-op** cost in the freshen/refresh path. One
instrumentation pass for later (log each `p_mmap` miss + bench dir ops in
`sd_bench`).
- Ref/reflog-update cost on the real repo — the bench's `commit(None)` writes
no ref, so the shipping commit's last leg is unmeasured.
- The push's ~6.5 s network floor
([`../notes/sync-latency.md`](../notes/sync-latency.md)) — a separate curve.
## Adjacent lever: should the images be on the card at all?
Explicit-path staging makes the walk skip the images, but they still cost 150 MB
of SD space, inflate the 570 MB clone, and slow provisioning + the pull-before-push
paths. Whether the device should carry image blobs at all — vs. markdown-only, or
Git-LFS-style pointers — is a separate decision tracked in
[`../notes/git-sync-images-and-repo-size.md`](../notes/git-sync-images-and-repo-size.md).
That lever shrinks N and the clone; this one stops the walk from paying for N. They
compose.
## What this does *not* touch
The network half of `:sync` (TLS handshake + push round-trips, ~6.5 s of the warm
path) is a separate floor covered in [`sync-latency.md`](../notes/sync-latency.md);
this curve is only about the local commit. Radio *frequency* (how often we pay any
sync at all) is [`wifi-auto-sync.md`](wifi-auto-sync.md).

View File

@@ -0,0 +1,128 @@
# Wi-Fi energy vs auto-sync interval
> **Decision:** `auto_sync` defaults to **10 min**, and is an *opportunistic,
> rate-limited* push — not a wall-clock timer that wakes the device. See
> [Policy](#policy). Backs the `.typoena.toml` `auto_sync` key in
> [`../macroplan.md`](../macroplan.md) (v0.5), whose runtime timer lands in v0.7 and
> must respect sleep (v0.8).
>
> Tradeoff-curves index: [`README.md`](README.md). Docs index:
> [`../README.md`](../README.md).
## The model
For a **text** commit the git payload is a few KB — negligible. Almost all the
energy of one sync is a *fixed* radio burst that costs the same no matter how
little changed:
```
radio wake → AP association → TLS handshake → tiny push → teardown
```
So energy per unit time scales as **(fixed cost per sync) × (syncs per hour)**:
```
E(T) = K / T T = interval in minutes, K = one burst's worth of energy
```
A hyperbola. Doubling the frequency doubles the cost; the words you actually
wrote barely move it.
Placeholder constants (pending the v0.8 bench measurement — "measure idle /
typing / push current draw"): an ~8 s radio burst at ~150 mA average ⇒
**0.33 mAh per sync**, so `K ≈ 20 mAh·min/hr`. The vertical scale below moves
with the real measurement; the *shape* and the knee do not.
**One assumption is baked into that burst: the radio is fully off between
syncs**, not parked in modem-sleep. Holding the association awake to skip the
per-sync handshake costs ~1520 mAh/hr on the WROOM — more than a 1-min interval
and ~10× the 10-min default — and only pays back above ~150 syncs/hr (one sync
every ~24 s), which a writing appliance never reaches. So each sync legitimately
pays a full fresh `wake → associate → handshake` burst, and "off" everywhere
below means radio **de-init**, not beacon-listening. Tear the connection down
immediately after each push, too: with syncs ≥2 min apart a keep-alive window
saves nothing, and Typoena only ever *pushes* — there's no inbound traffic that
would justify staying reachable.
> **Status (v0.1) — the shipped firmware does *not* cycle the radio yet.** It
> brings Wi-Fi up lazily on the first `:sync` and then keeps it up for the rest
> of the session: `run_git_service` in
> [`../../firmware/src/git_sync.rs`](../../firmware/src/git_sync.rs) holds the
> `wifi` handle across its whole request loop and never stops, disconnects, or
> drops it (grep the module for `stop`/`disconnect`/`drop` — zero hits). So
> today's device runs the *stay-associated* strategy this section argues
> against, at ~1520 mAh/hr after the first push. The off-between-syncs
> assumption above is the **target**, not current behaviour: the modem is
> `.take()`n exactly once, so per-sync teardown is a v0.8 refactor of that
> ownership, not a config flip — and a prerequisite before any sleep mode ships.
## The curve
```
Wi-Fi energy vs auto-sync interval E(T) ≈ K / T
mAh/hr
20 | * each sync ≈ one fixed radio burst,
| * independent of how much text changed
| *
15 | * ← STEEP: every extra sync/min costs a full burst
| * for zero payload benefit
| *
| *
10 | *
| *
| *
| * ← knee
5 | *·.___ (5 min)
| `·-·__ ______
| `·-·__·--·______ (15) diminishing returns:
0 | `·--·----·----·----·----·--- the tail is ~flat
+----+----+----+----+----+----+----+----+----+----+----+----+
0 5 10 15 20 25 30 35 40 45 50 55 min
└── knee: 510 min. Left of here you pay a lot;
right of here you save almost nothing.
```
| interval | syncs/hr | Wi-Fi mAh/hr | vs 5-min | per 8 h day |
| ---: | ---: | ---: | ---: | ---: |
| 1 min | 60 | 20.0 | 5.0× | 160 mAh |
| 2 min | 30 | 10.0 | 2.5× | 80 mAh |
| 5 min | 12 | 4.0 | 1.0× | 32 mAh |
| **10 min** | 6 | **2.0** | **0.5×** | 16 mAh |
| 15 min | 4 | 1.33 | 0.33× | 10.7 mAh |
| 30 min | 2 | 0.67 | 0.17× | 5.3 mAh |
| 60 min | 1 | 0.33 | 0.08× | 2.7 mAh |
## Two things that move where "best" sits
**`save_on_idle` already prevents data loss — auto-sync is only remote-mirror
freshness.** The durable local copy is the SD write on the idle pause. A longer
sync interval never risks *losing work*; it only means the GitHub mirror is a
few minutes staler. That's a weak cost, and it pushes the optimum toward
*longer* intervals.
**The real battery risk is the sleep interaction, not the awake case.** While
you're typing, the CPU/e-ink baseline dwarfs the sync cost — 5 vs 15 min is
noise. The damage happens when the device is idle or asleep and a wall-clock
timer wakes it *just to push*: each wake pays the radio burst plus the wake/boot
cost and blocks the low-power state. That turns "closed on the desk overnight"
from weeks of standby into dead-by-morning.
## Policy
Ship `auto_sync` as an opportunistic, rate-limited push, with the config value
read as a *max-staleness cap* rather than a timer period:
- **Push when already awake + dirty**, coalesced into the existing idle-pause,
rate-limited to at most once per `auto_sync` — so a fast typist pausing every
20 s doesn't sync 100×/hr.
- **Push once on the way into sleep** (idle → light sleep, and especially
lid-close → deep sleep) if dirty. This is the highest-value sync: nearly free
(the device is spinning up anyway) and it's the freshness guarantee.
- **Never wake from deep sleep purely to sync.** The one behavior that wrecks
standby life.
On the single number: **10 min** halves the sync energy versus a 5-min default
for essentially no real cost, because `save_on_idle` already owns data safety.
Clamp the minimum to **~2 min** so a palette command (`> auto sync: 10s`) can't
quietly drain the battery.

147
docs/typoena-snippets.md Normal file
View File

@@ -0,0 +1,147 @@
# `.typoena.snippets.json` — snippet library
> The git-tracked file that holds your trigger-driven text expansions for
> Markdown authoring. Hand-editable (and Zed-compatible, so you can paste your
> existing snippets straight in), synced across devices like your notes. Landed
> in **v0.6** (see [`macroplan.md`](macroplan.md)). The editing surfaces — inline
> Tab-expansion and the `$` palette — are specified in
> [`v0.6-markdown.md`](v0.6-markdown.md).
>
> **Three files, three concerns, don't confuse them.** `.typoena.snippets.json`
> is *content* (your templates). [`.typoena.toml`](typoena-toml.md) is *behaviour*
> (auto-save, gutter). `/sd/typoena.conf` is *secrets* (Wi-Fi, PAT), gitignored
> and never committed. The first two live in the repo and sync; the third is
> per-device.
## Location
```
/sd/repo/.typoena.snippets.json
```
It sits in the Tracked repo beside [`.typoena.toml`](typoena-toml.md), so it is
**committed and pushed** like any note and **syncs to every device** that clones
the repo. Your snippet library follows you. It is read **once at boot**; a
**missing, empty, or malformed file is fine** — you simply have no snippets, and
the editor runs unchanged.
## Format
Deliberately **Zed's snippet JSON shape**, so the contents of a Zed
`snippets/markdown.json` paste in unmodified:
```json
{
"Markdown link": {
"prefix": "link",
"body": "[$1]($2)$0",
"description": "Inline link"
},
"Book notes": {
"prefix": "fiche",
"body": ["# $1", "", "## $2 — $3", "", "## What the book is about", ""],
"description": "Fiche de lecture"
}
}
```
- The top-level key is the **display name** (what the `$` palette shows).
- `prefix` — the word that triggers inline Tab-expansion.
- `body` — a **string**, or an **array of lines** joined with `\n` (Zed's form;
it sidesteps embedded-newline escaping and reads cleanly for multi-line
templates).
- `description` — optional but recommended: the `$` palette fuzzy-matches it and
shows it, so it is how you find a snippet you don't remember the prefix for.
### Tab stops
A body is literal text plus numbered stops:
- `$1 … $n` — empty stops the caret visits in order.
- `$0` — the final resting place (defaults to the end of the insertion if absent).
- `${n:label}`**accepted, but the label is stripped** to a bare `$n`. The
editor has no selection/overtype model, so a label would just be text to
delete; on a device with no completion popup it could never be shown as a
prompt anyway. The **headings and structure carry the template** — the labels
were only hints. This is what lets a Zed file with `${1:Titre}` load as-is.
- **No dynamic or computed values** (no `date`, no `clipboard`). There is no RTC
— the wall clock is valid only after Wi-Fi + SNTP, so a `date` snippet would
stamp 1970 on a cold boot. A stop is empty or it is literal; nothing else.
## The two surfaces
Every snippet works both ways — there is **no hidden two-tier rule** where some
are "inline only" and some are "palette only". Inline Tab is the fast path you
reach for once a prefix is in muscle memory; the `$` palette is discovery.
### Inline Tab-expansion (Insert mode)
Type a prefix, press **Tab**. If the word immediately before the caret matches a
snippet prefix, it expands; otherwise Tab inserts spaces as it does today. (Tab
arrives as an ordinary character, so this is a check inside the Insert-mode
handler, alongside the existing list-continuation transform.)
On a **typing pause** — the same throttle as the word-count / cursor refresh, so
never a per-keystroke e-ink flash — if the word before the caret is a prefix, the
right side panel shows a quiet hint (`» Book notes`), on the row above the mode
line. The panel is ~15 columns, so the hint is the **snippet name**, not the whole
body; the full preview is what the `$` palette is for. (The marker is `»`, not a
tab glyph: the panel font is ISO-8859-15, which has no `↹`.)
### `$` palette (browse + insert)
Open the palette (`Cmd-P`) and type **`$`** — the same sigil mechanism as `>` for
commands. The query after the `$` fuzzy-matches name, prefix, and description;
`Ctrl-N`/`Ctrl-P` move the selection; **Enter inserts the body at the caret** and
starts the tab-stop session (dropping you into Insert at `$1`). The empty-palette
placeholder legends the sigils: `Go to file · > settings · $ snippets`.
## The tab-stop session
Identical whether the snippet was expanded inline or inserted from the palette:
- After insertion the caret lands on **`$1`** (or the end, if the body has no
stops), in **Insert** mode.
- **Tab advances** to the next stop, **forward only** (no Shift-Tab). The last
Tab lands on `$0` / the end and ends the session.
- Pending stop offsets sit **after the caret** and shift with the edits you make
at each stop, so typing at `$1` keeps `$2 … $n` correctly placed.
- The session **auto-aborts** on Esc, a mode change, or a motion that leaves the
stop range — after which the buffer is just text and Tab inserts spaces again.
## Parsing
The parse lives in the host-testable `editor` crate (`Snippets::parse`), using
`serde_json` — JSON string escapes (`\n`, `\"`, `\uXXXX`) are a foot-gun to
hand-roll, and `serde_json` is battle-tested; the editor crate is `std`, so it
compiles for xtensa via esp-idf. This is the **one new dependency** the feature
adds. The firmware reads the file at boot and hands the parsed list to
`Editor::set_snippets`, mirroring how `.typoena.toml` is read and applied via
`set_prefs`. A parse error is **non-fatal**: log it and boot with no snippets,
rather than refusing to start over a stray comma.
## Editing it
- **On your computer (the normal path).** It's plain JSON in your notes repo —
edit it in your real editor, copy entries over from Zed, commit, and it reaches
the device on the next clone/sync. This is deliberately where the heavy editing
happens; the appliance is for writing, not for maintaining a JSON library.
- **First-time setup.** [`just init`](../firmware/README.md#provisioning-an-sd-card)
seeds this file from a curated catalog (`firmware/snippets-catalog/`) — you pick
which snippet groups you want and it `jq`-merges the selected subset into
`repo/.typoena.snippets.json` (committed on the device's first `:sync`). It
writes **only if the file is absent**, so re-running `init` on a card whose
clone already carries your library never overwrites it. See
[`v0.6-markdown.md`](v0.6-markdown.md) for the catalog.
- **On-device hand-edit — deferred.** The palette hides dotfiles, and `:e` was
dropped in v0.6, so there is no in-editor path to this file yet. When one is
wanted it returns as a discoverable `> edit snippets` command that opens the
file directly, rather than resurrecting a general `:e`.
## See also
- [`v0.6-markdown.md`](v0.6-markdown.md) — the editing surfaces, the `$`/`>`
palette model, and the setup-recipe snippet catalog.
- [`typoena-toml.md`](typoena-toml.md) — the sibling prefs file this is kept
separate from, and the `>` command palette snippets share the surface with.
- [`macroplan.md`](macroplan.md) — v0.6 scope.

176
docs/typoena-toml.md Normal file
View File

@@ -0,0 +1,176 @@
# `.typoena.toml` — editor preferences
> The git-tracked file that controls how the editor behaves — auto-save,
> format-on-save, the line-number gutter, and the panel theme. Hand-editable, or
> changed live from the `Cmd-P` palette (booleans flip; the theme and auto-sync
> interval rotate through preset options on **Enter**). Landed in **v0.5** (see
> [`macroplan.md`](macroplan.md)).
>
> **Not to be confused with `/sd/typoena.conf`** — that holds the device
> *secrets* (Wi-Fi, PAT, remote URL, commit author), is gitignored, and is never
> committed. `.typoena.toml` is *behaviour*, shared across devices; `typoena.conf`
> is *secrets*, per-device. See [v0.1 product](v0.1-mvp-product.md).
## Location
```
/sd/repo/.typoena.toml
```
It lives inside the Tracked repo (`/sd/repo`), so it is **committed and pushed**
like any note — which means the preferences **sync to every device** that clones
the repo. That is deliberate: your editor behaviour follows you. (A per-device
override for the one genuinely device-specific key, `auto_sync`, may layer on top
later via `typoena.conf` — worth it only once `auto_sync` actually does something
in v0.7. See the [auto_sync](#auto_sync) note.)
The file is read **once at boot**, before the first screen is drawn (so
`line_numbers` shapes the opening frame). A **missing, empty, or partial file is
fine** — every absent key falls back to its default below, so a fresh card just
works with no config present.
## Keys
| Key | Type | Default | Options | Effect |
| --- | --- | --- | --- | --- |
| `save_on_idle` | bool | `true` | `true` / `false` | Auto-save the current buffer on the idle typing-pause, so `:w` is optional. |
| `format_on_save` | bool | `true` | `true` / `false` | Run `:fmt` on the buffer before an explicit `:w`/`:sync`. |
| `line_numbers` | bool | `true` | `true` / `false` | Show the absolute line-number gutter. Off reclaims its columns for text. |
| `theme` | string | `"light"` | `light` / `dark` | Panel colour polarity. `dark` inverts the whole frame to white-on-black. |
| `auto_sync` | string | `"10m"` | `2m` / `5m` / `10m` / `15m` / `30m` | Max-staleness cap for opportunistic auto-publish. **Value only — no behaviour yet** (rides v0.7). |
The **Options** column is what the palette rotates through on **Enter**; a
boolean is just the two-option case. Hand-editing a string key can still set any
value — the palette only cycles the presets.
### Example
```toml
# Typoena editor preferences — hand-editable, git-tracked.
# Edit here, or change live from the Cmd-P palette (type `>`).
save_on_idle = true
format_on_save = true
line_numbers = true
theme = "light"
auto_sync = "10m"
```
### `save_on_idle`
When on, the firmware quietly persists a dirty, named buffer once typing has
paused (~1.5 s), so a power pull can't cost more than the last couple of seconds
of writing. It is a **safety net, not an action**:
- **Silent.** No snackbar, no forced screen refresh. A visible confirmation on
every pause would cost a ~630 ms e-ink flash purely to say "saved" — exactly
the gratuitous flashing the panel avoids elsewhere. `:w` remains the *loud*
save (it posts `saved`).
- **Unformatted.** The idle save never runs `:fmt` — see the
[format_on_save](#format_on_save) note for why.
- Fires **once per typing burst**; a failed save doesn't retry-storm (it's kept
in RAM and re-attempted on the next burst, or on `:w`).
### `format_on_save`
Runs `:fmt` — table alignment, blank-line collapse, trailing-whitespace strip —
on the buffer *before* it is persisted, so `:sync` is **fmt → save → commit →
push** and `:w` saves formatted.
**Formatting only happens on an explicit `:w`/`:sync`.** The `save_on_idle`
auto-save is deliberately left unformatted: if it reformatted on every idle
pause, tables would reflow and blank lines collapse *mid-session*, with the caret
jumping under you every time you paused to think. Formatting is a deliberate act;
the safety-net save is not.
### `line_numbers`
Shows the absolute line-number gutter (built always-on in v0.2). Turning it off
returns the gutter's columns to the text, so prose gets the full writing width.
Applied **live** — toggling it from the palette redraws immediately with (or
without) the gutter.
### `theme`
Panel colour polarity: `light` (the native black-ink-on-white-paper) or `dark`
(white-on-black). On the 1-bit e-paper panel this is not a palette swap but a
**whole-frame invert** applied at the very end of the render, so text, selection,
caret, side panel and command palette all flip together and each stays legible.
Any value other than `dark` reads as light. Applied **live** — cycling it from
the palette repaints inverted at once.
> **On e-paper, `dark` is not free.** Partial refreshes over a mostly-black field
> ghost more than over white, and the panel is tuned for white-background reading.
> It works, but expect a slightly muddier refresh than `light` — verify on-device.
### `auto_sync`
A duration string that will one day cap how stale the published copy is allowed
to get — an *opportunistic, rate-limited* push, not a wall-clock timer. The
palette rotates it through the presets `2m` / `5m` / `10m` / `15m` / `30m`
(hand-editing can still set any string, e.g. `"0"`/empty to disable). **The value
is only stored and displayed in v0.5 — nothing reads it yet:** the periodic push
rides the better-git work in v0.7 and must interact with sleep in v0.8, so
cycling the interval today changes what will be honoured *then*, not now.
Rationale for the `"10m"` default:
[`tradeoff-curves/wifi-auto-sync.md`](tradeoff-curves/wifi-auto-sync.md).
## Editing it
Two ways, both landing in the same file:
1. **By hand** — it's plain text on the card; edit it on your computer and reboot
to apply. (The palette hides dotfiles, but you can still open it in-editor with
`:e repo/.typoena.toml`.)
2. **Live, from the device** — open the settings list either way:
- **`:settings`** — drops you straight into it, or
- **`Cmd-P`** then type **`>`** — switches the file palette to the command
list (VS Code semantics).
Every pref appears carrying its current state:
```
> save on idle: on
format on save: on
line numbers: on
theme: light
auto sync: 10m
```
`Ctrl-N`/`Ctrl-P` move the selection; **Enter** advances the selected pref to
its next value, applies it at once, writes the change back to `.typoena.toml`,
and confirms the new state on the snackbar (e.g. `theme: dark - saved`). A
boolean flips; the theme and auto-sync interval **rotate through their preset
options and wrap** — same key, so the palette is uniformly "press Enter to
change". **The list stays open** so you can change several prefs in a row;
**Esc** (or `Cmd-P`) closes it. Each change rides the next `:sync` to your
other devices.
`auto_sync` is a value command now, but has no behaviour to drive until v0.7 —
cycling it sets the interval that the future periodic push will honour.
## Parsing
The reader is a deliberately tiny **line-based** parser, not a general TOML
library — the file is flat `key = value` pairs (a bool, or a quoted string) with
`#` comments, so a full TOML crate isn't worth pulling onto the firmware build.
It lives in the host-testable `editor` crate (`Prefs::parse` / `Prefs::to_toml`).
Rules:
- A `#` starts a comment to end of line (whole-line or trailing).
- Blank lines and lines without `=` are ignored.
- An **unrecognized key** is ignored; an **unparseable value** (e.g.
`save_on_idle = yes`) leaves *that key* at its default rather than reading as
`false`.
- Any key not present falls back to its default, so partial files are valid.
Because `Prefs::to_toml` round-trips with `Prefs::parse`, a palette edit rewrites
the whole file in canonical form (with the header comment) — hand-added comments
elsewhere in the file are not preserved across a palette toggle.
## See also
- [`macroplan.md`](macroplan.md) — v0.5 scope and the decisions behind these keys.
- [`v0.1-mvp-product.md`](v0.1-mvp-product.md) — the `typoena.conf` device secrets
this file is kept separate from.
- [`tradeoff-curves/wifi-auto-sync.md`](tradeoff-curves/wifi-auto-sync.md) — why
`auto_sync` defaults to 10 minutes.

View File

@@ -8,7 +8,7 @@
> [technical design](v0.1-mvp-technical.md) (how it's built) ·
> [ADR log](adr.md) (load-bearing decisions) ·
> [QFD](qfd.md) (requirements ↔ functions ↔ components) ·
> [roadmap](roadmap.md) (where v0.1 sits in the sequence).
> [macroplan](macroplan.md) (where v0.1 sits in the sequence).
## One-line summary
@@ -101,17 +101,17 @@ context," not a full page. Hardware rationale: [ADR-003](adr.md#adr-003-display-
```
┌───────────────────────────────────────────────────────┬─────────────────────┐
│ Lorem ipsum dolor sit amet, consectetur adipiscing │ notes.md ● │
│ elit. Sed do eiusmod tempor incididunt ut labore et │ INSERT
│ elit. Sed do eiusmod tempor incididunt ut labore et │
│ dolore magna aliqua. Ut enim ad minim veniam, quis │ │
│ nostrud exercitation ullamco laboris nisi ut aliquip │ 1 240 words │
│ ex ea commodo consequat. │ +318 this session
│ ex ea commodo consequat. │
│ │ 12:07 elapsed │
│ Duis aute irure dolor in reprehenderit in voluptate │ │
│ velit esse cillum dolore eu fugiat nulla pariatur. │ │
│ Excepteur sint occaecat cupidatat non proident. │ │
│ │ 14:02 │
│ The quick brown fox jumps over the lazy▎ │ Wi-Fi — │
│ │ ✓ pushed abc1234
│ │ -- INSERT --
└───────────────────────────────────────────────────────┴─────────────────────┘
writing column (~60 cols, full height) side panel (~20 ch)
```
@@ -122,12 +122,13 @@ context," not a full page. Hardware rationale: [ADR-003](adr.md#adr-003-display-
This is the only region that repaints as you type.
- **Side panel** (right, ~150 px / ~20 cols, full height): all metadata lives
here, so the writing column keeps the full height. Top — **filename** + dirty
dot (●/○) and **mode** (always `INSERT` in v0.1). Middle — **word count** and
the **session** (words written + elapsed time), both refreshed on a short
typing pause, *not* per keystroke. Bottom — ambient state: **clock** (if SNTP
has set the time), **Wi-Fi** (`—` = off/on-demand, `✓` = connected,
`✗` = failed), a **keyboard-disconnect** flag (`⌨ ✗`, shown *only* while the
keyboard is dropped; blank when healthy — no permanent `⌨ ✓`, per
dot (●/○). Middle — **word count** and **elapsed time**, refreshed on a short
typing pause, *not* per keystroke. Bottom-left — the **mode** indicator
(always `INSERT` in v0.1) with any pending count/operator echo. Bottom —
ambient state: **clock** (if SNTP has set the time), **Wi-Fi** (`—` =
off/on-demand, `✓` = connected, `✗` = failed), a **keyboard-disconnect** flag
(rendered `NO KBD` — Latin-9 has no `⌨`/`✗` glyph — shown *only* while the
keyboard is dropped; blank when healthy, per
[`CONTEXT.md`](../CONTEXT.md) "No state the user didn't ask for"), and
**publish state**. `Ctrl-S` briefly flashes "saved HH:MM";
`Ctrl-G` transitions through `✓ committed abc1234 · pushing…` at ~0.2 s — the
@@ -204,7 +205,23 @@ to an engineering function with a measured target in
[qfd.md §6](qfd.md#6-critical-performance-budget); that's the
place to check before declaring an item done.
- [ ] After a cold boot with valid pre-flashed config, cursor is ready in ≤ 5 s.
> **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, 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).
- [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
@@ -214,8 +231,11 @@ place to check before declaring an item done.
has moved on since the last publish).
- [ ] Pulling power during typing never corrupts the file; the previous saved
state is recoverable.
- [ ] One hour of continuous typing without crash, freeze, or memory
exhaustion.
- [x] One hour of continuous typing without crash, freeze, or memory
exhaustion. — attested by the author 2026-07-11 from sustained real-use
sessions (no crash, freeze, or memory exhaustion since the editor was wired
up). Author attestation, not a controlled instrumented run; a heap-watermark
log over a timed session would make it rigorous.
## Non-goals as success criteria

View File

@@ -6,7 +6,7 @@
> Decisions referenced inline point at [`adr.md`](adr.md). Tradeoff weights
> and the critical-performance budget live in [`qfd.md`](qfd.md). Project
> overview: [`../README.md`](../README.md). Release sequence:
> [`roadmap.md`](roadmap.md).
> [`macroplan.md`](macroplan.md).
## Architecture
@@ -65,14 +65,17 @@ read snapshot (render diff).
4. Mount FAT on SD → verify /sd/repo and /sd/repo/notes.md exist
├─ missing → fatal: "missing /sd/repo — re-mount SD and reboot"
└─ present → continue
5. Init SPI bus (shared: EPD + SD on different CS)
5. Init two SPI buses: EPD on SPI2, SD on SPI3 (separate hosts — ADR-012)
6. Init EPD, full refresh: splash + boot log
7. Start tasks: usb, wifi (spawned in `Off` state — no radio bring-up), ui, render
8. ui_task opens /sd/repo/notes.md, places cursor, enqueues full render
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
@@ -89,8 +92,8 @@ spike 4 is the gate for
— critically — whether `epd-waveshare` already supports the panel's
controller (SSD1683-class) or whether we write a thin custom driver
against `embedded-hal`. Either way, ~300 LoC; this spike answers which.
3. **Spike 3 — SD.** Mount FAT, read/write a file. Validates SPI sharing with
EPD (or separate bus if needed).
3. **Spike 3 — SD.** Mount FAT, read/write a file. Verified 2026-07-11; the
shared-bus question resolved to a separate SPI3 for the SD (ADR-012).
4. **Spike 4 — USB host.** Enumerate the Nuphy as a boot-protocol HID
keyboard, log keycodes over UART.
5. **Spike 5 — Partial refresh.** Type a string letter-by-letter, partial
@@ -163,11 +166,26 @@ in [qfd.md §3](qfd.md#3-house-of-quality--whats--hows).
Storage split rationale:
[ADR-007](adr.md#adr-007-storage-split--fat-on-sd-for-working-copy-littlefs-on-flash-for-config).
- Atomic save: write to `notes.md.tmp`, fsync, rename. We accept FAT's
weakness here; on power loss between rename and dir flush, the user gets
the previous version, which is the documented behavior.
Implemented in [`firmware::persistence`](../firmware/src/persistence.rs)
(`Storage::{mount, load, save, recover}`), graduated from Spike 3 and
hardware-verified 2026-07-11.
- Atomic save: write to `notes.md.tmp`, fsync, unlink the target (FatFS
`f_rename` won't overwrite — ADR-007), rename. On power loss the user gets
the previous version, the documented behavior. Boot recovery reconciles a
leftover `*.tmp`: kept-target-if-both-present, promote-if-target-gone (see
ADR-007 for why "just promote" is unsafe).
- The file is read fully into the rope at boot. v0.1 caps file size at
256 KB; larger files refuse to open with a clear message.
256 KB; larger files refuse to open with a clear message. Saving is not
capped — the buffer is always persistable.
- **Wired into `main.rs`** (2026-07-11): the editor boot-loads the note via
`Editor::with_text(Storage::load())` — a missing card / repo / unreadable note
halts boot with the reason painted on the panel — and `:w`/`:sync` persist
through `Storage::save`, inline on the UI loop (a small-file write is tens of
ms). Save errors are logged and the RAM buffer kept, so a card pulled
mid-session doesn't lose work. The git-push half of `:sync` is **not** wired
yet: it awaits `git_sync` being graduated into a module and run on the
dedicated git thread (see `git` below), so `:sync` currently just saves.
### `wifi` — on-demand station
@@ -342,7 +360,7 @@ Mirrored as live conflicts in
| TinyUSB host drops HID reports under load | dropped keystrokes during fast typing | enable larger USB rx buffer; if still bad, fall back to BLE-HID for v0.1 |
| EPD partial refresh slower than 200 ms | typing feels laggy | reduce font size to shrink dirty area; or render multi-char bursts |
| TLS heap pressure on PSRAM | OOM during push | tune mbedtls to smaller cipher suites; force GC of glyph cache before push |
| SD + EPD on same SPI bus collide | corruption on save during render | move SD to a separate SPI peripheral (ESP32-S3 has two) |
| SD + EPD on same SPI bus collide | corruption on save during render | **ADOPTED (ADR-012):** SD on its own SPI3 host |
Every one of these is detected by a spike before integration starts — we are
not finding them at the end.

39
docs/v0.2-navigation.md Normal file
View File

@@ -0,0 +1,39 @@
# v0.2 — Vim navigation
> Part of the [Typoena macro plan](macroplan.md). Requirements and targets:
> [qfd.md](qfd.md). Load-bearing decisions: [adr.md](adr.md).
**Status:** COMPLETE 2026-07-11. Navigation done in core; the **UTF-8-correct
buffer** and **`Ctrl-d/u` half-page scroll** landed and are hardware-verified,
and the **absolute line-number gutter** is built, host-tested, and **confirmed
on the panel (Spike 13) 2026-07-11** — a single-line edit repaints only the rows
at/below the change and forces no extra full refresh. Shipped early beyond scope:
a read-only **View** mode and the full `d`/`c` operator + text-object grammar
(see [v0.3](v0.3-editing.md) / [v0.4](v0.4-visual-and-ex.md)).
- [x] Mode state machine (Normal / Insert / View), mode indicator in the status strip
- [x] Movement: `h j k l`, `w b e`, `0 $`, `gg G`, `Ctrl-d Ctrl-u`. `Ctrl-d/u`
step **display** (soft-wrapped) rows, not logical lines — half a page is
half the visible window however prose wraps; decoded as `HalfPageDown/Up`
intents in the keymap, caret moves and the viewport follows.
- [x] `i a o O A` to enter Insert
- [x] `Esc` returns to Normal
- [x] Line numbers in the left gutter: **absolute**, built + host-tested
2026-07-11, **confirmed on the panel (Spike 13) 2026-07-11** — numbered on a
logical line's first display row, blank on wrapped continuation rows; the
gutter width tracks the buffer's line count (2 digits + separator, widening
past 99 lines) and steals its columns from the soft-wrap. **Always on** in
v0.2; the on/off toggle rides the [v0.5](v0.5-palette-and-multi-file.md)
`.typoena.toml` prefs.
Relative numbering was dropped (2026-07-11): renumbering the whole gutter on
every `j`/`k` burns the e-ink ghosting budget for no proportionate gain,
whereas absolute renumbers only the rows below an edit — the on-panel check
confirmed a single-line edit repaints only rows at/below it with no extra
full refresh.
- [x] Groundwork — UTF-8-correct buffer: caret motions and edits step by
character, not byte (dropped the ASCII == byte-offset assumption), so every
motion stays correct with accented input. **Done 2026-07-11** alongside
extracting the editor into a host-testable crate — char-step
motions/deletes, byte-vs-char split in `layout`/`caret_rc`, `word_end`/`de`
fixed; 15 host tests. Render font is ISO-8859-15 (Latin-9), so accented
glyphs display.

View File

@@ -0,0 +1,25 @@
# v0.2.5 — International input
> Part of the [Typoena macro plan](macroplan.md). Requirements and targets:
> [qfd.md](qfd.md). Load-bearing decisions: [adr.md](adr.md).
**Status:** DONE in core, **hardware-verified 2026-07-11** (typed ç é è ñ on the
bench, no crash). US-International dead-key accent composition lives in the
`keymap` crate — a `Composer` downstream of the decoder — wired into
`usb_kbd.rs` so the editor still receives a single `Key::Char`. Builds on the
[v0.2](v0.2-navigation.md) UTF-8-correct buffer and the ISO-8859-15 render font.
Host-tested.
- [x] Dead keys — grave, acute, circumflex, diaeresis, tilde — compose with
the next letter: à é ê ë ñ, ç (via `'`+c), both cases
- [x] `'`+space emits a literal apostrophe (the everyday apostrophe path); a
dead key followed by a non-composing letter emits the accent then the
letter
- [x] A non-character event (Enter, Backspace, arrows) flushes any pending
accent as its literal first
- [ ] ~~Pending-accent indicator in the side-panel status strip~~ — **DROPPED
(2026-07-11 decision):** at typing speed it would be stale before the
~630 ms panel repaint, so it conveys nothing. Left unbuilt on purpose.
- [x] Bonus (2026-07-11): the physical **Esc key** (HID 0x29) now types
`` ` ``/`~` — Esc comes from the Caps tap — so grave/tilde accents and
Markdown code fences are reachable on a 60% board without a Fn layer.

30
docs/v0.3-editing.md Normal file
View File

@@ -0,0 +1,30 @@
# v0.3 — Vim editing
> Part of the [Typoena macro plan](macroplan.md). Requirements and targets:
> [qfd.md](qfd.md). Load-bearing decisions: [adr.md](adr.md).
**Status:** COMPLETE in core 2026-07-11, host-tested (65 editor + 28 keymap
tests) and **partially smoke-tested on the panel 2026-07-11**. The three
remaining pieces landed together: a single unnamed **register** with
`y`/`yy`/`p`/`P` (and `x`/`d`/`c` filling it, so `dd``p` moves a line),
**undo/redo** (`u`/`Ctrl-r`, snapshot-based, bounded to 100 groups in PSRAM — a
whole Insert session undoes as one group), and **`.` repeat** (keystroke-recorded,
so it replays an insert session like `ciwfoo<Esc>`). The `d`/`c` operator grammar
and text objects had already landed ahead of schedule. On device, `dd`, `yy`, and
`Ctrl-r` confirmed good; the one issue found was that a **multi-line paste near
the bottom left its later lines below the fold** — `adjust_scroll` only kept the
caret's (first) pasted line visible. Fixed by a `reveal()` that scrolls the end of
the pasted block into view while the caret stays on its first line (reflash to
re-confirm on panel).
- [x] `x dd`, `dw dd d$` (✓); `yy p P` (✓) and `.` repeat (✓) — register + a
keystroke-recorded last-change both landed 2026-07-11
- [x] Undo / redo (`u`, `Ctrl-r`) — snapshot history bounded to 100 groups in
PSRAM; one Insert session = one undo group
- [x] Numeric prefixes (`3dd`, `5j`)
- [x] Ahead of schedule: `c` change operator + text objects
(`ciw`, `di(`, `ca"`, … — inner/around, nesting-aware)
Known limits (deferred): `.` drops a *leading* count (`3x` then `.` deletes one;
a count inside an operator like `d2w` is kept); no named registers; `.` after an
aborted operator (`d<Esc>`) is a no-op.

View File

@@ -0,0 +1,34 @@
# v0.4 — Visual mode + ex commands
> Part of the [Typoena macro plan](macroplan.md). Requirements and targets:
> [qfd.md](qfd.md). Load-bearing decisions: [adr.md](adr.md).
**Status:** COMPLETE in core 2026-07-11, host-tested (83 editor tests), on-device
smoke-test pending. Charwise **Visual** (`v`) and linewise **VisualLine** (`V`)
selection landed with `y`/`d`/`c` on the span: charwise is vim-inclusive of the
char under the further caret, linewise spans whole logical lines and fills the
register linewise (so `Vy``p` copies a line, `Vd` deletes it like `dd`). Motions
(`h j k l`, `w b e`, `0 $`, `gg G`, `Ctrl-d/u`) and counts extend the selection;
`v`/`V` toggle/switch submode, `Esc` cancels. The selection renders as
reverse-video cells (black fill, glyphs redrawn white) — the only selection
affordance on a 1-bit panel — with the caret cell punched back to *normal* video
so the active end stands out. The Normal-mode motions were factored into a shared
`move_by` helper so Normal and Visual can't drift.
**DECISION (2026-07-07, resolved 2026-07-11):** `v`/`V` = **Visual** selection
(vim-standard). The read-only **View** (reading/scroll) mode that used to sit on
`v`/`V` moved to **`gr`** (go-read) — a `g`-prefixed gesture reusing the existing
pending-`g` machinery, no vim clash. View mode stays; `v`/`V` are now Visual.
- [x] Visual char (`v`) and line (`V`) modes, `y d c` on selections — landed
2026-07-11 (18 new tests). Known limits (deferred): no `o` swap-ends, no
`x`/`s` operator aliases, no Visual `.` repeat, no `:'<,'>` range commands.
- [~] `:` command line (mechanism ✓; `:w`/`:wq`/`:x` save, `:fmt`/`:sync`/`:gl`
wired; `:q` deliberately dropped — nothing to quit to). Command-line
editing added 2026-07-11: Ctrl-W deletes the previous word, Cmd-Backspace
clears the line. **`:e <path>` deferred to [v0.5](v0.5-palette-and-multi-file.md)** — opening another file
needs host file-IO + buffer switching, which is v0.5's multi-file work
(gated behind Spikes 11/14); half-building it here ahead of its
dirty-buffer handling wasn't worth it.
- [x] Ahead of schedule / unscheduled: `:fmt` Markdown formatter
(table alignment, blank-line collapse, trailing-whitespace strip)

View File

@@ -0,0 +1,286 @@
# v0.5 — File palette + multi-file
> Part of the [Typoena macro plan](macroplan.md). Requirements and targets:
> [qfd.md](qfd.md). Load-bearing decisions: [adr.md](adr.md). Prefs reference:
> [typoena-toml.md](typoena-toml.md).
**Status:** buffer **foundation** landed in core 2026-07-11 (slice 1 of 4),
host-tested; the palette + transient panel (Spike 11) and delete → git-staging
(Spike 14) remain the on-device gates. The single-file `Effect` return became a
drained **effect queue** (`Save{path,contents}` / `Load{path}` / `Publish` /
`Pull`), so one action can ask the host for several steps in order — opening a
non-resident file queues a `Save` of the outgoing dirty buffer *then* a `Load` of
the target. The multi-buffer state deliberately avoids a rope-per-buffer rewrite:
the active buffer keeps its fields inline on `Editor`, inactive buffers park in a
small LRU `Vec<Buffer>` (≤ 3 resident = active + 2), and a switch marshals fields
in/out so the ~3k-line editing engine is untouched. A dirty parked buffer is
saved before it is evicted (nothing leaves RAM unsaved); `:e <path>` opens by
prefix (`/sd/repo` → Tracked, `/sd/local` → Local); `:sync` is refused in-core in
a Local buffer. Firmware drains the queue **to empty** each batch (a `Load` can
cascade an eviction `Save`), and `persistence::{load_path,save_path}` generalise
the atomic save off the hard-coded `notes.md`.
**Slice 2 of 4 landed in core 2026-07-11**, host-tested: the `Cmd-P` file
**palette** — a modal transient panel over the writing column with a bare
fuzzy-search input (no `>` prefix: `>` is reserved for the command palette,
slice 4 — VS Code semantics), the ranked list, and the selected row in reverse
video. A pure host-testable fuzzy matcher (`fuzzy_score`: subsequence match,
boundary + consecutive-run bonuses, no penalties) ranks results; an in-core MRU
floats recently-opened files to the top on an empty query and is **shared with
`:e`** (both flow through `open_path`). The host feeds the file list once at boot
(`set_file_list`, enumerating `/sd/repo` + `/sd/local`, dotfiles skipped);
`Ctrl-n`/`Ctrl-p` (fzf-style; `Ctrl-d`/`Ctrl-u` too) move the selection — the
60 % board has no arrow keys — Enter opens via the same park/evict path as `:e`,
Esc (or `Cmd-P` again) closes. Same slice: **`Ctrl-n`/`Ctrl-p` also work as
down/up line motions in Normal mode** (vim `CTRL-N``j`, `CTRL-P``k`,
count-aware), which is why the palette opener moved to `Cmd-P` alone. Scope
shows as the inline `repo/…` vs `local/…` label rather than the planned
`[git]`/`[local]` badge — it also disambiguates subpaths, not just scope. 111
editor tests + 28 keymap tests pass; the no-git firmware binary builds clean.
The transient-panel refresh (**Spike 11**) is **CONFIRMED ON DEVICE 2026-07-12 —
no ghosting** (user flashed it and eyeballed the full-area partial the palette
forces); Cmd-P opens it on-device too. Remaining v0.5 slice: 4 prefs +
palette command mode.
**Slice 3 (`:enew` + delete) COMPLETE + CONFIRMED ON DEVICE 2026-07-12**
(committed `c9c0716`). `:enew <name>` creates a new file: empty, active, marked **dirty**
so eviction/`:w` persists it, and added to the in-core file list so the palette
finds it without a disk re-enumeration — no card IO until it is saved. `:delete`
unlinks the **current** file (a new `Effect::Delete` the host services), then
switches to the most-recently-parked buffer or an empty scratch; the discarded
buffer is never saved even when dirty. **Scope for a new file is read from the
path, not a modal prompt** — `local/x` / `repo/x` (the palette label form) select
the scope, a bare name uses the current buffer's scope. Same change made the
**`/sd` prefix optional everywhere** in `resolve_path`: `/sd/repo/x`, `/repo/x`,
and `repo/x` all name one file and nothing resolves outside `/sd` (the writer
can't reach anything else). **Spike 14 (delete → git-staging) DID need a firmware
fix.** The first on-device test found `add_all(["*"])` alone does **not** stage a
deletion on this libgit2 build (the tree came back unchanged, so the second push
was a silent "up to date" no-op — the "delete didn't work" report). Fix:
`stage_and_commit` now runs `add_all` **then `update_all(["*"])`** (`git add -u`),
which removes index entries whose working-tree file is gone — together they are
`git add -A`. Also, `:delete` gave no clear feedback, so the snackbar now names
the scoped file and, for a Tracked file, that it is local until `:sync`
(`deleted repo/notes.md - :sync to publish`). Deferred to later: greying the
Publish affordance for a Local buffer, and the multi-file publish count. 123
editor tests + 28 keymap tests pass; the no-git firmware binary builds clean. The
`update_all` fix (behind `--features git`, unbuildable locally) was **verified on
device 2026-07-12** — `:enew test.txt``:sync``:delete``:sync` removed
test.txt from origin.
**Slice 4 (`.typoena.toml` prefs + palette `>` command mode) COMPLETE in core
2026-07-12, HOST-TESTED not yet on-device.** A `Prefs` type (host-testable
line-based TOML parse/serialize — flat `key = value` bools + one string with `#`
comments, no crate pulled onto xtensa) lives on `Editor`; the host reads
`/sd/repo/.typoena.toml` at boot and applies it before the first render, and a
missing/partial file falls back to per-key defaults. Keys: `save_on_idle`,
`format_on_save`, `line_numbers` (all bool, default on) and `auto_sync`
(string, default `"10m"`, **schema + default only** — nothing reads it yet).
`line_numbers` is live: `gutter_cols()` returns 0 when off, so the text reclaims
the gutter's columns (the `gutter - 1` field width made saturating to avoid the
underflow). The palette `>` command mode (VS Code semantics — a leading `>` in
the query switches file search to the command list) exposes the three booleans
as live toggles; Enter flips the pref, applies it at once, queues a new
`Effect::SavePrefs` (the editor serializes; the host does the atomic write to
`.typoena.toml`, which rides the next `:sync` to other devices), and confirms
the new state on the snackbar. **The list stays open after a toggle** so several
prefs flip in one visit (Esc/`Cmd-P` closes); **`:settings` opens the palette
straight into `>` mode** as a one-command shortcut (both requested by the user
2026-07-12, chosen over a separate settings modal — same surface, no duplicate
machinery). Committed `c535864`. **Three "decide before build" calls:** (1) the
idle auto-save is **unformatted**`:fmt` runs only on explicit `:w`/`:sync`, so
tables/blank-lines are never reflowed mid-session; (2) the per-device `auto_sync`
override (card-local `typoena.conf`) is **deferred** — auto_sync is inert in
v0.5, so there is nothing yet to override; (3) `> auto sync: <dur>` as a palette
command is **deferred to v0.7** — a control that changes a value nothing reads
would be a dead switch. `save_on_idle` is honoured host-side: a silent idle
auto-save (no snackbar, no forced e-ink flash — a safety net, not an action)
fires once per typing burst after a 1.5 s pause. 141 editor tests + 28 keymap
tests pass; the no-git firmware binary builds clean. Firmware bumped **0.4.0 →
0.5.0** (the v0.5 feature set is met). **Boot-read of the prefs file CONFIRMED ON
DEVICE 2026-07-12** — a `.typoena.toml` in `typoena-test` with non-default values
(`save_on_idle=false`, `line_numbers=false`, `auto_sync="5m"`) logged back
`prefs: Prefs { save_on_idle: false, format_on_save: true, line_numbers: false,
auto_sync: "5m" }` at boot, a byte-exact parse (comments skipped, bools + quoted
string read). **Full gate CLOSED 2026-07-12:** the palette `>` live-toggle
round-trip is confirmed — origin's `.typoena.toml` went `line_numbers` false →
true via a *device*-authored publish (`3c79f38`), proving toggle → `SavePrefs`
atomic write → `git add -A` → push — and the `save_on_idle` autosave works on
device too. **v0.5 slice 4 fully DONE + on-device confirmed.**
**Amendment 2026-07-12 — non-boolean prefs (`theme`, `auto_sync`).** Two of the
"decide before build" calls above are **superseded**: `auto_sync` is now a live
palette command, and a new `theme` (`light`/`dark`) key ships. The generalising
idea is that a boolean toggle is just the two-option case of *rotate through a
preset list on Enter* — so the palette gains one uniform gesture: **Enter
advances the selected pref to its next value and wraps** (a bool flips; a string
pref cycles its options). `theme` rotates `light``dark` and is applied by a
single whole-frame invert at the end of the render ([`Frame::invert`]), so text,
selection, caret, panel and palette all flip together; `auto_sync` rotates
`2m`/`5m`/`10m`/`15m`/`30m`. Decision (3) — "a value control that changes nothing
readable would be a dead switch" — is knowingly overridden: cycling `auto_sync`
persists and displays the interval, but **still drives no behaviour until v0.7**;
we accept a set-ahead control so the surface is ready and the value syncs now.
Decision (2) (per-device `typoena.conf` override) stays deferred. Kept simple: no
enum machinery — both string prefs share a `next_option(current, &OPTIONS)`
helper, and hand-editing the TOML can still set any value (the palette only
cycles presets; an off-list value snaps to the head on the next Enter). Editor
tests cover the rotate/wrap/snap, the live theme invert, and the round-trip.
**Trailing-newline handling — a saved note ends with a *visible* blank line
(commit `d14d9e7`, 2026-07-12; host-tested, on-device gate open).** Two
adjustments to how the buffer meets the file. First, format-on-save no longer
strips *every* trailing blank line — it collapses a run to **at most one** and
keeps it, so a writer who presses Enter to open the next line doesn't have that
line (and the caret) yanked away on save (the caret used to jump up to the last
non-empty line). Second, persistence treats the file's POSIX terminator as
content the editor *shows*: `load_path` reads the file **verbatim** and
`save_path` writes the buffer, appending a final newline **only if one is
missing** (guarded, not unconditional). Because the editor lays out
`rows = #\n + 1`, that terminating newline renders as a **visible trailing empty
line** the caret can land on — open a note and the blank line the newline stands
for is there. The two are an identity round-trip for any device-written file
(all end in `\n`); the file stays git-clean (exactly one terminator — vim and
GitHub show no phantom blank line); and a trailing blank line the writer leaves
is mirrored, never doubled. This replaced an interim model that stripped the
terminator on load and hid it (the file was correct, the newline just wasn't
shown). On-device check: reflash → open a note (trailing empty line visible) →
`:w` (caret stays on it) → reopen (still there). `Prefs::to_toml` ends in a
newline for the same reason; the guarded save leaves the prefs file with exactly
one.
**Amendment 2026-07-13 — recursive enumeration + a 2-char search threshold.**
Loading a real repo (`jcalixte/notes`) exposed that `enumerate_files` listed
only the **top-level** files of `/sd/repo` and `/sd/local` — a nested notes tree
showed a single file in the palette (subpaths always *opened* fine via
`:e repo/sub/x.md`; only the listing was flat). The enumeration is now a
recursive walk: dot entries are skipped at every level (so `.git` is never
descended into), each directory is read fully before recursing (one FatFS dir
handle open at a time — the `remove_dir_recursive` pattern, kind to the
FD-bounded mount), depth is capped at 8, and the boot-time walk logs its file
count and duration (`file walk: N files in Xms`) so the FAT dir-IO cost on a
big repo is measurable, not assumed. With the list now card-sized, the palette
gained a **search threshold**: below 2 typed chars the result list is the
**recents (MRU) only** — quick-switch (`Cmd-P`, `Enter`) stays one keystroke
away — and the full fuzzy-ranked list appears from 2 chars on
(`PALETTE_MIN_QUERY`). A fresh boot with no opens yet shows `(type to search)`.
`>` commands and `$` snippets are short curated lists; the threshold does not
apply to them.
**TODO (on-device, next time the device is on the bench)** — two measurements
from the same boot log, both already instrumented:
- [ ] **Re-measure the walk time** after the d_type fix (`2660a3e` — dirent
`file_type()` instead of a per-entry `metadata()` stat, which cost
~32 ms/file and made run 1 take 35 s for 1098 files). Read the
`file walk: N files in Xms` line. Only if it's still slow does the
async-walk idea come back on the table.
- [ ] **Read the file-list DRAM cost** from the new
`file list: internal heap <before> -> <after> (<N> KB consumed)` line
(the build is bracketed with `MALLOC_CAP_INTERNAL` readings in
`main.rs`). The 1098 path Strings are each below the 16 KB SPIRAM
malloc threshold, so they all land in internal DRAM — estimated
6070 KB, competing with Wi-Fi/TLS. Decision rule: **~6070 KB
confirms** interning the paths into one shared buffer (a single
>16 KB alloc goes to PSRAM; only a ~9 KB offset index stays in DRAM);
**well under that (≤~30 KB)** kills the idea — the next DRAM suspect
is then parked-buffer text.
- [x] `Cmd-P` opens fuzzy file palette over **both** `/sd/repo/` and
`/sd/local/`**landed and CONFIRMED ON DEVICE 2026-07-12** (Spike 11: no
ghosting on the transient panel); scope shows as the inline
`repo/…` / `local/…` label instead of a `[git]`/`[local]` badge.
- [~] Open, switch, close buffers (keep ≤ 3 in memory) — **open + switch + the
≤ 3 LRU-resident model with dirty-aware save-before-evict done in core**
(host-tested); `:e <path>` **and the palette** drive it today. Explicit
**close** still to come.
- [x] `:e` and palette share the same recent-files list — both open via
`open_path`, which pushes to the in-core MRU that orders the palette.
- [x] `:enew` creates a new file — **done in core (host-tested) 2026-07-12.**
Scope is read from the path (`local/x` / `repo/x` select it, the palette
label form; a bare name uses the current scope) rather than a modal
prompt — the resolved scope is echoed in the snackbar. The `/sd` prefix is
optional throughout (`/sd/repo/x` = `/repo/x` = `repo/x`).
- [x] Delete a file — **core done (host-tested) 2026-07-12;** `:delete` unlinks
the current file via `Effect::Delete`. For a Tracked file the removal reaches
the next `:sync` Publish's staged set. **Spike 14 (on-device) found the
staging incomplete:** `add_all(["*"])` alone did not stage the deletion, so
`stage_and_commit` now also runs `update_all(["*"])` (`git add -u`) — the
two together are `git add -A`. A Local file is just unlinked. The snackbar
now confirms the delete and flags that a Tracked file needs `:sync`.
**Verified on device 2026-07-12** — the `:enew``:sync``:delete``:sync`
cycle removed test.txt from origin.
- [~] `Ctrl-G` is disabled / hidden when the current buffer is local-scope —
**`:sync` / Publish is blocked in-core for a Local buffer** (posts "Publish
unavailable (Local)"); the side-panel affordance that hides/greys the
gesture is the remaining half.
- [ ] The side panel briefly shows file count on `Ctrl-G` when the publish bundles
more than one dirty Tracked file (e.g. `"publishing 3 files: abc1234"`),
so workspace-scoped behaviour stays visible to the user
- [x] **Preferences file** `/sd/repo/.typoena.toml` — a git-tracked,
hand-editable TOML file for editor behaviour, deliberately **distinct from
the `/sd/typoena.conf` card secrets** (Wi-Fi / PAT / remote / author,
gitignored, never committed — see [v0.1](v0.1-mvp-product.md)). Read at boot; a missing file or
key falls back to the defaults below. **Core done 2026-07-12** (a `Prefs`
type on `Editor`, host-testable parse/serialize, applied via
`Editor::set_prefs` before the first render); full reference:
[`typoena-toml.md`](typoena-toml.md). Keys:
- [x] `save_on_idle` (bool, default `true`) — auto-save the current buffer on
the idle typing-pause, so `:w` becomes optional rather than required.
**Honoured host-side** as a *silent* save (no snackbar, no forced e-ink
flash — a safety net, not an action), unformatted, once per typing burst
after a 1.5 s pause.
- [x] `format_on_save` (bool, default `true`) — run `:fmt` (table alignment,
blank-line collapse, trailing-whitespace strip) on the buffer before it
is persisted, so `:sync` is **fmt → save → commit → push** and `:w`
saves formatted. Implemented in-core 2026-07-11 (`Editor`), now **driven
by this key**. **Open question RESOLVED (2026-07-12):** fmt runs only on
an explicit `:w`/`:sync`; the `save_on_idle` auto-save is deliberately
**unformatted**, so tables/blank lines are never reflowed mid-session
(the caret would jump under you on every thinking pause).
- [x] `line_numbers` (bool, default `true`) — show the absolute line-number
gutter (built always-on in v0.2). Off reclaims the gutter's columns for
text (`gutter_cols()` → 0); the palette `> line numbers: on/off` command
toggles it live. **Done 2026-07-12.**
- [x] `theme` (string, default `"light"`; options `light`/`dark`) — panel
colour polarity. `dark` is a single whole-frame invert at the end of the
render (`Frame::invert`), so everything flips together and stays legible.
Palette `> theme` rotates `light``dark` live. **Added 2026-07-12
(amendment); host-tested, on-device check pending.** Caveat: `dark`
partial-refreshes ghost more on e-paper than `light` — verify on panel.
- [ ] `auto_sync` (duration string, default `"10m"`; `"0"` / omitted
disables; **min clamp ~`"2m"`** so a palette typo can't drain the
battery) — a *max-staleness cap*, not a wall-clock timer:
**opportunistic, rate-limited** Publish. Push when already awake + dirty
(coalesced into the idle-pause, ≤ once per `auto_sync`) and once on the
way into sleep if dirty; **never wake from deep sleep purely to sync**.
Wi-Fi energy is a `1/T` curve whose knee sits at 510 min, and
`save_on_idle` already owns local data safety — so 10 min halves the
sync energy of a 5-min default for no real risk. Full derivation:
[`tradeoff-curves/wifi-auto-sync.md`](tradeoff-curves/wifi-auto-sync.md).
The **schema + default (`"10m"`) live here in v0.5** and round-trip
through `Prefs`; **nothing reads the value yet** — the periodic side
rides the better-git work (v0.7) and must interact with light / deep
sleep (v0.8). Marked `[~]`: parsed and preserved, no behaviour.
**Amended 2026-07-12:** now a palette preset command — Enter rotates it
through `2m`/`5m`/`10m`/`15m`/`30m` (the `~2m` min is baked into the
preset list). Set-ahead only: still read by nothing until v0.7.
- [x] Open question RESOLVED (2026-07-12): the per-device sync cadence override
(a card-local `typoena.conf` layer over the committed prefs) is
**deferred**`auto_sync` is inert in v0.5, so there is nothing yet to
override; revisit when v0.7 makes the periodic push real.
- [x] **Palette command mode** — typing `>` at the `Cmd-P` palette switches it
from file search to a command list (VS Code-style). **Done in core
2026-07-12.** The v0.5 commands toggle the three boolean `.typoena.toml`
prefs — `> save on idle`, `> format on save`, `> line numbers` — each label
carrying its live state; Enter flips the pref, applies it at once, queues
`Effect::SavePrefs` (persist to the file), and confirms on the snackbar.
**The list stays open after a toggle** (flip several, Esc/`Cmd-P` closes),
and **`:settings` opens it directly** — both added 2026-07-12 as the "change
config from the device" surface (chosen over a separate settings modal).
This command list is the discoverable surface later actions (`:fmt`, font)
also register into. **Amended 2026-07-12:** the palette now also carries the
non-boolean prefs `> theme` (`light`/`dark`, live whole-frame invert) and
`> auto sync` (`2m`..`30m`), both cycled by the same Enter-rotates-to-next
gesture. `auto_sync` is exposed **set-ahead** (no behaviour until v0.7),
knowingly overriding the earlier "dead switch" call.

112
docs/v0.6-markdown.md Normal file
View File

@@ -0,0 +1,112 @@
# v0.6 — Markdown affordances
> Part of the [Typoena macro plan](macroplan.md). Requirements and targets:
> [qfd.md](qfd.md). Load-bearing decisions: [adr.md](adr.md).
**Status:** render affordances done early. The snippet engine (tab-stop core,
inline Tab-expansion, hint-on-pause, `$` palette) and the `>` command-palette
generalisation are **done in core, host-tested** (187 editor tests); the
remaining work is the **firmware boot-read + `just init` catalog** (slice 5, the
on-device gate). Snippets are net-new scope, added 2026-07-08; reshaped
2026-07-12 from a hard-coded table into a git-synced, Zed-compatible library with
a `$` palette launcher — see [`typoena-snippets.md`](typoena-snippets.md) for the
file format.
- [x] Heading lines bolded in render (faux-bold double-strike)
- [x] List continuation on Enter inside `- ` / `1. ` (with empty-item exit)
- [x] Soft-wrap at word boundaries
## Snippets
Trigger-driven text expansion for Markdown authoring (Zed-inspired, but **no
completion popup**: e-ink's ~630 ms refresh rules out a live filtering menu, and
it fights the distraction-free premise). The library is a git-synced,
Zed-compatible JSON file — full file-format reference in
[`typoena-snippets.md`](typoena-snippets.md). This section is the *editor*
behaviour.
- [x] **The tab-stop engine** — the shared core both surfaces drive. A body is
literal text plus numbered stops `$1 … $n` and a final `$0`; on insertion
the caret lands on `$1` (or the end), in Insert. Tab advances to the next
stop, **forward only** (no Shift-Tab); pending stops sit after the caret
and shift with edits there. The session auto-aborts on Esc, a mode change,
or a motion that leaves the stops. `${n:label}` parses to a bare `$n` (the
label is stripped — no selection model to fill); no dynamic values (no RTC,
so no `date`).
- [x] **Inline Tab-expansion (Insert mode).** If the word immediately before the
caret matches a snippet prefix, Tab expands it and starts the tab-stop
session; otherwise Tab inserts spaces as today. A check in the Insert
handler alongside the existing `list_marker` transform
(`expand_snippet(word) -> Option<Snippet>`). Tab already arrives as
`Key::Char('\t')`, so no new key event.
- [x] **Hint-on-pause.** On the typing pause (same throttle as the word-count /
cursor refresh — never a per-keystroke repaint), if the word before the
caret is a prefix, the right side panel shows a quiet hint (`» name`, on the
row above the mode line, sharing the slot with the `NO KBD` flag — the two
never co-occur since the hint means you're typing). The panel is ~15 cols,
so the hint is the snippet **name**, not the whole body — the full preview is
the `$` palette's job. Snapshotted in `refresh_stats` so it rides the pause,
and the firmware's Insert-pause repaint already carries it (no firmware
change). Latin-9 has no `↹` glyph, hence the `»` marker.
- [x] **`$` palette (browse + insert).** `Cmd-P` then `$` switches the palette to
the snippet list (the same sigil mechanism as `>`). Fuzzy-matches name /
prefix / description; Enter inserts the body at the caret and starts the
tab-stop session. Lists **all** snippets — the fuzzy filter handles clutter,
so there's no hidden "inline-only vs palette-only" split. Rows read
`Name [prefix]`, so browsing also teaches the inline trigger.
- [x] **Boot wiring.** The host reads `/sd/repo/.typoena.snippets.json` at boot
and calls `Editor::set_snippets` (mirroring `set_prefs`); a missing or
malformed file is non-fatal (no snippets, editor runs). Parse lives in the
host-testable `editor` crate via `serde_json` — the one new dependency,
confirmed to build for xtensa (`cargo check`, firmware 0.6.0). On-device
smoke-test still pending.
## The palette, generalised (`Cmd-P` · `>` · `$`) — done in core
v0.5 shipped `Cmd-P` = files and `>` = a five-entry settings list (`save_on_idle`,
`format_on_save`, `line_numbers` toggles + `theme`/`auto_sync` rotations). v0.6
makes the sigils a clean split by verb, and the empty-palette placeholder legends
them: `Go to file · > settings · $ snippets`.
- **bare `Cmd-P`** → *navigate*: go to file (unchanged).
- **`>`** → *act on the editor* — the command palette, a real action registry (the
`PALETTE_CMDS` list, actions first then settings). The pref toggles are just its
stateful entries, not a special section. Dispatch is by `PaletteCmd::kind`: a
**toggle** flips and the list **stays open**; a **one-shot** (`format`,
`publish` — the latter shares `run_publish` with `:sync`) runs and **closes**; a
**parameterised** command (`new file...`) morphs the palette into a second
**input step** (the box becomes a filename prompt → Enter creates it, scope read
from a `repo/`/`local/` prefix as `:enew` does; backspacing past the start steps
back to the `>` list). This **retired `:e`** — bare `Cmd-P` covers file-opening;
dotfiles can get a dedicated `> edit ...` command if/when wanted.
- **`$`** → *insert content* — the snippet launcher above.
Labels avoid `…`/`↹` — the palette/panel fonts are ISO-8859-15, which has neither
glyph — so `new file...` uses ASCII dots and the pause hint leads with `»`.
## First-time setup — snippet catalog (`just init`) — done
[`just init`](../firmware/README.md#provisioning-an-sd-card) seeds the two
git-tracked config files into `repo/` (so they commit + sync on the device's
first `:sync`), **writing only a file that is absent** so a re-`init` never
clobbers a synced library: a starter [`.typoena.toml`](typoena-toml.md) (the five
keys at their defaults) and a [`.typoena.snippets.json`](typoena-snippets.md)
assembled from a **curated catalog** (`firmware/snippets-catalog/`, `jq`-merged).
The catalog is grouped and opt-in — you pick the groups (each `[Y/n]`, all-yes on
a non-interactive run) rather than getting one writer's whole personal set. Not
every Zed snippet is worth proposing: the Slidev/blog-pipeline ones (`@[youtube]`,
`<v-clicks>`, frontmatter, mermaid) and hyper-specific personal ones are left out,
since a distraction-free prose appliance can't render them and you'd never miss
them. Prefixes are **English** (except `edanso`, the mnemonic for `œ`), bodies
translated from the source Zed snippets. **Three groups, 17 snippets:**
- [x] **Symbols** (inline, the keyboard can't type them): `arrow``→`,
`neq``≠`, `times``×`, `middot``·`, `deg``°`, `euro``€`, `edanso``œ`
(dead keys in v0.2.5 cover accents, *not* these). **Caveat:** `→` (U+2192)
and `≠` (U+2260) are outside ISO-8859-15, so they store correctly and sync
but render as a missing-glyph box on the device panel until the font is
extended; the other five are Latin-9 and draw fine. Kept deliberately.
- [x] **Structure**: `todo``- [ ] `, `link``[$1]($2)$0`, `img``![$1]($2)$0`,
`table` (2-col), `code` (fenced block). `img`/`code` are net-new.
- [x] **Prose / PKM templates** (`${n:label}` stripped to `$n`): `booknotes`,
`reference` (the English reference block — `refangl` folded in), `bias`,
`capture`, `standard`. (`5w1h` dropped.)

View File

@@ -0,0 +1,14 @@
# v0.7 — Search + better git
> Part of the [Typoena macro plan](macroplan.md). Requirements and targets:
> [qfd.md](qfd.md). Load-bearing decisions: [adr.md](adr.md).
**Status:** the **`:gl` pull command landed in the editor** (2026-07-11,
host-tested) — `Effect::Pull` + a firmware stub; the on-device fetch +
fast-forward is still to build. Search not started.
- [ ] `/` forward search, `n N`
- [~] `:gl` — pull: fetch + **fast-forward only**, refuse on divergence and
surface it (renamed from the planned `:Gpull`). Editor command +
`Effect::Pull` done 2026-07-11 (host-tested); the git-thread
fetch/fast-forward in `git_sync` remains (only push is wired today).

View File

@@ -0,0 +1,12 @@
# v0.8 — Power: battery + sleep
> Part of the [Typoena macro plan](macroplan.md). Requirements and targets:
> [qfd.md](qfd.md). Load-bearing decisions: [adr.md](adr.md).
**Status:** not started.
- [ ] Measure idle / typing / push current draw on bench
- [ ] 18650 + IP5306 charge board, soft power switch
- [ ] Light sleep on idle > 30 s (keyboard interrupt wakes)
- [ ] Deep sleep on lid close (reed switch); restore cursor + buffer
- [ ] Battery indicator in the side panel

14
docs/v0.9-robustness.md Normal file
View File

@@ -0,0 +1,14 @@
# v0.9 — Robustness
> Part of the [Typoena macro plan](macroplan.md). Requirements and targets:
> [qfd.md](qfd.md). Load-bearing decisions: [adr.md](adr.md).
**Status:** not started.
- [ ] Crash-safe writes (write to `.tmp`, fsync, rename)
- [ ] Recover from interrupted push (re-attempt on next save)
- [ ] SD card removal / reinsert handling
- [ ] Wi-Fi reconnect with backoff
- [ ] On-device provisioning + settings screen: SSID, PAT rotation, default
remote, commit author (replaces the v0.1 dev-only NVS-flashing path —
first release usable by someone who is not the firmware author)

16
docs/v1.0-polish.md Normal file
View File

@@ -0,0 +1,16 @@
# v1.0 — Polish
> Part of the [Typoena macro plan](macroplan.md). Requirements and targets:
> [qfd.md](qfd.md). Load-bearing decisions: [adr.md](adr.md).
**Status:** not started.
- [ ] 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
persisted across reboots
- [ ] Enclosure design files in `hardware/`
- [ ] User guide

11
docs/v1.x-stretch.md Normal file
View File

@@ -0,0 +1,11 @@
# v1.x — Stretch / nice-to-have
> Part of the [Typoena macro plan](macroplan.md). Requirements and targets:
> [qfd.md](qfd.md). Load-bearing decisions: [adr.md](adr.md).
Post-1.0 ideas, not committed to any release:
- 10.3" panel upgrade via IT8951
- Multiple remotes / repos
- Stats: words today, streak
- BLE-HID fallback for wireless keyboards

2
editor/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/target
/Cargo.lock

15
editor/Cargo.toml Normal file
View File

@@ -0,0 +1,15 @@
[package]
name = "editor"
version = "0.1.0"
edition = "2021"
description = "Modal (vim-style) text-editor core: the buffer, motions, edits, and e-paper layout/render. Depends only on embedded-graphics plus the display and keymap crates, so it builds and is tested on the host off the xtensa target (unlike the firmware crate it used to live in)."
[dependencies]
embedded-graphics = "0.8"
display = { path = "../display" }
keymap = { path = "../keymap" }
# Snippet library parse (v0.6): the `.typoena.snippets.json` file uses Zed's JSON
# shape. serde_json handles the string-escape corner cases a hand-rolled reader
# would get wrong; the crate is `std` (esp-idf provides std on xtensa).
serde = { version = "1", features = ["derive"] }
serde_json = "1"

6153
editor/src/lib.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@ target = "xtensa-esp32s3-espidf"
[target.'cfg(target_os = "espidf")']
linker = "ldproxy"
runner = "espflash flash --monitor"
runner = "espflash flash --monitor --baud 921600"
rustflags = [ "--cfg", "espidf_time64"]
[unstable]

View File

@@ -1,6 +1,6 @@
[package]
name = "firmware"
version = "0.1.0"
version = "0.6.0"
authors = ["Julien Calixte <juliencalixte@gmail.com>"]
edition = "2024"
resolver = "2"
@@ -10,6 +10,13 @@ rust-version = "1.85"
# rust-analyzer — see README "harness = false" note).
autobins = false
# Shared library surface (currently just resilient Wi-Fi bring-up in
# `firmware::net`), reused by the editor bin and the spike bins so the connect
# retry logic lives in exactly one place rather than being copied per binary.
[lib]
name = "firmware"
path = "src/lib.rs"
[[bin]]
name = "firmware"
path = "src/main.rs"
@@ -29,6 +36,30 @@ name = "sd_fat"
path = "src/bin/sd_fat.rs"
harness = false
# SD/FAT primitive-op micro-benchmark — attributes the ~700 ms/loose-object write
# floor (see docs/tradeoff-curves/sync-commit-staging.md). Pure SD, no git feature.
# Flash with `just flash-bench`.
[[bin]]
name = "sd_bench"
path = "src/bin/sd_bench.rs"
harness = false
# git-level micro-benchmark — localizes the ~700 ms/object libgit2 overhead (see
# docs/tradeoff-curves/sync-commit-staging.md). Needs the `git` feature.
[[bin]]
name = "git_bench"
path = "src/bin/git_bench.rs"
harness = false
required-features = ["git"]
# Spike 9 — boot splash. Standalone bench program that paints the Typoena
# wordmark-in-a-circle on the EPD. No git, no SD, no Wi-Fi. Flash with
# `just flash-splash`.
[[bin]]
name = "splash"
path = "src/bin/splash.rs"
harness = false
# Spike 7 Path 2 — libgit2 link/run smoke via the git2 safe API. Gated behind
# the `git` feature so the editor build never pulls libgit2-sys/pkg-config.
# Build: cargo build --release --bin git_smoke --features git (env in justfile).
@@ -69,15 +100,29 @@ esp-idf-hal = { git = "https://github.com/esp-rs/esp-idf-hal.git" }
esp-idf-svc = { git = "https://github.com/esp-rs/esp-idf-svc.git" }
[features]
# Pulls the git2 safe API. libgit2 itself is built by the esp-idf component
# (firmware/components/libgit2/) with mbedTLS; git2/libgit2-sys are used in
# system mode (LIBGIT2_NO_VENDOR=1) purely for the Rust bindings, so we disable
# their default features to avoid dragging in openssl-sys/libssh2-sys.
# A Cargo feature, OFF by default — so a bare `cargo build` (and `just
# build-light`) is a LIGHT editor build. The justfile's nominal `build`/`flash`
# opt in via `--features git`. Enabling it (a) pulls the git2 safe API and (b)
# turns on the `#[cfg(feature = "git")]` publish path in main.rs (see
# `publish()`), so git code is only ever compiled with `--features git`. libgit2
# itself is built by the esp-idf component (firmware/components/libgit2/) with
# mbedTLS — but ONLY when the justfile also sets LIBGIT2_SRC (the full recipes
# do; `build-light` doesn't, so the component is an empty no-op). git2/libgit2-sys
# run in system mode (LIBGIT2_NO_VENDOR=1) purely for the Rust bindings, with
# default features off to avoid dragging in openssl-sys/libssh2-sys.
git = ["dep:git2"]
[dependencies]
anyhow = "1"
log = "0.4"
# Pure HID decode (Key type + edge-detecting Decoder), split out so it is
# host-testable off the xtensa target. See ../keymap and MEMORY_AUDIT.md.
keymap = { path = "../keymap" }
# Editor core (buffer, motions, edits, layout/render) and the panel framebuffer,
# extracted from src/editor.rs and src/epd.rs so `cargo test` can exercise them
# off-device. The firmware links them; the host tests live in each crate.
editor = { path = "../editor" }
display = { path = "../display" }
git2 = { version = "0.20", default-features = false, optional = true }
esp-idf-svc = { version = "0.52.1", features = ["critical-section", "embassy-time-driver", "embassy-sync"] }
# Remove `generic-queue-8` if you plan to use `embassy-time` WITH `embassy-executor`

View File

@@ -61,11 +61,43 @@ need `CONFIG_SPIRAM` turned on first.
Credentials are build-time: copy [`.env.example`](.env.example) to `.env`, set
`TW_WIFI_SSID` / `TW_WIFI_PASS`, and `just` loads them (dotenv) so `build.rs`
bakes them in. `.env` is gitignored; the editor build (`just flash`) needs none
of it. `sdkconfig.defaults` gains the full certificate bundle and a bigger main
bakes them in. `.env` is gitignored; the light editor build (`just flash-light`)
needs none of it. `sdkconfig.defaults` gains the full certificate bundle and a bigger main
task stack for the mbedtls handshake — a one-time esp-idf reconfigure on the
next build.
**Spike 3 — SD card (FAT) on dedicated SPI3: verified 2026-07-11.** A separate
binary — [`src/bin/sd_fat.rs`](src/bin/sd_fat.rs), flashed with `just flash-sd`
is now a thin on-device harness over the real
[`firmware::persistence`](src/persistence.rs) module: it mounts the card, reports
FAT usage, and round-trips an atomic save/load (write `*.tmp` → fsync → unlink →
rename → read-back). Per ADR-012 the SD runs on its **own SPI3 host**
**SCK 14 · MOSI 15 · MISO 13 · SD CS 10** — leaving the EPD alone on SPI2.
Verified on the dedicated SPI3 bus 2026-07-11 (same mount + round-trip result as
the initial shared-SPI2 bring-up).
Bench result (genuine 32 GB SDHC card): mounts at 10 MHz, `29806 MiB total`,
atomic round-trip byte-identical. Two findings baked into the code:
- **Card compatibility.** A 133 GB SDXC card failed init at `CMD59` (SPI-mode
CRC); a genuine ≤32 GB card works. We keep CRC required and reject bad cards
with a swap-the-card message rather than run over an unchecked bus. See the
[Spike 3 postmortem](../docs/postmortems/2026-07-05-spike3-sd-cmd59.md).
- **FatFS rename ≠ POSIX rename.** `f_rename` won't overwrite an existing
target (returns `FR_EXIST`), so the atomic save unlinks the destination first.
`firmware::persistence` pairs this with `*.tmp` boot-recovery
(`Storage::recover`): if a `*.tmp` is found *alongside* the target the crash
may have been mid-write, so it keeps the committed file and discards the tmp;
it only promotes the tmp when the target was already unlinked. Long filenames
(`CONFIG_FATFS_LFN_HEAP`) are required for the two-dot `*.md.tmp` name.
**Arbitration resolved (ADR-012):** the EPD driver holds an exclusive SPI2 lock
for its whole lifetime, and persistence runs on its own thread, so a shared bus
would need an EPD rewrite plus a cross-thread mutex on the save path. Instead the
SD gets its own SPI3 — the EPD stays untouched, no arbitration. Remaining before
persistence lands in `main.rs`: wire the atomic save (unlink-then-rename +
`*.tmp` boot-recovery) into a `persistence` module.
**Spike 5 — partial refresh + typing: verified 2026-07-04.** `main.rs` wires
the keyboard to the panel: [`src/usb_kbd.rs`](src/usb_kbd.rs) feeds decoded
key-downs (US layout, edge-detected) into a queue, and the main loop keeps a
@@ -118,8 +150,8 @@ reseat the jumpers (CS first) before debugging code.
Next up per
[`docs/v0.1-mvp-technical.md`](../docs/v0.1-mvp-technical.md#hardware-bring-up-order):
Wi-Fi/TLS (Spike 6, implemented above), then gitoxide push (Spike 7); SD is
deferred.
Wi-Fi/TLS (Spike 6, implemented above), then git push (Spike 7), then SD
(Spike 3) — all verified.
**Spike 1 — Blink: verified 2026-07-04.** GPIO 2 + on-board WS2812 toggled
at 1 Hz with `blink N` on USB-serial, proving toolchain, esp-idf link, and
@@ -141,14 +173,48 @@ the Xtensa GCC to `PATH`):
. ~/export-esp.sh
```
Then from this directory:
Then from this directory, `just build` (the nominal product build) or, for fast
iteration without git, `just build-light`:
```sh
cargo build --release
just build # full: firmware + git publishing (libgit2 + git2)
just build-light # light: editor only, no git — much faster
```
(A bare `cargo build --release` with no env is equivalent to `build-light` — the
`git` feature is off by default.)
The first build is slow (the esp-idf C sources are checked out and built
under `.embuild/`). Subsequent builds are incremental.
under `.embuild/`; the full build also compiles libgit2 + mbedTLS). Subsequent
builds are incremental.
### Build modes — git (default) vs light
Publishing (`:sync` → git push) is expensive to build: it drags in libgit2 +
mbedTLS (compiled as an esp-idf component) and the `git2` crate. It sits behind
a switch. The nominal build turns it on (it's the product); a **light** build
leaves it off — ideal for iterating on the editor, EPD, USB, or SD without
paying for libgit2:
| Build | Command | libgit2 component | `git2` crate | `:sync` |
| ----- | ------- | ----------------- | ------------ | ------- |
| **Full / git** (default) | `just build` / `just flash` | compiled | linked | save → push |
| **Light** | `just build-light` / `just flash-light` | not compiled (empty no-op) | not linked | saves locally, skips push |
Two independent switches make this work, and the justfile flips them together:
1. **`git` Cargo feature** (`--features git`) — pulls the `git2` crate and
turns on the `#[cfg(feature = "git")]` publish path in
[`src/main.rs`](src/main.rs) (`publish()`). Off by default; the full recipes
pass it.
2. **`LIBGIT2_SRC` env** — the [libgit2 component](components/libgit2/CMakeLists.txt)
only compiles its sources when this points at the vendored tree; unset, it
registers an *empty* component. Only the full recipes set it.
Because git code in the firmware binary is only ever compiled under
`--features git`, `just build-light` can never drag libgit2 in. (Git isn't wired
into `main.rs` yet, so the full `just build` currently just builds slower and
behaves like the light build — the seam is in place ahead of the integration.)
## Flash (when hardware is on the bench)
@@ -166,6 +232,58 @@ over USB you should see:
at 1 Hz on the serial monitor, and — if an LED is wired from GPIO 2 → 330 Ω
→ GND — the LED blinks in lockstep.
## Provisioning an SD card
Typoena reads its config and its notes repo from the SD card — it never
cold-clones the ~566 MB repo over Wi-Fi + mbedTLS (the
[git-sync sizing decision](../docs/notes/git-sync-images-and-repo-size.md)).
Instead a Mac prepares the card over a reader, and the device only ever takes
the `open` + fast-forward path. The [`justfile`](justfile) has three entry
points, each ejecting the card when done:
```sh
just init ~/code/notes # full prep of a fresh card: notes repo + config
just load ~/code/notes # (re)copy just the notes repo → /sd/repo
just provision # (re)write just the config (rotate PAT, switch Wi-Fi)
```
`init` is the once-per-card command; `load` and `provision` each refresh one
half without touching the other. Add a `/Volumes/<name>` as the last argument if
more than one removable card is mounted — auto-detect refuses on ambiguity,
since a wrong guess would let `rsync --delete` wipe the wrong disk's `repo/`.
### Config with little to type
`typoena.conf` (Wi-Fi + PAT + git identity) needs **no `.env`**. Each value runs
a ladder — `.env` if present, else derived from tools already on the machine,
else an interactive prompt with the derived value as the default:
| Value | Derived from |
| --- | --- |
| `TW_REMOTE_URL` | the source repo's `origin` (or the card's existing clone) |
| `TW_AUTHOR_NAME` / `TW_AUTHOR_EMAIL` | `git config user.name` / `user.email` |
| `TW_GH_USER` | `gh api user` |
| `TW_WIFI_SSID` | the Mac's active Wi-Fi network |
| `TW_WIFI_PASS` | the System keychain for that SSID (else prompt) |
| `TW_PAT` | **never derived** — always typed by hand |
So a first run is usually: `just init ~/code/notes`, press Enter through the
auto-filled defaults, approve the macOS Keychain dialog for the Wi-Fi password
(or type it), and paste a fine-grained PAT once. Reading a saved Wi-Fi password
triggers a macOS authorization dialog (login password / Touch ID → Allow) —
that's macOS guarding a System-keychain secret, not something the recipe can
suppress. Keeping [`.env`](.env.example) populated stays a valid override and
skips all prompts.
### Secrets on the card
FAT has no file permissions, so **physical custody of the card is the only
control** over the plaintext `TW_PAT`. Scope it to a fine-grained token with
`contents:write` on just the notes repo, so a lost card is a one-token revoke.
The PAT is never derived from `gh auth token` (a broad token on removable media
would defeat the point) and never echoed — the recipes report each value only as
`set` / `MISSING`.
## Pin choice
GPIO 2 is a safe general-purpose pin on the ESP32-S3-DevKitC-1: it's not

View File

@@ -41,6 +41,12 @@ file(GLOB LG2_SRCS
"${LG2}/deps/pcre/*.c"
"${LG2}/deps/zlib/*.c"
)
# streams/mbedtls.c is replaced by our patched copy: its wrap() error path
# double-freed the socket stream (tlsf abort on device when ssl_setup failed
# under memory pressure). See esp_mbedtls_stream.c's header for the one-hunk
# delta; keep the copy in lockstep on submodule bumps.
list(REMOVE_ITEM LG2_SRCS "${LG2}/src/libgit2/streams/mbedtls.c")
list(APPEND LG2_SRCS
"${LG2}/src/util/allocators/failalloc.c"
"${LG2}/src/util/allocators/stdalloc.c"
@@ -48,6 +54,7 @@ list(APPEND LG2_SRCS
"${LG2}/src/util/hash/mbedtls.c" # SHA1 + SHA256 via mbedtls
"${CMAKE_CURRENT_LIST_DIR}/esp_map.c" # p_mmap via malloc+read (no <sys/mman.h>)
"${CMAKE_CURRENT_LIST_DIR}/esp_stubs.c" # getuid/readlink/utimes/... stubs
"${CMAKE_CURRENT_LIST_DIR}/esp_mbedtls_stream.c" # streams/mbedtls.c + double-free fix
# NOTE: unix/map.c replaced by esp_map.c — picolibc has no <sys/mman.h>.
# NOTE: unix/process.c deliberately excluded — needs fork()/sys/wait.h,
# only used by the SSH-exec transport we don't enable.
@@ -107,13 +114,18 @@ target_compile_options(${COMPONENT_LIB} PRIVATE
-Wno-error=implicit-int
)
# FATFS can't do POSIX rename-replace (f_rename fails if the target exists) and
# has no hardlinks, so libgit2's p_rename (link-then-rename) can't overwrite the
# config/refs/HEAD/index files its lock→commit writes depend on. Compile
# posix.c's p_rename under a throwaway name — scoped to this ONE file so no other
# TU is touched — and provide our own remove-then-rename p_rename in esp_stubs.c
# (p_rename is the single atomic-rename path: filebuf/futils/indexer/refdb_fs).
# posix.c holds three low-level fs primitives we must replace for FATFS; compile
# the originals under throwaway names — scoped to this ONE file so no other TU is
# touched — and provide FATFS-correct versions in esp_stubs.c:
# p_rename — FATFS f_rename can't replace an existing target and FAT has no
# hardlinks, so libgit2's link-then-rename can't overwrite config/refs/HEAD/
# index during its lock→commit. Ours does remove-then-rename.
# p_open / p_creat — libgit2 creates objects/packs mode 0444; FATFS turns that
# into AM_RDO and then won't f_unlink them (and chmod can't clear it), so
# objects become undeletable (breaks re-clone/fetch/repack). Ours force
# owner-write so nothing is ever created read-only.
set_source_files_properties(
"${LG2}/src/util/posix.c"
PROPERTIES COMPILE_DEFINITIONS "p_rename=libgit2_unused_p_rename"
PROPERTIES COMPILE_DEFINITIONS
"p_rename=libgit2_unused_p_rename;p_open=libgit2_unused_p_open;p_creat=libgit2_unused_p_creat"
)

View File

@@ -7,9 +7,19 @@
* Allocations go through git__malloc, so with PSRAM in the heap they land in
* the 8 MB external RAM rather than the ~340 KB internal DRAM.
*
* Limitation: writable/shared mappings are not written back (libgit2 does not
* mmap for writing in the paths we exercise). If that ever changes it will
* surface at runtime, not here.
* There is deliberately NO cache here. A window cache was built and removed
* on 2026-07-12: across four instrumented real-repo bench runs it scored
* exactly 0 hits, because libgit2's mwindow layer reuses its open windows and
* only genuinely new (offset, len) ranges ever reach p_mmap — and the one
* memory bug it "fixed" (7.4 MB resident starving zlib) was caused by the
* cache itself holding buffers past p_munmap. Free-at-munmap keeps
* GIT_OPT_SET_MWINDOW_MAPPED_LIMIT honest by construction: when libgit2
* releases a window, the memory really is back in git__malloc's pool. Full
* trail: docs/tradeoff-curves/sync-commit-staging.md (final-bench section).
* The stats counters below are kept so a future workload that *does* repeat
* ranges (push? reconcile?) can be spotted before anyone rebuilds a cache.
*
* Limitation: writable/shared mappings are not written back.
*/
#include "git2_util.h"
@@ -18,6 +28,7 @@
#include <unistd.h>
#include <string.h>
#include <errno.h>
#include <stdint.h>
int git__page_size(size_t *page_size)
{
@@ -31,10 +42,49 @@ int git__mmap_alignment(size_t *alignment)
return 0;
}
/* Diagnostics, read from the bench via esp_map_stats(). The signature predates
* the cache removal: `hits` is always 0, `misses` counts every mapping, and
* `cached_kb` now reports the LIVE mapped bytes (the mwindow working set). */
static uint32_t g_maps;
static uint64_t g_read_bytes;
static size_t g_live_bytes;
void esp_map_stats(uint32_t *hits, uint32_t *misses, uint32_t *read_kb, uint32_t *cached_kb)
{
if (hits) *hits = 0;
if (misses) *misses = g_maps;
if (read_kb) *read_kb = (uint32_t)(g_read_bytes / 1024);
if (cached_kb) *cached_kb = (uint32_t)(g_live_bytes / 1024);
}
static int read_range(int fd, off64_t offset, size_t len, unsigned char *data)
{
size_t got = 0;
if (lseek(fd, offset, SEEK_SET) < 0) {
git_error_set(GIT_ERROR_OS, "failed to seek for mmap emulation");
return -1;
}
while (got < len) {
ssize_t n = read(fd, data + got, len - got);
if (n < 0) {
if (errno == EINTR)
continue;
git_error_set(GIT_ERROR_OS, "failed to read for mmap emulation");
return -1;
}
if (n == 0)
break; /* short file: zero-fill the tail, like a real mapping */
got += (size_t)n;
}
if (got < len)
memset(data + got, 0, len - got);
return 0;
}
int p_mmap(git_map *out, size_t len, int prot, int flags, int fd, off64_t offset)
{
unsigned char *data;
size_t got = 0;
GIT_UNUSED(prot);
GIT_UNUSED(flags);
@@ -45,29 +95,13 @@ int p_mmap(git_map *out, size_t len, int prot, int flags, int fd, off64_t offset
data = git__malloc(len);
GIT_ERROR_CHECK_ALLOC(data);
if (lseek(fd, offset, SEEK_SET) < 0) {
git_error_set(GIT_ERROR_OS, "failed to seek for mmap emulation");
if (read_range(fd, offset, len, data) < 0) {
git__free(data);
return -1;
}
while (got < len) {
ssize_t n = read(fd, data + got, len - got);
if (n < 0) {
if (errno == EINTR)
continue;
git_error_set(GIT_ERROR_OS, "failed to read for mmap emulation");
git__free(data);
return -1;
}
if (n == 0)
break; /* short file: zero-fill the tail, like a real mapping */
got += (size_t)n;
}
if (got < len)
memset(data + got, 0, len - got);
g_maps++;
g_read_bytes += len;
g_live_bytes += len;
out->data = data;
out->len = len;
@@ -77,7 +111,12 @@ int p_mmap(git_map *out, size_t len, int prot, int flags, int fd, off64_t offset
int p_munmap(git_map *map)
{
GIT_ASSERT_ARG(map);
git__free(map->data);
if (g_live_bytes >= map->len)
g_live_bytes -= map->len;
else
g_live_bytes = 0;
map->data = NULL;
map->len = 0;
return 0;

View File

@@ -0,0 +1,498 @@
/*
* Copyright (C) the libgit2 contributors. All rights reserved.
*
* This file is part of libgit2, distributed under the GNU GPL v2 with
* a Linking Exception. For full terms see the included COPYING file.
*/
/*
* esp_mbedtls_stream.c — verbatim copy of the vendored
* src/libgit2/streams/mbedtls.c (v1.9.4) with ONE fix; it replaces the
* vendored file in CMakeLists.txt (same pattern as esp_map.c).
*
* THE FIX (2026-07-13): mbedtls_stream_wrap()'s `out_err` path closed and
* freed `st->io` — the caller's socket stream — but every caller frees that
* stream on error too (git_mbedtls_stream_new does close+free right after),
* and wrap's OTHER error paths (calloc/strdup failures) do NOT free it, so
* the caller cannot compensate either way. When mbedtls_ssl_setup failed on
* the device (internal-RAM exhaustion during the first real-repo push), the
* double git__free tripped tlsf ("block already marked as free") and reset
* the chip instead of surfacing a clean error. Delta from vendor: the
* `out_err` label no longer touches st->io — on error, ownership of `in`
* stays with the caller, consistently — and it frees the git__malloc'd
* st->ssl struct the vendored path leaked.
*
* Keep this file in lockstep with the vendored one on submodule bumps (diff
* against it; the delta must stay this one hunk).
*/
#include "streams/mbedtls.h"
#ifdef GIT_MBEDTLS
#include <ctype.h>
#include "runtime.h"
#include "stream.h"
#include "streams/socket.h"
#include "git2/transport.h"
#include "util.h"
#ifndef GIT_DEFAULT_CERT_LOCATION
#define GIT_DEFAULT_CERT_LOCATION NULL
#endif
/* Work around C90-conformance issues */
#if !defined(__STDC_VERSION__) || (__STDC_VERSION__ < 199901L)
# if defined(_MSC_VER)
# define inline __inline
# elif defined(__GNUC__)
# define inline __inline__
# else
# define inline
# endif
#endif
#include <mbedtls/ssl.h>
#include <mbedtls/error.h>
#include <mbedtls/entropy.h>
#include <mbedtls/ctr_drbg.h>
#undef inline
#define GIT_SSL_DEFAULT_CIPHERS "TLS1-3-AES-128-GCM-SHA256:TLS1-3-AES-256-GCM-SHA384:TLS1-3-CHACHA20-POLY1305-SHA256:TLS-ECDHE-ECDSA-WITH-AES-128-GCM-SHA256:TLS-ECDHE-RSA-WITH-AES-128-GCM-SHA256:TLS-ECDHE-ECDSA-WITH-AES-256-GCM-SHA384:TLS-ECDHE-RSA-WITH-AES-256-GCM-SHA384:TLS-ECDHE-ECDSA-WITH-CHACHA20-POLY1305-SHA256:TLS-ECDHE-RSA-WITH-CHACHA20-POLY1305-SHA256:TLS-DHE-RSA-WITH-AES-128-GCM-SHA256:TLS-DHE-RSA-WITH-AES-256-GCM-SHA384:TLS-DHE-RSA-WITH-CHACHA20-POLY1305-SHA256:TLS-ECDHE-ECDSA-WITH-AES-128-CBC-SHA256:TLS-ECDHE-RSA-WITH-AES-128-CBC-SHA256:TLS-ECDHE-ECDSA-WITH-AES-128-CBC-SHA:TLS-ECDHE-RSA-WITH-AES-128-CBC-SHA:TLS-ECDHE-ECDSA-WITH-AES-256-CBC-SHA384:TLS-ECDHE-RSA-WITH-AES-256-CBC-SHA384:TLS-ECDHE-ECDSA-WITH-AES-256-CBC-SHA:TLS-ECDHE-RSA-WITH-AES-256-CBC-SHA:TLS-DHE-RSA-WITH-AES-128-CBC-SHA256:TLS-DHE-RSA-WITH-AES-256-CBC-SHA256:TLS-RSA-WITH-AES-128-GCM-SHA256:TLS-RSA-WITH-AES-256-GCM-SHA384:TLS-RSA-WITH-AES-128-CBC-SHA256:TLS-RSA-WITH-AES-256-CBC-SHA256:TLS-RSA-WITH-AES-128-CBC-SHA:TLS-RSA-WITH-AES-256-CBC-SHA"
#define GIT_SSL_DEFAULT_CIPHERS_COUNT 28
static int ciphers_list[GIT_SSL_DEFAULT_CIPHERS_COUNT];
static bool initialized = false;
static mbedtls_ssl_config mbedtls_config;
static mbedtls_ctr_drbg_context mbedtls_rng;
static mbedtls_entropy_context mbedtls_entropy;
static bool has_ca_chain = false;
static mbedtls_x509_crt mbedtls_ca_chain;
/**
* This function aims to clean-up the SSL context which
* we allocated.
*/
static void shutdown_ssl(void)
{
if (has_ca_chain) {
mbedtls_x509_crt_free(&mbedtls_ca_chain);
has_ca_chain = false;
}
if (initialized) {
mbedtls_ctr_drbg_free(&mbedtls_rng);
mbedtls_ssl_config_free(&mbedtls_config);
mbedtls_entropy_free(&mbedtls_entropy);
initialized = false;
}
}
int git_mbedtls_stream_global_init(void)
{
int loaded = 0;
char *crtpath = GIT_DEFAULT_CERT_LOCATION;
struct stat statbuf;
size_t ciphers_known = 0;
char *cipher_name = NULL;
char *cipher_string = NULL;
char *cipher_string_tmp = NULL;
mbedtls_ssl_config_init(&mbedtls_config);
mbedtls_entropy_init(&mbedtls_entropy);
mbedtls_ctr_drbg_init(&mbedtls_rng);
if (mbedtls_ssl_config_defaults(&mbedtls_config,
MBEDTLS_SSL_IS_CLIENT,
MBEDTLS_SSL_TRANSPORT_STREAM,
MBEDTLS_SSL_PRESET_DEFAULT) != 0) {
git_error_set(GIT_ERROR_SSL, "failed to initialize mbedTLS");
goto cleanup;
}
/* configure TLSv1.1 or better */
#ifdef MBEDTLS_SSL_MINOR_VERSION_2
mbedtls_ssl_conf_min_version(&mbedtls_config, MBEDTLS_SSL_MAJOR_VERSION_3, MBEDTLS_SSL_MINOR_VERSION_2);
#endif
/* verify_server_cert is responsible for making the check.
* OPTIONAL because REQUIRED drops the certificate as soon as the check
* is made, so we can never see the certificate and override it. */
mbedtls_ssl_conf_authmode(&mbedtls_config, MBEDTLS_SSL_VERIFY_OPTIONAL);
/* set the list of allowed ciphersuites */
ciphers_known = 0;
cipher_string = cipher_string_tmp = git__strdup(GIT_SSL_DEFAULT_CIPHERS);
GIT_ERROR_CHECK_ALLOC(cipher_string);
while ((cipher_name = git__strtok(&cipher_string_tmp, ":")) != NULL) {
int cipherid = mbedtls_ssl_get_ciphersuite_id(cipher_name);
if (cipherid == 0) continue;
if (ciphers_known >= ARRAY_SIZE(ciphers_list)) {
git_error_set(GIT_ERROR_SSL, "out of cipher list space");
goto cleanup;
}
ciphers_list[ciphers_known++] = cipherid;
}
git__free(cipher_string);
if (!ciphers_known) {
git_error_set(GIT_ERROR_SSL, "no cipher could be enabled");
goto cleanup;
}
mbedtls_ssl_conf_ciphersuites(&mbedtls_config, ciphers_list);
/* Seeding the random number generator */
if (mbedtls_ctr_drbg_seed(&mbedtls_rng, mbedtls_entropy_func,
&mbedtls_entropy, NULL, 0) != 0) {
git_error_set(GIT_ERROR_SSL, "failed to initialize mbedTLS entropy pool");
goto cleanup;
}
mbedtls_ssl_conf_rng(&mbedtls_config, mbedtls_ctr_drbg_random, &mbedtls_rng);
/* load default certificates */
if (crtpath != NULL && stat(crtpath, &statbuf) == 0 && S_ISREG(statbuf.st_mode))
loaded = (git_mbedtls__set_cert_location(crtpath, NULL) == 0);
if (!loaded && crtpath != NULL && stat(crtpath, &statbuf) == 0 && S_ISDIR(statbuf.st_mode))
loaded = (git_mbedtls__set_cert_location(NULL, crtpath) == 0);
initialized = true;
return git_runtime_shutdown_register(shutdown_ssl);
cleanup:
mbedtls_ctr_drbg_free(&mbedtls_rng);
mbedtls_ssl_config_free(&mbedtls_config);
mbedtls_entropy_free(&mbedtls_entropy);
return -1;
}
static int bio_read(void *b, unsigned char *buf, size_t len)
{
git_stream *io = (git_stream *) b;
return (int) git_stream_read(io, buf, min(len, INT_MAX));
}
static int bio_write(void *b, const unsigned char *buf, size_t len)
{
git_stream *io = (git_stream *) b;
return (int) git_stream_write(io, (const char *)buf, min(len, INT_MAX), 0);
}
static int ssl_set_error(mbedtls_ssl_context *ssl, int error)
{
char errbuf[512];
int ret = -1;
GIT_ASSERT(error != MBEDTLS_ERR_SSL_WANT_READ);
GIT_ASSERT(error != MBEDTLS_ERR_SSL_WANT_WRITE);
if (error != 0)
mbedtls_strerror( error, errbuf, 512 );
switch(error) {
case 0:
git_error_set(GIT_ERROR_SSL, "SSL error: unknown error");
break;
case MBEDTLS_ERR_X509_CERT_VERIFY_FAILED:
git_error_set(GIT_ERROR_SSL, "SSL error: %#04x [%x] - %s", error, mbedtls_ssl_get_verify_result(ssl), errbuf);
ret = GIT_ECERTIFICATE;
break;
default:
git_error_set(GIT_ERROR_SSL, "SSL error: %#04x - %s", error, errbuf);
}
return ret;
}
static int ssl_teardown(mbedtls_ssl_context *ssl)
{
int ret = 0;
ret = mbedtls_ssl_close_notify(ssl);
if (ret < 0)
ret = ssl_set_error(ssl, ret);
mbedtls_ssl_free(ssl);
return ret;
}
static int verify_server_cert(mbedtls_ssl_context *ssl)
{
int ret = -1;
if ((ret = mbedtls_ssl_get_verify_result(ssl)) != 0) {
char vrfy_buf[512];
int len = mbedtls_x509_crt_verify_info(vrfy_buf, sizeof(vrfy_buf), "", ret);
if (len >= 1) vrfy_buf[len - 1] = '\0'; /* Remove trailing \n */
git_error_set(GIT_ERROR_SSL, "the SSL certificate is invalid: %#04x - %s", ret, vrfy_buf);
return GIT_ECERTIFICATE;
}
return 0;
}
typedef struct {
git_stream parent;
git_stream *io;
int owned;
bool connected;
char *host;
mbedtls_ssl_context *ssl;
git_cert_x509 cert_info;
} mbedtls_stream;
static int mbedtls_connect(git_stream *stream)
{
int ret;
mbedtls_stream *st = (mbedtls_stream *) stream;
if (st->owned && (ret = git_stream_connect(st->io)) < 0)
return ret;
st->connected = true;
mbedtls_ssl_set_hostname(st->ssl, st->host);
mbedtls_ssl_set_bio(st->ssl, st->io, bio_write, bio_read, NULL);
if ((ret = mbedtls_ssl_handshake(st->ssl)) != 0)
return ssl_set_error(st->ssl, ret);
return verify_server_cert(st->ssl);
}
static int mbedtls_certificate(git_cert **out, git_stream *stream)
{
unsigned char *encoded_cert;
mbedtls_stream *st = (mbedtls_stream *) stream;
const mbedtls_x509_crt *cert = mbedtls_ssl_get_peer_cert(st->ssl);
if (!cert) {
git_error_set(GIT_ERROR_SSL, "the server did not provide a certificate");
return -1;
}
/* Retrieve the length of the certificate first */
if (cert->raw.len == 0) {
git_error_set(GIT_ERROR_NET, "failed to retrieve certificate information");
return -1;
}
encoded_cert = git__malloc(cert->raw.len);
GIT_ERROR_CHECK_ALLOC(encoded_cert);
memcpy(encoded_cert, cert->raw.p, cert->raw.len);
st->cert_info.parent.cert_type = GIT_CERT_X509;
st->cert_info.data = encoded_cert;
st->cert_info.len = cert->raw.len;
*out = &st->cert_info.parent;
return 0;
}
static int mbedtls_set_proxy(git_stream *stream, const git_proxy_options *proxy_options)
{
mbedtls_stream *st = (mbedtls_stream *) stream;
return git_stream_set_proxy(st->io, proxy_options);
}
static ssize_t mbedtls_stream_write(git_stream *stream, const char *data, size_t len, int flags)
{
mbedtls_stream *st = (mbedtls_stream *) stream;
int written;
GIT_UNUSED(flags);
/*
* `mbedtls_ssl_write` can only represent INT_MAX bytes
* written via its return value. We thus need to clamp
* the maximum number of bytes written.
*/
len = min(len, INT_MAX);
if ((written = mbedtls_ssl_write(st->ssl, (const unsigned char *)data, len)) <= 0)
return ssl_set_error(st->ssl, written);
return written;
}
static ssize_t mbedtls_stream_read(git_stream *stream, void *data, size_t len)
{
mbedtls_stream *st = (mbedtls_stream *) stream;
int ret;
if ((ret = mbedtls_ssl_read(st->ssl, (unsigned char *)data, len)) <= 0)
ssl_set_error(st->ssl, ret);
return ret;
}
static int mbedtls_stream_close(git_stream *stream)
{
mbedtls_stream *st = (mbedtls_stream *) stream;
int ret = 0;
if (st->connected && (ret = ssl_teardown(st->ssl)) != 0)
return -1;
st->connected = false;
return st->owned ? git_stream_close(st->io) : 0;
}
static void mbedtls_stream_free(git_stream *stream)
{
mbedtls_stream *st = (mbedtls_stream *) stream;
if (st->owned)
git_stream_free(st->io);
git__free(st->host);
git__free(st->cert_info.data);
mbedtls_ssl_free(st->ssl);
git__free(st->ssl);
git__free(st);
}
static int mbedtls_stream_wrap(
git_stream **out,
git_stream *in,
const char *host,
int owned)
{
mbedtls_stream *st;
int error;
st = git__calloc(1, sizeof(mbedtls_stream));
GIT_ERROR_CHECK_ALLOC(st);
st->io = in;
st->owned = owned;
st->ssl = git__malloc(sizeof(mbedtls_ssl_context));
GIT_ERROR_CHECK_ALLOC(st->ssl);
mbedtls_ssl_init(st->ssl);
if (mbedtls_ssl_setup(st->ssl, &mbedtls_config)) {
git_error_set(GIT_ERROR_SSL, "failed to create ssl object");
error = -1;
goto out_err;
}
st->host = git__strdup(host);
GIT_ERROR_CHECK_ALLOC(st->host);
st->parent.version = GIT_STREAM_VERSION;
st->parent.encrypted = 1;
st->parent.proxy_support = git_stream_supports_proxy(st->io);
st->parent.connect = mbedtls_connect;
st->parent.certificate = mbedtls_certificate;
st->parent.set_proxy = mbedtls_set_proxy;
st->parent.read = mbedtls_stream_read;
st->parent.write = mbedtls_stream_write;
st->parent.close = mbedtls_stream_close;
st->parent.free = mbedtls_stream_free;
*out = (git_stream *) st;
return 0;
out_err:
/* ESP FIX: do NOT close/free st->io here — on error the caller keeps
* ownership of `in` (git_mbedtls_stream_new closes+frees it right after;
* the vendored code freed it here too → double free → tlsf abort). */
mbedtls_ssl_free(st->ssl);
git__free(st->ssl);
git__free(st);
return error;
}
int git_mbedtls_stream_wrap(
git_stream **out,
git_stream *in,
const char *host)
{
return mbedtls_stream_wrap(out, in, host, 0);
}
int git_mbedtls_stream_new(
git_stream **out,
const char *host,
const char *port)
{
git_stream *stream;
int error;
GIT_ASSERT_ARG(out);
GIT_ASSERT_ARG(host);
GIT_ASSERT_ARG(port);
if ((error = git_socket_stream_new(&stream, host, port)) < 0)
return error;
if ((error = mbedtls_stream_wrap(out, stream, host, 1)) < 0) {
git_stream_close(stream);
git_stream_free(stream);
}
return error;
}
int git_mbedtls__set_cert_location(const char *file, const char *path)
{
int ret = 0;
char errbuf[512];
GIT_ASSERT_ARG(file || path);
if (has_ca_chain)
mbedtls_x509_crt_free(&mbedtls_ca_chain);
mbedtls_x509_crt_init(&mbedtls_ca_chain);
if (file)
ret = mbedtls_x509_crt_parse_file(&mbedtls_ca_chain, file);
if (ret >= 0 && path)
ret = mbedtls_x509_crt_parse_path(&mbedtls_ca_chain, path);
/* mbedtls_x509_crt_parse_path returns the number of invalid certs on success */
if (ret < 0) {
mbedtls_x509_crt_free(&mbedtls_ca_chain);
mbedtls_strerror( ret, errbuf, 512 );
git_error_set(GIT_ERROR_SSL, "failed to load CA certificates: %#04x - %s", ret, errbuf);
return -1;
}
mbedtls_ssl_conf_ca_chain(&mbedtls_config, &mbedtls_ca_chain, NULL);
has_ca_chain = true;
return 0;
}
#else
#include "stream.h"
int git_mbedtls_stream_global_init(void)
{
return 0;
}
#endif

View File

@@ -14,8 +14,17 @@
#include <pwd.h>
#include <errno.h>
#include <sys/time.h>
#include <sys/stat.h> /* stat() for the existence-gated utimes() below */
#include <stdio.h> /* remove(), rename() for the p_rename replacement */
#include <sys/stat.h> /* stat() + S_IWUSR for utimes()/p_open() below */
#include <stdio.h> /* remove(), rename() for the p_rename replacement */
#include <fcntl.h> /* open(), O_* flags for the p_open()/p_creat() shims */
#include <stdarg.h> /* va_list for the variadic p_open() */
#ifndef O_BINARY
#define O_BINARY 0 /* no text/binary distinction on esp-idf */
#endif
#ifndef O_CLOEXEC
#define O_CLOEXEC 0 /* no exec() on esp-idf, so close-on-exec is a no-op */
#endif
/* One implicit root user/group. */
uid_t getuid(void) { return 0; }
@@ -106,3 +115,32 @@ int p_rename(const char *from, const char *to)
(void)remove(to); /* ignore ENOENT when `to` doesn't exist yet */
return rename(from, to) == 0 ? 0 : -1;
}
/* libgit2 creates loose objects and packfiles with mode 0444 (read-only) — the
* git convention that objects are immutable. FATFS honours that as the AM_RDO
* attribute and then refuses to f_unlink the file (EACCES), and esp-idf's FATFS
* VFS chmod() can't clear AM_RDO — so a written object can NEVER be deleted,
* which breaks re-clone recovery and (later) fetch/repack. We force owner-write
* into every create mode so libgit2's files stay writable and therefore
* deletable. Immutability is only a safety hint on an appliance where nothing
* but libgit2 touches these files. posix.c's originals are compiled as
* libgit2_unused_p_open/p_creat (see the component CMakeLists), so these are the
* definitions every other TU links against. Mirrors posix.c's p_open/p_creat. */
int p_open(const char *path, int flags, ...)
{
mode_t mode = 0;
if (flags & O_CREAT) {
va_list arg_list;
va_start(arg_list, flags);
mode = (mode_t)va_arg(arg_list, int);
va_end(arg_list);
mode |= S_IWUSR; /* never create read-only → FATFS won't set AM_RDO */
}
return open(path, flags | O_BINARY | O_CLOEXEC, mode);
}
int p_creat(const char *path, mode_t mode)
{
return open(path, O_WRONLY | O_CREAT | O_TRUNC | O_BINARY | O_CLOEXEC,
mode | S_IWUSR);
}

BIN
firmware/docs/sd-card.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@@ -10,6 +10,7 @@ esp_env := ". ~/export-esp.sh &&"
elf := "target/xtensa-esp32s3-espidf/release/firmware"
elf_wifi := "target/xtensa-esp32s3-espidf/release/wifi_tls"
elf_sd := "target/xtensa-esp32s3-espidf/release/sd_fat"
elf_splash := "target/xtensa-esp32s3-espidf/release/splash"
elf_git := "target/xtensa-esp32s3-espidf/release/git_smoke"
elf_git_push := "target/xtensa-esp32s3-espidf/release/git_push"
elf_git_sync := "target/xtensa-esp32s3-espidf/release/git_sync"
@@ -31,12 +32,24 @@ git_env := "LIBGIT2_SRC=" + libgit2_src + " LIBGIT2_NO_VENDOR=1 PKG_CONFIG_ALLOW
default:
@just --list
# compile (release)
# compile (release) — the nominal product build: full firmware WITH git
# publishing (compiles libgit2 + mbedTLS, links git2). For fast iteration on the
# editor / EPD / USB / SD without paying for libgit2, use `build-light`.
build:
{{esp_env}} {{git_env}} cargo build --release --bin firmware --features git
# build + flash + open serial monitor (full firmware, see `build`)
flash:
{{esp_env}} {{git_env}} cargo run --release --bin firmware --features git
# LIGHT editor build — no git: no libgit2 component (LIBGIT2_SRC unset) and no
# git2 crate (`git` feature off), so it builds much faster. `:sync` saves locally
# and skips the push. Use for anything but exercising publish.
build-light:
{{esp_env}} cargo build --release --bin firmware
# build + flash + open serial monitor
flash:
# light build + flash + open serial monitor (see `build-light`)
flash-light:
{{esp_env}} cargo run --release --bin firmware
# serial monitor only, with decoded backtraces
@@ -67,6 +80,44 @@ flash-sd:
monitor-sd:
espflash monitor --elf {{elf_sd}}
# SD primitive-op micro-benchmark — attributes the ~700 ms/loose-object write
# floor (docs/tradeoff-curves/sync-commit-staging.md). Writes only to /sd/sdbench.
build-bench:
{{esp_env}} cargo build --release --bin sd_bench
# build + flash + monitor the SD bench
flash-bench:
{{esp_env}} cargo run --release --bin sd_bench
# serial monitor for the SD bench, with decoded backtraces
monitor-bench:
espflash monitor --elf target/xtensa-esp32s3-espidf/release/sd_bench
# git-level micro-benchmark — localizes the ~700 ms/object libgit2 overhead
# (docs/tradeoff-curves/sync-commit-staging.md). Read-mostly on /sd/repo.
build-gitbench:
{{esp_env}} {{git_env}} cargo build --release --bin git_bench --features git
# build + flash + monitor the git-level bench
flash-gitbench:
{{esp_env}} {{git_env}} cargo run --release --bin git_bench --features git
# serial monitor for the git-level bench, with decoded backtraces
monitor-gitbench:
espflash monitor --elf target/xtensa-esp32s3-espidf/release/git_bench
# Spike 9 — build the boot splash spike (no .env needed)
build-splash:
{{esp_env}} cargo build --release --bin splash
# Spike 9 — flash + monitor the boot splash spike
flash-splash:
{{esp_env}} cargo run --release --bin splash
# serial monitor for the splash spike, with decoded backtraces
monitor-splash:
espflash monitor --elf {{elf_splash}}
# Spike 7 Path 2 — build the git2/libgit2 smoke (git2 safe API on device)
build-git:
{{esp_env}} {{git_env}} cargo build --release --bin git_smoke --features git
@@ -87,7 +138,7 @@ build-git-push:
# (so the `storage` FAT partition exists) and sets 16 MB flash. Uses espflash
# directly, not `cargo run`, so the table is applied only to this binary.
flash-git-push: build-git-push
espflash flash --monitor --partition-table {{partition_table}} --flash-size 16mb {{elf_git_push}}
espflash flash --monitor --baud 921600 --partition-table {{partition_table}} --flash-size 16mb {{elf_git_push}}
# serial monitor for the git push, with decoded backtraces
monitor-git-push:
@@ -100,7 +151,7 @@ build-git-sync:
# Milestone #2A — flash + monitor. Applies the same custom partition table as
# git-push (the `storage` FAT partition holds the persistent clone).
flash-git-sync: build-git-sync
espflash flash --monitor --partition-table {{partition_table}} --flash-size 16mb {{elf_git_sync}}
espflash flash --monitor --baud 921600 --partition-table {{partition_table}} --flash-size 16mb {{elf_git_sync}}
# serial monitor for the git sync, with decoded backtraces
monitor-git-sync:
@@ -113,3 +164,381 @@ info:
# list USB serial devices
ports:
@ls /dev/cu.usb* 2>/dev/null || echo "no USB serial device found"
# ─── SD card provisioning ────────────────────────────────────────────────────
# Prepare an SD card on a computer so it can go straight into Typoena (the
# decision in docs/notes/git-sync-images-and-repo-size.md): the device never
# cold-clones 566 MB over Wi-Fi + mbedTLS; a laptop copies the clone onto the
# card via a reader, and the device only ever takes the `open` + fast-forward
# path. Three entry points, each ejecting the card when done:
#
# just init ~/code/notes # full prep of a fresh card: repo + config
# just load ~/code/notes # (re)copy just the notes repo → /sd/repo
# just provision # (re)write just the config (derive + prompt)
#
# Config (Wi-Fi + PAT + git identity) needs no firmware/.env: each value is
# resolved from firmware/.env if present, else derived from tools the machine
# already has (git config, gh, the active Wi-Fi network + its Keychain
# password), else asked for at an interactive prompt with the derived value as
# the default. The PAT is always typed by hand — never derived (a broad
# `gh auth token` on a plaintext card would defeat the scoped-token model).
#
# Add a /Volumes/<name> as the last arg if more than one card is mounted.
# The `_`-prefixed recipes are shared internals (hidden from `just --list`).
sd_repo_dir := "repo"
# Full prep of a fresh card: copy the notes repo, seed the git-tracked config
# files (first-time only), write the runtime config, then eject. Run this once
# per card. <repo-src> is a clone made on this computer.
init repo_src sd_volume="":
#!/usr/bin/env bash
set -euo pipefail
vol="$(just _card "{{sd_volume}}" | tail -n1)"
just _load-repo "{{repo_src}}" "$vol"
just _seed-configs "$vol"
just _write-conf "$vol" "{{repo_src}}"
just _eject "$vol"
# (Re)copy just the notes repo to /sd/repo: fast-forward the source clone from
# its remote first, then rsync it across (full clone, gitignored paths excluded)
# and eject — e.g. to refresh the card after the device pushed new notes.
load repo_src sd_volume="":
#!/usr/bin/env bash
set -euo pipefail
vol="$(just _card "{{sd_volume}}" | tail -n1)"
just _load-repo "{{repo_src}}" "$vol"
just _eject "$vol"
# (Re)write just the config (Wi-Fi + PAT + git identity), then eject — e.g. to
# rotate the PAT or switch networks without touching repo/. Values are derived +
# prompted (see the section header); firmware/.env is an optional override. With
# no repo arg, the git remote is derived from the card's existing clone.
provision sd_volume="":
#!/usr/bin/env bash
set -euo pipefail
vol="$(just _card "{{sd_volume}}" | tail -n1)"
just _write-conf "$vol"
just _eject "$vol"
# Resolve the target card volume. Prints the /Volumes/<name> path as the last
# stdout line (callers capture it); all diagnostics go to stderr. Prefers an
# explicit name; else auto-detects exactly one removable/SD volume (covers cards
# in a built-in Mac reader, which report an "Internal" bus), and refuses on 0 or
# >1 — a wrong guess here means rsync --delete wipes the wrong disk's repo/.
_card sd_volume="":
#!/usr/bin/env bash
set -euo pipefail
want="{{sd_volume}}"
if [ -n "$want" ]; then
vol="/Volumes/$want"
[ -d "$vol" ] || { echo "error: '$vol' is not mounted" >&2; exit 1; }
else
cands=()
for v in /Volumes/*; do
[ -d "$v" ] || continue
info="$(diskutil info "$v" 2>/dev/null || true)"
# Treat a volume as a candidate card if ANY removable/SD signal is
# present. A card in a Mac's *built-in* reader shows Protocol "Secure
# Digital" + Removable Media "Removable", but sits on the "Internal"
# bus with no whole-disk "Ejectable" line — so the old
# "Ejectable: Yes AND external" test missed it entirely. None of these
# match the fixed internal APFS disk; a spurious extra match just
# trips the ">1 — name one" refusal below, which is safe.
if echo "$info" | grep -qiE 'Protocol:[[:space:]]+Secure Digital' \
|| echo "$info" | grep -qiE 'Removable Media:[[:space:]]+(Removable|Yes)' \
|| echo "$info" | grep -qiE 'Ejectable:[[:space:]]+Yes' \
|| echo "$info" | grep -qiE 'Device Location:[[:space:]]+External'; then
cands+=("$v")
fi
done
case "${#cands[@]}" in
0) echo "error: no removable card detected under /Volumes — insert an SD card (FAT32)" >&2; exit 1 ;;
1) vol="${cands[0]}" ;;
*) echo "error: multiple removable volumes — name one as the volume arg:" >&2
printf ' %s\n' "${cands[@]#/Volumes/}" >&2; exit 1 ;;
esac
fi
# The device won't mount a non-FAT card (esp_vfs_fat, format_if_mount_failed=false).
fs="$(diskutil info "$vol" 2>/dev/null | grep -iE 'File System Personality' | sed 's/.*:[[:space:]]*//' || true)"
case "$fs" in
*FAT32*|*MS-DOS*) : ;;
*) echo "warning: '$vol' is '$fs', not FAT32 — the device may fail to mount it" >&2 ;;
esac
echo "$vol"
# Copy a full clone of the notes repo to <vol>/repo. Everything the repo's
# .gitignore ignores is excluded (node_modules is 3.9 GB, gitignored, never in
# .git) — the device needs .git + the checkout (~720 MB), not the JS deps or
# secrets like firmware/.env. Copying (not a fresh `git clone` of the local
# path) preserves origin → GitHub so on-device fetch/push still work — except
# the URL scheme: the card copy's origin is rewritten to HTTPS at the end,
# because the device's libgit2 has no SSH transport (HTTPS+PAT over mbedTLS
# only). The source clone is never touched.
_load-repo repo_src vol:
#!/usr/bin/env bash
set -euo pipefail
src="{{repo_src}}"; src="${src%/}" # strip trailing slash
[ -d "$src/.git" ] || { echo "error: '$src' is not a git repo (no .git/)"; exit 1; }
origin="$(git -C "$src" remote get-url origin 2>/dev/null || true)"
case "$origin" in
https://*|git@*|ssh://*|git://*) echo "source origin: $origin" ;;
"") echo "warning: '$src' has no 'origin' remote — the device can't fetch/push after loading" ;;
*) echo "warning: origin is '$origin' (looks like a local path, not a remote) —"
echo " the device fetch/push will fail; set origin to the GitHub URL first" ;;
esac
if [ -n "${TW_REMOTE_URL:-}" ] && [ -n "$origin" ] && [ "$origin" != "${TW_REMOTE_URL}" ]; then
echo "warning: origin ($origin) != TW_REMOTE_URL (${TW_REMOTE_URL})"
fi
# Refresh the source clone from its remote before mirroring, so the card
# carries the latest — e.g. notes the device pushed since the last load.
# Fast-forward-only and best-effort: a dirty tree, diverged history, detached
# HEAD, or being offline just warns and copies the current on-disk state
# rather than aborting the card prep. Set TW_NO_PULL=1 to skip the pull.
case "$origin" in
https://*|git@*|ssh://*|git://*)
if [ -n "${TW_NO_PULL:-}" ]; then
echo "TW_NO_PULL set — skipping pull, copying current on-disk state"
else
branch="$(git -C "$src" symbolic-ref --quiet --short HEAD || true)"
if [ -z "$branch" ]; then
echo "warning: '$src' is in detached HEAD — skipping pull, copying current state" >&2
elif git -C "$src" pull --ff-only origin "$branch"; then
:
else
echo "warning: 'git pull --ff-only origin $branch' failed (dirty, diverged, or offline) —" >&2
echo " copying the current on-disk state instead" >&2
fi
fi
;;
esac
dest="{{vol}}/{{sd_repo_dir}}"
echo "copying repo: $src -> $dest"
mkdir -p "$dest"
# Build the exclude list from git's own ignore resolution (.gitignore +
# .git/info/exclude + global). --directory collapses a fully-ignored dir
# (node_modules/, 3.9 GB) to one line. Driving off git — not rsync's own
# dir-merge — means we never list a tracked file or anything under .git/, so
# a .gitignore pattern like `logs/` or `*.pack` can't corrupt the clone.
ignore_list="$(mktemp)"
trap 'rm -f "$ignore_list"' EXIT
git -C "$src" -c core.quotePath=false ls-files \
--others --ignored --exclude-standard --directory --no-empty-directory \
> "$ignore_list"
echo "excluding $(wc -l < "$ignore_list" | tr -d ' ') gitignored path(s) (incl. node_modules, .env)"
# -rt (no perms/owner/symlinks — meaningless on FAT), --modify-window=1 for
# FAT's 2 s timestamp granularity, --delete to mirror (re-runs stay clean),
# -P for progress on the ~700 MB copy. Scoped to repo/, never the card root.
rsync -rtP --delete --modify-window=1 --exclude-from="$ignore_list" "$src/" "$dest/"
# The device's libgit2 speaks HTTPS+PAT only (mbedTLS — no SSH transport),
# so an SSH origin copied verbatim fails every on-device push/fetch with
# "unsupported URL protocol" (found the hard way, 2026-07-13). Rewrite the
# CARD copy's origin to the HTTPS equivalent; the source clone keeps its
# own URL. Its embedded trust store carries GitHub's roots, hence the
# non-github warning.
case "$origin" in
git@*:*)
host="${origin#git@}"; host="${host%%:*}"
card_origin="https://$host/${origin#git@*:}"
;;
ssh://git@*)
rest="${origin#ssh://git@}"
card_origin="https://${rest%%[:/]*}/${rest#*/}"
;;
https://*) card_origin="$origin" ;;
*) card_origin="" ;;
esac
if [ -n "$card_origin" ]; then
if [ "$card_origin" != "$origin" ]; then
echo "rewriting card origin for the device: $origin -> $card_origin"
fi
git -C "$dest" remote set-url origin "$card_origin"
case "$card_origin" in
https://github.com/*) ;;
*) echo "warning: origin host isn't github.com — the device's embedded trust store" \
"only carries GitHub roots, so on-device TLS will fail without its CA" ;;
esac
elif [ -n "$origin" ]; then
echo "warning: origin '$origin' has no HTTPS equivalent I can derive —"
echo " on-device push/fetch will fail; set it by hand:"
echo " git -C '$dest' remote set-url origin https://github.com/<owner>/<repo>.git"
fi
# First-time setup: seed the two git-tracked config files into the card's repo/
# so they commit + sync on the device's first `:sync`. Writes ONLY a file that is
# absent — a card whose clone already carries `.typoena.toml` /
# `.typoena.snippets.json` keeps its own, so a re-`init` never clobbers a synced
# library. The starter `.typoena.toml` mirrors the editor defaults; the snippet
# library is assembled from the curated catalog (snippets-catalog/, opt-in per
# group) with `jq`. Non-interactive runs take every default (all groups).
_seed-configs vol:
#!/usr/bin/env bash
set -euo pipefail
repo="{{vol}}/{{sd_repo_dir}}"
catalog="{{justfile_directory()}}/snippets-catalog"
interactive=1; [ -t 0 ] || interactive=0
# ask_yn "label" → 0 (yes) / 1 (no); default yes, non-interactive → yes.
ask_yn() {
local ans
if [ "$interactive" = 0 ]; then return 0; fi
read -r -p " $1 [Y/n]: " ans || ans=""
[[ "${ans:-Y}" =~ ^[Yy] ]]
}
# ── starter prefs (.typoena.toml) ────────────────────────────────────────
prefs="$repo/.typoena.toml"
if [ -e "$prefs" ]; then
echo "keeping existing .typoena.toml"
elif ask_yn "seed a starter .typoena.toml (editor defaults)?"; then
# printf per line (matching _write-conf), so no heredoc indentation trap.
{
printf '# Typoena editor preferences — hand-editable, git-tracked.\n'
printf '# Edit here, or change live from the Cmd-P palette (type `>`).\n'
printf 'save_on_idle = true\n'
printf 'format_on_save = true\n'
printf 'line_numbers = true\n'
printf 'theme = "light"\n'
printf 'auto_sync = "10m"\n'
} > "$prefs"
echo "wrote .typoena.toml"
fi
# ── snippet library (.typoena.snippets.json) from the catalog ────────────
snips="$repo/.typoena.snippets.json"
if [ -e "$snips" ]; then
echo "keeping existing .typoena.snippets.json"
elif ! command -v jq >/dev/null 2>&1; then
echo "warning: jq not found — skipping the snippet catalog (install jq, or" >&2
echo " hand-write $snips later)" >&2
else
echo "snippet catalog — choose groups to include:" >&2
picked=()
ask_yn "Symbols (→ ≠ × · ° € œ)" && picked+=("$catalog/symbols.json")
ask_yn "Structure (todo, link, img, table, code)" && picked+=("$catalog/structure.json")
ask_yn "Prose (booknotes, reference, bias, capture, standard)" && picked+=("$catalog/prose.json")
if [ "${#picked[@]}" -gt 0 ]; then
jq -s 'add' "${picked[@]}" > "$snips"
echo "wrote .typoena.snippets.json ($(jq 'length' "$snips") snippets, ${#picked[@]} group(s))"
else
echo "no groups chosen — skipping .typoena.snippets.json"
fi
fi
# Resolve + write <vol>/typoena.conf (Wi-Fi + PAT + git identity). Each value
# runs a ladder: firmware/.env (loaded via `set dotenv-load`) → derived from
# tools already on the machine (git config, gh, the active Wi-Fi network + its
# System-keychain password) → interactive prompt, with the derived value as the
# default. So a newcomer needs no .env: they Enter through the defaults and paste
# a PAT once. The PAT is never derived (a broad `gh auth token` on plaintext
# removable media would defeat the scoped-token model) and never echoed. FAT has
# no file permissions, so physical custody of the card is the control: use a
# fine-grained PAT (contents:write on just the notes repo) so a lost card is a
# one-token revoke. <repo_src> (optional) seeds the git remote; without it the
# remote is derived from the card's existing clone.
_write-conf vol repo_src="":
#!/usr/bin/env bash
set -euo pipefail
conf="{{vol}}/typoena.conf"
repo_src="{{repo_src}}"; repo_src="${repo_src%/}"
interactive=1; [ -t 0 ] || interactive=0
# ask "label" "default" → chosen value on stdout; prompt shown on stderr (so
# it survives the $(...) capture). Non-interactive → returns the default.
ask() {
local label="$1" def="${2:-}" ans
if [ "$interactive" = 0 ]; then printf '%s' "$def"; return; fi
read -r -p " $label${def:+ [$def]}: " ans || ans=""
printf '%s' "${ans:-$def}"
}
# like ask but hides input (secrets). A present default reads as "keep current".
ask_secret() {
local label="$1" def="${2:-}" ans
if [ "$interactive" = 0 ]; then printf '%s' "$def"; return; fi
read -r -s -p " $label${def:+ [keep current]}: " ans || ans=""; printf '\n' >&2
printf '%s' "${ans:-$def}"
}
# ── derive defaults (silent; empty when a tool is missing/unauthed) ──────────
# remote: .env → source repo's origin → the card's existing clone.
remote="${TW_REMOTE_URL:-}"
if [ -z "$remote" ] && [ -n "$repo_src" ] && [ -d "$repo_src/.git" ]; then
remote="$(git -C "$repo_src" remote get-url origin 2>/dev/null || true)"
fi
if [ -z "$remote" ] && [ -d "{{vol}}/{{sd_repo_dir}}/.git" ]; then
remote="$(git -C "{{vol}}/{{sd_repo_dir}}" remote get-url origin 2>/dev/null || true)"
fi
a_name="${TW_AUTHOR_NAME:-$(git config user.name 2>/dev/null || true)}"
a_email="${TW_AUTHOR_EMAIL:-$(git config user.email 2>/dev/null || true)}"
gh_user="${TW_GH_USER:-}"
[ -z "$gh_user" ] && gh_user="$(gh api user --jq .login 2>/dev/null || true)"
# ssid: .env → the Mac's active Wi-Fi network.
wifi_if="$(networksetup -listallhardwareports 2>/dev/null | awk '/Wi-Fi/{getline; print $2; exit}')"
ssid="${TW_WIFI_SSID:-}"
[ -z "$ssid" ] && ssid="$(networksetup -getairportnetwork "${wifi_if:-en0}" 2>/dev/null | sed -n 's/^Current Wi-Fi Network: //p')"
wifi_pass="${TW_WIFI_PASS:-}"
pat="${TW_PAT:-}"
# ── confirm / fill via prompts ──────────────────────────────────────────────
[ "$interactive" = 1 ] && echo "configuring $conf — press Enter to accept each [default]:" >&2
ssid="$(ask "Wi-Fi SSID" "$ssid")"
# Keychain read happens after the SSID is final (the user may have edited it).
# Reading a System-keychain Wi-Fi password can pop a macOS auth dialog — only
# attempt it interactively so scripted runs never trigger a surprise prompt.
if [ -z "$wifi_pass" ] && [ -n "$ssid" ] && [ "$interactive" = 1 ]; then
echo " looking up Wi-Fi password for '$ssid' in Keychain (approve the macOS dialog if it appears)…" >&2
wifi_pass="$(security find-generic-password -wa "$ssid" 2>/dev/null || true)"
fi
wifi_pass="$(ask_secret "Wi-Fi password" "$wifi_pass")"
remote="$(ask "Git remote URL" "$remote")"
gh_user="$(ask "GitHub username" "$gh_user")"
pat="$(ask_secret "GitHub PAT (fine-grained, contents:write)" "$pat")"
a_name="$(ask "Commit author name" "$a_name")"
a_email="$(ask "Commit author email" "$a_email")"
# ── require the essentials (a blank config ships a dead device) ──────────────
missing=""
[ -n "$ssid" ] || missing="$missing Wi-Fi-SSID"
[ -n "$remote" ] || missing="$missing git-remote-URL"
[ -n "$pat" ] || missing="$missing GitHub-PAT"
if [ -n "$missing" ]; then
echo "error: missing required config:$missing" >&2
[ "$interactive" = 0 ] && echo " (no TTY — set these in firmware/.env or run interactively)" >&2
exit 1
fi
# ── write ───────────────────────────────────────────────────────────────────
{
printf '# Typoena runtime config — generated by `just init`/`provision`.\n'
printf '# Plaintext secrets on removable media: keep the card safe; scope TW_PAT\n'
printf '# to contents:write on just the notes repo. `key=value`, `#` = comment.\n'
printf 'TW_WIFI_SSID=%s\n' "$ssid"
printf 'TW_WIFI_PASS=%s\n' "$wifi_pass"
printf 'TW_REMOTE_URL=%s\n' "$remote"
printf 'TW_GH_USER=%s\n' "$gh_user"
printf 'TW_PAT=%s\n' "$pat"
printf 'TW_AUTHOR_NAME=%s\n' "$a_name"
printf 'TW_AUTHOR_EMAIL=%s\n' "$a_email"
} > "$conf"
mask() { if [ -n "${1:-}" ]; then printf 'set'; else printf 'MISSING'; fi; }
echo "wrote $conf (secrets never printed):"
echo " wifi: ssid=$(mask "$ssid") pass=$(mask "$wifi_pass")"
echo " git: remote=$(mask "$remote") gh_user=$(mask "$gh_user") pat=$(mask "$pat")"
echo " author: name=$(mask "$a_name") email=$(mask "$a_email")"
# Flush + eject so the card can go straight into Typoena.
_eject vol:
#!/usr/bin/env bash
set -euo pipefail
sync
echo "ejecting {{vol}}"
if diskutil eject "{{vol}}" >/dev/null 2>&1; then
echo "✅ card ejected — remove it and insert into Typoena"
else
echo "⚠️ eject failed (a file may still be open) — eject '{{vol}}' from Finder before removing"
fi

View File

@@ -26,12 +26,6 @@ CONFIG_FREERTOS_IDLE_TASK_STACKSIZE=4096
# `std::thread::Builder::new().stack_size(XXX)` for spawning
CONFIG_PTHREAD_TASK_STACK_SIZE_DEFAULT=4096
# Raise the compile-time log ceiling so the sdmmc/sdspi drivers' per-command
# DEBUG logs (the raw R1 response bytes) are built in. Runtime level stays INFO
# by default; the SD spike bumps just the sdmmc/sdspi tags to DEBUG so we can see
# exactly what each init command returns. (Diagnostic for Spike 3 init failures.)
CONFIG_LOG_MAXIMUM_LEVEL_DEBUG=y
# FatFS long filenames (Spike 3 — SD). Default is 8.3-only, which rejects the
# persistence module's atomic-save temp name (`notes.md.tmp` has two dots).
# LFN on the heap keeps the working buffer off the (small) task stack.
@@ -50,9 +44,42 @@ CONFIG_SPIRAM=y
CONFIG_SPIRAM_MODE_OCT=y
CONFIG_SPIRAM_USE_MALLOC=y
# FatFS fast seek (sync-latency root cause, 2026-07-12). Without it, lseek
# resolves by walking the file's FAT cluster chain over SPI — forward from the
# current position, from the CHAIN HEAD on any backward seek. sd_bench measured
# ~190 ms per long seek into the 263 MB packfile (5.8 ms at offset 0), and
# libgit2 pays ~8 such seeks per loose-object write via p_mmap's lseek+read →
# the ~1.5 s/object commit cost (docs/tradeoff-curves/sync-commit-staging.md).
# Fast seek builds an in-memory cluster-link map (CLMT) per READ-mode file,
# making lseek O(1); write-mode files transparently fall back to the walk. The
# buffer is per-open-file, 4 B × size: 256 words = 1 KB, covering ~127 fragments
# (the default 64 covers ~31 — a freshly-provisioned pack is near-contiguous,
# but don't let card aging silently disable the fix; vfs_fat falls back to slow
# seeks when the map doesn't fit).
CONFIG_FATFS_USE_FASTSEEK=y
CONFIG_FATFS_FAST_SEEK_BUFFER_SIZE=256
# Silence the legacy-I2C boot warning. esp-idf-hal's i2c.rs is always compiled
# and binds the old `driver/i2c.h` API, so the legacy driver's TU (and its
# `__attribute__((constructor))` deprecation warning) gets linked into every
# image — even though Typoena uses no I2C at all (EPD/SD are SPI, keyboard is
# USB). This flag wraps that constructor out. Safe here: we link neither the
# new i2c_master driver nor call the old one, so the conflict-abort check it
# also removes can never fire.
CONFIG_I2C_SKIP_LEGACY_CONFLICT_CHECK=y
# TLS trust store (Spike 6 — Wi-Fi + TLS, the gate for Spike 7 git push).
# The certificate bundle backs esp_crt_bundle_attach so an HTTPS GET to
# api.github.com validates against real roots. FULL rather than the common
# subset so a less common CA in the chain can't surprise us on the bench.
CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y
CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_FULL=y
# mbedTLS allocations go to PSRAM. The default (internal-only) needs ~33 KB of
# contiguous internal RAM per TLS connection (two ~17 KB I/O buffers + contexts)
# at the moment `:sync` pushes — with Wi-Fi, USB host, the editor and libgit2
# all resident, mbedtls_ssl_setup failed exactly there on the first real-repo
# push (2026-07-13; the failure then tripped the vendored stream double-free —
# see components/libgit2/esp_mbedtls_stream.c). TLS buffers are CPU-only data,
# so PSRAM is safe; the handshake is network-bound, not memory-bandwidth-bound.
CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC=y

View File

@@ -0,0 +1,58 @@
{
"Book notes": {
"prefix": "booknotes",
"body": ["# $1", "", "## $2 - $3", "", "## What the book is about", ""],
"description": "Reading notes (title / author - year)"
},
"Reference block": {
"prefix": "reference",
"body": ["___", "", "## References", ""],
"description": "Reference block"
},
"Cognitive bias": {
"prefix": "bias",
"body": [
"# $1",
"",
"## Misconception",
"",
"$3",
"",
"## The reality",
"",
"$4",
"",
"___",
"",
"## References",
"",
"- You are not so smart - p. $2",
""
],
"description": "Cognitive-bias note"
},
"Capture": {
"prefix": "capture",
"body": ["# $1", "", "## Captured inspiration", "", "$2", ""],
"description": "Inspiration capture"
},
"Standard": {
"prefix": "standard",
"body": [
"# $1",
"",
"## Key points",
"",
"## Mistakes to avoid",
"",
"___",
"",
"## Examples",
"",
"___",
"",
"## References"
],
"description": "Standard / playbook"
}
}

View File

@@ -0,0 +1,27 @@
{
"Todo item": {
"prefix": "todo",
"body": "- [ ] ",
"description": "Task-list item"
},
"Link": {
"prefix": "link",
"body": "[$1]($2)$0",
"description": "Inline link"
},
"Image": {
"prefix": "img",
"body": "![$1]($2)$0",
"description": "Image"
},
"Table": {
"prefix": "table",
"body": ["| $1 | $2 |", "|-|-|", "| $3 | $4 |"],
"description": "Two-column table"
},
"Code block": {
"prefix": "code",
"body": ["```$1", "$2", "```"],
"description": "Fenced code block"
}
}

View File

@@ -0,0 +1,37 @@
{
"Arrow": {
"prefix": "arrow",
"body": "→",
"description": "Rightwards arrow (→)"
},
"Not equal": {
"prefix": "neq",
"body": "≠",
"description": "Not-equal sign (≠)"
},
"Times": {
"prefix": "times",
"body": "×",
"description": "Multiplication sign (×)"
},
"Middle dot": {
"prefix": "middot",
"body": "·",
"description": "Middle dot (·), inclusive writing"
},
"Degree": {
"prefix": "deg",
"body": "°",
"description": "Degree sign (°)"
},
"Euro": {
"prefix": "euro",
"body": "€",
"description": "Euro sign (€)"
},
"OE ligature": {
"prefix": "edanso",
"body": "œ",
"description": "Ligature œ (e dans l'o)"
}
}

View File

@@ -0,0 +1,371 @@
//! git-level micro-benchmark — localizes the ~700 ms/object libgit2 overhead the
//! `:sync` commit split showed (2026-07-12), now that `sd_bench` proved the raw
//! card does a *full* loose-object write (stat+create+write+rename) in ~86 ms.
//! The ~8× gap between that and `write_tree`'s 710 ms lives inside libgit2, not
//! FAT — this bench times the git2 ODB/index primitives in isolation to find it.
//!
//! HEADLINE OP (since the 2026-07-12 real-repo run): `splice stage→tree` — the
//! O(depth) TreeBuilder walk that replaces the index-based commit entirely
//! (docs/tradeoff-curves/sync-commit-staging.md). It runs FIRST so its first iteration is
//! the cold number; acceptance bar: **sub-second cold on the real 570 MB-pack
//! clone, heap staying healthy**. The index paths it supersedes run LAST, for
//! regression tracking — they previously OOM'd, and a late crash can't cost the
//! splice data.
//!
//! Read-mostly on `/sd/repo`: the only writes are unreferenced ("orphan") loose
//! blobs/trees/commits — never reachable from a ref, so never pushed, and
//! gc-able. Safe on the test card.
//!
//! Flash with `just flash-gitbench` (needs the `git` feature; env in the recipe).
use std::time::Instant;
use anyhow::{Context, Result};
use esp_idf_svc::hal::delay::FreeRtos;
use git2::{IndexEntry, IndexTime, ObjectType, Oid, Repository, Signature, Tree};
use firmware::git_sync::GIT_STACK;
use firmware::persistence::{Storage, REPO_DIR};
const BUILD_TAG: &str = concat!("build ", env!("BUILD_TIME"), " @", env!("BUILD_GIT"));
/// Iterations per op. Small — some ops write to the card, and the first vs rest
/// spread (min vs max) is itself the signal (e.g. cold vs warm, write vs
/// freshen-skip). Kept low (3) on the real 570 MB-pack clone so a slow op still
/// finishes in seconds.
const N: usize = 3;
fn main() -> Result<()> {
esp_idf_svc::sys::link_patches();
esp_idf_svc::log::EspLogger::initialize_default();
log::info!("Typoena — git-level bench, {BUILD_TAG}");
// libgit2 nests ~67 KB of GIT_PATH_MAX stack buffers (postmortem #3), so the
// git work must run on the same 96 KB stack the real git service uses. On the
// small main-task stack `index.write()` overflows → nested panic → boot loop.
let handle = std::thread::Builder::new()
.name("git_bench".into())
.stack_size(GIT_STACK)
.spawn(run)
.expect("spawn git_bench thread");
match handle.join() {
Ok(Ok(())) => log::info!("git_bench: done"),
Ok(Err(e)) => log::error!("git_bench failed: {e:?}"),
Err(_) => log::error!("git_bench thread panicked"),
}
loop {
FreeRtos::delay_ms(1000);
}
}
fn run() -> Result<()> {
// libgit2 holds the pack + idx (+ commit-graph) fds open and reads loose
// objects on top; the editor's default 4-FD budget can't cover read_tree.
let _sd = Storage::mount_for_git().context("mounting SD")?;
// A 32 MB default mwindow window (mwindow.c) would git__malloc > PSRAM on the
// real 570 MB pack; small windows keep each p_mmap read cheap, and the
// esp_map cache keeps them from being re-read on every freshen→refresh.
// SAFETY: process-global libgit2 options, set once before any repo work.
unsafe {
git2::opts::set_mwindow_size(256 * 1024).ok();
git2::opts::set_mwindow_mapped_limit(4 * 1024 * 1024).ok();
}
// Repository open — one-time, but shows the cost of scanning .git (config,
// refs, ODB backends/packs) which every later op may implicitly refresh.
let t = Instant::now();
let repo = Repository::open(REPO_DIR)
.with_context(|| format!("opening git repo at {REPO_DIR}"))?;
log::info!("Repository::open {:.1} ms", t.elapsed().as_micros() as f64 / 1000.0);
log_map_stats("open");
// 1) THE FIX — `splice stage→tree`, the O(depth) TreeBuilder walk
// (docs/tradeoff-curves/sync-commit-staging.md): patch the edited file's ancestor
// subtree chain onto HEAD's tree; never materialise the 1179-entry index,
// never index.write(), never read_tree the whole tree. Runs FIRST so
// iteration #1 is genuinely cold (only `open` has touched the pack).
let head_tree = repo
.head()?
.peel_to_commit()
.context("HEAD → commit")?
.tree()
.context("HEAD tree")?;
// A nested path that already exists in HEAD's tree, found by an O(depth)
// descent — NOT read_tree, which is itself the 77 s op — so the splice
// REPLACES a real file and rebuilds a real ancestor chain, not just the root.
let edit_path = find_edit_path(&repo, &head_tree)?;
log::info!(
"splice: editing {} (depth {})",
edit_path.join("/"),
edit_path.len()
);
// The blob write is inside the timing: the real commit pays blob + trees, and
// it keeps the number comparable to `index-free stage→tree` below. Not
// measured here: the ref/reflog update (commit(Some("HEAD"))) — flat FAT
// writes, ~350 ms on the toy repo.
bench("splice stage→tree", |i| {
let data = format!("typoena splice bench edit #{i}\n");
let oid = repo.blob(data.as_bytes()).context("write blob")?;
let parts: Vec<&str> = edit_path.iter().map(String::as_str).collect();
splice(&repo, Some(&head_tree), &parts, Some(oid)).map(|_| ())
})?;
log_map_stats("splice");
// 2) commit(None, …) — create a commit OBJECT without moving HEAD or writing a
// reflog (update_ref = None → an orphan commit, gc-able). Isolates commit-
// object creation from the ref-update + reflog cost; splice + this projects
// the full real-repo commit. Reuses the parent's tree (no new tree needed);
// unique message each iter forces a real write.
let parent = repo.head()?.peel_to_commit().context("HEAD → commit")?;
let sig = Signature::now("typoena-bench", "bench@typoena.local").context("sig")?;
bench("commit(None) orphan obj", |i| {
let msg = format!("typoena git_bench orphan commit #{i}");
repo.commit(None, &sig, &sig, &msg, &head_tree, &[&parent])
.map(|_| ())
.context("commit(None)")
})?;
log_map_stats("commit");
// 3) odb.write(blob) in isolation — unique content each iter forces a real
// write (no freshen-skip). If ~100 ms the ODB write path is fine and any
// slow op above is in the tree/ref layer; if ~1 s the cost is inside the
// ODB write itself (deflate/sha/freshen) and the mmap cache regressed.
let odb = repo.odb().context("opening odb")?;
bench("odb.write(blob)", |i| {
let data = format!("typoena git_bench orphan blob #{i} — unique so the write is real\n");
odb.write(ObjectType::Blob, data.as_bytes())
.map(|_| ())
.context("odb.write")
})?;
log_map_stats("odb.write");
// 3b) LOCALIZE the commit-vs-blob gap. The fast-seek A/B (2026-07-12) left
// `commit(None)` at 1.7 s while `odb.write` dropped to ~0.4 s — commit
// additionally VALIDATES its parent + tree OIDs against the odb (strict
// object creation → pack header resolves) and freshens the packed tree.
// Price the two suspects, then re-bench commit + splice with strict
// creation OFF. If commit collapses toward odb.write, validation was the
// gap — and git_sync can ship with strict off (every OID it inserts
// comes from HEAD's tree or a blob it just wrote).
let parent_id = parent.id();
let tree_id = head_tree.id();
bench("odb.read_header(packed)", |i| {
let id = if i % 2 == 0 { tree_id } else { parent_id };
odb.read_header(id).map(|_| ()).context("read_header")
})?;
bench("odb.exists(missing)", |i| {
let id = Oid::from_str(&format!("{:040x}", 0xdead_beef_u64 + i as u64))?;
let _ = odb.exists(id); // miss → freshen fails → git_odb_refresh path
Ok(())
})?;
log_map_stats("probes");
// Process-global libgit2 flag; this bench owns the process.
git2::opts::strict_object_creation(false);
bench("commit(None) [strict off]", |i| {
let msg = format!("typoena git_bench strict-off commit #{i}");
repo.commit(None, &sig, &sig, &msg, &head_tree, &[&parent])
.map(|_| ())
.context("commit strict-off")
})?;
bench("splice [strict off]", |i| {
let data = format!("typoena splice strict-off edit #{i}\n");
let oid = repo.blob(data.as_bytes()).context("write blob")?;
let parts: Vec<&str> = edit_path.iter().map(String::as_str).collect();
splice(&repo, Some(&head_tree), &parts, Some(oid)).map(|_| ())
})?;
git2::opts::strict_object_creation(true);
log_map_stats("strict-off");
// 4) on-disk index LOAD (no write). Times loading all ~1179 entries from the
// card and prints the count. We deliberately do NOT bench index.write():
// it calls truncate_racily_clean, which diffs the whole working tree
// against the index and — because a fresh FAT clone makes every entry look
// "racy" (2 s mtime granularity) — re-hashes ~170 MB over SPI, up to ~10 min
// on this repo (proven 2026-07-12, index.write max 611 s). The splice
// never touches the on-disk index, so that path never runs.
bench("repo.index() load", |_| {
repo.index().map(|_| ()).context("index open")
})?;
let n_entries = repo.index().map(|i| i.len()).unwrap_or(0);
log::info!("on-disk index has {n_entries} entries");
log_map_stats("index load");
// 5) REFUTED ALTERNATIVE — the index-free in-memory-index commit
// (read_tree(HEAD) + add + write_tree_to). It dodges truncate_racily_clean
// but is still O(N_tree): the 2026-07-12 real-repo run measured ~77 s for
// the cold read_tree and drove the mmap cache to 7.4 MB (zlib OOM). Kept
// for regression tracking, run LAST so a crash here can't cost the splice
// data above. The cold read_tree is now timed explicitly (the 77 s was
// previously visible only via log timestamps); the ops above warmed only
// ~depth of the ~158 tree windows, so this is still ~cold.
let t = Instant::now();
{
let mut idx = git2::Index::new().context("Index::new")?;
idx.read_tree(&head_tree).context("seed read_tree")?;
}
log::info!(
"seed read_tree(HEAD) cold {:.1} ms",
t.elapsed().as_micros() as f64 / 1000.0
);
log_map_stats("read_tree");
// Warm repeats: windows resident → pure CPU + cache lookups.
bench("Index::new + read_tree", |_| {
let mut idx = git2::Index::new().context("Index::new")?;
idx.read_tree(&head_tree).context("read_tree")?;
Ok(())
})?;
let edit_path_bytes = edit_path.join("/").into_bytes();
bench("index-free stage→tree", |i| {
let mut idx = git2::Index::new().context("Index::new")?;
idx.read_tree(&head_tree).context("read_tree")?;
let data = format!("typoena index-free bench edit #{i}\n");
let oid = repo.blob(data.as_bytes()).context("write blob")?;
idx.add(&blob_entry(&edit_path_bytes, oid)).context("index.add")?;
idx.write_tree_to(&repo).map(|_| ()).context("write_tree_to")
})?;
log_map_stats("index-free");
Ok(())
}
/// PROTOTYPE of the real fix (destined for `git_sync::stage_and_commit`): return
/// a new tree OID equal to `base` with `path` set to `new` — `Some(blob)` to
/// add/replace, `None` to delete. Reads ~depth subtree objects, writes ~depth
/// trees; every other entry (all 1179 files, the 150 MB of images) is carried
/// forward by OID without ever being read. `base = None` builds a fresh subtree
/// chain (new file in a new directory). The git_sync version must additionally
/// drop a directory entry when a delete empties its subtree; the bench only
/// exercises replace.
fn splice(repo: &Repository, base: Option<&Tree>, path: &[&str], new: Option<Oid>) -> Result<Oid> {
let (head, rest) = path.split_first().context("splice: empty path")?;
let mut tb = repo.treebuilder(base).context("treebuilder")?;
if rest.is_empty() {
match new {
Some(oid) => {
tb.insert(*head, oid, 0o100644).context("insert blob")?;
}
None => {
let _ = tb.remove(*head); // already absent ⇒ nothing to delete
}
}
} else {
let sub = match base.and_then(|b| b.get_name(head)) {
Some(e) if e.kind() == Some(ObjectType::Tree) => {
Some(repo.find_tree(e.id()).context("loading subtree")?)
}
_ => None, // no such dir yet (or a blob in the way): build from empty
};
let new_sub = splice(repo, sub.as_ref(), rest, new)?;
tb.insert(*head, new_sub, 0o040000).context("insert subtree")?;
}
tb.write().context("treebuilder write")
}
/// Find a real file to "edit": descend the first subtree at each level (capped),
/// then take the first blob of the deepest tree reached. Reads O(depth) tree
/// objects — never `read_tree`/materialise the whole tree (that's the 77 s op
/// this bench exists to retire).
fn find_edit_path(repo: &Repository, root: &Tree) -> Result<Vec<String>> {
let mut path = Vec::new();
let mut cur_id = root.id();
for _ in 0..6 {
let cur = repo.find_tree(cur_id).context("descending tree")?;
match cur.iter().find(|e| e.kind() == Some(ObjectType::Tree)) {
Some(sub) => {
path.push(sub.name().context("non-utf8 tree name")?.to_string());
cur_id = sub.id();
}
None => break,
}
}
let cur = repo.find_tree(cur_id).context("leaf tree")?;
let blob = cur
.iter()
.find(|e| e.kind() == Some(ObjectType::Blob))
.context("no blob along the first-subtree chain — pick an edit path manually")?;
path.push(blob.name().context("non-utf8 blob name")?.to_string());
Ok(path)
}
unsafe extern "C" {
/// Counters from the p_mmap emulation in `components/libgit2/esp_map.c`.
/// Post cache-removal: `hits` is always 0, `misses` counts every mapping,
/// `cached_kb` reports the LIVE mapped bytes (the mwindow working set).
fn esp_map_stats(hits: *mut u32, misses: *mut u32, read_kb: *mut u32, cached_kb: *mut u32);
}
/// Log the p_mmap counters — mappings performed, total KB read from the card,
/// and KB currently live-mapped (should track mwindow's open windows and stay
/// well under MWINDOW_MAPPED_LIMIT now that munmap frees immediately).
fn log_map_stats(label: &str) {
let (mut hits, mut misses, mut read_kb, mut cached_kb) = (0u32, 0u32, 0u32, 0u32);
unsafe { esp_map_stats(&mut hits, &mut misses, &mut read_kb, &mut cached_kb) };
let _ = hits; // always 0 since the cache removal; slot kept for ABI stability
// Free heap spans PSRAM here; a drop toward 0 during write_tree/commit on the
// real repo would point at mwindow/idx allocation pressure (or thrash) as the
// cause of an apparent hang, not CPU.
let free_kb = unsafe { esp_idf_svc::sys::esp_get_free_heap_size() } / 1024;
log::info!(
"mmap @ {label:<11} {misses} maps, {read_kb} KB read, {cached_kb} KB live, {free_kb} KB heap free"
);
}
/// Announce, time, and summarize an op. The `→ label …` line prints BEFORE the op
/// runs, so if an op hangs on the real 570 MB-pack repo we can see which one it
/// entered — a bare `summarize` prints only after all N iters, hiding the culprit.
fn bench<F: FnMut(usize) -> Result<()>>(label: &str, op: F) -> Result<()> {
log::info!("→ {label} …");
summarize(label, time_each(op)?);
Ok(())
}
/// A minimal index entry pointing at an already-written blob — for `index.add`,
/// which (unlike `add_frombuffer`) needs no repo owner, so it works on a bare
/// in-memory index. Only `id`, `path` and `mode` feed the tree write.
fn blob_entry(path: &[u8], oid: Oid) -> IndexEntry {
IndexEntry {
ctime: IndexTime::new(0, 0),
mtime: IndexTime::new(0, 0),
dev: 0,
ino: 0,
mode: 0o100644,
uid: 0,
gid: 0,
file_size: 0,
id: oid,
flags: 0,
flags_extended: 0,
path: path.to_vec(),
}
}
/// Run `op(i)` for `i in 0..N`, returning each call's wall time in microseconds.
fn time_each<F: FnMut(usize) -> Result<()>>(mut op: F) -> Result<Vec<u64>> {
let mut times = Vec::with_capacity(N);
for i in 0..N {
let t = Instant::now();
op(i)?;
times.push(t.elapsed().as_micros() as u64);
}
Ok(times)
}
/// Log min / p50 / mean / max in ms for a set of per-call microsecond timings.
fn summarize(label: &str, mut times: Vec<u64>) {
times.sort_unstable();
let n = times.len();
let mean = times.iter().sum::<u64>() / n as u64;
let ms = |us: u64| us as f64 / 1000.0;
log::info!(
"{label:<26} min {:>6.1} p50 {:>6.1} mean {:>6.1} max {:>6.1} ms",
ms(times[0]),
ms(times[n / 2]),
ms(mean),
ms(times[n - 1]),
);
}

View File

@@ -58,7 +58,8 @@ use esp_idf_svc::hal::peripherals::Peripherals;
use esp_idf_svc::nvs::EspDefaultNvsPartition;
use esp_idf_svc::sntp::{EspSntp, SyncStatus};
use esp_idf_svc::sys::{self, esp};
use esp_idf_svc::wifi::{AuthMethod, BlockingWifi, ClientConfiguration, Configuration, EspWifi};
use esp_idf_svc::wifi::{BlockingWifi, EspWifi};
use firmware::net::connect_wifi;
use git2::{
CertificateCheckStatus, Cred, CredentialType, IndexAddOption, PushOptions, RemoteCallbacks,
Repository, Signature,
@@ -140,7 +141,7 @@ fn run() -> Result<()> {
EspWifi::new(peripherals.modem, sys_loop.clone(), Some(nvs))?,
sys_loop,
)?;
connect_wifi(&mut wifi)?;
connect_wifi(&mut wifi, WIFI_SSID, WIFI_PASS)?;
let ip = wifi.wifi().sta_netif().get_ip_info()?;
log::info!("Wi-Fi up — IP {}", ip.ip);
wifi
@@ -182,26 +183,6 @@ fn run() -> Result<()> {
}
}
/// Associate with the configured AP and wait for DHCP. Mirrors Spike 6.
fn connect_wifi(wifi: &mut BlockingWifi<EspWifi<'static>>) -> Result<()> {
let auth_method = if WIFI_PASS.is_empty() {
AuthMethod::None
} else {
AuthMethod::WPA2Personal
};
wifi.set_configuration(&Configuration::Client(ClientConfiguration {
ssid: WIFI_SSID.try_into().ok().context("SSID > 32 bytes")?,
password: WIFI_PASS.try_into().ok().context("password > 64 bytes")?,
auth_method,
..Default::default()
}))?;
wifi.start()?;
log::info!("associating with \"{WIFI_SSID}\"");
wifi.connect().context("Wi-Fi association failed")?;
wifi.wait_netif_up().context("DHCP / netif never came up")?;
Ok(())
}
/// Kick off SNTP and block until first sync. Required before TLS (cert validity)
/// and before committing (signature timestamp). Mirrors Spike 6.
fn sync_clock() -> Result<()> {

View File

@@ -44,7 +44,8 @@ use esp_idf_svc::hal::peripherals::Peripherals;
use esp_idf_svc::nvs::EspDefaultNvsPartition;
use esp_idf_svc::sntp::{EspSntp, SyncStatus};
use esp_idf_svc::sys::{self, esp};
use esp_idf_svc::wifi::{AuthMethod, BlockingWifi, ClientConfiguration, Configuration, EspWifi};
use esp_idf_svc::wifi::{BlockingWifi, EspWifi};
use firmware::net::connect_wifi;
use git2::build::{CheckoutBuilder, RepoBuilder};
use git2::{
CertificateCheckStatus, Commit, Cred, CredentialType, FetchOptions, IndexAddOption,
@@ -76,6 +77,15 @@ const REPO_DIR: &str = "/spiflash/repo";
/// The tracked file we append to. Stands in for the editor's note file(s).
const NOTES_FILE: &str = "notes.md";
/// Debug/recovery + the read-only-delete test knob: when true, wipe REPO_DIR and
/// re-clone from scratch every boot instead of opening the existing clone.
/// Deleting the existing clone's objects only succeeds with the esp_stubs
/// p_open/p_creat fix (libgit2 objects are otherwise mode 0444 → AM_RDO →
/// un-deletable on FAT). Ships **false** — the product opens the persistent
/// clone and fast-forwards; flip true to validate the RO-delete fix or to force
/// a clean re-clone.
const RECLONE_EACH_BOOT: bool = false;
/// GitHub's root CAs, embedded so the push can verify the server's TLS chain.
/// Shared with `git_push` (same file). Written to FAT and handed to libgit2 via
/// GIT_OPT_SET_SSL_CERT_LOCATIONS.
@@ -126,7 +136,7 @@ fn run() -> Result<()> {
EspWifi::new(peripherals.modem, sys_loop.clone(), Some(nvs))?,
sys_loop,
)?;
connect_wifi(&mut wifi)?;
connect_wifi(&mut wifi, WIFI_SSID, WIFI_PASS)?;
let ip = wifi.wifi().sta_netif().get_ip_info()?;
log::info!("Wi-Fi up — IP {}", ip.ip);
wifi
@@ -235,19 +245,31 @@ fn publish() -> Result<String> {
/// whether a clone happened. Clone carries the auth + cert callbacks (the remote
/// may be private, and the TLS chain is verified either way).
fn open_or_clone() -> Result<(Repository, bool)> {
if RECLONE_EACH_BOOT && Path::new(REPO_DIR).exists() {
log::warn!("RECLONE_EACH_BOOT — removing {REPO_DIR} to re-clone from scratch");
remove_tree(Path::new(REPO_DIR)).context("RECLONE_EACH_BOOT wipe")?;
}
match Repository::open(REPO_DIR) {
Ok(repo) => {
log::info!("opened existing clone at {REPO_DIR}");
Ok((repo, false))
}
Err(_) => {
log::info!("no repo at {REPO_DIR} — cloning {REMOTE_URL}");
// A leftover at REPO_DIR is a partial clone (libgit2 refuses to clone
// into a non-empty dir, code=Exists). Remove it and re-clone via
// remove_tree (path-based) — std::fs::remove_dir_all EACCESes on
// esp-idf's FATFS VFS (see remove_tree). Hardware-verified.
if Path::new(REPO_DIR).exists() {
log::warn!("{REPO_DIR} exists but is not a valid repo — removing stale clone");
remove_tree(Path::new(REPO_DIR)).context("removing stale clone")?;
}
log::info!("cloning {REMOTE_URL} → {REPO_DIR}");
let mut fo = FetchOptions::new();
fo.remote_callbacks(auth_callbacks());
let repo = RepoBuilder::new()
.fetch_options(fo)
.clone(REMOTE_URL, Path::new(REPO_DIR))
.context("clone (is REPO_DIR a leftover partial clone? it must not exist)")?;
.context("clone")?;
Ok((repo, true))
}
}
@@ -331,6 +353,34 @@ fn fetch_and_integrate(repo: &Repository, branch: &str) -> Result<()> {
bail!("origin/{branch} diverged from local — a real merge commit is needed (increment B, deferred)")
}
/// Recursively remove a directory tree by PATH — deliberately NOT
/// `std::fs::remove_dir_all`. std's version deletes race-free via the
/// openat/unlinkat/fdopendir family, which esp-idf's path-based FATFS VFS does
/// not implement → it fails with EACCES on the first entry. A plain
/// read_dir → remove_file/remove_dir walk uses only what FATFS supports.
/// (Hardware-diagnosed: std::remove_dir_all EACCES'd where this succeeds, and
/// every entry reported writable — so read-only was never the blocker; the
/// esp_stubs p_open/p_creat shim keeps objects writable so this — and libgit2's
/// own gc/repack/fetch-prune deletes — never hit a read-only file on FAT.)
/// Reads each directory fully before recursing so no readdir handle is open
/// during removal.
fn remove_tree(path: &Path) -> Result<()> {
let meta = fs::symlink_metadata(path).with_context(|| format!("stat {}", path.display()))?;
if meta.is_dir() {
let children: Vec<_> = fs::read_dir(path)
.with_context(|| format!("read_dir {}", path.display()))?
.filter_map(|e| e.ok())
.map(|e| e.path())
.collect();
for child in children {
remove_tree(&child)?;
}
fs::remove_dir(path).with_context(|| format!("rmdir {}", path.display()))
} else {
fs::remove_file(path).with_context(|| format!("unlink {}", path.display()))
}
}
/// Auth + cert callbacks shared by clone, fetch, and push. Captures only the
/// baked consts, so a fresh set can be built per operation (RemoteCallbacks is
/// consumed by each). The PAT is handed to libgit2 here and never logged.
@@ -356,26 +406,6 @@ fn short(oid: git2::Oid) -> String {
oid.to_string()[..8].to_string()
}
/// Associate with the configured AP and wait for DHCP. Mirrors Spike 6 / git_push.
fn connect_wifi(wifi: &mut BlockingWifi<EspWifi<'static>>) -> Result<()> {
let auth_method = if WIFI_PASS.is_empty() {
AuthMethod::None
} else {
AuthMethod::WPA2Personal
};
wifi.set_configuration(&Configuration::Client(ClientConfiguration {
ssid: WIFI_SSID.try_into().ok().context("SSID > 32 bytes")?,
password: WIFI_PASS.try_into().ok().context("password > 64 bytes")?,
auth_method,
..Default::default()
}))?;
wifi.start()?;
log::info!("associating with \"{WIFI_SSID}\"");
wifi.connect().context("Wi-Fi association failed")?;
wifi.wait_netif_up().context("DHCP / netif never came up")?;
Ok(())
}
/// Kick off SNTP and block until first sync. Required before TLS (cert validity)
/// and before committing (signature timestamp). Mirrors Spike 6 / git_push.
fn sync_clock() -> Result<()> {

View File

@@ -0,0 +1,212 @@
//! SD/FAT primitive-op micro-benchmark — investigating the ~700 ms-per-loose-
//! object write floor found in the `:sync` commit split (2026-07-12, see
//! `docs/tradeoff-curves/sync-commit-staging.md`).
//!
//! The split showed a single small git loose object (`write_tree` = one tree
//! object) takes ~710 ms to land on the card, and it is **not** fsync
//! (`GIT_OPT_ENABLE_FSYNC_GITDIR` is off). libgit2's loose-object write
//! (`odb_loose.c` `loose_backend__write` → `git_filebuf_commit_at`) is, per object:
//!
//! stat(final) — freshen probe, misses (our `utimes` stub → `stat`)
//! open+write+close — a temp file (`GIT_FILEBUF_TEMPORARY`)
//! [mkdir objects/xx once per fan-out]
//! p_rename — our stub: remove(final) [ENOENT] + rename(temp → final)
//!
//! i.e. **two directory-mutating writes** (temp create + rename) per object. This
//! bench times each FAT primitive in isolation, then a composite that mirrors the
//! sequence above, so we can attribute the ~700 ms to specific ops and get a
//! baseline to compare an A1/A2 card or a 20 MHz bus against. All writes go to
//! `/sd/sdbench` (cleaned up at the end); the pack-seek op additionally opens
//! `/sd/repo`'s packfile READ-ONLY — it never writes there.
//!
//! Flash with `just flash-bench`. Needs no `.env`, no `git` feature (pure SD).
use std::fs::{self, File};
use std::io::{Read, Seek, SeekFrom, Write};
use std::time::Instant;
use anyhow::{Context, Result};
use esp_idf_svc::hal::delay::FreeRtos;
use firmware::persistence::Storage;
/// Injected by build.rs so serial output identifies the exact build.
const BUILD_TAG: &str = concat!("build ", env!("BUILD_TIME"), " @", env!("BUILD_GIT"));
/// Scratch dir on the card ROOT — outside `/sd/repo`, so a later `:sync` never
/// stages it and the user's notes are never touched.
const BENCH_DIR: &str = "/sd/sdbench";
/// Iterations per op: enough to read min/p50/mean past controller jitter, few
/// enough that total write volume stays tiny.
const N: usize = 20;
/// ~ the size of a small deflated git loose object (blob/tree/commit).
const PAYLOAD: [u8; 200] = [b'x'; 200];
fn main() -> Result<()> {
// Required once before any esp-idf-svc call (see esp-idf-template#71).
esp_idf_svc::sys::link_patches();
esp_idf_svc::log::EspLogger::initialize_default();
log::info!("Typoena — SD primitive bench, {BUILD_TAG}");
match run() {
Ok(()) => log::info!("sd_bench: done"),
Err(e) => log::error!("sd_bench failed: {e:?}"),
}
loop {
FreeRtos::delay_ms(1000);
}
}
fn run() -> Result<()> {
let sd = Storage::mount().context("mounting SD")?;
let (max_khz, real_khz) = sd.negotiated_khz();
log::info!("bus: max {max_khz} kHz, negotiated {real_khz} kHz — {N} iters, {}-byte payload", PAYLOAD.len());
// Fresh scratch dir.
let _ = fs::remove_dir_all(BENCH_DIR);
fs::create_dir_all(BENCH_DIR).with_context(|| format!("creating {BENCH_DIR}"))?;
// Warm-up: the first write after mount pays one-time settling — don't measure it.
{
let mut f = File::create(format!("{BENCH_DIR}/warmup"))?;
f.write_all(&PAYLOAD)?;
}
// 1) create + write(200B) + close, a fresh unique file each time. The drop at
// the block's end is the close (FatFS f_close flushes dir entry + data).
summarize("create+write(200B)+close", time_each(|i| {
let mut f = File::create(format!("{BENCH_DIR}/c{i}"))?;
f.write_all(&PAYLOAD)?;
Ok(())
})?);
// 2) rename c{i} -> o{i}. Sources exist from step 1 (untimed setup).
summarize("rename", time_each(|i| {
fs::rename(format!("{BENCH_DIR}/c{i}"), format!("{BENCH_DIR}/o{i}"))
.map_err(Into::into)
})?);
// 3) stat, hit.
summarize("stat (hit)", time_each(|i| {
fs::metadata(format!("{BENCH_DIR}/o{i}")).map(|_| ()).map_err(Into::into)
})?);
// 4) stat, miss (ENOENT) — the freshen-probe analogue. A read, expected cheap.
summarize("stat (miss/ENOENT)", time_each(|i| {
let _ = fs::metadata(format!("{BENCH_DIR}/nope{i}"));
Ok(())
})?);
// 5) remove o{i}.
summarize("remove", time_each(|i| {
fs::remove_file(format!("{BENCH_DIR}/o{i}")).map_err(Into::into)
})?);
// 6) Composite: the exact loose-object write sequence libgit2 performs, with a
// git-length (38-hex) final name so LFN directory-entry cost is included.
// If the model is right this lands near the ~700 ms/object from the split.
summarize("loose-object composite", time_each(|i| {
let tmp = format!("{BENCH_DIR}/tmp_obj{i}");
let fin = format!("{BENCH_DIR}/{i:038x}");
let _ = fs::metadata(&fin); // freshen probe, misses
{
let mut f = File::create(&tmp)?; // temp create + write + close
f.write_all(&PAYLOAD)?;
}
let _ = fs::remove_file(&fin); // p_rename's remove(to) — ENOENT
fs::rename(&tmp, &fin)?; // temp -> final
Ok(())
})?);
// Clean up so the card is left as we found it.
fs::remove_dir_all(BENCH_DIR).with_context(|| format!("removing {BENCH_DIR}"))?;
// 7) THE ~1.5 s LOOSE-WRITE SUSPECT (git_bench, 2026-07-12 second real-repo
// run): lseek inside a huge file. Without CONFIG_FATFS_USE_FASTSEEK,
// FatFS resolves lseek by walking the file's FAT cluster chain — forward
// from the current position, from the CHAIN HEAD on any backward seek.
// The 570 MB pack is ~36k clusters ≈ ~146 KB of FAT reads over SPI per
// long walk. `p_mmap` (esp_map.c) does lseek+read per window, and
// libgit2's freshen path probes the pack TRAILER (near the end) while
// tree windows sit at low offsets — so each loose write pays ~one full
// walk. Prediction: "@start" stays ~ms; "@end" costs ~1.5 s per iter.
// If so, the fix is CONFIG_FATFS_USE_FASTSEEK=y (fast-seek applies to
// read-mode files only — exactly how the pack is opened).
match find_pack()? {
Some(pack) => {
let len = fs::metadata(&pack)?.len();
log::info!("pack seek bench: {pack} ({} MB)", len / (1024 * 1024));
if len < 1024 * 1024 {
log::info!("pack too small to show chain-walk cost — skipping (toy card?)");
} else {
let mut f = File::open(&pack).with_context(|| format!("opening {pack}"))?;
let mut buf = vec![0u8; 4096];
// Baseline: rewind + read at the chain head — no walk to resolve.
summarize("pack seek+read 4KB @start", time_each(|_| {
f.seek(SeekFrom::Start(0))?;
f.read_exact(&mut buf)?;
Ok(())
})?);
// Rewind (cheap, measured above), then seek near the end — pays
// one full cluster-chain walk per iteration if fast-seek is off.
let high = len - 4096;
summarize("pack seek+read 4KB @end", time_each(|_| {
f.seek(SeekFrom::Start(0))?;
f.read_exact(&mut buf)?;
f.seek(SeekFrom::Start(high))?;
f.read_exact(&mut buf)?;
Ok(())
})?);
}
}
None => log::info!("no packfile under /sd/repo/.git/objects/pack — skipping seek bench"),
}
Ok(())
}
/// Largest `*.pack` under the repo's pack dir, if the card carries a clone.
/// Skips macOS AppleDouble sidecars (`._pack-*.pack`, 4 KB of Finder metadata) —
/// the Spike-14 cruft in its latest disguise.
fn find_pack() -> Result<Option<String>> {
let Ok(entries) = fs::read_dir("/sd/repo/.git/objects/pack") else {
return Ok(None);
};
let mut best: Option<(u64, String)> = None;
for e in entries.flatten() {
let p = e.path();
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
if name.starts_with("._") || !name.ends_with(".pack") {
continue;
}
let len = fs::metadata(&p).map(|m| m.len()).unwrap_or(0);
if best.as_ref().is_none_or(|(l, _)| len > *l) {
best = Some((len, p.to_string_lossy().into_owned()));
}
}
Ok(best.map(|(_, p)| p))
}
/// Run `op(i)` for `i in 0..N`, returning each call's wall time in microseconds.
fn time_each<F: FnMut(usize) -> Result<()>>(mut op: F) -> Result<Vec<u64>> {
let mut times = Vec::with_capacity(N);
for i in 0..N {
let t = Instant::now();
op(i)?;
times.push(t.elapsed().as_micros() as u64);
}
Ok(times)
}
/// Log min / p50 / mean / max in ms for a set of per-call microsecond timings.
fn summarize(label: &str, mut times: Vec<u64>) {
times.sort_unstable();
let n = times.len();
let mean = times.iter().sum::<u64>() / n as u64;
let ms = |us: u64| us as f64 / 1000.0;
log::info!(
"{label:<26} min {:>6.1} p50 {:>6.1} mean {:>6.1} max {:>6.1} ms",
ms(times[0]),
ms(times[n / 2]),
ms(mean),
ms(times[n - 1]),
);
}

View File

@@ -1,97 +1,44 @@
//! Spike 3 — SD card (FAT) over the EPD's shared SPI2 bus.
//! Spike 3 — SD card (FAT) on its own SPI3 host, now a thin on-device harness
//! over the real [`firmware::persistence`] module.
//!
//! A small standalone bench program (separate binary from the editor firmware)
//! that proves the storage stack the persistence module will sit on:
//! The raw storage stack (SPI3 bring-up, hand-rolled SDSPI descriptors, the
//! atomic write/fsync/unlink/rename dance) was proven here first and has since
//! graduated into `firmware::persistence` so the editor and this spike share one
//! implementation. This binary now just drives that module on hardware:
//!
//! 1. Bring up SPI2 with the SD's four lines. Three are shared with the EPD
//! (SCK 12, MOSI 11) plus a MISO line (13) the write-only EPD never used,
//! and the SD gets its own chip-select (10); the EPD's CS is 7.
//! 2. Mount a FAT filesystem on the card at `/sd` via `esp_vfs_fat_sdspi_mount`.
//! 3. Exercise the exact atomic-save pattern the persistence module specifies
//! (ADR-007): write `*.tmp`, fsync, rename over the target, then read back
//! and byte-compare. Report the card's negotiated clock and FAT usage.
//! 1. [`Storage::mount`] — SPI3 (SCK 14, MOSI 15, MISO 13, CS 10; ADR-012) +
//! FAT mount at `/sd` + boot crash-recovery. `format_if_mount_failed` is
//! false in the module, so the card must already be FAT-formatted (it is
//! Spike 3 formatted it 2026-07-11).
//! 2. Report the card's negotiated clock and FAT usage.
//! 3. Load `/sd/repo/notes.md` (non-destructive) and report its size.
//! 4. Only if there is no notes.md yet (a blank bench card, nothing to lose)
//! exercise the real [`Storage::save`] → [`Storage::load`] round-trip and
//! byte-compare. On a provisioned card the write test is skipped so the
//! user's writing is never clobbered by the bench tool.
//!
//! Why SD-only (no EPD in the same pass): the EPD driver uses esp-idf-hal's
//! `SpiBusDriver`, whose constructor calls `spi_device_acquire_bus(BLOCK)` and
//! holds that *exclusive* bus lock for the driver's whole lifetime (it needs CS
//! held across a cmd→data sequence while DC toggles). While that lock is held,
//! any other device on SPI2 — i.e. the SD — blocks. So the EPD and an arbitrated
//! SD device can't both be live on one host as things stand; proving the SD
//! stack + wiring first is the useful de-risking step. The shared-bus
//! arbitration question (release/re-acquire around EPD ops, or give the SD its
//! own SPI3 — the risk-table fallback) is what this spike hands data to.
//!
//! Two esp-idf notes baked in below:
//! - The `SDSPI_HOST_DEFAULT()` / `SDSPI_DEVICE_CONFIG_DEFAULT()` C macros are
//! dropped by bindgen, so the descriptors are filled by hand. The
//! `SDMMC_HOST_FLAG_*` values are `BIT(n)` macros bindgen can't fold either,
//! so they're inlined with a reference to sd_protocol_types.h.
//! - The `.tmp` rename target (`notes.md.tmp`) is not a valid 8.3 name, and
//! FatFS defaults to 8.3-only. `CONFIG_FATFS_LFN_HEAP=y` (sdkconfig.defaults)
//! turns on long filenames — required here and by the real persistence path.
//!
//! Flash with `just flash-sd`. Needs no `.env` (unlike the Wi-Fi spike).
//! Flash with `just flash-sd`. Needs no `.env`.
use std::ffi::CStr;
use std::fs;
use std::io::{Read, Write};
use std::mem::MaybeUninit;
use std::ptr;
use anyhow::{bail, Context, Result};
use esp_idf_svc::hal::delay::FreeRtos;
use esp_idf_svc::sys::{self, esp};
use firmware::persistence::{Storage, MAX_FILE_BYTES, NOTES, REPO_DIR};
/// Injected by build.rs so serial output identifies the exact build.
const BUILD_TAG: &str = concat!("build ", env!("BUILD_TIME"), " @", env!("BUILD_GIT"));
// SPI2 wiring. SCK/MOSI are shared with the EPD (epd.rs: SCK 12, MOSI 11); the
// SD adds MISO 13 (EPD is write-only, never wired it) and its own CS 10.
const PIN_SCK: i32 = 12;
const PIN_MOSI: i32 = 11;
const PIN_MISO: i32 = 13;
const PIN_CS: i32 = 10;
/// The EPD's chip-select (epd.rs). It sits on this same bus; we don't drive the
/// panel here, so we pin it HIGH (deselected) instead of leaving it floating.
const EPD_CS: i32 = 7;
/// SD clock. Deliberately conservative: the EPD was validated at 4 MHz on these
/// bench jumper wires, and SDSPI's 20 MHz default is prone to CRC errors on the
/// same wiring (stub reflections off the EPD's MOSI/SCK taps) — which would look
/// like a stack failure when it's really signal integrity. 10 MHz keeps margin
/// while staying a real speed; raise toward 20 MHz once on a clean PCB.
const SD_FREQ_KHZ: i32 = 10_000;
/// Host flags from sd_protocol_types.h — `BIT(3)` / `BIT(5)`. Inlined because
/// bindgen doesn't fold the nested `BIT()` macro into a constant.
const SDMMC_HOST_FLAG_SPI: u32 = 1 << 3;
const SDMMC_HOST_FLAG_DEINIT_ARG: u32 = 1 << 5;
/// VFS mount point. `MOUNT` is the C string handed to esp-idf; `MOUNT_STR` is
/// the same path for std::fs.
const MOUNT: &CStr = c"/sd";
const MOUNT_STR: &str = "/sd";
fn main() -> Result<()> {
// Required once before any esp-idf-svc call; some runtime patches only link
// if this symbol is referenced. See esp-idf-template#71.
esp_idf_svc::sys::link_patches();
esp_idf_svc::log::EspLogger::initialize_default();
log::info!("Typoena — Spike 3 (SD/FAT on shared SPI2), {BUILD_TAG}");
// Diagnostic: surface the sdmmc/sdspi drivers' per-command DEBUG logs (the
// raw R1 response bytes) so an init failure shows *which* command the card
// rejects and with what response — not just the final propagated error.
for tag in [c"sdmmc_sd", c"sdmmc_cmd", c"sdmmc_init", c"sdspi_transaction", c"sdspi_host"] {
unsafe { sys::esp_log_level_set(tag.as_ptr(), sys::esp_log_level_t_ESP_LOG_DEBUG) };
}
log::info!("Typoena — Spike 3 (SD/FAT via firmware::persistence), {BUILD_TAG}");
match run() {
Ok(()) => {
log::info!("✅ Spike 3 complete — mount + atomic write/fsync/rename/read-back on shared bus")
}
Ok(()) => log::info!("✅ Spike 3 complete — persistence::Storage mounts and round-trips"),
Err(e) => log::error!("❌ Spike 3 failed: {e:?}"),
}
@@ -102,155 +49,59 @@ fn main() -> Result<()> {
}
fn run() -> Result<()> {
let card = mount_sd().context("mounting SD over SPI2")?;
let storage = Storage::mount().context("mounting SD via persistence module")?;
// SAFETY: `card` is a live handle returned by a successful mount.
let (max_khz, real_khz) = unsafe { ((*card).max_freq_khz, (*card).real_freq_khz) };
log::info!("card mounted at /sd — max {max_khz} kHz, negotiated {real_khz} kHz");
let (max_khz, real_khz) = storage.negotiated_khz();
log::info!("card clock — max {max_khz} kHz, negotiated {real_khz} kHz");
let (total, free) = fs_info().context("reading FAT usage")?;
let (total, free) = storage.usage().context("reading FAT usage")?;
log::info!(
"FAT usage — {} MiB total, {} MiB free",
total / (1024 * 1024),
free / (1024 * 1024)
);
file_roundtrip().context("atomic write/fsync/rename/read-back")?;
list_root(); // best-effort, informational
if storage.repo_present() {
log::info!("{REPO_DIR} present (card is provisioned)");
} else {
log::warn!("{REPO_DIR} missing — card not provisioned; run `just init` on the host");
}
// Read-back is always safe. An empty string means "no notes.md yet".
let existing = storage.load().context("loading notes.md")?;
log::info!("notes.md load OK — {} bytes", existing.len());
if existing.is_empty() {
write_test(&storage).context("save/load round-trip")?;
} else {
log::info!(
"notes.md already has content — skipping the destructive write test to protect it \
(v0.1 caps notes at {} KiB)",
MAX_FILE_BYTES / 1024
);
}
Ok(())
}
/// Init the shared SPI2 bus and mount the card. Returns the card handle (kept
/// alive for the program's lifetime; the spike never unmounts).
fn mount_sd() -> Result<*mut sys::sdmmc_card_t> {
// 0) Deselect the EPD. It shares SCK/MOSI; its CS is GPIO 7 and the panel is
// write-only (can't contend on MISO), but a floating CS while we clock the
// shared lines is a variable worth removing. Pin it HIGH.
esp!(unsafe { sys::gpio_reset_pin(EPD_CS) }).context("reset EPD CS")?;
esp!(unsafe { sys::gpio_set_direction(EPD_CS, sys::gpio_mode_t_GPIO_MODE_OUTPUT) })
.context("EPD CS as output")?;
esp!(unsafe { sys::gpio_set_level(EPD_CS, 1) }).context("EPD CS high")?;
// 1) Initialize SPI2 with the SD's lines. This is the bus the EPD also uses;
// the difference vs. epd.rs's init is the added MISO line (SD data-out).
// SAFETY: zeroed spi_bus_config_t is valid (all pins default 0); we then set
// the used pins and mark the quad lines unused (-1).
let mut bus: sys::spi_bus_config_t = unsafe { MaybeUninit::zeroed().assume_init() };
bus.__bindgen_anon_1.mosi_io_num = PIN_MOSI;
bus.__bindgen_anon_2.miso_io_num = PIN_MISO;
bus.sclk_io_num = PIN_SCK;
bus.__bindgen_anon_3.quadwp_io_num = -1;
bus.__bindgen_anon_4.quadhd_io_num = -1;
bus.max_transfer_sz = 4096;
esp!(unsafe {
sys::spi_bus_initialize(
sys::spi_host_device_t_SPI2_HOST,
&bus,
sys::spi_common_dma_t_SPI_DMA_CH_AUTO as _,
)
})
.context("spi_bus_initialize(SPI2)")?;
// 1b) Enable internal pull-ups on the SD lines. The SD spec wants ~10 kΩ
// pull-ups on the data lines; the bench jumpers have none, so MISO
// floats between response bytes and a stray bit reads back as a spurious
// R1 "illegal command" (ESP_ERR_NOT_SUPPORTED) that fails init. The
// ESP32's internal ~45 kΩ pull-ups are usually enough on short wires;
// an external 10 kΩ MISO→3V3 is the proper fix on a real board. Set
// after bus init so the SPI pin config doesn't clobber it (CS gets
// reconfigured by the mount below — harmless; MISO is the one that
// matters).
for pin in [PIN_SCK, PIN_MOSI, PIN_MISO, PIN_CS] {
esp!(unsafe { sys::gpio_set_pull_mode(pin, sys::gpio_pull_mode_t_GPIO_PULLUP_ONLY) })
.with_context(|| format!("pull-up on GPIO {pin}"))?;
/// Exercise the module's real atomic save + load, then confirm the bytes match.
/// Only called when notes.md is empty, so nothing of the user's is at risk.
fn write_test(storage: &Storage) -> Result<()> {
// The module deliberately never creates the repo dir (a missing one means an
// unprovisioned card, which the editor treats as fatal). On a blank bench
// card there's nothing to protect, so create it here as explicit bench setup
// to give `Storage::save` a directory to write into.
if !storage.repo_present() {
log::info!("{REPO_DIR} missing — creating it (bench setup) so the write test can run");
fs::create_dir_all(REPO_DIR).with_context(|| format!("create {REPO_DIR}"))?;
}
// 2) SDSPI host descriptor — hand-rolled SDSPI_HOST_DEFAULT(). The function
// pointers are esp-idf's sdspi_host_* ops; the driver calls them to drive
// the card. `slot` picks the SPI host the device attaches to.
// SAFETY: zeroed is a valid starting point (all fn-pointer Options = None);
// we fill exactly the fields the C macro sets.
let mut host: sys::sdmmc_host_t = unsafe { MaybeUninit::zeroed().assume_init() };
host.flags = SDMMC_HOST_FLAG_SPI | SDMMC_HOST_FLAG_DEINIT_ARG;
host.slot = sys::spi_host_device_t_SPI2_HOST as i32;
host.max_freq_khz = SD_FREQ_KHZ;
host.io_voltage = 3.3;
host.driver_strength = sys::sdmmc_driver_strength_t_SDMMC_DRIVER_STRENGTH_B;
host.current_limit = sys::sdmmc_current_limit_t_SDMMC_CURRENT_LIMIT_200MA;
host.init = Some(sys::sdspi_host_init);
host.set_card_clk = Some(sys::sdspi_host_set_card_clk);
host.do_transaction = Some(sys::sdspi_host_do_transaction);
host.__bindgen_anon_1.deinit_p = Some(sys::sdspi_host_remove_device);
host.io_int_enable = Some(sys::sdspi_host_io_int_enable);
host.io_int_wait = Some(sys::sdspi_host_io_int_wait);
host.get_real_freq = Some(sys::sdspi_host_get_real_freq);
host.input_delay_phase = sys::sdmmc_delay_phase_t_SDMMC_DELAY_PHASE_0;
host.check_buffer_alignment = Some(sys::sdspi_host_check_buffer_alignment);
// 3) Device (slot) config — CS 10, no card-detect / write-protect / SDIO int.
// SAFETY: zeroed is valid; we set the host, CS, and mark the rest unused.
let mut slot: sys::sdspi_device_config_t = unsafe { MaybeUninit::zeroed().assume_init() };
slot.host_id = sys::spi_host_device_t_SPI2_HOST;
slot.gpio_cs = PIN_CS;
slot.gpio_cd = -1;
slot.gpio_wp = -1;
slot.gpio_int = -1;
// 4) Mount config. format_if_mount_failed = false is load-bearing: a mount
// hiccup must never reformat (and wipe) the user's card. allocation size
// only matters when formatting, which we've disabled.
let mount = sys::esp_vfs_fat_mount_config_t {
format_if_mount_failed: false,
max_files: 4,
allocation_unit_size: 16 * 1024,
disk_status_check_enable: false,
use_one_fat: false,
};
let mut card: *mut sys::sdmmc_card_t = ptr::null_mut();
let rc = unsafe {
sys::esp_vfs_fat_sdspi_mount(MOUNT.as_ptr(), &host, &slot, &mount, &mut card)
};
// Turn the driver's opaque error into something actionable. The one we hit
// in practice is a card that rejects CMD59 (SPI-mode CRC on/off): init gets
// through CMD0/CMD8 cleanly, then the CRC-enable step returns NOT_SUPPORTED.
// That's a card-firmware limitation (common on large/counterfeit SDXC), not
// a wiring fault — and we deliberately keep CRC required rather than run the
// user's notes over an unchecked bus, so we reject the card with guidance.
if rc == sys::ESP_ERR_NOT_SUPPORTED {
bail!(
"SD card rejected CMD59 (SPI-mode CRC). CMD0/CMD8 succeeded, so wiring is \
fine — this card's firmware just doesn't support CRC in SPI mode (common on \
large/counterfeit SDXC). Use a genuine card, ideally ≤32 GB. We keep CRC \
required on purpose: a writing device shouldn't run over an unchecked bus."
);
}
esp!(rc).context("esp_vfs_fat_sdspi_mount (card present? inserted? FAT-formatted?)")?;
Ok(card)
}
/// The persistence module's atomic save (ADR-007), proven end to end: write to
/// a temp file, fsync, rename over the target, then reopen and byte-compare.
fn file_roundtrip() -> Result<()> {
let path = format!("{MOUNT_STR}/spike3.md");
let tmp = format!("{path}.tmp"); // two dots → exercises long-filename support
let payload =
format!("typoena spike 3\n{BUILD_TAG}\nshared SPI2: SCK12 MOSI11 MISO13, SD CS10\n");
{
let mut f = fs::File::create(&tmp).context("create tmp")?;
f.write_all(payload.as_bytes()).context("write tmp")?;
f.sync_all().context("fsync tmp")?; // FatFS f_sync — flush before rename
}
fs::rename(&tmp, &path).context("rename tmp -> final")?;
let mut back = String::new();
fs::File::open(&path)
.context("reopen final")?
.read_to_string(&mut back)
.context("read back")?;
// End the payload with '\n', like a real editor buffer (whose visible trailing
// blank line is exactly that terminating newline). `save` inserts a final
// newline only when one is missing and `load` reads verbatim, so a payload that
// already ends in '\n' round-trips byte-for-byte — which this exact-equality
// check relies on.
let payload = format!("typoena spike 3\n{BUILD_TAG}\ndedicated SPI3: SCK14 MOSI15 MISO13 CS10\n");
storage.save(&payload).context("Storage::save")?;
let back = storage.load().context("Storage::load after save")?;
if back != payload {
bail!(
"read-back mismatch: wrote {} bytes, read {} bytes",
@@ -259,32 +110,8 @@ fn file_roundtrip() -> Result<()> {
);
}
log::info!(
"round-trip OK — {} bytes: create {tmp} → fsync → rename {path} → read back identical",
"round-trip OK — {} bytes: save {NOTES} (tmp→fsync→unlink→rename) → load identical",
payload.len()
);
Ok(())
}
/// FAT total/free bytes for the mount.
fn fs_info() -> Result<(u64, u64)> {
let mut total: u64 = 0;
let mut free: u64 = 0;
esp!(unsafe { sys::esp_vfs_fat_info(MOUNT.as_ptr(), &mut total, &mut free) })
.context("esp_vfs_fat_info")?;
Ok((total, free))
}
/// Log the root directory (informational — shows the card's existing content
/// and confirms our file landed).
fn list_root() {
match fs::read_dir(MOUNT_STR) {
Ok(entries) => {
log::info!("/sd contents:");
for entry in entries.flatten() {
let len = entry.metadata().map(|m| m.len()).unwrap_or(0);
log::info!(" {} ({len} B)", entry.file_name().to_string_lossy());
}
}
Err(e) => log::warn!("could not list /sd: {e}"),
}
}

View File

@@ -0,0 +1,72 @@
//! Spike 9 — boot splash.
//!
//! Paints the Typoena wordmark inside a circle, centred on the 792×272 panel,
//! with one clean full refresh — the image the appliance shows at boot before
//! the editor opens (v0.1's "e-ink shows Typoena splash").
//!
//! The frame itself is [`display::Frame::splash`], a pure `embedded-graphics`
//! drawing shared with `main.rs`'s boot path (so this bench binary and the real
//! boot show the identical mark). It draws **vectors** — a stroked circle + a
//! centred `FONT_10X20` string — rather than the embedded 1-bit *bitmap* asset
//! sketched in `docs/spikes.md`: a deliberate trade. Spike 2 already proved
//! vector + font rendering, so the splash carries no new stack risk and needs
//! no asset-embed step, but it also does NOT retire the image-blit pipeline the
//! doc named as Spike 9's only risk. A raster logo is deferred to v1.0 polish.
//!
//! EPD bring-up mirrors `main.rs` (SPI2, SCK 12 · DIN/MOSI 11 · CS 7 · DC 6 ·
//! RST 5 · BUSY 4), driving the shared [`firmware::epd`] driver. Flash with
//! `just flash-splash`. Needs no `.env`.
use esp_idf_svc::hal::delay::FreeRtos;
use esp_idf_svc::hal::gpio::{AnyIOPin, PinDriver, Pull};
use esp_idf_svc::hal::peripherals::Peripherals;
use esp_idf_svc::hal::spi::config::{Config, DriverConfig};
use esp_idf_svc::hal::spi::{Dma, SpiBusDriver, SpiDriver};
use esp_idf_svc::hal::units::FromValueType;
use display::Frame;
use firmware::epd::Epd;
/// Injected by build.rs so serial output identifies the exact build.
const BUILD_TAG: &str = concat!("build ", env!("BUILD_TIME"), " @", env!("BUILD_GIT"));
fn main() -> anyhow::Result<()> {
// Required once before any esp-idf-svc call; some runtime patches only link
// if this symbol is referenced. See esp-idf-template#71.
esp_idf_svc::sys::link_patches();
esp_idf_svc::log::EspLogger::initialize_default();
log::info!("Typoena — Spike 9 (boot splash), {BUILD_TAG}");
let peripherals = Peripherals::take()?;
let pins = peripherals.pins;
// GDEY0579T93 on the same S3-safe GPIOs main.rs uses (Spike 2 wiring):
// SCK 12 · DIN/MOSI 11 · CS 7 · DC 6 · RST 5 · BUSY 4
let spi = SpiDriver::new(
peripherals.spi2,
pins.gpio12,
pins.gpio11,
None::<AnyIOPin>,
&DriverConfig::new().dma(Dma::Auto(4096)),
)?;
let bus = SpiBusDriver::new(spi, &Config::new().baudrate(4.MHz().into()))?;
let cs = PinDriver::output(pins.gpio7)?;
let dc = PinDriver::output(pins.gpio6)?;
let rst = PinDriver::output(pins.gpio5)?;
let busy = PinDriver::input(pins.gpio4, Pull::Down)?;
let mut epd = Epd::new(bus, dc, rst, cs, busy);
log::info!("EPD reset + init…");
epd.reset()?;
epd.init()?;
log::info!("painting splash…");
epd.display_frame(Frame::splash().bytes())?;
log::info!("✅ Spike 9 complete — splash on panel");
// Idle so the splash stays up and the result stays on the monitor.
loop {
FreeRtos::delay_ms(1000);
}
}

View File

@@ -28,7 +28,8 @@ use esp_idf_svc::hal::peripherals::Peripherals;
use esp_idf_svc::http::client::{Configuration as HttpConfig, EspHttpConnection};
use esp_idf_svc::nvs::EspDefaultNvsPartition;
use esp_idf_svc::sntp::{EspSntp, SyncStatus};
use esp_idf_svc::wifi::{AuthMethod, BlockingWifi, ClientConfiguration, Configuration, EspWifi};
use esp_idf_svc::wifi::{BlockingWifi, EspWifi};
use firmware::net::connect_wifi;
/// Injected by build.rs so serial output identifies the exact build.
const BUILD_TAG: &str = concat!("build ", env!("BUILD_TIME"), " @", env!("BUILD_GIT"));
@@ -81,7 +82,7 @@ fn run() -> Result<()> {
sys_loop,
)?;
connect_wifi(&mut wifi)?;
connect_wifi(&mut wifi, WIFI_SSID, WIFI_PASS)?;
let ip = wifi.wifi().sta_netif().get_ip_info()?;
log::info!("Wi-Fi up — IP {}, GW {}", ip.ip, ip.subnet.gateway);
@@ -91,35 +92,6 @@ fn run() -> Result<()> {
Ok(())
}
/// Associate with the configured AP and wait for the netif (DHCP) to come up.
fn connect_wifi(wifi: &mut BlockingWifi<EspWifi<'static>>) -> Result<()> {
// Open network → no auth; otherwise WPA2-Personal (see .env.example for WPA3).
let auth_method = if WIFI_PASS.is_empty() {
AuthMethod::None
} else {
AuthMethod::WPA2Personal
};
wifi.set_configuration(&Configuration::Client(ClientConfiguration {
ssid: WIFI_SSID
.try_into()
.ok()
.context("SSID longer than 32 bytes")?,
password: WIFI_PASS
.try_into()
.ok()
.context("password longer than 64 bytes")?,
auth_method,
..Default::default()
}))?;
wifi.start()?;
log::info!("associating with \"{WIFI_SSID}\"");
wifi.connect().context("Wi-Fi association failed")?;
wifi.wait_netif_up().context("DHCP / netif never came up")?;
Ok(())
}
/// Kick off SNTP and block until the first sync (or time out). Required before
/// TLS: cert validity is checked against wall time.
fn sync_clock() -> Result<()> {

View File

@@ -1,781 +0,0 @@
//! Modal text editor core: a vim-style buffer with Normal / Insert (edit) /
//! View (read-only) modes, rendered onto the e-paper [`Frame`].
//!
//! The buffer is plain ASCII — the US-QWERTY decoder only ever produces ASCII
//! and Tab expands to spaces on insert — so a byte offset into the `String` is
//! also a character index, and `caret` is that offset. Motions and edits work
//! on the logical (`\n`-delimited) buffer; word-wrapping and scrolling are a
//! render-time concern handled by [`Editor::draw`].
// ISO-8859-15 (Latin-9) rather than the ascii subset: same glyph cells, but it
// carries the accented Latin glyphs (à é ê ç … plus œ €) that international
// input will emit. ASCII rendering is byte-for-byte unchanged.
use embedded_graphics::mono_font::iso_8859_15::{FONT_6X10, FONT_10X20};
use embedded_graphics::mono_font::MonoTextStyle;
use embedded_graphics::pixelcolor::BinaryColor;
use embedded_graphics::prelude::*;
use embedded_graphics::primitives::{PrimitiveStyle, Rectangle};
use embedded_graphics::text::{Baseline, Text};
use crate::epd::{self, Frame};
use crate::usb_kbd::Key;
/// FONT_10X20 cell size and the grid it tiles the panel into.
pub const CW: i32 = 10;
pub const CH: i32 = 20;
const COLS: usize = (epd::WIDTH / 10) as usize; // 79 characters per line
const ROWS: usize = (epd::HEIGHT / 20) as usize; // 13 text rows; bottom 12 px = status
/// Tab stop, in spaces. Tabs never enter the buffer — they expand on insert so
/// the buffer stays 1 char = 1 column.
const TAB: &str = " ";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
/// Navigation and commands (hjkl, w/b/e, dd, x, …).
Normal,
/// Text entry — keys insert at the caret.
Insert,
/// Read-only reading: keys scroll the viewport, edits are locked out.
View,
}
/// A pending operator awaiting a motion or text object (`d`elete / `c`hange).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Op {
Delete,
Change,
}
/// The editor state: buffer, caret, mode, viewport, and pending command state.
pub struct Editor {
text: String,
/// Byte offset of the caret (== char index; the buffer is ASCII). Ranges
/// over `0..=text.len()`.
caret: usize,
mode: Mode,
/// Index of the first visible display line.
scroll_top: usize,
/// Pending numeric count prefix (`0` = none), e.g. the `3` in `3j`.
count: usize,
/// Operator awaiting a motion/text object (`dd`, `dw`, `ciw`, `di(`, …).
pending_op: Option<Op>,
/// After an operator, an `i`/`a` text-object prefix awaiting the object
/// char. `Some(false)` = inner (`i`), `Some(true)` = around (`a`).
pending_obj: Option<bool>,
/// First `g` of a `gg` awaiting the second.
pending_g: bool,
}
/// One wrapped display line: its text and the buffer offset of its first char.
struct Line {
start: usize,
text: String,
}
impl Editor {
pub fn new() -> Self {
Editor {
text: String::new(),
caret: 0,
mode: Mode::Insert, // writing appliance: power-on = ready to type
scroll_top: 0,
count: 0,
pending_op: None,
pending_obj: None,
pending_g: false,
}
}
pub fn mode(&self) -> Mode {
self.mode
}
pub fn scroll_top(&self) -> usize {
self.scroll_top
}
/// Dispatch one decoded key event according to the current mode.
pub fn handle(&mut self, key: Key) {
match self.mode {
Mode::Insert => self.insert_key(key),
Mode::Normal => self.normal_key(key),
Mode::View => self.view_key(key),
}
}
// --- Insert mode -------------------------------------------------------
fn insert_key(&mut self, key: Key) {
match key {
Key::Char('\t') => self.insert_str(TAB),
Key::Char(c) => self.insert_char(c),
Key::Enter => self.insert_char('\n'),
Key::Backspace => self.backspace(),
Key::DeleteWord => self.delete_word_before(),
Key::DeleteLine => self.delete_to_line_start(),
Key::Escape => {
self.mode = Mode::Normal;
// vim drops the caret onto the last inserted char.
if self.caret > self.line_start(self.caret) {
self.caret -= 1;
}
}
}
}
// --- Normal mode -------------------------------------------------------
fn normal_key(&mut self, key: Key) {
let c = match key {
Key::Char(c) => c,
// Esc and non-character events cancel any pending command.
_ => {
self.reset_pending();
return;
}
};
// Operator pending (d/c): expect a text object, motion, or doubled op.
if let Some(op) = self.pending_op {
// After an i/a prefix, `c` is the text-object selector.
if let Some(around) = self.pending_obj {
self.pending_obj = None;
self.pending_op = None;
if let Some((s, e)) = self.text_object(c, around) {
self.apply_op(op, s, e);
}
self.count = 0;
return;
}
// A count between the operator and its motion (e.g. `d2w`).
if c.is_ascii_digit() && !(c == '0' && self.count == 0) {
self.count = self.count.saturating_mul(10) + (c as usize - '0' as usize);
return;
}
let n = self.count.max(1);
match c {
'i' => {
self.pending_obj = Some(false);
self.count = 0;
return;
}
'a' => {
self.pending_obj = Some(true);
self.count = 0;
return;
}
'd' if op == Op::Delete => (0..n).for_each(|_| self.delete_current_line()),
'c' if op == Op::Change => self.change_current_line(),
'w' => {
let mut t = self.caret;
(0..n).for_each(|_| t = self.word_forward_pos(t));
self.apply_op(op, self.caret, t);
}
'b' => {
let mut t = self.caret;
(0..n).for_each(|_| t = self.word_back_pos(t));
self.apply_op(op, self.caret, t);
}
'e' => {
let mut t = self.caret;
(0..n).for_each(|_| t = self.word_end_pos(t));
self.apply_op(op, self.caret, t + 1);
}
'0' => self.apply_op(op, self.line_start(self.caret), self.caret),
'$' => self.apply_op(op, self.caret, self.line_end(self.caret)),
_ => {}
}
self.pending_op = None;
self.count = 0;
return;
}
if self.pending_g {
self.pending_g = false;
if c == 'g' {
self.caret = 0;
}
self.count = 0;
return;
}
// Count prefix: a leading `0` is the line-start motion, not a digit.
if c.is_ascii_digit() && !(c == '0' && self.count == 0) {
self.count = self.count.saturating_mul(10) + (c as usize - '0' as usize);
return;
}
let n = self.count.max(1);
match c {
'h' => (0..n).for_each(|_| self.move_left()),
'l' => (0..n).for_each(|_| self.move_right()),
'j' => (0..n).for_each(|_| self.move_down()),
'k' => (0..n).for_each(|_| self.move_up()),
'w' => (0..n).for_each(|_| self.caret = self.word_forward_pos(self.caret)),
'b' => (0..n).for_each(|_| self.caret = self.word_back_pos(self.caret)),
'e' => (0..n).for_each(|_| self.caret = self.word_end_pos(self.caret)),
'0' => self.caret = self.line_start(self.caret),
'$' => self.caret = self.line_end(self.caret),
'G' => self.caret = self.line_start(self.text.len()),
'g' => {
self.pending_g = true;
return;
}
'x' => (0..n).for_each(|_| self.delete_at_caret()),
'd' => {
self.pending_op = Some(Op::Delete);
return;
}
'c' => {
self.pending_op = Some(Op::Change);
return;
}
'i' => self.mode = Mode::Insert,
'a' => {
self.move_right_append();
self.mode = Mode::Insert;
}
'A' => {
self.caret = self.line_end(self.caret);
self.mode = Mode::Insert;
}
'I' => {
self.caret = self.line_start(self.caret);
self.mode = Mode::Insert;
}
'o' => {
self.caret = self.line_end(self.caret);
self.insert_char('\n');
self.mode = Mode::Insert;
}
'O' => {
let p = self.line_start(self.caret);
self.text.insert(p, '\n');
self.caret = p;
self.mode = Mode::Insert;
}
'v' | 'V' => self.mode = Mode::View,
_ => {}
}
self.count = 0;
}
fn reset_pending(&mut self) {
self.count = 0;
self.pending_op = None;
self.pending_obj = None;
self.pending_g = false;
}
// --- View mode ---------------------------------------------------------
fn view_key(&mut self, key: Key) {
match key {
Key::Char('j') => self.scroll_top += 1, // clamped in draw()
Key::Char('k') => self.scroll_top = self.scroll_top.saturating_sub(1),
Key::Char(' ') => self.scroll_top += ROWS,
Key::Char('G') => {
let total = self.layout().len();
self.scroll_top = total.saturating_sub(ROWS);
}
Key::Char('g') => {
if self.pending_g {
self.scroll_top = 0;
self.pending_g = false;
} else {
self.pending_g = true;
}
}
Key::Escape => {
self.mode = Mode::Normal;
self.pending_g = false;
}
_ => {}
}
}
// --- Motions (all on the logical buffer) -------------------------------
/// Offset of the start of the line containing `pos`.
fn line_start(&self, pos: usize) -> usize {
let b = self.text.as_bytes();
let mut i = pos;
while i > 0 && b[i - 1] != b'\n' {
i -= 1;
}
i
}
/// Offset of the end of the line containing `pos` (the `\n`, or buffer end).
fn line_end(&self, pos: usize) -> usize {
let b = self.text.as_bytes();
let mut i = pos;
while i < b.len() && b[i] != b'\n' {
i += 1;
}
i
}
fn move_left(&mut self) {
if self.caret > self.line_start(self.caret) {
self.caret -= 1;
}
}
fn move_right(&mut self) {
if self.caret < self.line_end(self.caret) {
self.caret += 1;
}
}
/// Like `l` but allowed to land one past the last char (for `a`).
fn move_right_append(&mut self) {
if self.caret < self.line_end(self.caret) {
self.caret += 1;
}
}
fn move_down(&mut self) {
let col = self.caret - self.line_start(self.caret);
let le = self.line_end(self.caret);
if le >= self.text.len() {
return; // already on the last line
}
let next_start = le + 1;
let next_end = self.line_end(next_start);
self.caret = (next_start + col).min(next_end);
}
fn move_up(&mut self) {
let ls = self.line_start(self.caret);
if ls == 0 {
return; // already on the first line
}
let col = self.caret - ls;
let prev_start = self.line_start(ls - 1);
let prev_end = ls - 1; // the '\n' that ends the previous line
self.caret = (prev_start + col).min(prev_end);
}
/// Start of the next whitespace-delimited word after `from`.
fn word_forward_pos(&self, from: usize) -> usize {
let b = self.text.as_bytes();
let n = b.len();
let mut i = from;
while i < n && !b[i].is_ascii_whitespace() {
i += 1;
}
while i < n && b[i].is_ascii_whitespace() {
i += 1;
}
i
}
/// Start of the word at or before `from`.
fn word_back_pos(&self, from: usize) -> usize {
let b = self.text.as_bytes();
let mut i = from;
while i > 0 && b[i - 1].is_ascii_whitespace() {
i -= 1;
}
while i > 0 && !b[i - 1].is_ascii_whitespace() {
i -= 1;
}
i
}
/// End of the current/next word (lands on its last char).
fn word_end_pos(&self, from: usize) -> usize {
let b = self.text.as_bytes();
let n = b.len();
let mut i = from + 1;
if i >= n {
return from;
}
while i < n && b[i].is_ascii_whitespace() {
i += 1;
}
while i < n && !b[i].is_ascii_whitespace() {
i += 1;
}
i.saturating_sub(1)
}
// --- Edits -------------------------------------------------------------
fn insert_char(&mut self, c: char) {
self.text.insert(self.caret, c);
self.caret += c.len_utf8();
}
fn insert_str(&mut self, s: &str) {
self.text.insert_str(self.caret, s);
self.caret += s.len();
}
fn backspace(&mut self) {
if self.caret > 0 {
self.caret -= 1;
self.text.remove(self.caret);
}
}
/// `x` — delete the char under the caret (never a newline).
fn delete_at_caret(&mut self) {
let b = self.text.as_bytes();
if self.caret < b.len() && b[self.caret] != b'\n' {
self.text.remove(self.caret);
// Keep the caret on a char: if it fell off the line end, step back.
if self.caret >= self.line_end(self.caret) && self.caret > self.line_start(self.caret) {
self.caret -= 1;
}
}
}
/// `dd` — delete the current logical line, including its newline (or the
/// preceding one for the last line, so no blank line is left behind).
fn delete_current_line(&mut self) {
let ls = self.line_start(self.caret);
let le = self.line_end(self.caret);
let (start, end) = if le < self.text.len() {
(ls, le + 1) // eat the trailing newline
} else if ls > 0 {
(ls - 1, le) // last line: eat the preceding newline instead
} else {
(ls, le) // whole buffer
};
self.text.replace_range(start..end, "");
self.caret = self.line_start(start.min(self.text.len()));
}
/// `cc` — clear the current line's text and drop into insert.
fn change_current_line(&mut self) {
let ls = self.line_start(self.caret);
let le = self.line_end(self.caret);
self.text.replace_range(ls..le, "");
self.caret = ls;
self.mode = Mode::Insert;
}
/// Apply a pending operator over the buffer range `[start, end)` (order
/// independent). Delete removes it; Change removes it and enters insert.
fn apply_op(&mut self, op: Op, start: usize, end: usize) {
let s = start.min(end);
let e = start.max(end).min(self.text.len());
self.text.replace_range(s..e, "");
self.caret = s.min(self.text.len());
if op == Op::Change {
self.mode = Mode::Insert;
}
}
/// Resolve a text object to a buffer range. `around` selects `a` (include
/// delimiters / trailing space) vs `i` (inner). Returns `None` if there's
/// no matching object under the caret.
fn text_object(&self, obj: char, around: bool) -> Option<(usize, usize)> {
match obj {
'w' => Some(self.word_object(around)),
'(' | ')' | 'b' => self.pair_object(b'(', b')', around),
'{' | '}' | 'B' => self.pair_object(b'{', b'}', around),
'[' | ']' => self.pair_object(b'[', b']', around),
'<' | '>' => self.pair_object(b'<', b'>', around),
'"' => self.quote_object(b'"', around),
'\'' => self.quote_object(b'\'', around),
'`' => self.quote_object(b'`', around),
_ => None,
}
}
/// `iw`/`aw`: the run of same-class chars (word vs space, never crossing a
/// newline) under the caret. `aw` also takes the trailing run of spaces, or
/// the leading one if there is no trailing space. Word class is
/// whitespace-delimited (so this behaves like vim's `iW`/`aW`).
fn word_object(&self, around: bool) -> (usize, usize) {
let b = self.text.as_bytes();
let n = b.len();
if n == 0 {
return (0, 0);
}
let pos = self.caret.min(n - 1);
let ws = |c: u8| c == b' ' || c == b'\t';
let target_ws = ws(b[pos]);
let same = |c: u8| ws(c) == target_ws && c != b'\n';
let mut s = pos;
while s > 0 && same(b[s - 1]) {
s -= 1;
}
let mut e = pos + 1;
while e < n && same(b[e]) {
e += 1;
}
if around && !target_ws {
let mut a = e;
while a < n && ws(b[a]) {
a += 1;
}
if a > e {
return (s, a);
}
let mut ls = s;
while ls > 0 && ws(b[ls - 1]) {
ls -= 1;
}
return (ls, e);
}
(s, e)
}
/// `i(`/`a(` and friends: the range between the bracket pair enclosing the
/// caret, nesting-aware. `around` includes the brackets themselves.
fn pair_object(&self, open: u8, close: u8, around: bool) -> Option<(usize, usize)> {
let b = self.text.as_bytes();
let n = b.len();
if n == 0 {
return None;
}
let start = self.caret.min(n - 1);
// Scan left for the enclosing open bracket.
let mut depth = 0i32;
let mut i = start;
let open_idx = loop {
let ch = b[i];
if ch == close && i != start {
depth += 1;
} else if ch == open {
if depth == 0 {
break Some(i);
}
depth -= 1;
}
if i == 0 {
break None;
}
i -= 1;
}?;
// Scan right for its matching close.
let mut depth = 0i32;
let mut j = open_idx + 1;
let close_idx = loop {
if j >= n {
break None;
}
let ch = b[j];
if ch == open {
depth += 1;
} else if ch == close {
if depth == 0 {
break Some(j);
}
depth -= 1;
}
j += 1;
}?;
Some(if around {
(open_idx, close_idx + 1)
} else {
(open_idx + 1, close_idx)
})
}
/// `i"`/`a"` and friends: the range between a matching quote pair on the
/// current line. `around` includes the quotes.
fn quote_object(&self, q: u8, around: bool) -> Option<(usize, usize)> {
let b = self.text.as_bytes();
let ls = self.line_start(self.caret);
let le = self.line_end(self.caret);
let quotes: Vec<usize> = (ls..le).filter(|&i| b[i] == q).collect();
// Pair them left-to-right; take the first pair closing at/after the caret.
let mut k = 0;
while k + 1 < quotes.len() {
let (a, z) = (quotes[k], quotes[k + 1]);
if self.caret <= z {
return Some(if around { (a, z + 1) } else { (a + 1, z) });
}
k += 2;
}
None
}
/// Insert-mode Ctrl+W / Ctrl+Backspace: delete the word before the caret.
fn delete_word_before(&mut self) {
let b = self.text.as_bytes();
let mut i = self.caret;
while i > 0 && (b[i - 1] == b' ' || b[i - 1] == b'\t') {
i -= 1;
}
while i > 0 && !b[i - 1].is_ascii_whitespace() {
i -= 1;
}
self.text.replace_range(i..self.caret, "");
self.caret = i;
}
/// Insert-mode Cmd+Backspace: delete back to the start of the line, or the
/// preceding newline if already there.
fn delete_to_line_start(&mut self) {
let ls = self.line_start(self.caret);
if ls == self.caret {
if self.caret > 0 {
self.caret -= 1;
self.text.remove(self.caret);
}
} else {
self.text.replace_range(ls..self.caret, "");
self.caret = ls;
}
}
// --- Rendering ---------------------------------------------------------
/// Wrap the buffer into display lines, tracking each line's buffer offset.
fn layout(&self) -> Vec<Line> {
let mut lines = vec![Line {
start: 0,
text: String::new(),
}];
let mut idx = 0usize;
for ch in self.text.chars() {
if ch == '\n' {
idx += 1;
lines.push(Line {
start: idx,
text: String::new(),
});
continue;
}
if lines.last().unwrap().text.chars().count() >= COLS {
lines.push(Line {
start: idx,
text: String::new(),
});
}
lines.last_mut().unwrap().text.push(ch);
idx += 1;
}
lines
}
/// Display (row, col) of the caret within `lay`.
fn caret_rc(&self, lay: &[Line]) -> (usize, usize) {
let mut row = 0;
for (i, l) in lay.iter().enumerate() {
if l.start <= self.caret {
row = i;
} else {
break;
}
}
(row, self.caret - lay[row].start)
}
/// Move the viewport so the caret stays visible (Normal/Insert), or just
/// clamp it to the content (View).
fn adjust_scroll(&mut self, caret_row: usize, total: usize) {
match self.mode {
Mode::View => {
let max = total.saturating_sub(ROWS);
if self.scroll_top > max {
self.scroll_top = max;
}
}
_ => {
if caret_row < self.scroll_top {
self.scroll_top = caret_row;
} else if caret_row >= self.scroll_top + ROWS {
self.scroll_top = caret_row + 1 - ROWS;
}
}
}
}
/// Render the current state into a frame. `insert_cursor_on` gates the
/// Insert-mode bar caret (suppressed while typing, shown after a pause);
/// Normal draws a block caret and View draws none, regardless.
pub fn draw(&mut self, insert_cursor_on: bool) -> Frame {
let lay = self.layout();
let (crow, ccol) = self.caret_rc(&lay);
self.adjust_scroll(crow, lay.len());
let mut f = Frame::new_white();
let text_style = MonoTextStyle::new(&FONT_10X20, BinaryColor::On);
let end = (self.scroll_top + ROWS).min(lay.len());
for (vis, li) in (self.scroll_top..end).enumerate() {
Text::with_baseline(
&lay[li].text,
Point::new(0, vis as i32 * CH),
text_style,
Baseline::Top,
)
.draw(&mut f)
.unwrap();
}
if crow >= self.scroll_top && crow < self.scroll_top + ROWS {
let x = ccol.min(COLS - 1) as i32 * CW;
let y = (crow - self.scroll_top) as i32 * CH;
match self.mode {
Mode::Normal => {
// Block caret: fill the cell, redraw the glyph in white.
Rectangle::new(Point::new(x, y), Size::new(CW as u32, CH as u32))
.into_styled(PrimitiveStyle::with_fill(BinaryColor::On))
.draw(&mut f)
.unwrap();
if let Some(ch) = lay[crow].text.chars().nth(ccol) {
let mut buf = [0u8; 4];
let inv = MonoTextStyle::new(&FONT_10X20, BinaryColor::Off);
Text::with_baseline(
ch.encode_utf8(&mut buf),
Point::new(x, y),
inv,
Baseline::Top,
)
.draw(&mut f)
.unwrap();
}
}
Mode::Insert if insert_cursor_on => {
// Bar caret at the left edge of the cell.
Rectangle::new(Point::new(x, y), Size::new(2, CH as u32))
.into_styled(PrimitiveStyle::with_fill(BinaryColor::On))
.draw(&mut f)
.unwrap();
}
_ => {}
}
}
self.draw_status(&mut f);
f
}
/// Draw the mode indicator (and any pending count/operator) in the bottom
/// strip, in the small 6×10 font so it fits below the 13 text rows.
fn draw_status(&self, f: &mut Frame) {
let name = match self.mode {
Mode::Normal => "NORMAL",
Mode::Insert => "INSERT",
Mode::View => "VIEW",
};
let mut s = format!(" -- {name} --");
if self.count > 0 {
s.push_str(&format!(" {}", self.count));
}
match self.pending_op {
Some(Op::Delete) => s.push('d'),
Some(Op::Change) => s.push('c'),
None => {}
}
match self.pending_obj {
Some(false) => s.push('i'),
Some(true) => s.push('a'),
None => {}
}
if self.pending_g {
s.push('g');
}
let style = MonoTextStyle::new(&FONT_6X10, BinaryColor::On);
Text::with_baseline(&s, Point::new(2, ROWS as i32 * CH + 1), style, Baseline::Top)
.draw(f)
.unwrap();
}
}

View File

@@ -12,75 +12,22 @@
//! `embedded-graphics` `DrawTarget` (`Frame`), full refresh (`display_frame`),
//! and partial refresh (`display_frame_partial`) — Spikes 2 and 5.
use embedded_graphics::pixelcolor::BinaryColor;
use embedded_graphics::prelude::*;
use esp_idf_svc::hal::delay::FreeRtos;
use esp_idf_svc::hal::gpio::{Input, Output, PinDriver};
use esp_idf_svc::hal::spi::{SpiBusDriver, SpiDriver};
use esp_idf_svc::sys::EspError;
pub const WIDTH: u16 = 792;
pub const HEIGHT: u16 = 272;
// Panel geometry and the drawable `Frame` now live in the `display` crate, so
// the editor can render onto them off the xtensa target. Re-exported here so
// `epd::HEIGHT`, `epd::FB_BYTES`, etc. keep resolving for main.rs and the driver
// code below, and so the driver need not know they were relocated.
pub use display::{FB_BYTES, FB_BYTES_W, HEIGHT, WIDTH};
/// Each controller drives one half. SSD1683 X is byte-addressed; 396 px
/// rounds up to 50 bytes (400 px) of RAM width, full panel height (272 rows).
const CTRL_BYTES_W: usize = 50;
const CTRL_BYTES: usize = CTRL_BYTES_W * HEIGHT as usize; // 50 * 272 = 13600
/// Full-frame 1-bit framebuffer: 792 px = 99 bytes per row, MSB-first,
/// 1 = white, 0 = black (SSD16xx convention).
pub const FB_BYTES_W: usize = (WIDTH / 8) as usize; // 99
pub const FB_BYTES: usize = FB_BYTES_W * HEIGHT as usize; // 26928
/// In-memory 792×272 1-bit frame, drawable via `embedded-graphics`.
/// `BinaryColor::On` = black ink, `Off` = white paper.
pub struct Frame {
buf: Vec<u8>,
}
impl Frame {
pub fn new_white() -> Self {
Self { buf: vec![0xFF; FB_BYTES] }
}
#[allow(dead_code)] // symmetric with new_white; kept as part of the API
pub fn new_black() -> Self {
Self { buf: vec![0x00; FB_BYTES] }
}
pub fn bytes(&self) -> &[u8] {
&self.buf
}
}
impl OriginDimensions for Frame {
fn size(&self) -> Size {
Size::new(WIDTH as u32, HEIGHT as u32)
}
}
impl DrawTarget for Frame {
type Color = BinaryColor;
type Error = core::convert::Infallible;
fn draw_iter<I>(&mut self, pixels: I) -> Result<(), Self::Error>
where
I: IntoIterator<Item = Pixel<Self::Color>>,
{
for Pixel(p, color) in pixels {
if (0..WIDTH as i32).contains(&p.x) && (0..HEIGHT as i32).contains(&p.y) {
let idx = p.y as usize * FB_BYTES_W + p.x as usize / 8;
let bit = 0x80u8 >> (p.x % 8);
match color {
BinaryColor::On => self.buf[idx] &= !bit, // black ink
BinaryColor::Off => self.buf[idx] |= bit, // white paper
}
}
}
Ok(())
}
}
/// Max bytes per SPI transfer; matches the DMA size configured in `main`.
const SPI_CHUNK: usize = 4096;

652
firmware/src/git_sync.rs Normal file
View File

@@ -0,0 +1,652 @@
//! On-device git publish — the transport behind the editor's `:sync`.
//!
//! Graduated from the `src/bin/git_sync.rs` spike (milestone #2A, hardware-
//! verified 2026-07-07). The spike proved `open` + fast-forward `push` over
//! mbedTLS HTTPS+PAT against a persistent clone; this module lifts that logic
//! into a service the editor drives, with three changes for the product:
//!
//! 1. **Storage is the SD card `/sd/repo`** (the same working copy the editor
//! saves `notes.md` into via [`crate::persistence`]), not the spike's 4 MB
//! flash-FAT `/spiflash/repo`. The real notes repo can't fit in flash, so the
//! card is the only viable home — and there's a single source of truth: git
//! commits the exact file the editor just wrote. The git thread reaches the
//! card through plain `std::fs`; FatFS's per-volume reentrancy lock serialises
//! it against the UI task's saves (see [`crate::persistence::Storage`]).
//! 2. **`open` only — never clone-and-wipe.** The spike re-cloned into a
//! throwaway flash dir; doing that to the user's card would delete their
//! notes. A `/sd/repo` that isn't a valid repo is a provisioning error
//! (`just init`), surfaced as such, not papered over.
//! 3. **No synthetic content.** The spike appended a marker line; here the
//! editor has already saved the user's buffers before `:sync` signals us,
//! so we just commit + push what's on disk.
//! 4. **The commit is an O(depth) TreeBuilder splice, not an index pass.**
//! The request carries the repo-relative paths saved/deleted since the last
//! confirmed publish (`Storage`'s journaled dirty set); `stage_and_commit`
//! patches exactly those onto HEAD's tree. The index pipeline it replaced
//! (`add_all` → `index.write` → `write_tree`) is O(N_tree) and measured up
//! to 611 s on the real 1179-file / 570 MB-pack clone — see
//! docs/tradeoff-curves/sync-commit-staging.md for the whole trail.
//!
//! Runs on a dedicated 96 KB thread (libgit2's init→push chain nests ~67 KB of
//! `GIT_PATH_MAX` stack buffers — see git_push.rs / postmortem #3). Config is
//! baked at build time (`TW_*`, ADR-007: v0.1 device config is compiled in).
use std::cell::RefCell;
use std::collections::BTreeSet;
use std::fs;
use std::rc::Rc;
use std::sync::mpsc::{Receiver, Sender};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use anyhow::{bail, Context, Result};
use esp_idf_svc::eventloop::EspSystemEventLoop;
use esp_idf_svc::hal::delay::FreeRtos;
use esp_idf_svc::hal::modem::Modem;
use esp_idf_svc::nvs::EspDefaultNvsPartition;
use esp_idf_svc::sntp::{EspSntp, SyncStatus};
use esp_idf_svc::sys;
use esp_idf_svc::wifi::{BlockingWifi, EspWifi};
use git2::{
CertificateCheckStatus, Commit, Cred, CredentialType, FetchOptions, ObjectType, Oid,
PushOptions, RemoteCallbacks, Repository, Signature, Tree,
};
use crate::net::connect_wifi;
use crate::persistence::REPO_DIR;
// Baked in at build time from firmware/.env (see build.rs). Empty when unset;
// checked at runtime before the first publish so a misconfigured build fails
// with a clear message rather than a cryptic git error.
const WIFI_SSID: &str = env!("TW_WIFI_SSID");
const WIFI_PASS: &str = env!("TW_WIFI_PASS");
const REMOTE_URL: &str = env!("TW_REMOTE_URL");
const GH_USER: &str = env!("TW_GH_USER");
const PAT: &str = env!("TW_PAT");
const AUTHOR_NAME: &str = env!("TW_AUTHOR_NAME");
const AUTHOR_EMAIL: &str = env!("TW_AUTHOR_EMAIL");
/// GitHub's root CAs, embedded so the push can verify the server's TLS chain.
/// Shared with the spikes. Written to the card and handed to libgit2 via
/// `GIT_OPT_SET_SSL_CERT_LOCATIONS`.
const GITHUB_ROOTS_PEM: &str = include_str!("bin/github_roots.pem");
/// CA bundle on the card root — outside `/sd/repo`, so it's never staged.
const CA_BUNDLE_PATH: &str = "/sd/ca.pem";
/// SNTP first-sync budget (same as Spike 6): required before TLS (cert validity)
/// and before committing (signature timestamp).
const SNTP_TIMEOUT: Duration = Duration::from_secs(20);
/// Stack for the dedicated git thread. The init→push chain measured ~67 KB;
/// keep the proven 96 KB (see git_push.rs / postmortem #3). Wi-Fi association
/// now also runs here, but it's shallow next to libgit2's path-buffer nesting.
pub const GIT_STACK: usize = 96 * 1024;
/// A request to publish. The UI task has already saved every dirty buffer to
/// the card before sending this; `paths` is `Storage::take_dirty`'s snapshot —
/// the repo-relative paths saved or `:delete`d since the last confirmed
/// publish. The working tree stays the source of truth: at commit time a path
/// that exists on the card is spliced into the tree from disk, a missing one
/// is spliced out. An unchanged path is a no-op, so over-reporting is safe.
pub struct PublishRequest {
pub paths: BTreeSet<String>,
}
/// Result of a publish attempt, sent back to the UI task for the snackbar. The
/// detailed error always goes to the serial log; the panel gets a short line.
pub enum PublishOutcome {
/// Committed and pushed. Carries the short commit id for the panel.
Pushed(String),
/// The working tree matched HEAD — nothing new to push.
UpToDate,
/// Something failed; the string is a short reason for the panel (full error
/// is logged).
Failed(String),
}
/// The git service loop, run on the dedicated git thread. Owns the Wi-Fi stack,
/// bringing it up lazily on the first request and keeping it up afterwards.
/// Blocks on `rx`; for each request it ensures connectivity + clock + trust
/// store, runs one publish cycle, and reports the outcome on `tx`. Returns when
/// the request channel closes (UI task gone). Errors are reported, never
/// panicked — a failed push must not take the thread (and its Wi-Fi) down.
pub fn run_git_service(
modem: Modem<'static>,
sys_loop: EspSystemEventLoop,
nvs: EspDefaultNvsPartition,
rx: Receiver<PublishRequest>,
tx: Sender<PublishOutcome>,
) {
// Process-global libgit2 tuning, once, before any repo work. The 32-bit
// defaults (32 MB window / 256 MB mapped budget, mwindow.c) would
// git__malloc past PSRAM on the first pack access of the real 570 MB-pack
// clone; these are the bench-proven values (git_bench), and ~1.9 MB of
// windows stays live during git ops — the p_mmap emulation (esp_map.c)
// relies on this mapped limit being real.
// SAFETY: set on the git thread before any Repository is opened.
unsafe {
if let Err(e) = git2::opts::set_mwindow_size(256 * 1024) {
log::error!("set_mwindow_size failed ({e}); first pack access may OOM");
}
if let Err(e) = git2::opts::set_mwindow_mapped_limit(4 * 1024 * 1024) {
log::error!("set_mwindow_mapped_limit failed ({e}); first pack access may OOM");
}
}
// Lazily initialised on the first request, then reused across publishes.
let mut wifi: Option<BlockingWifi<EspWifi<'static>>> = None;
let mut modem = Some(modem);
let mut nvs = Some(nvs);
let mut clock_synced = false;
let mut tls_ready = false;
while let Ok(req) = rx.recv() {
let outcome = publish_cycle(
&sys_loop,
&mut wifi,
&mut modem,
&mut nvs,
&mut clock_synced,
&mut tls_ready,
&req.paths,
);
let msg = match outcome {
Ok(o) => o,
Err(e) => {
log::error!("❌ :sync failed: {e:?}");
PublishOutcome::Failed(short_reason(&e))
}
};
// If the UI task has gone away there's nothing to report to; exit.
if tx.send(msg).is_err() {
break;
}
}
log::info!("git service: request channel closed — exiting");
}
/// One full publish: ensure Wi-Fi + clock + trust store (each done once), then
/// open the repo, stage, commit, and fast-forward push.
fn publish_cycle(
sys_loop: &EspSystemEventLoop,
wifi: &mut Option<BlockingWifi<EspWifi<'static>>>,
modem: &mut Option<Modem<'static>>,
nvs: &mut Option<EspDefaultNvsPartition>,
clock_synced: &mut bool,
tls_ready: &mut bool,
paths: &BTreeSet<String>,
) -> Result<PublishOutcome> {
if REMOTE_URL.is_empty() || GH_USER.is_empty() || PAT.is_empty() || WIFI_SSID.is_empty() {
bail!("git config missing — set TW_WIFI_SSID / TW_REMOTE_URL / TW_GH_USER / TW_PAT in firmware/.env and rebuild");
}
// Nothing recorded dirty and origin's tracking ref already has HEAD: this
// `:sync` has nothing to do — say so without touching the radio (~150 ms
// instead of a Wi-Fi + TLS round). A stranded local commit (committed but
// never pushed, e.g. a push that failed mid-air) makes the check false and
// takes the full path below, where publish_once pushes it.
if paths.is_empty() && remote_current().unwrap_or(false) {
log::info!(":sync — no dirty paths and origin has HEAD; up to date, radio untouched");
return Ok(PublishOutcome::UpToDate);
}
// 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");
let mut w = BlockingWifi::wrap(
EspWifi::new(m, sys_loop.clone(), Some(n))?,
sys_loop.clone(),
)?;
connect_wifi(&mut w, WIFI_SSID, WIFI_PASS).context("connecting Wi-Fi")?;
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();
}
let t_publish = Instant::now();
let outcome = publish_once(paths)?;
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`, 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(paths: &BTreeSet<String>) -> Result<PublishOutcome> {
log::info!(
"publish started — {} dirty path(s), free heap {} ({} internal)",
paths.len(),
free_heap(),
internal_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")
})?;
let branch = repo
.head()?
.shorthand()
.context("HEAD has no branch shorthand")?
.to_string();
let refspec = format!("refs/heads/{branch}:refs/heads/{branch}");
let mut oid = match stage_and_commit(&repo, paths)? {
Some(oid) => oid,
None => {
// Nothing new to commit. Usually genuinely up to date — but a
// previous cycle may have committed and then failed to push,
// stranding a local-only commit (the old add_all path silently
// never retried those). Push whenever origin's tracking ref
// doesn't already have HEAD.
let head = repo.head()?.peel_to_commit()?.id();
if tracking_tip(&repo, &branch) == Some(head) {
return Ok(PublishOutcome::UpToDate);
}
log::info!(
"tree unchanged but origin/{branch} lacks HEAD {} — pushing the stranded commit",
short(head)
);
head
}
};
// Optimistic push. A non-fast-forward *rejection* means the remote moved
// under us: reconcile onto origin and replay the dirty paths on the new
// tip, then retry once (reconcile_onto_origin soft-resets — ref move only —
// so the notes stay on the card and stage_and_commit splices them on top of
// origin). A transport-level failure is surfaced as-is: its fetch would die
// the same way, and the commit is safe locally — the stranded-commit check
// above pushes it once the transport works again.
if let Err(failure) = try_push(&repo, &refspec) {
let rejection = match failure {
PushFailure::Rejected(msg) => msg,
PushFailure::Other(e) => return Err(e),
};
log::warn!("push rejected ({rejection}); reconciling onto origin and replaying the note");
reconcile_onto_origin(&repo, &branch).context("reconciling after a rejected push")?;
match stage_and_commit(&repo, paths)? {
Some(replayed) => {
oid = replayed;
try_push(&repo, &refspec)
.map_err(PushFailure::into_error)
.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 {} ({} internal), min-ever {}",
free_heap(),
internal_free_heap(),
min_free_heap()
);
Ok(PublishOutcome::Pushed(short(oid)))
}
/// Build the commit for `paths` as an O(depth) TreeBuilder splice onto HEAD's
/// tree and return the new commit id — or `None` when the result matches the
/// parent (nothing to publish). Called on the first attempt and again to
/// replay the dirty paths after a reconcile.
///
/// This replaces the index pipeline (`add_all` → `index.write` → `write_tree`),
/// which is O(N_tree) and cannot run on the real 1179-file / 570 MB-pack clone:
/// `index.write`'s racy-clean pass re-hashes ~every entry on FAT's 2 s mtimes
/// (measured up to **611 s**), and even the index-free `read_tree` walk was
/// 77 s. The splice reads and writes only the dirty paths' ancestor chains —
/// O(depth × dirty), flat in repo size, **~22.8 s measured on the real
/// clone** — and carries every untouched entry (including the ~150 MB of
/// images) forward by OID without ever opening it. Trail + bench numbers:
/// docs/tradeoff-curves/sync-commit-staging.md.
///
/// The working tree is the source of truth: a recorded path that exists on the
/// card is spliced in from disk, a missing one is spliced out (a `:delete`).
/// Unrecorded paths are never visited — so Finder cruft (`._*`, `.DS_Store`)
/// on the FAT card can no longer ride into a commit the way it once did with
/// `add_all` (07d87772), and the old cruft filter is gone with the walk.
fn stage_and_commit(repo: &Repository, paths: &BTreeSet<String>) -> Result<Option<Oid>> {
// Commit on top of the current branch tip (None on an empty/unborn remote,
// where the splice starts from an empty base and makes a parentless commit).
let parent = repo.head().ok().and_then(|h| h.peel_to_commit().ok());
let base = match &parent {
Some(c) => Some(c.tree().context("loading HEAD tree")?),
None => None,
};
let t_splice = Instant::now();
let mut tree = base;
for path in paths {
let parts: Vec<&str> = path.split('/').filter(|p| !p.is_empty()).collect();
if parts.is_empty() {
continue;
}
let blob = match fs::read(format!("{REPO_DIR}/{path}")) {
Ok(bytes) => Some(
repo.blob(&bytes)
.with_context(|| format!("writing blob for {path}"))?,
),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, // deleted → splice out
Err(e) => return Err(e).with_context(|| format!("reading {path}")),
};
let spliced = splice(repo, tree.as_ref(), &parts, blob)
.with_context(|| format!("splicing {path}"))?;
tree = Some(repo.find_tree(spliced).context("loading spliced tree")?);
}
let Some(tree) = tree else {
return Ok(None); // unborn branch and nothing dirty — nothing to commit
};
let splice_ms = t_splice.elapsed().as_millis();
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 t_commit = Instant::now();
let oid = repo
.commit(Some("HEAD"), &sig, &sig, &message, &tree, &parents)
.context("creating commit")?;
log::info!(
"commit split — splice {splice_ms}ms ({} path(s)), commit-obj {}ms; committed {} — free heap {} ({} internal)",
paths.len(),
t_commit.elapsed().as_millis(),
short(oid),
free_heap(),
internal_free_heap()
);
Ok(Some(oid))
}
/// Return a new tree = `base` with `path` set to `blob` (`Some` inserts or
/// replaces, `None` removes). Recurses down the path's subtree chain: reads
/// ~depth tree objects and writes ~depth new ones, leaving every sibling entry
/// untouched (carried by OID — never opened). A missing intermediate directory
/// is synthesized on the way down; a directory emptied by a remove is pruned
/// on the way up rather than left behind as an empty tree entry.
fn splice(repo: &Repository, base: Option<&Tree>, path: &[&str], blob: Option<Oid>) -> Result<Oid> {
let (head, rest) = path.split_first().context("splice: empty path")?;
let mut tb = repo.treebuilder(base).context("treebuilder")?;
if rest.is_empty() {
match blob {
Some(oid) => {
tb.insert(*head, oid, 0o100644)
.context("inserting blob entry")?;
}
// Removing a never-committed path is a no-op, not an error (a note
// created and deleted between two syncs).
None => {
let _ = tb.remove(*head);
}
}
} else {
let sub = match base.and_then(|b| b.get_name(head)) {
Some(e) if e.kind() == Some(ObjectType::Tree) => {
Some(repo.find_tree(e.id()).context("loading subtree")?)
}
// Absent (a new directory) or a non-tree shadowing the name —
// build the subtree from scratch either way.
_ => None,
};
let new_sub = splice(repo, sub.as_ref(), rest, blob)?;
if repo.find_tree(new_sub)?.len() == 0 {
let _ = tb.remove(*head); // the remove emptied this directory — prune it
} else {
tb.insert(*head, new_sub, 0o040000)
.context("inserting subtree entry")?;
}
}
tb.write().context("writing spliced tree")
}
/// Origin's remote-tracking tip for `branch`, if the ref exists. libgit2
/// updates it after a successful push/fetch, so it is "the newest commit we
/// know origin has" — without touching the network.
fn tracking_tip(repo: &Repository, branch: &str) -> Option<Oid> {
repo.find_reference(&format!("refs/remotes/origin/{branch}"))
.ok()?
.peel_to_commit()
.ok()
.map(|c| c.id())
}
/// Whether origin is known to already have HEAD (local refs only, no network).
/// Errors read as "not current", so the caller falls through to the full
/// publish path where the real failure surfaces with context.
fn remote_current() -> Result<bool> {
let repo = Repository::open(REPO_DIR)?;
let head = repo.head()?.peel_to_commit()?.id();
let branch = repo
.head()?
.shorthand()
.context("HEAD has no branch shorthand")?
.to_string();
Ok(tracking_tip(&repo, &branch) == Some(head))
}
/// How a push attempt failed — this decides whether reconciling can help.
enum PushFailure {
/// The server processed the push but refused the ref update (arrives via
/// the `push_update_reference` callback — e.g. non-fast-forward): the
/// remote moved under us, and reconcile + replay is the right response.
Rejected(String),
/// Transport / TLS / auth / URL — the push never reached a ref decision,
/// so a reconcile (whose fetch needs the same transport) cannot help.
/// Surfaced directly; the 2026-07-13 on-device run burned a doomed
/// reconcile on an "unsupported URL protocol" because this wasn't split.
Other(anyhow::Error),
}
impl PushFailure {
fn into_error(self) -> anyhow::Error {
match self {
Self::Rejected(msg) => anyhow::anyhow!("remote rejected ref: {msg}"),
Self::Other(e) => e,
}
}
}
/// One push attempt over HTTPS. Binds the PAT credential + the cert-verify
/// callback, and separates a server-side ref rejection (reconcilable) from a
/// transport-level failure (not).
fn try_push(repo: &Repository, refspec: &str) -> Result<(), PushFailure> {
let mut remote = repo
.find_remote("origin")
.map_err(|e| PushFailure::Other(anyhow::Error::new(e).context("finding remote origin")))?;
let rejection: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
let mut cbs = auth_callbacks();
{
let rejection = rejection.clone();
cbs.push_update_reference(move |refname, status| {
if let Some(msg) = status {
*rejection.borrow_mut() = Some(format!("{refname}: {msg}"));
}
Ok(())
});
}
let mut opts = PushOptions::new();
opts.remote_callbacks(cbs);
remote
.push(&[refspec], Some(&mut opts))
.map_err(|e| PushFailure::Other(anyhow::Error::new(e).context("push transport")))?;
if let Some(msg) = rejection.borrow().clone() {
return Err(PushFailure::Rejected(msg));
}
log::info!("push accepted by remote");
Ok(())
}
/// Fetch origin and *soft*-reset the local branch onto it, so our changes can
/// be replayed on the current tip. Only runs after a non-fast-forward push
/// rejection — i.e. the remote moved under us.
///
/// **SOFT**, deliberately: it moves only the branch ref. The previous Mixed
/// reset also rewrote the index — pure waste now that the splice commit never
/// reads the index, and on the real repo an index write is exactly the
/// racy-clean wall the splice exists to avoid. Neither flavor touches the
/// working tree, so the notes being published survive on the card and the
/// replay splices them onto the new tip. For a single-writer appliance this
/// resolves last-writer-wins: a concurrent remote *edit* to a note we're
/// publishing loses to ours, while a remote-only added/changed file is simply
/// carried forward — origin's tree is now the splice base, so the replay
/// keeps it (an improvement over the old `add --all` replay, which dropped
/// files the card didn't have). A real merge stays increment-B work.
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());
remote
.fetch(&[branch], Some(&mut fo), None)
.context("fetch origin")?;
let fetch_head = repo
.find_reference("FETCH_HEAD")
.context("no FETCH_HEAD after fetch")?;
let theirs = repo.reference_to_annotated_commit(&fetch_head)?;
log::info!(
"reconcile: resetting local {branch} onto origin @ {} (soft — ref move only, notes stay on the card)",
short(theirs.id())
);
let their_obj = repo.find_object(theirs.id(), None)?;
repo.reset(&their_obj, git2::ResetType::Soft, None)
.context("soft reset onto origin")?;
Ok(())
}
/// Auth + cert callbacks shared by fetch and push. Captures only the baked
/// consts, so a fresh set can be built per operation. The PAT is handed to
/// libgit2 here and never logged.
fn auth_callbacks<'a>() -> RemoteCallbacks<'a> {
let mut cbs = RemoteCallbacks::new();
cbs.credentials(|_url, _user_from_url, allowed| {
if allowed.contains(CredentialType::USER_PASS_PLAINTEXT) {
return Cred::userpass_plaintext(GH_USER, PAT);
}
Err(git2::Error::from_str(
"server did not offer USER_PASS_PLAINTEXT — cannot authenticate with a PAT",
))
});
cbs.certificate_check(|_cert, host| {
log::info!("verifying {host} TLS chain against embedded GitHub CA bundle");
Ok(CertificateCheckStatus::CertificatePassthrough)
});
cbs
}
/// Kick off SNTP and block until first sync. Required before TLS (cert validity)
/// and before committing (signature timestamp). Mirrors Spike 6 / the spike.
fn sync_clock() -> Result<()> {
let sntp = EspSntp::new_default()?;
log::info!("SNTP started, waiting for first sync…");
let start = Instant::now();
while sntp.get_sync_status() != SyncStatus::Completed {
if start.elapsed() >= SNTP_TIMEOUT {
bail!("SNTP did not sync within {SNTP_TIMEOUT:?} — TLS + commit time would be wrong");
}
FreeRtos::delay_ms(100);
}
let unix = now_unix();
if unix < 1_700_000_000 {
bail!("clock still at {unix} after SNTP — refusing TLS/commit with a bad wall clock");
}
log::info!("clock synced — unix {unix}");
Ok(())
}
/// Write the embedded GitHub root CAs to the card and point libgit2's mbedTLS
/// stream at them. Must run before any TLS. Mirrors the spike, but writes to the
/// card root (`/sd/ca.pem`) instead of flash-FAT.
fn install_tls_trust_store() -> Result<()> {
std::fs::write(CA_BUNDLE_PATH, GITHUB_ROOTS_PEM)
.with_context(|| format!("writing CA bundle to {CA_BUNDLE_PATH}"))?;
// SAFETY: sets a process-global libgit2 option once, before any TLS work.
unsafe { git2::opts::set_ssl_cert_file(CA_BUNDLE_PATH) }
.context("git2::opts::set_ssl_cert_file")?;
log::info!(
"TLS trust store installed — {} B of GitHub roots at {CA_BUNDLE_PATH}",
GITHUB_ROOTS_PEM.len()
);
Ok(())
}
/// A short, panel-friendly reason from an error chain (first line, clamped). The
/// full chain is logged separately; the editor clamps this to the panel width.
fn short_reason(e: &anyhow::Error) -> String {
let full = format!("{e}");
let first = full.lines().next().unwrap_or("sync failed");
format!("sync: {}", first.chars().take(24).collect::<String>())
}
/// First 8 hex chars of an OID, for readable logs and the panel.
fn short(oid: git2::Oid) -> String {
oid.to_string()[..8].to_string()
}
/// Current wall-clock seconds since the Unix epoch (valid after SNTP).
fn now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn free_heap() -> u32 {
unsafe { sys::esp_get_free_heap_size() }
}
/// Free INTERNAL RAM (DRAM), excluding PSRAM. `free_heap` is dominated by the
/// 8 MB PSRAM pool and masks internal exhaustion — which is what actually
/// killed the first real-repo push (mbedTLS's ssl_setup could not get its
/// ~33 KB while Wi-Fi + USB + editor + libgit2 were resident).
fn internal_free_heap() -> u32 {
unsafe { sys::heap_caps_get_free_size(sys::MALLOC_CAP_INTERNAL) as u32 }
}
fn min_free_heap() -> u32 {
unsafe { sys::esp_get_minimum_free_heap_size() }
}

22
firmware/src/lib.rs Normal file
View File

@@ -0,0 +1,22 @@
//! Shared library surface for the Typoena firmware crate.
//!
//! The editor binary (`src/main.rs`) and the spike binaries under `src/bin/`
//! are each separate crate roots; anything they need to share lives here and is
//! reached as `firmware::…`:
//!
//! - [`net`] — resilient Wi-Fi bring-up, extracted from three duplicated
//! `connect_wifi` copies so the retry logic lives in exactly one place.
//! - [`persistence`] — SD mount + atomic save/load, graduated from the Spike 3
//! bench binary so the editor and the spike share one implementation.
//! - [`epd`] — the SSD1683 panel driver, shared by the editor binary and the
//! Spike 9 boot-splash bench binary so both drive the panel through one copy.
pub mod epd;
pub mod net;
pub mod persistence;
// On-device git publish (the editor's `:sync` transport). Behind the `git`
// feature so a light build never pulls libgit2/git2 — see main.rs `publish` and
// the feature note in Cargo.toml.
#[cfg(feature = "git")]
pub mod git_sync;

View File

@@ -1,5 +1,3 @@
mod editor;
mod epd;
mod usb_kbd;
use std::time::Instant;
@@ -11,8 +9,13 @@ use esp_idf_svc::hal::spi::config::{Config, DriverConfig};
use esp_idf_svc::hal::spi::{Dma, SpiBusDriver, SpiDriver};
use esp_idf_svc::hal::units::FromValueType;
use editor::{Editor, Mode, CH};
use epd::Epd;
use display::Frame;
use editor::{
Editor, Effect, Mode, Prefs, Scope, Snippets, CH, LOCAL_DIR, PREFS_PATH, REPO_DIR,
SNIPPETS_PATH,
};
use firmware::epd::{self, Epd};
use firmware::persistence::{Storage, NOTES};
/// Injected by build.rs so serial output identifies the exact build.
const BUILD_TAG: &str = concat!("build ", env!("BUILD_TIME"), " @", env!("BUILD_GIT"));
@@ -26,6 +29,12 @@ const FULL_REFRESH_EVERY: u32 = 64;
/// reappears once you settle. Normal/View draw their own caret every action.
const CURSOR_DEBOUNCE_MS: u128 = 750;
/// How long input must pause before `save_on_idle` persists a dirty buffer.
/// Longer than the caret debounce so autosave settles after typing, not during
/// a mid-sentence pause. The save is silent (no snackbar, no forced e-ink
/// flash) — a safety net against power loss, not a user action.
const IDLE_SAVE_MS: u128 = 1500;
fn main() -> anyhow::Result<()> {
// Required once before any esp-idf-svc call; some runtime patches
// only link if this symbol is referenced. See esp-idf-template#71.
@@ -56,19 +65,138 @@ fn main() -> anyhow::Result<()> {
log::info!("EPD reset + init…");
epd.reset()?;
epd.init()?;
epd.clear_screen(0xFF)?; // white baseline; establishes the previous bank
// Boot splash (Spike 9): the Typoena mark, shown while the SD mounts and the
// note loads below. Its full refresh doubles as the baseline the old white
// clear used to establish (writes both RAM banks); the editor's first render
// further down cleanly replaces it with a second full refresh.
epd.display_frame(Frame::splash().bytes())?;
// Mount the SD and load the saved note. We bring the SD up *after* the EPD —
// the doc's boot order is SD-first, but a dead panel can't explain a missing
// card — and treat a missing card / repo / unreadable note as fatal: a
// writing appliance that silently started empty would clobber the note on
// the next `:w`. See docs/v0.1-mvp-technical.md, boot sequence.
let (storage, saved) = boot_storage(&mut epd);
// Bring up the USB keyboard in the background; keys arrive via next_key().
usb_kbd::start()?;
let mut ed = Editor::new();
// Spawn the dedicated git thread — the `:sync` publish transport. It owns
// the Wi-Fi stack (brought up lazily on the first `:sync`, so the radio
// stays off until you publish) and parks on `git_tx` until signalled; the
// push runs off the UI loop, and its outcome returns on `git_rx` for the
// snackbar. Behind the `git` feature so a light build carries no libgit2.
#[cfg(feature = "git")]
let (git_tx, git_rx) = {
use esp_idf_svc::eventloop::EspSystemEventLoop;
use esp_idf_svc::nvs::EspDefaultNvsPartition;
use firmware::git_sync::{run_git_service, PublishOutcome, PublishRequest, GIT_STACK};
let sys_loop = EspSystemEventLoop::take()?;
let nvs = EspDefaultNvsPartition::take()?;
let modem = peripherals.modem;
let (req_tx, req_rx) = std::sync::mpsc::channel::<PublishRequest>();
let (res_tx, res_rx) = std::sync::mpsc::channel::<PublishOutcome>();
std::thread::Builder::new()
.name("git".into())
.stack_size(GIT_STACK)
.spawn(move || run_git_service(modem, sys_loop, nvs, req_rx, res_tx))?;
log::info!(
"git thread up ({} KB stack); Wi-Fi comes up on the first :sync",
GIT_STACK / 1024
);
(req_tx, res_rx)
};
// Seed the editor from the saved note. Boots in Normal mode with the caret
// on the last character (the resume point) — press `i`/`a`/`o` to write.
// The boot note is Tracked (`/sd/repo/notes.md`); `:e` / the palette (v0.5)
// open others, Tracked or Local.
let mut ed = Editor::with_file(NOTES.to_string(), Scope::Tracked, saved);
// Confirm the boot-load on the panel (no serial console in normal use):
// "loaded <name>" using the note's filename without its suffix (notes.md ->
// notes). Cleared by the first keystroke, like any snackbar.
let name = std::path::Path::new(NOTES)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("notes");
ed.set_notice(format!("loaded {name}"));
// Feed the file palette (Ctrl-P). Enumerated once at boot — the v0.5 slices
// that create/delete files (`:enew`, delete) will re-feed it then.
// Bracketed with internal-DRAM readings: each path is a small String, kept
// internal by the SPIRAM malloc threshold (16 KB), so the list competes
// with Wi-Fi/TLS for DRAM. Estimate to confirm: ~60-70 KB at 1098 files —
// this number decides whether interning the paths into one shared buffer
// (a single >16 KB alloc, which lands in PSRAM) is worth the refactor.
let dram_before = internal_free_heap();
ed.set_file_list(enumerate_files());
let dram_after = internal_free_heap();
log::info!(
"file list: internal heap {dram_before} -> {dram_after} ({} KB consumed)",
dram_before.saturating_sub(dram_after) / 1024
);
// Editor preferences (.typoena.toml, git-tracked). Read before the first
// render so `line_numbers` shapes the opening frame. A missing / unreadable /
// partial file falls back to defaults, so a fresh card just works.
let prefs = match storage.load_path(PREFS_PATH) {
Ok(src) => Prefs::parse(&src),
Err(_) => Prefs::default(),
};
log::info!("prefs: {prefs:?}");
ed.set_prefs(prefs);
// Snippet library (.typoena.snippets.json, git-tracked). Parsed with
// serde_json in the editor crate; a missing / unreadable / malformed file is
// non-fatal — the editor simply has no snippets and runs unchanged.
let snippets = match storage.load_path(SNIPPETS_PATH) {
Ok(src) => match Snippets::parse(&src) {
Ok(s) => s,
Err(e) => {
log::warn!("snippets parse FAILED ({e}); none loaded");
Snippets::default()
}
},
Err(_) => Snippets::default(),
};
log::info!("snippets: {} loaded", snippets.0.len());
ed.set_snippets(snippets);
let mut updates: u32 = 0;
let mut cursor_shown = true; // the initial render includes the caret
let mut last_activity = Instant::now();
// Whether `save_on_idle` already persisted the current idle window, so it
// fires once per typing burst (and doesn't retry-storm if a save fails).
// Reset on the next activity.
let mut idle_saved = false;
// Set when a paint fails (see the refresh block below): the next paint then
// does a full refresh to re-establish both RAM banks, since a partial that
// died mid-transfer may have left them inconsistent.
let mut force_full = false;
// First render is full (establishes the on-screen baseline for partials).
// Keyboard attach/detach state drives the panel's disconnect flag; seed it
// (and the word-count snapshot) before the first render.
let mut last_kbd = usb_kbd::keyboard_present();
ed.set_keyboard_present(last_kbd);
ed.refresh_stats();
// First editor render. The splash's full refresh above already seeded both
// RAM banks (its image is the `0x26` "previous" baseline), so the editor
// comes up with a full-area *partial* (~630 ms) instead of a second full
// refresh (~1.9 s): the splash→editor swap rides the partial waveform,
// shaving ~1.3 s off cold boot. This large-area partial is the one boot
// refresh worth eyeballing for ghosting; the loop's periodic full refresh
// (every FULL_REFRESH_EVERY updates) clears any residue.
let mut shown = ed.draw(true);
epd.display_frame(shown.bytes())?;
epd.display_frame_partial_window(shown.bytes(), 0, epd::HEIGHT)?;
// Boot-time measurement (the ≤ 5 s v0.1 / ≤ 3 s v1.0 target). Two clocks, and
// they disagree by ~1.4 s here, so report both. `esp_log_timestamp()` counts
// from ~power-on (same value as this line's own log prefix) → the real
// cold-boot number. `esp_timer_get_time()` only starts ~1.4 s in, after the
// 2nd-stage bootloader + the ~0.74 s PSRAM memtest, so it captures just the
// app-side init, not total boot. "Cursor ready" = first editor frame on the
// panel, input loop below about to poll.
let total_ms = unsafe { esp_idf_svc::sys::esp_log_timestamp() };
let app_ms = (unsafe { esp_idf_svc::sys::esp_timer_get_time() } / 1000) as u32;
log::info!("boot: cursor ready — {total_ms} ms since power-on ({app_ms} ms app-side)");
loop {
// Drain all queued keystrokes (type-ahead absorbed during a refresh),
@@ -79,19 +207,156 @@ fn main() -> anyhow::Result<()> {
keys += 1;
}
// Service the host-side effects the batch queued, in order. A file open
// queues a Save of the outgoing dirty buffer *then* a Load of the target;
// `:sync` queues a Save of the current buffer *then* Publish. Save/Load
// are inline (fast SD IO); Publish hands off to the git thread — behind
// the `git` feature, so a light build carries no libgit2/git2.
//
// Drain to empty rather than once: servicing a Load can itself queue an
// eviction Save (when the swap pushes a dirty parked buffer out of the
// ≤3 window), and that must be persisted now, not deferred to the next
// keystroke where a power-off could lose it. The queue strictly shrinks
// (a Save/Publish/Pull queues nothing; a Load queues at most one Save),
// so this terminates.
loop {
let effects = ed.take_effects();
if effects.is_empty() {
break;
}
for effect in effects {
match effect {
Effect::Save { path, contents, .. } => {
save_buffer(&storage, &mut ed, &path, &contents)
}
Effect::Load { path, scope } => open_buffer(&storage, &mut ed, path, scope),
Effect::Publish => {
// Non-blocking, so the ~10 s push never stalls the editor.
// The outcome returns on `git_rx` and updates the snackbar
// (see the idle branch below). The Save that preceded this
// in the batch already persisted the buffer, so this is a
// pure git publish of the recorded dirty paths — the
// outcome decides whether the snapshot is forgotten
// (publish_succeeded) or retried (publish_failed).
#[cfg(feature = "git")]
{
let paths = storage.take_dirty();
match git_tx.send(firmware::git_sync::PublishRequest { paths }) {
Ok(()) => ed.set_notice("syncing..."),
Err(_) => {
// Thread gone — nothing will report back, so
// return the snapshot to pending ourselves.
storage.publish_failed();
ed.set_notice("sync: git thread down");
}
}
}
#[cfg(not(feature = "git"))]
log::info!(":sync — saved; light build (no `git` feature) — push skipped");
}
Effect::Pull => {
// `:gl` — fetch + fast-forward from the remote. The
// on-device fetch/fast-forward on the git thread is v0.7
// work (git_sync only exposes push today), so acknowledge
// and no-op for now.
ed.set_notice("pull: not wired yet (v0.7)");
}
Effect::Delete { path, scope } => delete_buffer(&storage, &mut ed, path, scope),
Effect::SavePrefs { contents } => save_prefs(&storage, &mut ed, &contents),
}
}
}
// Keyboard attach/detach feeds the panel's disconnect flag.
let kbd = usb_kbd::keyboard_present();
ed.set_keyboard_present(kbd);
let kbd_changed = kbd != last_kbd;
last_kbd = kbd;
if keys == 0 {
// A finished publish reports its outcome here (the push ran on the
// git thread while we idled). Show it in the snackbar with a silent
// full-area partial — no keystroke will arrive to trigger a repaint.
#[cfg(feature = "git")]
if let Ok(outcome) = git_rx.try_recv() {
use firmware::git_sync::PublishOutcome::*;
// Settle the dirty snapshot this publish took: confirmed
// published (or up to date) → forget it; failed → back to
// pending so the next :sync retries the same paths.
match &outcome {
Pushed(_) | UpToDate => storage.publish_succeeded(),
Failed(_) => storage.publish_failed(),
}
ed.set_notice(match outcome {
Pushed(oid) => format!("synced {oid}"),
UpToDate => "up to date".to_string(),
Failed(reason) => reason,
});
let f = ed.draw(true);
if let Err(e) = epd.display_frame_partial_window(f.bytes(), 0, epd::HEIGHT) {
log::warn!("sync-notice repaint FAILED ({e}); full refresh next");
force_full = true;
continue;
}
shown = f;
cursor_shown = true;
continue;
}
// A connect/disconnect while idle must still repaint the panel flag —
// no keystroke will arrive to trigger it otherwise.
if kbd_changed {
let f = ed.draw(true);
if let Err(e) = epd.display_frame_partial_window(f.bytes(), 0, epd::HEIGHT) {
log::warn!("kbd-flag repaint FAILED ({e}); full refresh next");
force_full = true;
continue;
}
shown = f;
cursor_shown = true;
log::info!("keyboard {}", if kbd { "connected" } else { "disconnected" });
continue;
}
// save_on_idle: once input has paused, quietly persist a dirty named
// buffer so a power pull can't cost more than the last couple seconds.
// Silent — no snackbar and no forced e-ink flash (a safety net, not an
// action; `:w` is the loud save). Unformatted: fmt only runs on an
// explicit `:w`/`:sync`, never reflowing text mid-session. Fires once
// per idle window (`idle_saved`), so a failing save can't busy-loop.
if !idle_saved
&& ed.prefs().save_on_idle
&& ed.dirty()
&& !ed.path().is_empty()
&& last_activity.elapsed().as_millis() >= IDLE_SAVE_MS
{
idle_saved = true;
let path = ed.path().to_string();
match storage.save_path(&path, ed.text()) {
Ok(()) => {
log::info!("idle-save: {} bytes to {path}", ed.text().len());
ed.mark_saved(&path);
}
Err(e) => log::warn!("idle-save FAILED ({e:#}); buffer kept in RAM"),
}
// No repaint: `dirty` clearing has no visible effect, and a flash
// here would defeat the point. Fall through to the caret/idle path.
}
// Debounced caret, Insert mode only: once typing pauses, bring the
// bar caret back with a silent full-area partial (no flash).
// Normal/View already draw their caret on every action.
// bar caret back and refresh the panel word count with a silent
// full-area partial (no flash). Normal/View draw their caret on action.
if ed.mode() == Mode::Insert
&& !cursor_shown
&& last_activity.elapsed().as_millis() >= CURSOR_DEBOUNCE_MS
{
ed.refresh_stats();
let f = ed.draw(true);
epd.display_frame_partial_window(f.bytes(), 0, epd::HEIGHT)?;
shown = f;
cursor_shown = true;
log::info!("caret shown");
if let Err(e) = epd.display_frame_partial_window(f.bytes(), 0, epd::HEIGHT) {
log::warn!("caret repaint FAILED ({e}); full refresh next");
force_full = true;
} else {
shown = f;
cursor_shown = true;
log::info!("caret shown");
}
} else {
FreeRtos::delay_ms(8);
}
@@ -99,6 +364,13 @@ fn main() -> anyhow::Result<()> {
}
last_activity = Instant::now();
idle_saved = false; // fresh activity reopens the save_on_idle window
// Non-Insert actions (Normal edits, mode switches) aren't rapid typing,
// so the panel word count can refresh immediately; in Insert the snapshot
// stays frozen until the typing-pause path above refreshes it.
if ed.mode() != Mode::Insert {
ed.refresh_stats();
}
// Suppress the Insert bar caret while typing (fast, no ghost); Normal
// and View render their caret regardless of this flag.
let insert_cursor_on = ed.mode() != Mode::Insert;
@@ -129,17 +401,29 @@ fn main() -> anyhow::Result<()> {
&& only_adds_ink(shown.bytes(), frame.bytes(), y0, y1);
let t0 = Instant::now();
let refresh = if periodic {
epd.display_frame(frame.bytes())?;
"FULL"
// `force_full` promotes to a full refresh after a failed paint: it
// rewrites both RAM banks, recovering from a partial that may have died
// mid-transfer and desynced them.
let (result, refresh) = if periodic || force_full {
(epd.display_frame(frame.bytes()), "FULL")
} else if additive {
epd.display_frame_partial_window(frame.bytes(), y0, y1 - y0 + 1)?;
"windowed"
(epd.display_frame_partial_window(frame.bytes(), y0, y1 - y0 + 1), "windowed")
} else {
epd.display_frame_partial_window(frame.bytes(), 0, epd::HEIGHT)?;
"full-area"
(epd.display_frame_partial_window(frame.bytes(), 0, epd::HEIGHT), "full-area")
};
let ms = t0.elapsed().as_millis();
if let Err(e) = result {
// Never fatal — the buffer is the source of truth and safe in RAM,
// exactly like a failed `save_buffer`. Drop this frame, leave `shown`
// untouched so the next paint repaints the same diff, and force a
// clean full refresh then. Typical cause: internal DMA-capable RAM
// briefly starved by Wi-Fi/TLS during a background `:sync`; it frees
// the moment the push finishes.
log::warn!("{refresh} refresh #{updates} FAILED ({e}); frame dropped, full refresh next");
force_full = true;
continue;
}
force_full = false;
log::info!(
"{refresh} refresh #{updates} [{:?}]: {ms} ms (rows {y0}..={y1}, {keys} key(s))",
ed.mode()
@@ -149,6 +433,211 @@ fn main() -> anyhow::Result<()> {
}
}
/// Mount the SD card and load the saved note, or halt with the reason on the
/// panel. Everything here is fatal by design (see the boot-sequence comment in
/// `main`): the note is the whole point of the appliance, so we refuse to run
/// in a state where the next save could destroy it.
fn boot_storage(epd: &mut Epd) -> (Storage, String) {
// A git build shares this mount with the git thread, and libgit2 keeps the
// pack + idx descriptors open across a publish — that overruns the
// editor's tight 4-FD budget, so mount with the 16-FD one (persistence.rs,
// MAX_FILES_GIT). The light build keeps the editor's own budget.
#[cfg(feature = "git")]
let mounted = Storage::mount_for_git();
#[cfg(not(feature = "git"))]
let mounted = Storage::mount();
let storage = match mounted {
Ok(s) => s,
Err(e) => boot_halt(epd, "SD card not ready", &format!("{e:#}")),
};
if !storage.repo_present() {
boot_halt(
epd,
"No repo on the SD card",
"Provision it on your computer (just init) and reboot.",
);
}
let note = match storage.load() {
Ok(text) => text,
Err(e) => boot_halt(epd, "Could not read your note", &format!("{e:#}")),
};
log::info!("boot: loaded {} bytes from {NOTES}", note.len());
(storage, note)
}
/// Show a terminal boot error on the panel and idle forever. Rebooting into the
/// same missing card would just thrash, so we stop and explain instead.
fn boot_halt(epd: &mut Epd, headline: &str, detail: &str) -> ! {
log::error!("boot halt — {headline}: {detail}");
if let Err(e) = show_message(epd, &format!("{headline}\n\n{detail}\n")) {
log::error!("(could not paint the boot error either: {e:#})");
}
loop {
FreeRtos::delay_ms(1000);
}
}
/// Render a plain full-frame message by borrowing the editor purely as a
/// text-layout engine, so boot failures surface on the panel, not a dead screen.
fn show_message(epd: &mut Epd, msg: &str) -> anyhow::Result<()> {
let frame = Editor::with_text(msg.to_string()).draw(false);
epd.display_frame(frame.bytes())?;
Ok(())
}
/// Persist a buffer to SD at `path`. Errors are logged, never propagated: the
/// in-RAM buffer is the source of truth and must survive a failed write (e.g. a
/// card pulled mid-session) so the user can fix the card and retry `:w`. On
/// success the editor's dirty flag for that path is cleared.
fn save_buffer(storage: &Storage, ed: &mut Editor, path: &str, contents: &str) {
match storage.save_path(path, contents) {
Ok(()) => {
log::info!(":w — saved {} bytes to {path}", contents.len());
ed.mark_saved(path);
ed.set_notice("saved");
}
Err(e) => {
log::error!("save FAILED ({e:#}); buffer kept in RAM, retry :w");
ed.set_notice("save FAILED - retry :w");
}
}
}
/// Persist the preferences file after a palette `>` command changed a pref
/// (`Effect::SavePrefs`). The editor already applied the change live and
/// serialized it; this is a plain atomic write to the fixed `.typoena.toml`
/// path. Under `/sd/repo`, so it rides the next `:sync` to other devices.
fn save_prefs(storage: &Storage, ed: &mut Editor, contents: &str) {
match storage.save_path(PREFS_PATH, contents) {
Ok(()) => log::info!("prefs saved to {PREFS_PATH}"),
Err(e) => {
log::error!("prefs save FAILED ({e:#})");
ed.set_notice("prefs save FAILED");
}
}
}
/// Read `path` from SD and install it as the active buffer (the multi-file open
/// path, from `:e` / the palette). A read failure keeps the current buffer and
/// surfaces the reason on the snackbar rather than swapping to an empty screen.
fn open_buffer(storage: &Storage, ed: &mut Editor, path: String, scope: Scope) {
match storage.load_path(&path) {
Ok(text) => {
log::info!("opened {path} ({} bytes, {scope:?})", text.len());
let name = file_stem(&path);
ed.set_notice(format!("loaded {name}"));
ed.install_loaded(path, scope, text);
}
Err(e) => {
log::error!("open {path} FAILED ({e:#})");
ed.set_notice(format!("can't open {}", file_stem(&path)));
}
}
}
/// Unlink a file from the card (`:delete`). The editor has already dropped it
/// from its model and switched away, so this is pure IO plus the snackbar. For a
/// Tracked file the removal is left in the git working copy — the next `:sync`'s
/// `add --all` stages the deletion — so nothing git-specific happens here. A
/// failure keeps the file on disk and says so; the buffer has still switched, so
/// the file is recoverable by re-opening it.
fn delete_buffer(storage: &Storage, ed: &mut Editor, path: String, scope: Scope) {
// Scope-qualified label (`repo/notes.md`), so the snackbar names exactly which
// file left the card — and, for a Tracked file, that the removal is only local
// until the next `:sync` publishes it (deleting from the card alone never
// touches the remote — that mirrors how a Save is local until Publish).
let label = path.strip_prefix("/sd/").unwrap_or(&path);
match storage.delete_path(&path) {
Ok(()) => {
log::info!("deleted {path} ({scope:?})");
ed.set_notice(match scope {
Scope::Tracked => format!("deleted {label} - :sync to publish"),
Scope::Local => format!("deleted {label}"),
});
}
Err(e) => {
log::error!("delete {path} FAILED ({e:#})");
ed.set_notice(format!("delete FAILED: {label}"));
}
}
}
/// Enumerate the palette's openable files: the regular files under `/sd/repo`
/// and `/sd/local`, recursively, as absolute paths. Skips dot entries at every
/// level (so `.git` and its thousands of object files, `.typoena.toml`, and the
/// like never show or get walked). Best-effort: an unreadable directory (e.g.
/// no `/sd/local` yet) contributes nothing rather than failing. The editor
/// sorts and dedupes. Runs once at boot, so the walk time is logged — on a big
/// repo the FAT directory IO is the cost to watch.
fn enumerate_files() -> Vec<String> {
let start = std::time::Instant::now();
let mut out = Vec::new();
for dir in [REPO_DIR, LOCAL_DIR] {
walk_files(std::path::Path::new(dir), 0, &mut out);
}
log::info!("file walk: {} files in {}ms", out.len(), start.elapsed().as_millis());
out
}
/// Depth bound for [`walk_files`] — belt-and-braces against pathological
/// nesting on a hand-edited card; notes trees are a couple of levels deep.
const WALK_MAX_DEPTH: usize = 8;
/// Recursive helper for [`enumerate_files`]: push `dir`'s files onto `out`,
/// then descend into its subdirectories. Reads each directory fully before
/// recursing (the `remove_dir_recursive` pattern in `git_sync`), so only one
/// FatFS directory handle is open at a time regardless of depth — relevant on
/// the FD-bounded SD mount.
fn walk_files(dir: &std::path::Path, depth: usize, out: &mut Vec<String>) {
if depth > WALK_MAX_DEPTH {
log::warn!("file walk: {} exceeds depth {WALK_MAX_DEPTH}, skipped", dir.display());
return;
}
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
// Keep the dirent's own file type: esp-idf's FAT VFS always fills d_type
// (DT_DIR/DT_REG, straight from the FILINFO readdir already holds), so
// `file_type()` is free. A per-entry `metadata()` stat instead re-walks
// the directory by path every time — measured at ~32ms/file on the SD
// card, it turned a 1098-file walk into 35s.
let children: Vec<_> = entries
.flatten()
.filter_map(|e| e.file_type().ok().map(|t| (e.path(), t)))
.collect();
for (path, ftype) in children {
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if name.starts_with('.') {
continue;
}
if ftype.is_file() {
if let Some(p) = path.to_str() {
out.push(p.to_string());
}
} else if ftype.is_dir() {
walk_files(&path, depth + 1, out);
}
}
}
/// Free internal DRAM (excludes the 8 MB PSRAM pool, which dominates the total
/// free-heap number and masks DRAM exhaustion). Same reading `git_sync` logs.
fn internal_free_heap() -> u32 {
use esp_idf_svc::sys;
unsafe { sys::heap_caps_get_free_size(sys::MALLOC_CAP_INTERNAL) as u32 }
}
/// A file's display name — its basename without extension (`/sd/repo/notes.md`
/// → `notes`), for the snackbar. Falls back to the raw path if it has no stem.
fn file_stem(path: &str) -> &str {
std::path::Path::new(path)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(path)
}
/// First and last (inclusive) framebuffer rows that differ between two frames,
/// or `None` if identical. Lets the partial refresh target just the band a
/// keystroke touched instead of all 272 rows.

78
firmware/src/net.rs Normal file
View File

@@ -0,0 +1,78 @@
//! Shared networking helpers.
//!
//! Extracted from the near-identical `connect_wifi` copies that lived in the
//! wifi_tls / git_push / git_sync spikes. The single copy adds the resilience
//! the spikes lacked: a bounded retry with exponential backoff around
//! association + DHCP.
use anyhow::{Context, Result};
use esp_idf_svc::hal::delay::FreeRtos;
use esp_idf_svc::wifi::{AuthMethod, BlockingWifi, ClientConfiguration, Configuration, EspWifi};
/// Association + DHCP attempts before giving up. The first attempt after a
/// reset frequently fails: the AP may still hold the pre-reset association and
/// reject the re-join until it ages out, the radio may not be settled, and DHCP
/// can drop the first DISCOVER on a cold interface. Retrying a handful of times
/// turns those transient misses into a clean connect on attempt 23 instead of
/// a failed boot.
const MAX_ATTEMPTS: u32 = 5;
/// Backoff before the first retry; doubles each attempt up to [`MAX_BACKOFF_MS`].
const INITIAL_BACKOFF_MS: u32 = 500;
const MAX_BACKOFF_MS: u32 = 4000;
/// Configure the station, start the radio, and associate with `ssid`, retrying
/// association + DHCP with exponential backoff.
///
/// `set_configuration` and `start` run once; only the association + netif-up
/// wait is retried — restarting the radio each attempt is wasteful and can wedge
/// the driver. Between attempts the station is disconnected to clear any
/// half-open state before the next `connect`.
///
/// An empty `pass` selects an open network; otherwise WPA2-Personal.
pub fn connect_wifi(
wifi: &mut BlockingWifi<EspWifi<'static>>,
ssid: &str,
pass: &str,
) -> Result<()> {
let auth_method = if pass.is_empty() {
AuthMethod::None
} else {
AuthMethod::WPA2Personal
};
wifi.set_configuration(&Configuration::Client(ClientConfiguration {
ssid: ssid.try_into().ok().context("SSID > 32 bytes")?,
password: pass.try_into().ok().context("password > 64 bytes")?,
auth_method,
..Default::default()
}))?;
wifi.start()?;
let mut backoff_ms = INITIAL_BACKOFF_MS;
for attempt in 1..=MAX_ATTEMPTS {
log::info!("associating with \"{ssid}\" (attempt {attempt}/{MAX_ATTEMPTS})…");
match associate_once(wifi) {
Ok(()) => return Ok(()),
Err(e) if attempt < MAX_ATTEMPTS => {
log::warn!("Wi-Fi attempt {attempt} failed: {e:#}; retrying in {backoff_ms} ms");
// Clear any half-open association before the next connect. Ignore
// the result — it errors harmlessly when we never associated.
let _ = wifi.disconnect();
FreeRtos::delay_ms(backoff_ms);
backoff_ms = (backoff_ms * 2).min(MAX_BACKOFF_MS);
}
Err(e) => {
return Err(e).with_context(|| format!("Wi-Fi failed after {MAX_ATTEMPTS} attempts"));
}
}
}
unreachable!("loop returns on success or on the final-attempt error")
}
/// One association + DHCP wait. Split out so the retry loop reads cleanly and to
/// keep the two `wifi` borrows in separate statements.
fn associate_once(wifi: &mut BlockingWifi<EspWifi<'static>>) -> Result<()> {
wifi.connect().context("Wi-Fi association failed")?;
wifi.wait_netif_up().context("DHCP / netif never came up")?;
Ok(())
}

523
firmware/src/persistence.rs Normal file
View File

@@ -0,0 +1,523 @@
//! SD-card persistence — mount, atomic save, crash recovery.
//!
//! The editor's notes live at `/sd/repo/notes.md` on a FAT filesystem on the
//! microSD card. This module owns bringing that card up and reading/writing the
//! buffer safely across power loss. It is the graduation of the Spike 3 bench
//! binary (`src/bin/sd_fat.rs`): that spike proved the raw stack on hardware
//! (verified 2026-07-11); the proven bits now live here so the editor and the
//! spike share one implementation instead of the spike being a dead-end proof.
//!
//! ## Storage split (ADR-007)
//!
//! FAT-on-SD holds the git working copy (`/sd/repo/`) and local scratch
//! (`/sd/local/`); device config is compiled into the binary in v0.1. The repo
//! is provisioned host-side (`just init` / `just load` copy a clone onto the
//! card) and opened — not cloned — on device, so this module never creates the
//! repo directory: a missing `/sd/repo` means the card wasn't provisioned, which
//! the boot path surfaces as a fatal "re-run `just init`" rather than silently
//! papering over.
//!
//! ## Dedicated SPI3 bus (ADR-012)
//!
//! The card sits on its own SPI3 host (SCK 14, MOSI 15, MISO 13, CS 10). The EPD
//! keeps SPI2. The EPD driver holds an exclusive `spi_device_acquire_bus` lock
//! for its whole lifetime, so a shared bus would lock the SD out; giving the SD
//! its own host sidesteps that for ~2 GPIOs. See the `mount` docs and ADR-012.
//!
//! ## Atomic save + crash recovery (the load-bearing part)
//!
//! FAT gives weak power-loss guarantees, so a save is: write `notes.md.tmp`,
//! `fsync`, unlink the target, rename the tmp over it. On FAT that unlink is
//! mandatory — FatFS's `f_rename` returns `FR_EXIST` on an existing destination
//! (it does *not* replace like POSIX `rename(2)`; Spike 3 finding). That unlink
//! opens a small window where the target is gone while the complete new content
//! sits in the tmp. [`Storage::recover`] closes the loop at boot — see its docs
//! for the exact case analysis, which is subtler than "promote the tmp."
use std::cell::RefCell;
use std::collections::BTreeSet;
use std::fs;
use std::io::Write as _;
use std::mem::MaybeUninit;
use std::path::Path;
use std::ptr;
use anyhow::{bail, Context, Result};
use esp_idf_svc::sys::{self, esp};
/// SD wiring on its own SPI3 host (ADR-012).
const PIN_SCK: i32 = 14;
const PIN_MOSI: i32 = 15;
const PIN_MISO: i32 = 13;
const PIN_CS: i32 = 10;
/// SD clock. Conservative for bench jumper wires: SDSPI's 20 MHz default is
/// prone to CRC errors on long unterminated jumpers, which look like a stack
/// failure when they're really signal integrity. 10 MHz keeps margin; raise
/// toward 20 MHz on a clean PCB. Init always runs at 400 kHz regardless.
const SD_FREQ_KHZ: i32 = 10_000;
/// Host flags from `sd_protocol_types.h` — `BIT(3)` / `BIT(5)`. Inlined because
/// bindgen doesn't fold the nested `BIT()` macro into a constant.
const SDMMC_HOST_FLAG_SPI: u32 = 1 << 3;
const SDMMC_HOST_FLAG_DEINIT_ARG: u32 = 1 << 5;
/// FAT mount point.
pub const MOUNT: &str = "/sd";
/// Git working copy — provisioned host-side, opened on device.
pub const REPO_DIR: &str = "/sd/repo";
/// The one file v0.1 opens.
pub const NOTES: &str = "/sd/repo/notes.md";
/// Staging name for the atomic save. Two dots → needs long-filename support
/// (`CONFIG_FATFS_LFN_HEAP=y`, set in sdkconfig.defaults).
const NOTES_TMP: &str = "/sd/repo/notes.md.tmp";
/// Largest file [`Storage::load`] will read into the buffer. v0.1 caps notes at
/// 256 KiB; a larger file refuses to open with a clear message rather than
/// exhausting the rope. Saving is *not* capped — never refuse to persist the
/// user's work once it's in the buffer.
pub const MAX_FILE_BYTES: u64 = 256 * 1024;
/// The C mount point (`/sd\0`) for the esp-idf FFI calls.
const MOUNT_C: &std::ffi::CStr = c"/sd";
/// Dirty-path journal — one repo-relative path per line, mirroring the in-RAM
/// dirty set (see [`Storage::take_dirty`]). At the card root, *outside*
/// `/sd/repo`, so it can never itself be committed. Without it a power pull
/// would strand every file saved-but-not-yet-published in that session: the
/// splice commit only visits recorded paths (nothing walks the tree anymore),
/// so an unrecorded change would never reach the remote.
const DIRTY_JOURNAL: &str = "/sd/.typoena-dirty";
/// VFS open-file budget for the editor path: it opens only a note and its
/// `*.tmp`, so a tight budget keeps FatFS's per-file buffers off the heap.
const MAX_FILES_EDITOR: i32 = 4;
/// VFS open-file budget for the git tooling. libgit2 keeps the pack + `.idx`
/// (and commit-graph) descriptors open for the repo's lifetime and opens loose
/// objects on top, so a `read_tree` walk overruns [`MAX_FILES_EDITOR`] with a
/// "no free file descriptors" error. Matches the flash-FAT git binaries' 16.
const MAX_FILES_GIT: i32 = 16;
/// A mounted SD card. Holds the live card handle for its lifetime; v0.1 never
/// unmounts (the card stays up for the whole power session). Not `Send` — the
/// handle lives on the task that mounted it (the ui/main task). The git thread
/// reaches `/sd/repo` through plain `std::fs`; FatFS's per-volume reentrancy
/// lock serialises the two, so no extra mutex is needed here.
pub struct Storage {
card: *mut sys::sdmmc_card_t,
/// Repo-relative paths saved or `:delete`d since the last confirmed
/// publish — the editor-side half of the O(depth) splice commit
/// (`git_sync::stage_and_commit` visits exactly these paths and nothing
/// else). Mirrored to [`DIRTY_JOURNAL`] whenever it changes, so the record
/// survives a power pull. `RefCell` because recording happens inside
/// `&self` save/delete calls; `Storage` already lives on one task only.
dirty: RefCell<Dirty>,
}
/// The two halves of the dirty record: `pending` accumulates between syncs;
/// `take_dirty` moves it to `in_flight` for the duration of a publish so a
/// failure can put it back (and a save landing *during* the publish re-enters
/// `pending`, riding the next one). The journal always carries the union.
#[derive(Default)]
struct Dirty {
pending: BTreeSet<String>,
in_flight: BTreeSet<String>,
}
/// What [`Storage::recover`] did with a leftover `*.tmp` at boot.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Recovery {
/// No `*.tmp` present — clean shutdown last time.
Clean,
/// `*.tmp` and the target both present: the crash could have landed
/// mid-write, so the tmp is untrustworthy. Kept the committed target,
/// discarded the tmp. The in-flight (unsaved) edit is lost — the documented
/// "you get the previous version" behaviour.
DiscardedTmp,
/// Only `*.tmp` present: the target had already been unlinked, so the tmp is
/// the newest complete, fsync'd copy. Promoted it to the target.
PromotedTmp,
}
impl Storage {
/// Bring up SPI3 and mount the FAT filesystem at `/sd`, then run crash
/// recovery ([`Storage::recover`]) so storage is in a consistent state
/// before the caller reads anything.
///
/// `format_if_mount_failed` is **false**: this is the user's card with their
/// writing on it, so a transient mount hiccup must never trigger a reformat.
/// (The Spike 3 bench binary sets it true for convenience on blank cards;
/// this path must not.)
pub fn mount() -> Result<Self> {
Self::mount_with_max_files(MAX_FILES_EDITOR)
}
/// Like [`Storage::mount`], but with the larger [`MAX_FILES_GIT`] open-file
/// budget the git tooling (bench / sync) needs — libgit2 holds several
/// descriptors open at once, which the editor's default budget can't cover.
pub fn mount_for_git() -> Result<Self> {
Self::mount_with_max_files(MAX_FILES_GIT)
}
fn mount_with_max_files(max_files: i32) -> Result<Self> {
// 1) SPI3 with the SD's four lines. Dedicated bus (ADR-012) — no EPD
// deselect needed: the panel is on SPI2 and can't contend here.
// SAFETY: a zeroed spi_bus_config_t is valid (all pins default 0); we
// set the used pins and mark the quad lines unused (-1).
let mut bus: sys::spi_bus_config_t = unsafe { MaybeUninit::zeroed().assume_init() };
bus.__bindgen_anon_1.mosi_io_num = PIN_MOSI;
bus.__bindgen_anon_2.miso_io_num = PIN_MISO;
bus.sclk_io_num = PIN_SCK;
bus.__bindgen_anon_3.quadwp_io_num = -1;
bus.__bindgen_anon_4.quadhd_io_num = -1;
bus.max_transfer_sz = 4096;
esp!(unsafe {
sys::spi_bus_initialize(
sys::spi_host_device_t_SPI3_HOST,
&bus,
sys::spi_common_dma_t_SPI_DMA_CH_AUTO as _,
)
})
.context("spi_bus_initialize(SPI3)")?;
// 1b) Internal pull-ups on the SD lines. The SD spec wants ~10 kΩ
// pull-ups; bench jumpers have none, so MISO floats between response
// bytes and a stray bit reads back as a spurious R1 "illegal
// command" that fails init. The ESP32's internal ~45 kΩ pull-ups are
// usually enough on short wires; an external 10 kΩ MISO→3V3 is the
// proper fix on a real board.
for pin in [PIN_SCK, PIN_MOSI, PIN_MISO, PIN_CS] {
esp!(unsafe { sys::gpio_set_pull_mode(pin, sys::gpio_pull_mode_t_GPIO_PULLUP_ONLY) })
.with_context(|| format!("pull-up on GPIO {pin}"))?;
}
// 2) SDSPI host descriptor — hand-rolled SDSPI_HOST_DEFAULT() (bindgen
// drops the macro). The fn pointers are esp-idf's sdspi_host_* ops.
// SAFETY: zeroed is a valid start (all fn-pointer Options = None); we
// fill exactly the fields the C macro sets.
let mut host: sys::sdmmc_host_t = unsafe { MaybeUninit::zeroed().assume_init() };
host.flags = SDMMC_HOST_FLAG_SPI | SDMMC_HOST_FLAG_DEINIT_ARG;
host.slot = sys::spi_host_device_t_SPI3_HOST as i32;
host.max_freq_khz = SD_FREQ_KHZ;
host.io_voltage = 3.3;
host.driver_strength = sys::sdmmc_driver_strength_t_SDMMC_DRIVER_STRENGTH_B;
host.current_limit = sys::sdmmc_current_limit_t_SDMMC_CURRENT_LIMIT_200MA;
host.init = Some(sys::sdspi_host_init);
host.set_card_clk = Some(sys::sdspi_host_set_card_clk);
host.do_transaction = Some(sys::sdspi_host_do_transaction);
host.__bindgen_anon_1.deinit_p = Some(sys::sdspi_host_remove_device);
host.io_int_enable = Some(sys::sdspi_host_io_int_enable);
host.io_int_wait = Some(sys::sdspi_host_io_int_wait);
host.get_real_freq = Some(sys::sdspi_host_get_real_freq);
host.input_delay_phase = sys::sdmmc_delay_phase_t_SDMMC_DELAY_PHASE_0;
host.check_buffer_alignment = Some(sys::sdspi_host_check_buffer_alignment);
// 3) Device (slot) config — CS 10, no card-detect / write-protect / int.
// SAFETY: zeroed is valid; we set the host, CS, and mark the rest unused.
let mut slot: sys::sdspi_device_config_t = unsafe { MaybeUninit::zeroed().assume_init() };
slot.host_id = sys::spi_host_device_t_SPI3_HOST;
slot.gpio_cs = PIN_CS;
slot.gpio_cd = -1;
slot.gpio_wp = -1;
slot.gpio_int = -1;
// 4) Mount config. format_if_mount_failed = FALSE — see method docs.
let mount = sys::esp_vfs_fat_mount_config_t {
format_if_mount_failed: false,
max_files,
allocation_unit_size: 16 * 1024,
disk_status_check_enable: false,
use_one_fat: false,
};
let mut card: *mut sys::sdmmc_card_t = ptr::null_mut();
let rc = unsafe {
sys::esp_vfs_fat_sdspi_mount(MOUNT_C.as_ptr(), &host, &slot, &mount, &mut card)
};
// Turn the driver's opaque error into something actionable. The one we
// hit in practice: a card that rejects CMD59 (SPI-mode CRC on/off) after
// CMD0/CMD8 succeed. That's a card-firmware limitation (common on
// large/counterfeit SDXC), not a wiring fault — and we keep CRC required
// rather than run the user's notes over an unchecked bus.
if rc == sys::ESP_ERR_NOT_SUPPORTED {
bail!(
"SD card rejected CMD59 (SPI-mode CRC). CMD0/CMD8 succeeded, so wiring is \
fine — this card's firmware just doesn't support CRC in SPI mode (common on \
large/counterfeit SDXC). Use a genuine card, ideally ≤32 GB. We keep CRC \
required on purpose: a writing device shouldn't run over an unchecked bus."
);
}
esp!(rc).context("esp_vfs_fat_sdspi_mount (card present? inserted? FAT-formatted?)")?;
let storage = Storage {
card,
dirty: RefCell::new(Dirty::default()),
};
let (max_khz, real_khz) = storage.negotiated_khz();
log::info!("SD mounted at {MOUNT} — max {max_khz} kHz, negotiated {real_khz} kHz");
match storage.recover().context("boot crash recovery")? {
Recovery::Clean => {}
Recovery::DiscardedTmp => log::warn!(
"recovery: found {NOTES_TMP} alongside {NOTES} — last save didn't finish; \
kept the committed file, discarded the incomplete tmp"
),
Recovery::PromotedTmp => log::warn!(
"recovery: found {NOTES_TMP} with no {NOTES} — promoted the tmp (it is the \
newest complete copy)"
),
}
let carried = storage.load_dirty_journal();
if carried > 0 {
log::info!(
"dirty journal: {carried} unpublished path(s) carried over from a previous \
session — the next :sync will commit them"
);
}
Ok(storage)
}
/// The card's ceiling and negotiated SPI clock, in kHz (`(max, real)`).
/// `real` is what SDSPI settled on after init and is the speed reads/writes
/// actually run at — worth logging on the bench where wiring caps it.
pub fn negotiated_khz(&self) -> (i32, i32) {
// SAFETY: `card` is a live handle for the lifetime of `self` (the mount
// is never torn down while a `Storage` exists).
unsafe {
(
(*self.card).max_freq_khz as i32,
(*self.card).real_freq_khz as i32,
)
}
}
/// Total / free bytes on the FAT volume.
pub fn usage(&self) -> Result<(u64, u64)> {
let mut total: u64 = 0;
let mut free: u64 = 0;
esp!(unsafe { sys::esp_vfs_fat_info(MOUNT_C.as_ptr(), &mut total, &mut free) })
.context("esp_vfs_fat_info")?;
Ok((total, free))
}
/// Whether the working copy exists. A missing `/sd/repo` means the card
/// wasn't provisioned (`just init`); the boot path treats that as fatal.
pub fn repo_present(&self) -> bool {
Path::new(REPO_DIR).is_dir()
}
/// Read `notes.md` into a `String` — the boot default note. Thin wrapper over
/// [`Storage::load_path`].
pub fn load(&self) -> Result<String> {
self.load_path(NOTES)
}
/// Read an arbitrary file under `/sd` into a `String`. Returns an empty string
/// if the file doesn't exist yet (a `:e` of a not-yet-created name, or a fresh
/// repo). Refuses a file larger than [`MAX_FILE_BYTES`] rather than loading it.
///
/// The multi-file (v0.5) load path: the editor names the file, the host reads
/// it here and hands the text back through `Editor::install_loaded`.
pub fn load_path(&self, path: &str) -> Result<String> {
match fs::metadata(path) {
Ok(m) if m.len() > MAX_FILE_BYTES => bail!(
"{path} is {} KiB — over the {} KiB limit; open it on a computer to split it",
m.len() / 1024,
MAX_FILE_BYTES / 1024
),
// Read the file verbatim. The editor's `rows = #\n + 1` model renders a
// trailing '\n' as an empty last line, and we *want* that: a note ends
// with a visible blank line that reflects its POSIX terminator. Since
// `save_path` guarantees that terminator, this load and that save form an
// identity round-trip for any device-written file (which always ends in
// '\n') — no strip needed, and none wanted.
Ok(_) => fs::read_to_string(path).with_context(|| format!("reading {path}")),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
Err(e) => Err(e).with_context(|| format!("stat {path}")),
}
}
/// Atomically persist `contents` to `notes.md`. Thin wrapper over
/// [`Storage::save_path`].
pub fn save(&self, contents: &str) -> Result<()> {
self.save_path(NOTES, contents)
}
/// Atomically persist `contents` to an arbitrary file under `/sd`: write the
/// tmp, fsync, unlink the target, rename over it. See the module docs for why
/// the unlink is mandatory on FAT. Boot recovery ([`Storage::recover`]) still
/// only covers the default `notes.md`; per-file recovery for the other v0.5
/// buffers is deferred to the v0.9 crash-safety work — the atomic swap here
/// already protects each individual save.
pub fn save_path(&self, path: &str, contents: &str) -> Result<()> {
// Record BEFORE writing: a crash in between leaves an over-approximate
// journal (the splice of an unchanged path is a no-op), whereas the
// reverse order could leave a changed file no record ever points at.
self.record_dirty(path);
Self::atomic_write(path, contents)
}
/// The atomic write primitive behind [`Storage::save_path`] and the dirty
/// journal: write `{path}.tmp`, fsync, unlink the target, rename over it.
fn atomic_write(path: &str, contents: &str) -> Result<()> {
let tmp = format!("{path}.tmp");
{
let mut f = fs::File::create(&tmp)
.with_context(|| format!("create {tmp} (does its directory exist?)"))?;
f.write_all(contents.as_bytes())
.with_context(|| format!("write {tmp}"))?;
// Insert a final newline only if the buffer lacks one (POSIX text
// convention; keeps git from flagging "No newline at end of file").
// `load_path` reads verbatim, so this is the sole place the terminator is
// guaranteed — and because it's guarded, the file mirrors the buffer's
// trailing newlines exactly: one visible trailing blank line stays one,
// never doubled. A buffer that already ends in '\n' passes through as-is.
if !contents.ends_with('\n') {
f.write_all(b"\n")
.with_context(|| format!("write final newline to {tmp}"))?;
}
// FatFS f_sync — flush the tmp fully before it can replace the target.
f.sync_all().with_context(|| format!("fsync {tmp}"))?;
}
// FatFS f_rename won't overwrite, so unlink the target first (tolerate a
// missing target: the first-ever save has nothing to remove).
match fs::remove_file(path) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e).with_context(|| format!("unlink {path} before rename")),
}
fs::rename(&tmp, path).with_context(|| format!("rename {tmp} -> {path}"))?;
Ok(())
}
/// Unlink a file under `/sd` (`:delete`). Tolerates a missing target — an
/// already-gone file is a success, so the call is idempotent. Also clears a
/// stray `{path}.tmp` best-effort, so a crash-interrupted save can't leave the
/// file half-present after a delete. For a Tracked file this leaves the
/// working copy short one file; the next publish's `add --all` stages it.
pub fn delete_path(&self, path: &str) -> Result<()> {
// Same record-first rule as `save_path`: the splice treats a recorded
// path with no file behind it as "remove from the tree".
self.record_dirty(path);
let _ = fs::remove_file(format!("{path}.tmp"));
match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e).with_context(|| format!("unlink {path}")),
}
}
/// Note a working-copy file as (possibly) differing from HEAD. Paths
/// outside `/sd/repo` (`/sd/local`, `/sd/ca.pem`, the journal itself) are
/// not git's business and are skipped. The journal is rewritten only when
/// the set actually grows, so re-saving the same note between syncs costs
/// no extra card I/O.
fn record_dirty(&self, abs_path: &str) {
let Some(rel) = abs_path
.strip_prefix(REPO_DIR)
.and_then(|r| r.strip_prefix('/'))
else {
return;
};
if rel.is_empty() {
return;
}
let grew = self.dirty.borrow_mut().pending.insert(rel.to_string());
if grew {
self.persist_dirty();
}
}
/// Snapshot the dirty paths for a publish (repo-relative). The snapshot
/// moves to `in_flight` — the journal keeps carrying it — until the UI
/// task reports the outcome: [`Storage::publish_succeeded`] forgets it,
/// [`Storage::publish_failed`] returns it to pending for the next `:sync`.
pub fn take_dirty(&self) -> BTreeSet<String> {
let mut d = self.dirty.borrow_mut();
let taken = std::mem::take(&mut d.pending);
d.in_flight.extend(taken.iter().cloned());
taken
}
/// The publish that took the last snapshot committed (or confirmed
/// up-to-date): drop its paths and shrink the journal. Anything saved
/// while it ran is still in `pending` and rides the next sync.
pub fn publish_succeeded(&self) {
self.dirty.borrow_mut().in_flight.clear();
self.persist_dirty();
}
/// The publish failed: return its snapshot to pending so the next `:sync`
/// retries it (the splice is idempotent, so a retry of an already-clean
/// path is free). The journal already carries these paths — no rewrite.
pub fn publish_failed(&self) {
let mut d = self.dirty.borrow_mut();
let inflight = std::mem::take(&mut d.in_flight);
d.pending.extend(inflight);
}
/// Mirror `pending in_flight` to [`DIRTY_JOURNAL`], atomically.
/// Best-effort: a failed journal write must not fail the save that
/// triggered it — the set stays correct in RAM and the journal heals on
/// the next change.
fn persist_dirty(&self) {
let contents = {
let d = self.dirty.borrow();
let mut out = String::new();
for p in d.pending.union(&d.in_flight) {
out.push_str(p);
out.push('\n');
}
out
};
if let Err(e) = Self::atomic_write(DIRTY_JOURNAL, &contents) {
log::warn!("dirty journal write FAILED ({e:#}); set kept in RAM only");
}
}
/// Seed the dirty set from the journal at mount — the paths a previous
/// session saved but never got confirmed as published (power pull, failed
/// sync, or simply no `:sync` before shutdown). Returns how many.
fn load_dirty_journal(&self) -> usize {
let Ok(text) = fs::read_to_string(DIRTY_JOURNAL) else {
return 0; // no journal yet — nothing carried over
};
let mut d = self.dirty.borrow_mut();
for line in text.lines().map(str::trim).filter(|l| !l.is_empty()) {
d.pending.insert(line.to_string());
}
d.pending.len()
}
/// Reconcile a leftover `notes.md.tmp` at boot. The save sequence is
/// write-tmp → fsync → unlink-target → rename, so a lingering tmp means the
/// last save was interrupted. Which way to recover depends on whether the
/// target survived:
///
/// - **tmp + target both present** — the crash could have been *during* the
/// tmp write (before fsync completed), so the tmp may be partial. The
/// target is the last fully-committed version. Keep it, delete the tmp.
/// Promoting a possibly-partial tmp over good data would be data loss.
/// - **tmp only, target absent** — the target was already unlinked, so we
/// crashed between unlink and rename. The tmp is the newest complete,
/// fsync'd copy and the only one left. Promote it (rename over the target).
/// - **neither / target only** — nothing to do.
///
/// Idempotent and safe to call on every mount; a no-op when `/sd/repo`
/// doesn't exist (no tmp can be there).
fn recover(&self) -> Result<Recovery> {
if fs::metadata(NOTES_TMP).is_err() {
return Ok(Recovery::Clean);
}
if fs::metadata(NOTES).is_ok() {
fs::remove_file(NOTES_TMP)
.with_context(|| format!("discard stale {NOTES_TMP}"))?;
Ok(Recovery::DiscardedTmp)
} else {
fs::rename(NOTES_TMP, NOTES)
.with_context(|| format!("promote {NOTES_TMP} -> {NOTES}"))?;
Ok(Recovery::PromotedTmp)
}
}
}

View File

@@ -38,22 +38,11 @@ use esp_idf_svc::sys::{
usb_transfer_t, EspError, ESP_INTR_FLAG_LEVEL1,
};
/// A decoded key-down event. Beyond plain characters, the decoder recognises a
/// few editing combos (resolved here so the main loop only sees intents) and a
/// dual-role Caps Lock: held it acts as Ctrl, tapped it emits `Escape`.
#[derive(Debug, Clone, Copy)]
pub enum Key {
Char(char),
Enter,
Backspace,
/// Ctrl+Backspace or Ctrl+W — delete the word before the caret.
DeleteWord,
/// Cmd/GUI+Backspace — delete back to the start of the current line.
DeleteLine,
/// Caps Lock tapped on its own. A no-op for now; groundwork for a future
/// vim-style normal mode.
Escape,
}
/// A decoded key-down event, re-exported from the `keymap` crate. The decode
/// logic (edge detection + US-QWERTY translation) lives there — pure, with no
/// esp/std deps — so it is host-testable and fuzzable off the xtensa target.
/// This module only bridges it to the USB transport. See MEMORY_AUDIT.md.
pub use keymap::Key;
/// Boot-keyboard parameters, confirmed by Spike 4's enumeration.
const KBD_INTERFACE: u8 = 0;
@@ -71,6 +60,10 @@ const SET_IDLE_INFINITE: [u8; 8] = [0x21, 0x0a, 0x00, 0x00, KBD_INTERFACE, 0x00,
static NEW_DEV_ADDR: AtomicU8 = AtomicU8::new(0);
/// Set when the open device is unplugged.
static DEV_GONE: AtomicBool = AtomicBool::new(false);
/// Whether a keyboard is currently open (attached + set up). Unlike `DEV_GONE`
/// (a one-shot detach event the client loop consumes), this is the persistent
/// connection state the side-panel disconnect flag reads via `keyboard_present`.
static KBD_PRESENT: AtomicBool = AtomicBool::new(false);
/// Control-transfer completion, published by `ctrl_cb` to the setup routine.
static CTRL_DONE: AtomicBool = AtomicBool::new(false);
static CTRL_STATUS: AtomicU32 = AtomicU32::new(0);
@@ -79,19 +72,36 @@ static CTRL_STATUS: AtomicU32 = AtomicU32::new(0);
/// mutex-guarded queue rather than a channel because `mpsc::Sender` is not
/// `Sync` and so can't live in a `static`.
static KEY_QUEUE: OnceLock<Mutex<VecDeque<Key>>> = OnceLock::new();
/// Keycodes held in the previous report, for key-down edge detection. Only
/// ever touched from the single client thread's `report_cb`.
static PREV_KEYS: Mutex<[u8; 6]> = Mutex::new([0; 6]);
/// Caps Lock dual-role tracking: set while Caps is held once any other key is
/// pressed, so releasing Caps only emits `Escape` on a clean tap. Only touched
/// from the client thread's `report_cb`.
static CAPS_USED: AtomicBool = AtomicBool::new(false);
/// Edge-detecting decode state (previous report + Caps dual-role), owned here
/// as one `keymap::Decoder` rather than the pair of loose statics it used to
/// be. Only ever touched from the single client thread's `report_cb`; the mutex
/// is for the `static`, not contention. The decode logic itself is the
/// host-tested `keymap` crate.
static DECODER: Mutex<keymap::Decoder> = Mutex::new(keymap::Decoder::new());
/// US-International dead-key accent composition, downstream of the decoder: it
/// folds a dead key (`'` `` ` `` `^` `"` `~`) plus the next letter into one
/// accented `Key::Char`. Like `DECODER`, only ever touched from the client
/// thread. Safe to feed the editor only because its buffer is UTF-8-correct —
/// this emits non-ASCII Latin-9 characters. Host-tested in the `keymap` crate.
static COMPOSER: Mutex<keymap::Composer> = Mutex::new(keymap::Composer::new());
/// Whether the interrupt-IN report transfer is currently in-flight (submitted
/// and awaiting completion). Set on submit, cleared the moment `report_cb`
/// fires. Read on unplug to quiesce the transfer before freeing it — freeing an
/// in-flight transfer races the library's pending completion into a
/// use-after-free (MEMORY_AUDIT.md finding #1).
static REPORT_INFLIGHT: AtomicBool = AtomicBool::new(false);
/// Pop the next decoded key-down event, if any.
pub fn next_key() -> Option<Key> {
KEY_QUEUE.get()?.lock().unwrap().pop_front()
}
/// Whether a USB keyboard is currently attached and set up. Read by the main
/// loop to drive the side-panel disconnect flag.
pub fn keyboard_present() -> bool {
KBD_PRESENT.load(Ordering::SeqCst)
}
/// Install the USB Host Library and spawn the daemon + client event pumps.
/// Returns once the stack is up; key events then arrive via `next_key()`.
pub fn start() -> Result<(), EspError> {
@@ -150,30 +160,73 @@ fn client_loop() {
let addr = NEW_DEV_ADDR.swap(0, Ordering::SeqCst);
if addr != 0 {
// A new attach while a device is still open means we missed the
// detach event; tear the old one down first so its transfer and
// handle aren't leaked and overwritten (MEMORY_AUDIT.md finding #3).
if !open_dev.is_null() {
log::warn!("new device while one is still open; closing the previous keyboard");
close_device(client, &mut open_dev, &mut report_xfer);
}
match setup_keyboard(client, addr) {
Ok((dev, xfer)) => {
open_dev = dev;
report_xfer = xfer;
KBD_PRESENT.store(true, Ordering::SeqCst);
}
Err(e) => log::error!("keyboard setup failed: {e:?}"),
}
}
if DEV_GONE.swap(false, Ordering::SeqCst) && !open_dev.is_null() {
log::info!("keyboard unplugged; releasing interface and closing");
// Order per the USB Host Library: free transfers, release
// interfaces, then close the device.
if !report_xfer.is_null() {
unsafe { usb_host_transfer_free(report_xfer) };
report_xfer = ptr::null_mut();
}
unsafe { usb_host_interface_release(client, open_dev, KBD_INTERFACE) };
unsafe { usb_host_device_close(client, open_dev) };
open_dev = ptr::null_mut();
*PREV_KEYS.lock().unwrap() = [0; 6];
close_device(client, &mut open_dev, &mut report_xfer);
}
}
}
/// Tear down the open keyboard: quiesce + free the report transfer, release the
/// interface, close the device, then reset the decode + presence state. Order
/// per the USB Host Library: free transfers, release interfaces, then close.
///
/// The report transfer is freed only once it is no longer in-flight. On unplug
/// the library completes the pending interrupt transfer with a canceled status
/// and fires `report_cb` (which clears `REPORT_INFLIGHT`); we pump client events
/// until that happens so we never hand `usb_host_transfer_free` a transfer the
/// lower layer still owns — doing so would race the pending completion into a
/// use-after-free (MEMORY_AUDIT.md finding #1). Bounded so a wedged transfer
/// can't spin the client loop forever; if it never quiesces we leak it rather
/// than risk the free.
fn close_device(
client: usb_host_client_handle_t,
open_dev: &mut usb_device_handle_t,
report_xfer: &mut *mut usb_transfer_t,
) {
if !(*report_xfer).is_null() {
let mut spins = 0;
while REPORT_INFLIGHT.load(Ordering::SeqCst) && spins < 100 {
unsafe { usb_host_client_handle_events(client, 10) };
spins += 1;
}
if REPORT_INFLIGHT.load(Ordering::SeqCst) {
log::error!(
"report transfer still in-flight after drain; leaking it rather than \
freeing (a free here would be a use-after-free)"
);
} else {
let err = unsafe { usb_host_transfer_free(*report_xfer) };
if err != 0 {
log::warn!("usb_host_transfer_free(report) returned {err}");
}
}
*report_xfer = ptr::null_mut();
}
unsafe { usb_host_interface_release(client, *open_dev, KBD_INTERFACE) };
unsafe { usb_host_device_close(client, *open_dev) };
*open_dev = ptr::null_mut();
DECODER.lock().unwrap().reset();
COMPOSER.lock().unwrap().reset(); // drop any half-typed accent
KBD_PRESENT.store(false, Ordering::SeqCst);
}
/// Client event callback — runs inside `usb_host_client_handle_events`. Keep
/// it minimal: stash what happened and let the client loop do the FFI work.
unsafe extern "C" fn client_event_cb(msg: *const usb_host_client_event_msg_t, _arg: *mut c_void) {
@@ -204,14 +257,28 @@ unsafe extern "C" fn ctrl_cb(transfer: *mut usb_transfer_t) {
/// `usb_host_client_handle_events`. On any non-completed status (e.g. the
/// device was unplugged and the transfer canceled) it stops resubmitting.
unsafe extern "C" fn report_cb(transfer: *mut usb_transfer_t) {
// A completion fired, so the transfer is no longer in-flight. Clear the flag
// first — the non-completed (canceled-on-unplug) path below returns without
// resubmitting, and leaving it false is what lets close_device free the
// transfer safely (MEMORY_AUDIT.md finding #1).
REPORT_INFLIGHT.store(false, Ordering::SeqCst);
let t = unsafe { &mut *transfer };
if t.status == usb_transfer_status_t_USB_TRANSFER_STATUS_COMPLETED {
let n = (t.actual_num_bytes as usize).min(BOOT_REPORT_LEN);
// SAFETY: data_buffer was allocated with BOOT_REPORT_LEN bytes and `n`
// is clamped to that, so the slice stays within the allocation even if
// the device reports a bogus actual_num_bytes.
let report = unsafe { core::slice::from_raw_parts(t.data_buffer, n) };
handle_report(report);
// Decode HID → keys, then fold dead-key accents before enqueuing.
DECODER
.lock()
.unwrap()
.feed(report, |k| COMPOSER.lock().unwrap().feed(k, enqueue));
let err = unsafe { usb_host_transfer_submit(transfer) };
if err != 0 {
log::error!("interrupt resubmit failed: {err}");
} else {
REPORT_INFLIGHT.store(true, Ordering::SeqCst);
}
} else {
log::info!("interrupt transfer stopped, status {}", t.status as u32);
@@ -226,116 +293,6 @@ fn enqueue(key: Key) {
}
}
/// Caps Lock usage ID — repurposed as a dual-role Ctrl/Escape key.
const CAPS: u8 = 0x39;
/// Edge-detect key-downs in an 8-byte boot report and enqueue translated keys.
/// Layout: [modifiers, reserved, key1..key6]; 0 means "no key".
fn handle_report(report: &[u8]) {
if report.len() < 3 {
return;
}
let mods = report[0];
let shift = mods & 0x22 != 0; // LShift 0x02 | RShift 0x20
let cmd = mods & 0x88 != 0; // LGUI 0x08 | RGUI 0x80
let current = &report[2..];
let mut prev = PREV_KEYS.lock().unwrap();
// Caps Lock is a normal key in the boot report (not a modifier bit), so we
// track its down/up edges here. Held, it acts as Ctrl; tapped alone, it
// emits Escape.
let caps_now = current.contains(&CAPS);
let caps_before = prev.contains(&CAPS);
let ctrl = mods & 0x11 != 0 || caps_now; // LCtrl 0x01 | RCtrl 0x10, or Caps
// Any other key down while Caps is held means it was used as Ctrl — so its
// release must not fire Escape.
if caps_now && current.iter().any(|&k| k != 0 && k != CAPS) {
CAPS_USED.store(true, Ordering::SeqCst);
}
for &k in current {
if k == 0 || k == CAPS || prev.contains(&k) {
continue; // empty slot, the Caps key itself, or already held
}
if let Some(key) = translate(k, shift, ctrl, cmd) {
enqueue(key);
}
}
// Caps released as a clean tap (nothing else pressed while it was down) →
// Escape. Reset the used-flag on both the press and release edges.
if caps_before && !caps_now {
if !CAPS_USED.swap(false, Ordering::SeqCst) {
enqueue(Key::Escape);
}
} else if caps_now && !caps_before {
CAPS_USED.store(false, Ordering::SeqCst);
}
let mut next = [0u8; 6];
for (slot, &k) in next.iter_mut().zip(current.iter()) {
*slot = k;
}
*prev = next;
}
/// Translate a HID keyboard usage ID to a key event using a US QWERTY layout.
/// Editing combos (Ctrl/Cmd chords) resolve to intents here and take priority
/// over character insertion; other keys with Ctrl or Cmd held are swallowed.
fn translate(usage: u8, shift: bool, ctrl: bool, cmd: bool) -> Option<Key> {
match usage {
0x2a => {
// Backspace: Cmd = delete line, Ctrl = delete word, else one char.
return Some(if cmd {
Key::DeleteLine
} else if ctrl {
Key::DeleteWord
} else {
Key::Backspace
});
}
0x1a if ctrl => return Some(Key::DeleteWord), // Ctrl+W, readline-style
_ => {}
}
// With Ctrl or Cmd held and no combo matched above, insert nothing — so
// Caps+J or Cmd+S don't type a stray character.
if ctrl || cmd {
return None;
}
let key = match usage {
0x04..=0x1d => {
let base = b'a' + (usage - 0x04);
Key::Char(if shift { base.to_ascii_uppercase() } else { base } as char)
}
0x1e..=0x27 => {
const UNSHIFTED: [char; 10] = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'];
const SHIFTED: [char; 10] = ['!', '@', '#', '$', '%', '^', '&', '*', '(', ')'];
let i = (usage - 0x1e) as usize;
Key::Char(if shift { SHIFTED[i] } else { UNSHIFTED[i] })
}
0x28 => Key::Enter,
0x2a => Key::Backspace,
0x2b => Key::Char('\t'),
0x2c => Key::Char(' '),
0x2d => Key::Char(if shift { '_' } else { '-' }),
0x2e => Key::Char(if shift { '+' } else { '=' }),
0x2f => Key::Char(if shift { '{' } else { '[' }),
0x30 => Key::Char(if shift { '}' } else { ']' }),
0x31 => Key::Char(if shift { '|' } else { '\\' }),
0x33 => Key::Char(if shift { ':' } else { ';' }),
0x34 => Key::Char(if shift { '"' } else { '\'' }),
0x35 => Key::Char(if shift { '~' } else { '`' }),
0x36 => Key::Char(if shift { '<' } else { ',' }),
0x37 => Key::Char(if shift { '>' } else { '.' }),
0x38 => Key::Char(if shift { '?' } else { '/' }),
_ => return None,
};
Some(key)
}
/// Open a newly-attached device, dump its descriptors, claim the keyboard
/// interface, put it in boot protocol, and start polling for reports.
fn setup_keyboard(
@@ -415,7 +372,12 @@ fn control_request(
}
CTRL_DONE.store(false, Ordering::SeqCst);
esp!(unsafe { usb_host_transfer_submit_control(client, xfer) })?;
if let Err(e) = esp!(unsafe { usb_host_transfer_submit_control(client, xfer) }) {
// Free the transfer we allocated before bailing, or it leaks
// (MEMORY_AUDIT.md finding #3).
unsafe { usb_host_transfer_free(xfer) };
return Err(e);
}
while !CTRL_DONE.load(Ordering::SeqCst) {
unsafe { usb_host_client_handle_events(client, u32::MAX) };
}
@@ -443,6 +405,12 @@ fn start_report_polling(dev: usb_device_handle_t) -> Result<*mut usb_transfer_t,
t.callback = Some(report_cb);
t.context = ptr::null_mut();
}
esp!(unsafe { usb_host_transfer_submit(xfer) })?;
if let Err(e) = esp!(unsafe { usb_host_transfer_submit(xfer) }) {
// Free the transfer we allocated before bailing, or it leaks
// (MEMORY_AUDIT.md finding #3).
unsafe { usb_host_transfer_free(xfer) };
return Err(e);
}
REPORT_INFLIGHT.store(true, Ordering::SeqCst);
Ok(xfer)
}

2
hardware/case/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
# generated mesh exports — regenerate with `just stl`
*.stl

206
hardware/case/README.md Normal file
View File

@@ -0,0 +1,206 @@
# Enclosure — typewriter body (concept)
Part of [**Typoena**](../../README.md) — the distraction-free DIY writing
machine. This page covers the enclosure only; the project root README covers
the whole appliance (hardware, software stack, roadmap).
A 3D-printable case for Typoena. The e-paper strip sits on a reclined **deck**
where a typewriter's sheet of paper would be; the keyboard you bring rests in
front. There is **no platen part** (it complicates the print) — the rounded
back-top edge is a subtle roll that nods to one for free.
> **Status: v0 concept, not yet printed.** Outer form and the screen-retention
> / board-mounting strategy are worked out and render cleanly. Board hole
> positions and port offsets are placeholders marked `<< MEASURE >>` in the
> `.scad` — confirm them against the real board before printing a final.
![The deck: bezel lip framing the e-paper aperture, screen ghosted in](renders/assembled.png)
![Assembled body, three-quarter — reclined deck and the back wall with its port cutouts](renders/front34.png)
## Files
| File | What |
| ---------------------------------------- | ------------------------------------------------------- |
| [`typoena-case.scad`](typoena-case.scad) | The parametric model. All dimensions live at the top. |
| [`concept.html`](concept.html) | Dimensioned side/front/top drawing (open in a browser). |
| `renders/` | PNG previews (regenerated by the commands below). |
## Render / preview
Needs [OpenSCAD](https://openscad.org). Open `typoena-case.scad` in the GUI and
flip the `show` variable, or from the CLI:
```sh
cd hardware/case
# assembled, coloured, screen ghosted in
openscad -o renders/assembled.png --imgsize=1100,825 --colorscheme=Tomorrow \
--camera=0,0,0,62,0,22,0 --viewall --autocenter \
-D 'show="assembled"' typoena-case.scad
# export a printable part to STL (body | bracket | baseplate)
openscad -o body.stl -D 'show="body"' typoena-case.scad
```
`show` accepts `assembled` · `body` · `bracket` · `baseplate` · `print_plate` ·
`section` (vertical cut) · `plan` (exploded horizontal) · `plan_up` / `plan_down`
(each half on its own).
## Dimensions
From the datasheets, baked into the model:
- **Panel — GDEY0579T93:** glass outline **150.92 × 56.94 × 1.0 mm**, active
area **139.00 × 47.74 mm**, pitch 0.1755 mm. Strip aspect ~2.9:1.
- **Board — ESP32-S3-DevKitC-1:** ~**70 × 28 mm**, USB-C ×2 + reset/boot on one
short edge (that edge faces the back wall).
- **Body (default):** 176 W × 104 D, 24 mm front → 58 mm back, deck reclined
**~21°**. Walls 2.4 mm, deck 2.6 mm, corner radius 8 mm.
The deck angle is the one knob worth tuning first — see below.
## How the hardware goes in — glueless
The whole design avoids glue on the fragile 1 mm glass and keeps every part
serviceable.
![The bare body shell — screen recess cut through the deck, FPC slot on the left short edge](renders/body.png)
### Screen (bezel lip + foam + screwed bracket)
```
front face the sandwich, front → back:
┌───────────────┐ 1. deck BEZEL LIP (overlaps ~45 mm of the
│ ┌─────────┐ │ glass's inactive border only, never the
│ │ active │ │ ← lip active area — lip_t = 1.4 mm of material)
│ └─────────┘ │ 2. GLASS drops into the recess from behind;
└───────────────┘ the recess walls locate it in X/Y
3. FOAM gasket (non-adhesive closed-cell,
[lip][glass][foam][bracket] foam_t ≈ 1 mm) spreads the clamp load
↑ screwed to 4 bosses so you never point-crack the glass
4. printed BRACKET, screwed to 4 bosses,
presses the stack forward
```
- The through-**aperture** is a hair larger than the active area and stays
_inside_ the glass-minus-lip envelope, so the lip covers only dead border.
- The recess opens into the cavity, and an **internal FPC clearance** on the
**left** (the user's left as they face the screen) — kept below the bezel lip
so it's invisible from outside — lets the flex fold back to the DESPI-C579
breakout. Nothing breaks the external bezel.
- Foam does three jobs: cushions the glass, takes up print tolerance
(±0.20.5 mm), and removes any need for adhesive. Cut it from a plain EVA/
PORON sheet — no sticky backing.
- Alternative if you want no screws: replace the bracket with printed
cantilever clips. It works, but clips point-load the glass edge; the
foam+bracket route is gentler and I'd default to it.
The lip alone can't hold the glass — it only stops it falling _out_ the front.
The glass is **trapped at both edges** between the front lip and the rear
foam+bracket; the bracket is what stops it dropping _into_ the cavity
(`show="section"`):
![Midline section: glass clamped between the front lip and the rear foam + bracket; the internal FPC clearance is hidden below the left bezel, standoffs on the cavity floor](renders/section.png)
![The screwed bracket — four corner holes, window clears the active area, and the left edge is relieved to give the FPC room to U-turn into the cavity](renders/bracket.png)
### Boards (the baseplate is the chassis)
Mount everything to the **baseplate** on the bench, then drop it in and close
from below — far easier than fishing screws inside a shell.
- ESP32 + DESPI-C579 sit on printed **standoffs** (M2.5 self-tap). Positions in
`esp_holes` / `brk_holes` are placeholders — set them to your board's holes.
No mounting holes on your board rev? Switch to slide-in edge rails.
- The **DESPI-C579 breakout** sits in the cavity on the **left**, right under the
FPC exit; short SPI jumpers (MOSI/SCLK/CS/DC/RST/BUSY + 3V3/GND) run across to
the ESP32. Keep that left channel clear.
- A **reset button** — a small momentary switch, soldered to the board's **EN**
and **GND** header pins — mounts through a hole in the back wall, out past the
µSD slot so it's never hit while typing (`rst_*` params, `<< MEASURE >>`). This
is our own switch, not the DevKitC's on-board buttons: those are top-actuated
and buried once the board lies flat. **BOOT is deliberately omitted** — on the
S3, auto-download (UART-bridge DTR/RTS or the native USB-Serial-JTAG) makes it
recovery-only, not worth a fat-fingerable button on a writing appliance.
- The baseplate screws **up into 4 corner posts** in the shell.
- A **cable relief** notch at the back lets the keyboard's USB-C cable exit and
route around to the front.
![The baseplate — four standoffs for the ESP32 (centre) and two for the DESPI-C579 breakout (left, under the FPC exit); mount at the bench, then close from below](renders/baseplate.png)
Exploded horizontal section (`show="plan"`) — the deck/screen half lifts off
the cavity half so you see both at once: ESP32 standoffs centre, DESPI-C579
standoffs left under the FPC exit, the two back corner posts, and the three
port openings in the back wall.
![Exploded plan section: deck/screen half lifted above the cavity half — standoffs, corner posts, back-wall ports](renders/plan.png)
Or inspect each half alone — `plan_up` (the deck/lid, shown from below) and
`plan_down` (the cavity):
![Top half (plan_up) — deck underside: the screen in its recess and the bracket bosses](renders/plan-up.png)
![Bottom half (plan_down) — the cavity: standoffs, corner posts, back-wall ports](renders/plan-down.png)
### Assembly order
1. Lay glass into the deck recess (from inside), add the foam gasket, screw the
bracket down onto the 4 bosses.
2. Screw ESP32 + breakout to the baseplate standoffs.
3. Connect the FPC (screen → breakout) through the slot.
4. Screw the baseplate up into the corner posts.
## Tune first
- **`Hb` (back height) → deck angle.** 1822° is typewriter-shallow; raise `Hb`
toward ~2835° if the screen reads too edge-on when you're sitting close.
- **`<< MEASURE >>` items:** `esp_holes`, `brk_holes`, `port_x`, `port_z`,
`rst_x`/`rst_z`/`rst_d` (reset button), `active_off_x/y` (the panel's active
area sits off-centre from the glass).
## Print notes
![The three printed parts laid out — body, bracket, baseplate](renders/print.png)
- **Material:** PLA/PETG. Print the body in matte **indigo** (`#130f40`), the
bracket/base in cream or brass — two-tone reads unmistakably "typewriter" for
the price of a filament swap.
- **Make the engrave read:** on a body this dark the recessed `TYPOENA` is
near-invisible until you give it contrast — a swipe of paint pen in the recess,
or a 34 layer filament swap across the nameplate band mid-print.
- **Shell, not solid:** 2.4 mm walls + open bottom keep material low despite the
chunky body form.
- **Orientation:** body deck-up (or on its back) needs little/no support; the
bezel lip is a small overhang. Bracket and baseplate print flat.
## Nameplate font
![The recessed TYPOENA engrave on the deck, in Monaspace Krypton](renders/nameplate.png)
The `TYPOENA` engrave on the deck (recessed, faces the user) is set via the
`name_font` parameter. Current pick: **Monaspace Krypton** (GitHub's mechanical
mono). OpenSCAD only renders fonts installed on the system:
```sh
# current: Monaspace Krypton (installs the whole family)
brew install --cask font-monaspace
# alternative tried: Cutive Mono (typewriter slab-serif)
curl -sL -o ~/Library/Fonts/CutiveMono-Regular.ttf \
https://github.com/google/fonts/raw/main/ofl/cutivemono/CutiveMono-Regular.ttf
fc-cache -f # so the OpenSCAD CLI picks either up
```
To audition other faces: [Google Fonts](https://fonts.google.com/?preview.text=TYPOENA%0ATypoena%0Atypoena&categoryFilters=Appearance:%2FMonospace%2FMonospace), filtered to monospace and previewing the
name.
## Open questions / TODO
- [ ] Confirm the GDEY0579T93 active-area **offset** (FPC confirmed on the left
edge); adjust `active_off_x/y`.
- [ ] Real board mounting-hole + port coordinates. **A custom carrier PCB is
planned** — when it lands, re-fix `esp_holes`, `port_x/z`, and the reset
`rst_*` to the PCB (and decide there whether to expose BOOT as a test pad).
- [ ] Optional **hinged lid** over the deck (portable-typewriter-case echo,
protects the glass in a bag) — `docs/hardware.md` calls for one; not yet
modelled.
- [ ] Decide feet: printed (modelled) vs. stick-on rubber bumpers.

255
hardware/case/concept.html Normal file
View File

@@ -0,0 +1,255 @@
<title>Typoena — Enclosure Concept</title>
<style>
:root{
--paper:#E7E1D0; --sheet:#F2EDE0; --ink:#26231D; --ink-soft:#5b564c;
--acc:#3C6152; --acc-l:#6E9483; --dim:#B0553B; --hair:#B9B09A;
--screen:#F7F4EA;
--serif:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,"Times New Roman",serif;
--mono:"SFMono-Regular",ui-monospace,Menlo,Consolas,"Courier New",monospace;
}
*{box-sizing:border-box;}
body{margin:0;}
.wrap{
background:
radial-gradient(120% 120% at 50% 0%, #ECE7D8 0%, var(--paper) 60%);
min-height:100%;
padding:clamp(16px,3vw,44px);
color:var(--ink);
font-family:var(--serif);
}
.sheet{
max-width:1180px;margin:0 auto;background:var(--sheet);
border:1.5px solid var(--ink);
box-shadow:0 1px 0 var(--ink),0 22px 50px -28px rgba(38,35,29,.55);
}
/* titleblock */
.titleblock{
display:grid;grid-template-columns:1.7fr .8fr .8fr .9fr;
border-bottom:1.5px solid var(--ink);
}
.tb{padding:12px 16px;border-right:1px solid var(--ink);}
.tb:last-child{border-right:0;}
.tb .k{font-family:var(--mono);font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-soft);}
.tb .v{font-family:var(--mono);font-size:14px;margin-top:3px;font-variant-numeric:tabular-nums;}
.tb-title .name{font-family:var(--serif);font-size:clamp(22px,3.4vw,34px);line-height:1.02;letter-spacing:-.01em;font-weight:600;text-wrap:balance;}
.tb-title .sub{font-family:var(--mono);font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--acc);margin-top:6px;}
/* intro */
.intro{padding:18px 22px 6px;border-bottom:1px solid var(--hair);}
.intro p{margin:0 auto;max-width:70ch;font-size:16px;line-height:1.5;color:#3a362e;}
.intro .lead{font-style:italic;color:var(--acc);}
/* view panels */
.views{display:grid;grid-template-columns:1fr;gap:0;}
.panel{padding:14px 18px 18px;border-bottom:1px solid var(--hair);}
.panel h2{
font-family:var(--mono);font-size:11px;letter-spacing:.2em;text-transform:uppercase;
color:var(--ink);margin:0 0 2px;display:flex;justify-content:space-between;align-items:baseline;
}
.panel h2 .tag{color:var(--dim);}
.panel .cap{font-family:var(--serif);font-style:italic;color:var(--ink-soft);font-size:13px;margin:0 0 8px;}
.lower{display:grid;grid-template-columns:1fr 1fr;}
.lower .panel:first-child{border-right:1px solid var(--hair);}
svg{width:100%;height:auto;display:block;}
svg text{font-family:var(--mono);}
/* notes */
.notes{padding:18px 22px 24px;display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:18px 26px;}
.note h3{font-family:var(--mono);font-size:11px;letter-spacing:.16em;text-transform:uppercase;margin:0 0 6px;color:var(--acc);display:flex;gap:8px;align-items:baseline;}
.note h3 .n{color:var(--dim);font-variant-numeric:tabular-nums;}
.note p{margin:0;font-size:14px;line-height:1.45;color:#3a362e;}
.note p b{color:var(--ink);font-weight:600;}
.foot{border-top:1.5px solid var(--ink);padding:10px 18px;display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;
font-family:var(--mono);font-size:10.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--ink-soft);}
.legend{display:flex;gap:16px;flex-wrap:wrap;align-items:center;font-family:var(--mono);font-size:10.5px;letter-spacing:.08em;color:var(--ink-soft);margin-top:6px;}
.legend span{display:inline-flex;gap:6px;align-items:center;}
.sw{width:14px;height:10px;border:1px solid var(--ink);display:inline-block;}
@media (max-width:720px){
.titleblock{grid-template-columns:1fr 1fr;}
.tb-title{grid-column:1/-1;border-right:0;border-bottom:1px solid var(--ink);}
.lower{grid-template-columns:1fr;}
.lower .panel:first-child{border-right:0;}
}
</style>
<div class="wrap">
<div class="sheet">
<div class="titleblock">
<div class="tb tb-title">
<div class="name">Typoena</div>
<div class="sub">Enclosure concept · typewriter body</div>
</div>
<div class="tb"><div class="k">Panel</div><div class="v">GDEY0579T93</div><div class="k" style="margin-top:8px">Board</div><div class="v">ESP32-S3</div></div>
<div class="tb"><div class="k">Units</div><div class="v">mm</div><div class="k" style="margin-top:8px">Scale</div><div class="v">to-fit</div></div>
<div class="tb"><div class="k">Rev</div><div class="v">v0 · concept</div><div class="k" style="margin-top:8px">Date</div><div class="v">2026-07-11</div></div>
</div>
<div class="intro">
<p><span class="lead">The screen is the sheet of paper.</span> A shallow sage wedge with a reclined deck; the e-paper strip sits where a typewriter's page would be, a decorative platen roller and two knobs ride the back edge, and the keyboard you bring rests in front. Every dimension below is from the real datasheets — this is the shape to react to before I cut the parametric model.</p>
<div class="legend">
<span><i class="sw" style="background:var(--acc)"></i>body</span>
<span><i class="sw" style="background:var(--acc-l)"></i>platen / deck / feet</span>
<span><i class="sw" style="background:var(--screen)"></i>e-paper</span>
<span><i class="sw" style="background:var(--dim);border-color:var(--dim)"></i>dimensions</span>
</div>
</div>
<div class="views">
<div class="panel">
<h2>Side profile <span class="tag">— the silhouette</span></h2>
<p class="cap">The money view: shallow deck reclined ~18°, platen &amp; knob at the back, ports on the tail, your keyboard ghosted in front.</p>
<svg id="v-side" viewBox="-82 -96 200 118" preserveAspectRatio="xMidYMid meet" role="img" aria-label="Side profile of the enclosure"></svg>
</div>
<div class="lower">
<div class="panel">
<h2>Front elevation <span class="tag">— width</span></h2>
<p class="cap">Letterbox screen across the deck; knobs poke past the sides.</p>
<svg id="v-front" viewBox="-34 -96 244 118" preserveAspectRatio="xMidYMid meet" role="img" aria-label="Front elevation"></svg>
</div>
<div class="panel">
<h2>Top plan <span class="tag">— deck layout</span></h2>
<p class="cap">Screen forward, platen band aft, ports along the back edge.</p>
<svg id="v-top" viewBox="-34 -120 244 136" preserveAspectRatio="xMidYMid meet" role="img" aria-label="Top plan view"></svg>
</div>
</div>
</div>
<div class="notes">
<div class="note"><h3><span class="n">01</span> Cheap-ish body</h3><p>You picked the higher-material form, but it stays a <b>shell</b>: ~2.4&nbsp;mm walls, open bottom, a screwed baseplate. Hollow inside, no infill spent on a solid block.</p></div>
<div class="note"><h3><span class="n">02</span> Color does the work</h3><p>Print the body in <b>matte sage</b>, platen &amp; knobs in cream or black. Two-tone via a filament change reads unmistakably "typewriter" for free.</p></div>
<div class="note"><h3><span class="n">03</span> Decorative cues</h3><p>Platen roller + two knobs are <b>cosmetic</b> (your call) — printed separately, no wiring, no GPIO. Round feet finish the machined look.</p></div>
<div class="note"><h3><span class="n">04</span> Fragile glass</h3><p>The 1&nbsp;mm panel drops into a rebate behind a bezel lip that overlaps ~6&nbsp;mm; the FPC folds back to the DESPI-C579 breakout in the cavity.</p></div>
<div class="note"><h3><span class="n">05</span> The one tunable</h3><p><b>Deck angle</b> is the ergonomics dial. 18° is typewriter-shallow; 2835° reads better seated. Say the word and I'll steepen it.</p></div>
<div class="note"><h3><span class="n">06</span> Optional lid</h3><p>Your docs say "hinged lid" — a cover latching over the deck echoes a portable typewriter's case and guards the glass in a bag. Toggled off by default.</p></div>
</div>
<div class="foot">
<span>Typoena · writing appliance</span>
<span>Screen 150.9 × 56.9 × 1.0 · active 139.0 × 47.7 · pitch 0.1755</span>
<span>Orthographic · third-angle · not for manufacture</span>
</div>
</div>
</div>
<script>
(function(){
// ---- parameters (mm) ----
var W=176, D=104, Hf=24, Hb=58; // body: width, depth, front/back height
var SW=151, SH=57, AW=139.0, AH=47.74; // screen glass + active area
var sx0=(W-SW)/2, sx1=(W+SW)/2; // screen x extents
var sy0=8, sy1=8+54; // screen y extents on deck (57 along slope ~ 54 in plan)
var Py=92, Pr=11, Kr=14; // platen y-centre, roller radius, knob radius
function zTop(y){ return Hf + (Hb-Hf)*y/D; }
var Pz=zTop(Py)+Pr; // platen centre height (sits on deck)
var footR=6;
// ---- palette ----
var INK="#26231D", PAPER="#F7F4EA", ACC="#3C6152", ACCL="#6E9483",
DIM="#B0553B", HAIR="#B9B09A", GHOST="#8f8a7c";
var MONO="'SFMono-Regular',ui-monospace,Menlo,Consolas,'Courier New',monospace";
// ---- primitives (model coords; +b is up, drawn as svg y=-b) ----
function poly(pts,fill,stroke,sw){var p=pts.map(function(q){return q[0]+","+(-q[1]);}).join(" ");
return '<polygon points="'+p+'" fill="'+fill+'" stroke="'+stroke+'" stroke-width="'+sw+'" stroke-linejoin="round"/>';}
function rect(a,b,w,h,fill,stroke,sw,rx,dash){
return '<rect x="'+a+'" y="'+(-(b+h))+'" width="'+w+'" height="'+h+'" rx="'+(rx||0)+'" fill="'+fill+'" stroke="'+stroke+'" stroke-width="'+sw+'"'+(dash?' stroke-dasharray="'+dash+'"':'')+'/>';}
function circ(a,b,r,fill,stroke,sw){return '<circle cx="'+a+'" cy="'+(-b)+'" r="'+r+'" fill="'+fill+'" stroke="'+stroke+'" stroke-width="'+sw+'"/>';}
function line(a1,b1,a2,b2,stroke,sw,dash){
return '<line x1="'+a1+'" y1="'+(-b1)+'" x2="'+a2+'" y2="'+(-b2)+'" stroke="'+stroke+'" stroke-width="'+sw+'" stroke-linecap="round"'+(dash?' stroke-dasharray="'+dash+'"':'')+'/>';}
function txt(a,b,s,o){o=o||{};var fs=o.fs||3.4,fill=o.fill||INK,anc=o.anchor||"middle",
rot=o.rot?' transform="rotate('+o.rot+' '+a+' '+(-b)+')"':'',ls=o.ls!=null?o.ls:0.2;
return '<text x="'+a+'" y="'+(-b)+'" font-size="'+fs+'" fill="'+fill+'" text-anchor="'+anc+'" letter-spacing="'+ls+'"'+rot+' font-family="'+MONO+'">'+s+'</text>';}
// dimension helpers (oxide red)
function hdim(a1,a2,b,label,below){var t=1.6,s="";
s+=line(a1,b,a2,b,DIM,0.3);
s+=line(a1-t,b-t,a1+t,b+t,DIM,0.3);
s+=line(a2-t,b-t,a2+t,b+t,DIM,0.3);
s+=txt((a1+a2)/2, b+(below?-4.2:2.4), label, {fs:3.2,fill:DIM});
return s;}
function vdim(b1,b2,a,label,right){var t=1.6,s="";
s+=line(a,b1,a,b2,DIM,0.3);
s+=line(a-t,b1-t,a+t,b1+t,DIM,0.3);
s+=line(a-t,b2-t,a+t,b2+t,DIM,0.3);
s+=txt(a+(right?3.2:-3.2),(b1+b2)/2,label,{fs:3.2,fill:DIM,rot:-90});
return s;}
function screenlines(x0,y0,x1,y1,n){var s="",i,y;for(i=1;i<=n;i++){y=y0+(y1-y0)*i/(n+1);
s+=line(x0+2,y,x1-2,y,HAIR,0.3);}return s;}
// ---- SIDE (a=depth Y, b=height Z) ----
(function(){var s="";
s+=line(-66,0,110,0,HAIR,0.4); // ground
s+=rect(-58,0,50,12,"rgba(60,97,82,.06)",GHOST,0.4,2,"2 1.6"); // keyboard ghost
s+=txt(-33,-4,"BYO KEYBOARD",{fs:2.9,fill:GHOST});
s+=poly([[0,0],[D,0],[D,Hb],[0,Hf]],ACC,INK,0.7); // wedge
s+=txt(30,37,"DECK ≈18°",{fs:3.1,fill:PAPER});
// screen strip on slope
s+=line(sy0,zTop(sy0),sy1,zTop(sy1),INK,3.9);
s+=line(sy0,zTop(sy0),sy1,zTop(sy1),PAPER,3.1);
// platen roller + knob
s+=circ(Py,Pz,Pr,ACCL,INK,0.5);
s+=circ(Py,Pz,Kr,ACCL,INK,0.6);
s+=circ(Py,Pz,2.4,ACC,INK,0.4);
s+=txt(Py,Pz+Kr+3,"PLATEN Ø22",{fs:3,fill:DIM});
// ports on back face
s+=rect(D-6,9,6,4,PAPER,INK,0.4,.6);
s+=rect(D-6,15,6,4,PAPER,INK,0.4,.6);
s+=rect(D-6,21,7,3,PAPER,INK,0.4,.6);
s+=line(D,16,D+8,16,DIM,0.3);
s+=txt(D+9,16,"USB-C ×2 + µSD",{fs:2.9,fill:INK,anchor:"start"});
// feet
s+=rect(6,-5,12,5,ACCL,INK,0.4,1);
s+=rect(D-18,-5,12,5,ACCL,INK,0.4,1);
// dims
s+=hdim(0,D,-9,"104 depth",true);
s+=vdim(0,Hf,-4,"24");
s+=vdim(0,Hb,D+4,"58",true);
document.getElementById("v-side").innerHTML=s;
})();
// ---- FRONT (a=width X, b=height Z) ----
(function(){var s="";
s+=line(-14,0,W+14,0,HAIR,0.4);
s+=rect(0,0,W,Hf,ACC,INK,0.7,2); // front face
s+=rect(0,Hf,W,Hb-Hf,ACCL,INK,0.5); // deck sliver behind
// screen (foreshortened band)
s+=rect(sx0,27,SW,18,PAPER,INK,0.5,1);
s+=screenlines(sx0,27,sx1,45,4);
// platen bar + knobs
s+=rect(2,Pz-4,W-4,8,ACCL,INK,0.5,4);
s+=circ(-2,Pz,Kr,ACCL,INK,0.6); s+=circ(-2,Pz,2.4,ACC,INK,0.4);
s+=circ(W+2,Pz,Kr,ACCL,INK,0.6); s+=circ(W+2,Pz,2.4,ACC,INK,0.4);
// feet
s+=rect(8,-5,14,5,ACCL,INK,0.4,1);
s+=rect(W-22,-5,14,5,ACCL,INK,0.4,1);
// dims
s+=hdim(0,W,-9,"176 width",true);
s+=hdim(-16,W+16,Pz+Kr+2,"≈208 across knobs");
s+=hdim(sx0,sx1,20,"151 glass · 139 active");
document.getElementById("v-front").innerHTML=s;
})();
// ---- TOP (a=width X, b=depth Y; front y=0 at bottom) ----
(function(){var s="";
s+=rect(0,0,W,D,"rgba(60,97,82,.10)",INK,0.7,4); // body outline
// screen
s+=rect(sx0,sy0,SW,sy1-sy0,PAPER,INK,0.5,1);
s+=screenlines(sx0,sy0,sx1,sy1,5);
s+=txt(W/2,(sy0+sy1)/2,"e-paper",{fs:3.4,fill:ACC});
s+=txt(W/2,sy0-4,"active 139.0 × 47.74",{fs:2.8,fill:INK});
// platen band + knobs
s+=rect(2,Py-6,W-4,12,ACCL,INK,0.5,4);
s+=circ(-6,Py,Kr,ACCL,INK,0.6); s+=circ(-6,Py,2.4,ACC,INK,0.4);
s+=circ(W+6,Py,Kr,ACCL,INK,0.6); s+=circ(W+6,Py,2.4,ACC,INK,0.4);
s+=txt(W/2,Py,"platen (decorative)",{fs:2.9,fill:INK});
// ports along back edge (y=D)
s+=rect(60,D-2,7,4,PAPER,INK,0.4,.6);
s+=rect(72,D-2,7,4,PAPER,INK,0.4,.6);
s+=rect(86,D-2,8,3,PAPER,INK,0.4,.6);
s+=txt(W/2,D+4,"USB-C ×2 · microSD",{fs:2.8,fill:DIM});
// dims
s+=hdim(0,W,-6,"176",true);
s+=vdim(0,D,W+8,"104 deep",true);
document.getElementById("v-top").innerHTML=s;
})();
})();
</script>

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -0,0 +1,384 @@
// ============================================================================
// Typoena — 3D-printed enclosure · "typewriter body" · rev v0 (concept)
// ----------------------------------------------------------------------------
// A shallow sage wedge. The e-paper strip sits on a reclined deck where a
// typewriter's sheet of paper would be; the keyboard you bring rests in front.
// No platen part (keeps the print simple) — the rounded back-top edge is a
// subtle roll that nods to one for free.
//
// Everything here is PARAMETRIC. Numbers that come from a datasheet are noted;
// numbers marked << MEASURE >> are best-guesses you must confirm against the
// real board / breakout before printing a final.
//
// Units: millimetres. Render: see hardware/case/README.md
//
// Parts (set `show` below):
// "assembled" everything in place, coloured (screen ghosted in)
// "body" the shell only (print deck-up or on its back)
// "bracket" the screen retaining frame (print flat)
// "baseplate" the chassis / bottom cover (print flat)
// "print_plate" all printed parts laid out side by side
// "section" vertical cross-section: how the screen is trapped
// "plan" exploded horizontal section: deck lifted off the cavity
// "plan_up" just the top half (deck / screen / bracket)
// "plan_down" just the bottom half (cavity: standoffs, posts, ports)
// ============================================================================
show = "assembled";
$fn = 20;
// ---- body envelope --------------------------------------------------------
W = 176; // width (X) — screen 150.9 + bezel + walls
D = 104; // depth (Y) — front (keyboard) .. back (ports)
Hf = 24; // height at the FRONT edge
Hb = 58; // height at the BACK edge (Hf<Hb makes the reclined deck)
wall = 2.4; // side/back wall thickness
top_wall = 2.6; // deck thickness (before the bezel lip is cut into it)
corner_r = 8; // rounded vertical + top-edge radius (the "machined" look)
// deck slope, derived from the pillar centres (this is the *true* top plane)
theta = atan((Hb - Hf) / (D - 2*corner_r)); // ~21 deg with the defaults
// >> THE ergonomics dial. Raise Hb for a more vertical, easier-to-read screen;
// lower it for a flatter, more typewriter-like deck. 18-22 deg = shallow,
// 28-35 deg reads better when you're sitting close.
// ---- e-paper panel : GDEY0579T93 (datasheet) ------------------------------
G_w = 150.92; G_h = 56.94; G_t = 1.0; // glass outline W x H x thickness
A_w = 139.00; A_h = 47.74; // active area (must stay uncovered)
// NOTE: the real panel's active area is offset toward the FPC edge — this model
// centres it. << MEASURE >> your panel's border and shift screen_off if needed.
screen_off = 0; // (legacy) kept 0; see active_off_*
// This panel's flex (FPC) leaves the LEFT short edge — the user's left as they
// face the screen, i.e. the low-X side (world x < W/2). The aperture is centred
// on the ACTIVE area, which sits off-centre on the glass — measure yours and
// nudge these (+x = toward the right, away from the FPC edge). << MEASURE >>
active_off_x = 0;
active_off_y = 0;
// ---- screen retention (glueless) ------------------------------------------
lip_over = 4.0; // how far the front bezel lip overlaps the glass border
lip_t = 1.4; // deck material left in FRONT of the glass (the visible lip)
glass_gap = 0.5; // clearance around the glass in its pocket
foam_t = 1.0; // non-adhesive closed-cell foam gasket behind the glass
bracket_t = 2.6; // printed retaining frame thickness
fpc_w = 26; // ribbon-slot span along the LEFT short edge (the FPC side)
// ---- deck nameplate (engraved, faces the user) ----------------------------
name_text = "TYPOENA";
name_size = 6.5; // cap height in mm
name_depth = 0.8; // engrave depth — raise for a bolder, deeper cut
name_font = "Monaspace Krypton"; // install once — see README (Nameplate font)
A_ap_w = A_w + 2; // through-aperture (a hair bigger than active)
A_ap_h = A_h + 1; // still smaller than glass minus 2*lip
P_w = G_w + glass_gap; // glass pocket (locates the glass in X/Y)
P_h = G_h + glass_gap;
// screen placed centred on the deck (measured up the slope)
deck_L = (D - 2*corner_r) / cos(theta); // deck length along the slope
screen_cy = deck_L/2; // centre it
boss_r = 3.4; // M2 self-tap boss for the bracket
// ---- ports on the back wall (ESP32-S3-DevKitC-1 edge) --------------------
port_z = 7; // height of the port centres off the desk << MEASURE >>
usbc_w = 9.6; usbc_h = 3.6; // USB-C opening (with clearance)
sd_w = 12; sd_h = 2.4; // microSD slot
// X positions of the three openings along the back << MEASURE to your board >>
port_x = [W/2 - 15, W/2, W/2 + 17]; // usb-c (kbd), usb-c (power), µSD
// ---- reset button (momentary wired to EN/GND) -----------------------------
// Our OWN switch, soldered to the board's EN + GND header pins — NOT the
// DevKitC's on-board buttons (top-actuated and buried once the board lies flat
// on its standoffs). It sits on the back wall, out past the µSD, so it's never
// hit while typing. BOOT is left off on purpose: on the S3, auto-download
// (UART-bridge DTR/RTS or the native USB-Serial-JTAG) makes it recovery-only —
// not worth a fat-fingerable button on a writing appliance.
rst_btn = true; // set false to omit the reset hole entirely
rst_d = 7.2; // through-hole Ø for the switch barrel << MEASURE >>
rst_x = W/2 + 40; // X along the back wall (past the µSD @ +17) << MEASURE >>
rst_z = 12; // centre height off the desk << MEASURE >>
// ---- baseplate / chassis --------------------------------------------------
bp_t = 2.6; // baseplate thickness
bp_gap = 0.5; // clearance so it drops into the shell
foot_r = 7; // round feet (the little typewriter feet)
foot_h = 3.5;
post_r = 4.2; // corner screw posts inside the shell (M2.5 self-tap)
post_pilot = 1.15;
// board mounting standoffs on the baseplate << MEASURE hole positions >>
standoff_h = 6;
standoff_pilot = 1.15;
// ESP32-S3-DevKitC-1 is ~70 x 28 mm; these are PLACEHOLDER hole coords:
esp_holes = [[W/2-33, 30],[W/2+33, 30],[W/2-33, 54],[W/2+33, 54]];
// DESPI-C579 breakout sits in the cavity on the LEFT, under the FPC exit; SPI
// wires (MOSI/SCLK/CS/DC/RST/BUSY + 3V3/GND) run from here across to the ESP32.
// PLACEHOLDER hole coords << MEASURE >>:
brk_holes = [[22, 40],[22, 66]];
// ---- colours (for the assembled render) -----------------------------------
C_body = "#B6CEB4";
C_plate = "#C9C3B2";
C_bracket= "#2B2B2B";
C_screen = "#F7F4EA";
C_foam = "#8a8f94";
// ---- cutaway sections -----------------------------------------------------
plan_z = 22; // height of the horizontal "plan" cut
plan_explode = 62; // gap between the halves in the exploded "plan" view
// ===========================================================================
// helpers
// ===========================================================================
module rrect(w, d, r) { // 2D rounded rectangle, centred
hull() for (mx=[-1,1], my=[-1,1])
translate([mx*(w/2-r), my*(d/2-r)]) circle(r=r);
}
// place children onto the reclined deck plane. Origin at the FRONT-TOP edge
// (world y=0, z=Hf) — where the true hull top surface actually begins; anchor
// it at the pillar centre instead and everything lands ~3mm below the surface.
// local frame: X = width, Y = up the slope, Z = out of the deck (normal).
module on_deck() {
translate([W/2, 0, Hf]) rotate([theta, 0, 0]) children();
}
// ===========================================================================
// body
// ===========================================================================
module body_outer() {
hull() {
translate([corner_r, corner_r, 0]) cylinder(h=Hf, r=corner_r);
translate([W-corner_r, corner_r, 0]) cylinder(h=Hf, r=corner_r);
translate([corner_r, D-corner_r, 0]) cylinder(h=Hb, r=corner_r);
translate([W-corner_r, D-corner_r, 0]) cylinder(h=Hb, r=corner_r);
}
}
module body_cavity() {
ri = corner_r - wall;
hull() {
translate([corner_r, corner_r, -3]) cylinder(h=Hf-top_wall+3, r=ri);
translate([W-corner_r, corner_r, -3]) cylinder(h=Hf-top_wall+3, r=ri);
translate([corner_r, D-corner_r, -3]) cylinder(h=Hb-top_wall+3, r=ri);
translate([W-corner_r, D-corner_r, -3]) cylinder(h=Hb-top_wall+3, r=ri);
}
}
// 4 corner posts the baseplate screws up into
module corner_posts() {
for (px=[corner_r+3, W-corner_r-3], py=[corner_r+3, D-corner_r-3]) {
h = (py < D/2) ? Hf-top_wall : Hb-top_wall;
translate([px, py, 0]) difference() {
cylinder(h=h, r=post_r);
translate([0,0,-1]) cylinder(h=h+2, r=post_pilot);
}
}
}
// 4 bosses just OUTSIDE the glass pocket for the retaining bracket
module bracket_bosses() {
on_deck() for (bx=[-(P_w/2+5), P_w/2+5],
by=[screen_cy-(P_h/2+5), screen_cy+(P_h/2+5)]) {
blen = lip_t + G_t + foam_t + bracket_t + 6;
translate([bx, by, -lip_t-blen]) difference() {
cylinder(h=blen, r=boss_r);
translate([0,0,-1]) cylinder(h=blen+2, r=1.0); // M2 self-tap
}
}
}
// deck cuts: through-aperture, glass pocket (leaves the front lip), FPC slot
module screen_cuts() {
on_deck() translate([0, screen_cy, 0]) {
// window — centred on the ACTIVE area (offset toward the FPC/left edge)
translate([active_off_x, active_off_y, -30])
cube([A_ap_w, A_ap_h, 66], center=true);
// glass pocket behind the lip — centred on the glass outline
translate([0, 0, -30-lip_t]) cube([P_w, P_h, 60], center=true);
// FPC clearance: an internal notch in the LEFT recess wall, kept BELOW
// the bezel lip so it stays invisible from outside — the flex passes the
// glass's left edge and folds back into the cavity, to the breakout
translate([-P_w/2, 0, -30-lip_t]) cube([14, fpc_w, 60], center=true);
}
}
module port_cuts() {
// USB-C x2 + microSD through the back wall (y = D)
for (i=[0:2]) {
pw = (i==2) ? sd_w : usbc_w;
ph = (i==2) ? sd_h : usbc_h;
translate([port_x[i], D-wall-1, port_z])
rotate([-90,0,0]) linear_extrude(wall+2)
offset(r=0.8) square([pw-1.6, ph-1.6], center=true);
}
}
// reset switch mounting hole through the back wall (y = D)
module reset_cut() {
if (rst_btn)
translate([rst_x, D-wall-1, rst_z])
rotate([-90,0,0]) cylinder(h=wall+2, r=rst_d/2);
}
// engraved nameplate on the DECK, in the band between the front edge and the
// screen — faces the user as they write. Sits flat on the reclined deck.
module nameplate() {
name_y = (screen_cy - P_h/2) / 2; // centre of the front deck band
on_deck() translate([screen_off, name_y, -name_depth])
linear_extrude(name_depth + 0.6)
text(name_text, size=name_size, halign="center", valign="center",
font=name_font, spacing=1.1);
}
module case_body() {
difference() {
union() {
difference() { body_outer(); body_cavity(); }
corner_posts();
bracket_bosses();
}
screen_cuts();
port_cuts();
reset_cut();
nameplate(); // engrave (comment out for a blank face)
}
}
// ===========================================================================
// screen retaining bracket (printed flat, screwed to the 4 bosses)
// ===========================================================================
module bracket() {
ow = P_w + 18; oh = P_h + 18;
// FPC U-turn clearance: a gap in the LEFT frame member. The flex leaves the
// glass's back plane and folds ~180° to dive into the cavity toward the
// breakout; a safe bend radius (~1.5-2 mm) makes that loop ~4 mm deep, too
// deep for the 1 mm foam gap, so it fouls this rigid frame unless relieved
// here. Lines up with the body's FPC slot (screen_cuts) and the foam relief.
difference() {
linear_extrude(bracket_t)
difference() {
rrect(ow, oh, 4);
rrect(A_ap_w+2, A_ap_h+2, 2);
translate([-(ow + A_ap_w+2)/4, 0])
square([(ow - (A_ap_w+2))/2 + 4, fpc_w], center=true);
}
for (bx=[-(P_w/2+5), P_w/2+5], by=[-(P_h/2+5), P_h/2+5])
translate([bx, by, -1]) cylinder(h=bracket_t+2, r=1.45); // M2 clear
}
}
// ===========================================================================
// baseplate / chassis
// ===========================================================================
module baseplate() {
iw = W - 2*wall - bp_gap;
id = D - 2*wall - bp_gap;
difference() {
union() {
// plate (centred on the footprint)
translate([W/2, D/2, 0]) linear_extrude(bp_t) rrect(iw, id, corner_r-wall);
// round feet underneath
for (fx=[corner_r+6, W-corner_r-6], fy=[corner_r+6, D-corner_r-6])
translate([fx, fy, -foot_h]) cylinder(h=foot_h+0.1, r=foot_r);
// board standoffs on top
for (h = concat(esp_holes, brk_holes))
translate([h[0], h[1], bp_t]) cylinder(h=standoff_h, r=3);
}
// corner screw clearance (into the body posts)
for (px=[corner_r+3, W-corner_r-3], py=[corner_r+3, D-corner_r-3])
translate([px, py, -foot_h-1]) cylinder(h=bp_t+foot_h+2, r=1.6);
// standoff pilot holes
for (h = concat(esp_holes, brk_holes))
translate([h[0], h[1], bp_t-1]) cylinder(h=standoff_h+2, r=standoff_pilot);
// cable / connector relief at the back
translate([W/2, D-wall-3, -1]) cube([30, 8, bp_t+2], center=false);
}
}
// ===========================================================================
// assemblies
// ===========================================================================
module ghost_screen() {
on_deck() translate([screen_off, screen_cy+screen_off, -lip_t-G_t/2])
color(C_screen) cube([G_w, G_h, G_t], center=true);
}
module placed_bracket() {
on_deck() translate([screen_off, screen_cy+screen_off,
-lip_t-G_t-foam_t-bracket_t])
color(C_bracket) bracket();
}
// foam gasket (non-adhesive) — a border frame between glass and bracket, with
// its LEFT border opened over the FPC span so the U-turning flex isn't clamped
module foam() {
linear_extrude(foam_t)
difference() {
rrect(P_w+4, P_h+4, 3);
rrect(A_ap_w, A_ap_h, 2);
translate([-((P_w+4) + A_ap_w)/4, 0])
square([((P_w+4) - A_ap_w)/2 + 4, fpc_w], center=true);
}
}
module placed_foam() {
on_deck() translate([screen_off, screen_cy+screen_off, -lip_t-G_t-foam_t])
color(C_foam) foam();
}
// full coloured assembly, reused by the plan sections
module plan_assembly() {
color(C_body) case_body();
ghost_screen();
placed_foam();
placed_bracket();
translate([0,0,-0.01]) color(C_plate) baseplate();
}
// the two halves of the horizontal cut at plan_z
module plan_down() { // bottom: the cavity (standoffs, posts, ports)
intersection() {
plan_assembly();
translate([-60, -60, plan_z-200]) cube([W+120, D+120, 200]);
}
}
module plan_up() { // top: the deck / lid (screen, bracket)
intersection() {
plan_assembly();
translate([-60, -60, plan_z]) cube([W+120, D+120, 200]);
}
}
if (show == "assembled") {
color(C_body) case_body();
ghost_screen();
placed_bracket();
translate([0,0,-0.01]) color(C_plate) baseplate();
} else if (show == "body") {
color(C_body) case_body();
} else if (show == "bracket") {
color(C_bracket) bracket();
} else if (show == "baseplate") {
color(C_plate) baseplate();
} else if (show == "print_plate") {
color(C_body) case_body();
translate([W+30, 0, 0]) color(C_plate) baseplate();
translate([W+30, D+30, foot_h]) color(C_bracket) bracket();
} else if (show == "section") {
// VERTICAL slice (remove +X half): cut face shows the screen clamp, and the
// retained LEFT half exposes the internal FPC clearance behind the bezel
difference() {
union() {
color(C_body) case_body();
ghost_screen();
placed_foam();
placed_bracket();
translate([0,0,-0.01]) color(C_plate) baseplate();
}
translate([W/2, -30, -70]) cube([W, D+60, 220]);
}
} else if (show == "plan") {
// EXPLODED horizontal section: deck/lid half lifted off the cavity half
plan_down();
translate([0, 0, plan_explode]) plan_up();
} else if (show == "plan_up") {
plan_up(); // just the top half — deck, screen, bracket
} else if (show == "plan_down") {
plan_down(); // just the bottom half — cavity, standoffs, ports
}

40
hardware/justfile Normal file
View File

@@ -0,0 +1,40 @@
# Typoena enclosure — 3D-printed case
# Run from the hardware/ directory (just sets cwd here automatically).
scad := "case/typoena-case.scad"
# shared OpenSCAD flags (everything but the camera + the part to show).
# $fn is pinned here so preview quality is stable no matter what the file's
# editing value is set to.
base := "--imgsize=1100,825 --colorscheme=Tomorrow --viewall --autocenter -D '$fn=48'"
# the standard 3/4 camera used by most previews (rotate 62° tilt, 22° azimuth)
std_cam := "--camera=0,0,0,62,0,22,0"
# list available recipes
default:
@just --list
# open the case model in OpenSCAD
open:
open -a OpenSCAD {{scad}}
# regenerate every preview PNG in case/renders/
render:
cd case && openscad -o renders/assembled.png {{base}} {{std_cam}} -D 'show="assembled"' typoena-case.scad
cd case && openscad -o renders/body.png {{base}} {{std_cam}} -D 'show="body"' typoena-case.scad
cd case && openscad -o renders/bracket.png {{base}} {{std_cam}} -D 'show="bracket"' typoena-case.scad
cd case && openscad -o renders/baseplate.png {{base}} {{std_cam}} -D 'show="baseplate"' typoena-case.scad
cd case && openscad -o renders/print.png {{base}} {{std_cam}} -D 'show="print_plate"' typoena-case.scad
cd case && openscad -o renders/section.png {{base}} {{std_cam}} -D 'show="section"' typoena-case.scad
cd case && openscad -o renders/plan.png {{base}} {{std_cam}} -D 'show="plan"' typoena-case.scad
cd case && openscad -o renders/plan-up.png {{base}} {{std_cam}} -D 'show="plan_up"' typoena-case.scad
cd case && openscad -o renders/plan-down.png {{base}} {{std_cam}} -D 'show="plan_down"' typoena-case.scad
# two bespoke cameras: the straight-on nameplate hero, and the low back 3/4
cd case && openscad -o renders/nameplate.png {{base}} --camera=0,0,0,62,0,0,0 -D 'show="body"' typoena-case.scad
cd case && openscad -o renders/front34.png {{base}} --camera=0,0,0,72,0,200,0 -D 'show="assembled"' typoena-case.scad
# export STLs for the three printable parts
stl:
cd case && openscad -o body.stl -D 'show="body"' typoena-case.scad
cd case && openscad -o bracket.stl -D 'show="bracket"' typoena-case.scad
cd case && openscad -o baseplate.stl -D 'show="baseplate"' typoena-case.scad

2
keymap/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/target
/Cargo.lock

10
keymap/Cargo.toml Normal file
View File

@@ -0,0 +1,10 @@
[package]
name = "keymap"
version = "0.1.0"
edition = "2021"
description = "Pure HID boot-keyboard decode: the Key event type, a US-QWERTY translate, and an edge-detecting Decoder. No esp/std deps, so it is host-testable and fuzzable off-device (see MEMORY_AUDIT.md)."
# Deliberately dependency-free and #![no_std] (except under test) so the decode
# path — the one place untrusted device bytes are parsed — can be exercised on
# the host without the ESP toolchain. The firmware crate depends on this by path.
[dependencies]

682
keymap/src/lib.rs Normal file
View File

@@ -0,0 +1,682 @@
//! Pure HID boot-keyboard decode — the logic half of `firmware/src/usb_kbd.rs`,
//! extracted so it can be built and tested on the host (the firmware crate is
//! pinned to the xtensa target and can't run `cargo test`).
//!
//! It owns nothing hardware-shaped: no USB transfers, no logging, no globals.
//! You feed it raw 8-byte boot reports and it emits decoded [`Key`] events via
//! a callback. `firmware` wires the USB interrupt endpoint to [`Decoder::feed`];
//! tests here drive it directly.
//!
//! Why this is the module worth testing: [`Decoder::feed`] is the one place
//! device-controlled bytes are parsed, and [`translate`] is the sole source of
//! *ASCII* `Key::Char`, whose byte==char guarantee the editor's indexing relies
//! on. [`Composer`] adds US-International dead-key accent folding downstream; it
//! is the one deliberate source of *non-ASCII* (Latin-9) `Key::Char`, and so
//! must not reach the editor until the buffer is UTF-8-correct (see its docs).
//! All three invariants are pinned by the tests below. See MEMORY_AUDIT.md.
#![cfg_attr(not(test), no_std)]
#![forbid(unsafe_code)]
/// A decoded key-down event. Beyond plain characters, the decoder recognises a
/// few editing combos (resolved here so the main loop only sees intents) and a
/// dual-role Caps Lock: held it acts as Ctrl, tapped it emits `Escape`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Key {
Char(char),
Enter,
Backspace,
/// Ctrl+Backspace or Ctrl+W — delete the word before the caret.
DeleteWord,
/// Cmd/GUI+Backspace — delete back to the start of the current line.
DeleteLine,
/// Ctrl+D — scroll down half a screen (vim `Ctrl-d`).
HalfPageDown,
/// Ctrl+U — scroll up half a screen (vim `Ctrl-u`).
HalfPageUp,
/// Ctrl+R — redo (vim `Ctrl-r`); the inverse of `u`. Meaningful in Normal;
/// ignored elsewhere.
Redo,
/// Cmd+P — open the file palette (fuzzy open, v0.5), VS Code "Go to File"
/// style. A Normal-mode gesture; **inside** the palette the same chord closes
/// it (toggle). Esc also closes. Ignored in Insert.
Palette,
/// Ctrl+N — move down: one line in Normal/View (vim `CTRL-N` ≡ `j`), or one
/// row in the file palette. Ignored in Insert.
Down,
/// Ctrl+P — move up: one line in Normal/View (vim `CTRL-P` ≡ `k`), or one row
/// in the file palette. Ignored in Insert.
Up,
/// Caps Lock tapped on its own. A no-op for now; groundwork for a future
/// vim-style normal mode.
Escape,
}
/// Caps Lock usage ID — repurposed as a dual-role Ctrl/Escape key.
const CAPS: u8 = 0x39;
/// Edge-detecting boot-report decoder. Holds the previous report's key slots
/// (for key-down edge detection) and the Caps dual-role state. Construct once
/// per attached keyboard; call [`reset`](Decoder::reset) on detach.
#[derive(Debug, Clone)]
pub struct Decoder {
/// Keycodes held in the previous report.
prev: [u8; 6],
/// Set while Caps is held once any other key is pressed, so releasing Caps
/// only emits `Escape` on a clean tap.
caps_used: bool,
}
impl Default for Decoder {
fn default() -> Self {
Self::new()
}
}
impl Decoder {
pub const fn new() -> Self {
Self { prev: [0; 6], caps_used: false }
}
/// Clear all state (call when the keyboard is unplugged so a stale "held"
/// slot from the old device can't suppress the first key of the next one).
pub fn reset(&mut self) {
*self = Self::new();
}
/// Edge-detect key-downs in an 8-byte boot report and emit translated keys.
/// Layout: `[modifiers, reserved, key1..key6]`; `0` means "no key". Robust
/// to any slice length — a short report (< 3 bytes) is ignored, and extra
/// bytes past the six key slots are simply processed too, never indexed
/// out of range.
pub fn feed(&mut self, report: &[u8], mut emit: impl FnMut(Key)) {
if report.len() < 3 {
return;
}
let mods = report[0];
let shift = mods & 0x22 != 0; // LShift 0x02 | RShift 0x20
let cmd = mods & 0x88 != 0; // LGUI 0x08 | RGUI 0x80
let current = &report[2..];
// Caps Lock is a normal key in the boot report (not a modifier bit), so
// we track its down/up edges here. Held, it acts as Ctrl; tapped alone,
// it emits Escape.
let caps_now = current.contains(&CAPS);
let caps_before = self.prev.contains(&CAPS);
let ctrl = mods & 0x11 != 0 || caps_now; // LCtrl 0x01 | RCtrl 0x10, or Caps
// Any other key down while Caps is held means it was used as Ctrl — so
// its release must not fire Escape.
if caps_now && current.iter().any(|&k| k != 0 && k != CAPS) {
self.caps_used = true;
}
for &k in current {
if k == 0 || k == CAPS || self.prev.contains(&k) {
continue; // empty slot, the Caps key itself, or already held
}
if let Some(key) = translate(k, shift, ctrl, cmd) {
emit(key);
}
}
// Caps released as a clean tap (nothing else pressed while it was down)
// → Escape. Reset the used-flag on both the press and release edges.
if caps_before && !caps_now {
if !core::mem::replace(&mut self.caps_used, false) {
emit(Key::Escape);
}
} else if caps_now && !caps_before {
self.caps_used = false;
}
let mut next = [0u8; 6];
for (slot, &k) in next.iter_mut().zip(current.iter()) {
*slot = k;
}
self.prev = next;
}
}
/// Translate a HID keyboard usage ID to a key event using a US QWERTY layout.
/// Editing combos (Ctrl/Cmd chords) resolve to intents here and take priority
/// over character insertion; other keys with Ctrl or Cmd held are swallowed.
///
/// Every `Key::Char` this returns is ASCII — the editor depends on it (a byte
/// offset into its buffer is also a char index). The `translate_only_emits_ascii`
/// test pins this for all 256 usage IDs × modifier combinations.
fn translate(usage: u8, shift: bool, ctrl: bool, cmd: bool) -> Option<Key> {
match usage {
0x2a => {
// Backspace: Cmd = delete line, Ctrl = delete word, else one char.
return Some(if cmd {
Key::DeleteLine
} else if ctrl {
Key::DeleteWord
} else {
Key::Backspace
});
}
0x1a if ctrl => return Some(Key::DeleteWord), // Ctrl+W, readline-style
0x07 if ctrl => return Some(Key::HalfPageDown), // Ctrl+D, half-page down
0x18 if ctrl => return Some(Key::HalfPageUp), // Ctrl+U, half-page up
0x15 if ctrl => return Some(Key::Redo), // Ctrl+R, redo
0x13 if ctrl => return Some(Key::Up), // Ctrl+P, move up (vim CTRL-P)
0x13 if cmd => return Some(Key::Palette), // Cmd+P, file palette
0x11 if ctrl => return Some(Key::Down), // Ctrl+N, move down (vim CTRL-N)
_ => {}
}
// With Ctrl or Cmd held and no combo matched above, insert nothing — so
// Caps+J or Cmd+S don't type a stray character.
if ctrl || cmd {
return None;
}
let key = match usage {
0x04..=0x1d => {
let base = b'a' + (usage - 0x04);
Key::Char(if shift { base.to_ascii_uppercase() } else { base } as char)
}
0x1e..=0x27 => {
const UNSHIFTED: [char; 10] = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'];
const SHIFTED: [char; 10] = ['!', '@', '#', '$', '%', '^', '&', '*', '(', ')'];
let i = (usage - 0x1e) as usize;
Key::Char(if shift { SHIFTED[i] } else { UNSHIFTED[i] })
}
0x28 => Key::Enter,
0x2a => Key::Backspace,
0x2b => Key::Char('\t'),
0x2c => Key::Char(' '),
0x2d => Key::Char(if shift { '_' } else { '-' }),
0x2e => Key::Char(if shift { '+' } else { '=' }),
0x2f => Key::Char(if shift { '{' } else { '[' }),
0x30 => Key::Char(if shift { '}' } else { ']' }),
0x31 => Key::Char(if shift { '|' } else { '\\' }),
0x33 => Key::Char(if shift { ':' } else { ';' }),
0x34 => Key::Char(if shift { '"' } else { '\'' }),
0x35 => Key::Char(if shift { '~' } else { '`' }),
// The physical Esc key (0x29) is repurposed to type backtick / tilde:
// Escape comes from a Caps tap instead, which frees this key to reach
// `~ — and their grave/tilde dead-key accents, and Markdown code fences —
// without a Fn layer on 60% boards.
0x29 => Key::Char(if shift { '~' } else { '`' }),
0x36 => Key::Char(if shift { '<' } else { ',' }),
0x37 => Key::Char(if shift { '>' } else { '.' }),
0x38 => Key::Char(if shift { '?' } else { '/' }),
_ => return None,
};
Some(key)
}
/// The five US-International dead keys, as the characters the QWERTY decoder
/// produces for them: acute `'`, grave `` ` ``, circumflex `^`, diaeresis `"`,
/// tilde `~`. Typing one arms the [`Composer`]; the next key resolves it.
const DEAD_KEYS: [char; 5] = ['\'', '`', '^', '"', '~'];
fn is_dead(c: char) -> bool {
DEAD_KEYS.contains(&c)
}
/// Fold a dead key and the following base letter into a single accented glyph,
/// for the ISO-8859-15 (Latin-9) repertoire the render font carries. Returns
/// `None` when the pair doesn't compose (e.g. `'`+`z`), so the caller can fall
/// back to emitting the accent then the letter.
fn compose(dead: char, base: char) -> Option<char> {
Some(match (dead, base) {
// Acute — plus ç, the roadmap's `'`+c special case.
('\'', 'a') => 'á', ('\'', 'e') => 'é', ('\'', 'i') => 'í',
('\'', 'o') => 'ó', ('\'', 'u') => 'ú', ('\'', 'y') => 'ý',
('\'', 'c') => 'ç',
('\'', 'A') => 'Á', ('\'', 'E') => 'É', ('\'', 'I') => 'Í',
('\'', 'O') => 'Ó', ('\'', 'U') => 'Ú', ('\'', 'Y') => 'Ý',
('\'', 'C') => 'Ç',
// Grave
('`', 'a') => 'à', ('`', 'e') => 'è', ('`', 'i') => 'ì',
('`', 'o') => 'ò', ('`', 'u') => 'ù',
('`', 'A') => 'À', ('`', 'E') => 'È', ('`', 'I') => 'Ì',
('`', 'O') => 'Ò', ('`', 'U') => 'Ù',
// Circumflex
('^', 'a') => 'â', ('^', 'e') => 'ê', ('^', 'i') => 'î',
('^', 'o') => 'ô', ('^', 'u') => 'û',
('^', 'A') => 'Â', ('^', 'E') => 'Ê', ('^', 'I') => 'Î',
('^', 'O') => 'Ô', ('^', 'U') => 'Û',
// Diaeresis
('"', 'a') => 'ä', ('"', 'e') => 'ë', ('"', 'i') => 'ï',
('"', 'o') => 'ö', ('"', 'u') => 'ü', ('"', 'y') => 'ÿ',
('"', 'A') => 'Ä', ('"', 'E') => 'Ë', ('"', 'I') => 'Ï',
('"', 'O') => 'Ö', ('"', 'U') => 'Ü', ('"', 'Y') => 'Ÿ',
// Tilde
('~', 'a') => 'ã', ('~', 'n') => 'ñ', ('~', 'o') => 'õ',
('~', 'A') => 'Ã', ('~', 'N') => 'Ñ', ('~', 'O') => 'Õ',
_ => return None,
})
}
/// US-International dead-key composer: folds a dead key plus the following letter
/// into one accented [`Key::Char`], so the editor still sees a single character.
/// Sits downstream of [`Decoder`] in the key stream — the decoder does HID
/// edge-detection + US-QWERTY translation, this does accent composition.
///
/// **Latin-9, not ASCII.** Unlike [`translate`], this is deliberately a source
/// of non-ASCII `Key::Char` (à, é, ç … the ISO-8859-15 set the render font
/// carries). Its output must therefore NOT be fed to the editor until the editor
/// buffer is UTF-8-correct — byte offsets stepped per character, not per byte
/// (the v0.2 groundwork item). Wiring it into `usb_kbd`'s decode path before
/// then would let a caret motion land mid-char and panic on the next edit, which
/// is why `Decoder` does not route through it yet.
#[derive(Debug, Clone, Default)]
pub struct Composer {
/// The armed dead key awaiting its base letter, if any.
pending: Option<char>,
}
impl Composer {
pub const fn new() -> Self {
Self { pending: None }
}
/// The currently-armed dead key, for the side-panel pending-accent indicator
/// (roadmap v0.2.5). `None` when nothing is pending.
pub fn pending(&self) -> Option<char> {
self.pending
}
/// Drop any pending accent (call on keyboard detach or a mode reset, so a
/// stale dead key can't swallow the next unrelated letter).
pub fn reset(&mut self) {
self.pending = None;
}
/// Feed one decoded key; emit zero, one, or two resolved keys.
///
/// - A dead key (`'` `` ` `` `^` `"` `~`) arms and emits nothing yet.
/// - Armed + a composing letter → the single accented char.
/// - Armed + space → the literal dead-key char (the everyday apostrophe
/// path: `'` then space is a plain `'`); the space is consumed.
/// - Armed + a non-composing char → the accent as a literal, then the char
/// processed fresh (so it may itself arm the next dead key).
/// - Armed + a non-character event (Enter, Backspace, arrows, …) → flush the
/// accent as a literal first, then the event.
pub fn feed(&mut self, key: Key, mut emit: impl FnMut(Key)) {
let Some(dead) = self.pending.take() else {
self.arm_or_emit(key, &mut emit);
return;
};
match key {
Key::Char(' ') => emit(Key::Char(dead)),
Key::Char(c) => match compose(dead, c) {
Some(accented) => emit(Key::Char(accented)),
None => {
emit(Key::Char(dead));
self.arm_or_emit(key, &mut emit);
}
},
other => {
emit(Key::Char(dead));
emit(other);
}
}
}
/// If `key` is a dead-key character, arm it (emitting nothing); otherwise
/// pass it straight through.
fn arm_or_emit(&mut self, key: Key, emit: &mut impl FnMut(Key)) {
if let Key::Char(c) = key {
if is_dead(c) {
self.pending = Some(c);
return;
}
}
emit(key);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Build an 8-byte boot report: modifier byte, reserved 0, then up to six
/// key slots (zero-padded).
fn report(mods: u8, keys: &[u8]) -> Vec<u8> {
let mut r = vec![mods, 0];
r.extend_from_slice(keys);
r.resize(8, 0);
r
}
fn feed(dec: &mut Decoder, report: &[u8]) -> Vec<Key> {
let mut out = Vec::new();
dec.feed(report, |k| out.push(k));
out
}
// ---- translate: the ASCII invariant the editor relies on ----
#[test]
fn translate_only_emits_ascii() {
for usage in 0u8..=255 {
for &shift in &[false, true] {
for &ctrl in &[false, true] {
for &cmd in &[false, true] {
if let Some(Key::Char(c)) = translate(usage, shift, ctrl, cmd) {
assert!(
c.is_ascii(),
"usage {usage:#04x} (shift={shift} ctrl={ctrl} cmd={cmd}) \
produced non-ASCII {c:?} — breaks editor byte==char indexing"
);
}
}
}
}
}
}
#[test]
fn translate_letters_and_shift() {
assert_eq!(translate(0x04, false, false, false), Some(Key::Char('a')));
assert_eq!(translate(0x04, true, false, false), Some(Key::Char('A')));
assert_eq!(translate(0x1d, false, false, false), Some(Key::Char('z')));
assert_eq!(translate(0x1d, true, false, false), Some(Key::Char('Z')));
}
#[test]
fn translate_digits_and_symbols() {
assert_eq!(translate(0x1e, false, false, false), Some(Key::Char('1')));
assert_eq!(translate(0x1e, true, false, false), Some(Key::Char('!')));
assert_eq!(translate(0x27, false, false, false), Some(Key::Char('0')));
assert_eq!(translate(0x27, true, false, false), Some(Key::Char(')')));
}
#[test]
fn translate_backspace_variants() {
assert_eq!(translate(0x2a, false, false, false), Some(Key::Backspace));
assert_eq!(translate(0x2a, false, true, false), Some(Key::DeleteWord)); // Ctrl
assert_eq!(translate(0x2a, false, false, true), Some(Key::DeleteLine)); // Cmd
assert_eq!(translate(0x1a, false, true, false), Some(Key::DeleteWord)); // Ctrl+W
}
#[test]
fn esc_key_is_repurposed_to_backtick_and_tilde() {
// 0x29 (the physical Esc key) types `/~ now; Escape comes from a Caps tap.
assert_eq!(translate(0x29, false, false, false), Some(Key::Char('`')));
assert_eq!(translate(0x29, true, false, false), Some(Key::Char('~')));
}
#[test]
fn translate_ctrl_navigation_and_redo_chords() {
assert_eq!(translate(0x07, false, true, false), Some(Key::HalfPageDown)); // Ctrl+D
assert_eq!(translate(0x18, false, true, false), Some(Key::HalfPageUp)); // Ctrl+U
assert_eq!(translate(0x15, false, true, false), Some(Key::Redo)); // Ctrl+R
assert_eq!(translate(0x13, false, true, false), Some(Key::Up)); // Ctrl+P, up
assert_eq!(translate(0x13, false, false, true), Some(Key::Palette)); // Cmd+P, palette
assert_eq!(translate(0x11, false, true, false), Some(Key::Down)); // Ctrl+N, down
assert_eq!(translate(0x11, false, false, true), None); // Cmd+N reserved (:enew, v0.5)
// Without a modifier these are ordinary letters, not intents.
assert_eq!(translate(0x15, false, false, false), Some(Key::Char('r')));
assert_eq!(translate(0x13, false, false, false), Some(Key::Char('p')));
assert_eq!(translate(0x11, false, false, false), Some(Key::Char('n')));
}
#[test]
fn translate_ctrl_or_cmd_swallows_plain_chars() {
assert_eq!(translate(0x04, false, true, false), None); // Ctrl+a
assert_eq!(translate(0x04, false, false, true), None); // Cmd+a
}
// ---- Decoder: edge detection ----
#[test]
fn key_down_emits_once_then_hold_is_silent() {
let mut d = Decoder::new();
assert_eq!(feed(&mut d, &report(0, &[0x04])), vec![Key::Char('a')]);
// Same key still held → no repeat.
assert_eq!(feed(&mut d, &report(0, &[0x04])), vec![]);
}
#[test]
fn release_then_press_again_re_emits() {
let mut d = Decoder::new();
feed(&mut d, &report(0, &[0x04]));
assert_eq!(feed(&mut d, &report(0, &[])), vec![]); // release
assert_eq!(feed(&mut d, &report(0, &[0x04])), vec![Key::Char('a')]); // re-press
}
#[test]
fn multiple_new_keys_in_one_report() {
let mut d = Decoder::new();
// 'a' (0x04) and 'b' (0x05) newly down in the same report.
assert_eq!(
feed(&mut d, &report(0, &[0x04, 0x05])),
vec![Key::Char('a'), Key::Char('b')]
);
}
#[test]
fn physical_esc_key_decodes_to_backtick_not_escape() {
// End to end: a report with usage 0x29 yields a backtick, not Escape.
let mut d = Decoder::new();
assert_eq!(feed(&mut d, &report(0, &[0x29])), vec![Key::Char('`')]);
}
// ---- Decoder: Caps Lock dual role ----
#[test]
fn caps_tap_emits_escape() {
let mut d = Decoder::new();
assert_eq!(feed(&mut d, &report(0, &[CAPS])), vec![]); // Caps down, nothing
assert_eq!(feed(&mut d, &report(0, &[])), vec![Key::Escape]); // clean release
}
#[test]
fn caps_held_as_ctrl_suppresses_escape() {
let mut d = Decoder::new();
feed(&mut d, &report(0, &[CAPS])); // Caps down
// Caps + Backspace → Ctrl+Backspace = DeleteWord.
assert_eq!(feed(&mut d, &report(0, &[CAPS, 0x2a])), vec![Key::DeleteWord]);
// Releasing Caps must NOT emit Escape (it was used as Ctrl).
assert_eq!(feed(&mut d, &report(0, &[])), vec![]);
}
#[test]
fn modifier_ctrl_and_cmd_backspace() {
let mut d = Decoder::new();
assert_eq!(feed(&mut d, &report(0x01, &[0x2a])), vec![Key::DeleteWord]); // LCtrl
feed(&mut d, &report(0, &[])); // release
assert_eq!(feed(&mut d, &report(0x08, &[0x2a])), vec![Key::DeleteLine]); // LGUI
}
// ---- Decoder: robustness on malformed / untrusted input ----
#[test]
fn short_report_is_ignored() {
let mut d = Decoder::new();
assert_eq!(feed(&mut d, &[]), vec![]);
assert_eq!(feed(&mut d, &[0x00]), vec![]);
assert_eq!(feed(&mut d, &[0x00, 0x00]), vec![]);
}
#[test]
fn never_panics_on_arbitrary_input() {
// The FFI layer clamps reports to 8 bytes, but the decoder must not
// panic on anything — feed it every length 0..=16, every fill byte, a
// full sweep of single-key usages, and a deterministic pseudo-random
// stream. A panic here fails the test.
let mut d = Decoder::new();
for len in 0..=16usize {
for fill in 0u8..=255 {
let buf = vec![fill; len];
d.feed(&buf, |_| {});
}
}
// Every usage ID as the sole key in a well-formed report.
for usage in 0u8..=255 {
d.feed(&report(0xff, &[usage]), |_| {});
}
// Deterministic LCG so the stream is reproducible without a rand dep.
let mut state = 0x1234_5678u32;
for _ in 0..10_000 {
state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
let len = (state >> 28) as usize; // 0..=15
let buf: Vec<u8> = (0..len)
.map(|i| (state.rotate_left(i as u32 * 3) & 0xff) as u8)
.collect();
d.feed(&buf, |_| {});
}
}
#[test]
fn reset_clears_held_state() {
let mut d = Decoder::new();
feed(&mut d, &report(0, &[0x04])); // 'a' held
d.reset();
// After reset the same key reads as a fresh down, not a held slot.
assert_eq!(feed(&mut d, &report(0, &[0x04])), vec![Key::Char('a')]);
}
// ---- Composer: US-International dead-key accent folding ----
fn ch(c: char) -> Key {
Key::Char(c)
}
/// Feed a sequence of keys through a fresh composer and collect the output.
fn compose_keys(seq: &[Key]) -> Vec<Key> {
let mut c = Composer::new();
let mut out = Vec::new();
for &k in seq {
c.feed(k, |k| out.push(k));
}
out
}
#[test]
fn dead_key_composes_accented_letter() {
// The roadmap's worked examples: à é ê ë ñ, and ç via `'`+c.
assert_eq!(compose_keys(&[ch('`'), ch('a')]), vec![ch('à')]);
assert_eq!(compose_keys(&[ch('\''), ch('e')]), vec![ch('é')]);
assert_eq!(compose_keys(&[ch('^'), ch('e')]), vec![ch('ê')]);
assert_eq!(compose_keys(&[ch('"'), ch('e')]), vec![ch('ë')]);
assert_eq!(compose_keys(&[ch('~'), ch('n')]), vec![ch('ñ')]);
assert_eq!(compose_keys(&[ch('\''), ch('c')]), vec![ch('ç')]);
}
#[test]
fn dead_key_composes_uppercase() {
assert_eq!(compose_keys(&[ch('\''), ch('E')]), vec![ch('É')]);
assert_eq!(compose_keys(&[ch('~'), ch('N')]), vec![ch('Ñ')]);
assert_eq!(compose_keys(&[ch('"'), ch('Y')]), vec![ch('Ÿ')]);
assert_eq!(compose_keys(&[ch('\''), ch('C')]), vec![ch('Ç')]);
}
#[test]
fn dead_key_plus_space_is_literal_diacritic() {
// The everyday apostrophe path: `'` then space → a single `'`, space
// consumed. Same for every dead key.
assert_eq!(compose_keys(&[ch('\''), ch(' ')]), vec![ch('\'')]);
assert_eq!(compose_keys(&[ch('^'), ch(' ')]), vec![ch('^')]);
assert_eq!(compose_keys(&[ch('"'), ch(' ')]), vec![ch('"')]);
assert_eq!(compose_keys(&[ch('`'), ch(' ')]), vec![ch('`')]);
assert_eq!(compose_keys(&[ch('~'), ch(' ')]), vec![ch('~')]);
}
#[test]
fn dead_key_plus_noncomposing_emits_accent_then_letter() {
assert_eq!(compose_keys(&[ch('\''), ch('z')]), vec![ch('\''), ch('z')]);
// Grave doesn't compose with 'c' (only acute does, → ç).
assert_eq!(compose_keys(&[ch('`'), ch('c')]), vec![ch('`'), ch('c')]);
}
#[test]
fn noncharacter_event_flushes_pending_accent_first() {
assert_eq!(compose_keys(&[ch('\''), Key::Enter]), vec![ch('\''), Key::Enter]);
assert_eq!(
compose_keys(&[ch('^'), Key::Backspace]),
vec![ch('^'), Key::Backspace]
);
assert_eq!(compose_keys(&[ch('~'), Key::Escape]), vec![ch('~'), Key::Escape]);
assert_eq!(
compose_keys(&[ch('"'), Key::DeleteWord]),
vec![ch('"'), Key::DeleteWord]
);
}
#[test]
fn dead_key_twice_emits_one_then_rearms() {
let mut c = Composer::new();
let mut out = Vec::new();
c.feed(ch('\''), |k| out.push(k)); // arm
assert_eq!(out, vec![]);
assert_eq!(c.pending(), Some('\''));
c.feed(ch('\''), |k| out.push(k)); // second acute: flush one, re-arm
assert_eq!(out, vec![ch('\'')]);
assert_eq!(c.pending(), Some('\''));
c.feed(ch('e'), |k| out.push(k)); // now composes with the re-armed acute
assert_eq!(out, vec![ch('\''), ch('é')]);
assert_eq!(c.pending(), None);
}
#[test]
fn pending_reflects_armed_dead_key() {
let mut c = Composer::new();
assert_eq!(c.pending(), None);
c.feed(ch('~'), |_| {});
assert_eq!(c.pending(), Some('~')); // side-panel indicator would show '~'
c.feed(ch('o'), |_| {}); // resolves
assert_eq!(c.pending(), None);
}
#[test]
fn reset_drops_pending() {
let mut c = Composer::new();
c.feed(ch('`'), |_| {});
assert_eq!(c.pending(), Some('`'));
c.reset();
assert_eq!(c.pending(), None);
// Next base letter is not swallowed by the dropped accent.
let mut out = Vec::new();
c.feed(ch('a'), |k| out.push(k));
assert_eq!(out, vec![ch('a')]);
}
#[test]
fn plain_ascii_passes_through_unchanged() {
let seq: Vec<Key> = "hello world".chars().map(ch).collect();
assert_eq!(compose_keys(&seq), seq);
}
#[test]
fn composes_within_a_word() {
// Keystrokes n a " i v e → "naïve" (the diaeresis folds into ï).
let seq = [ch('n'), ch('a'), ch('"'), ch('i'), ch('v'), ch('e')];
let out: String = compose_keys(&seq)
.into_iter()
.map(|k| match k {
Key::Char(c) => c,
_ => '?',
})
.collect();
assert_eq!(out, "naïve");
}
#[test]
fn every_composed_char_is_non_ascii() {
// The Composer is the deliberate non-ASCII (Latin-9) source; translate
// stays ASCII. If a mapping ever produced an ASCII char it would slip
// past the editor's UTF-8 gate unnoticed — pin it here.
for &dead in &DEAD_KEYS {
for base in [
'a', 'e', 'i', 'o', 'u', 'y', 'c', 'n', 'A', 'E', 'I', 'O', 'U', 'Y', 'C', 'N',
] {
if let Some(accented) = compose(dead, base) {
assert!(
!accented.is_ascii(),
"compose({dead:?}, {base:?}) = {accented:?} must be non-ASCII"
);
}
}
}
}
}