Compare commits
212 Commits
a1f58b7953
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c97e019ec | ||
|
|
7712de6153 | ||
|
|
1df25f75d6 | ||
|
|
acbafa3d6b | ||
|
|
e801d2d480 | ||
|
|
a771c3f6de | ||
|
|
4c92b0dddc | ||
|
|
af5b41a104 | ||
|
|
2660a3e9dd | ||
|
|
79fad4689c | ||
|
|
98fc817b3f | ||
|
|
d306caacf7 | ||
|
|
2166b932b6 | ||
|
|
02b7ed88b6 | ||
|
|
a5edaed810 | ||
|
|
e86a3b8254 | ||
|
|
9309f3f239 | ||
|
|
1a5e1736f5 | ||
|
|
ce3204a350 | ||
|
|
dd3d70cbec | ||
|
|
2c1c590c9e | ||
|
|
2c24ece3a5 | ||
|
|
da8ca7d8d2 | ||
|
|
dade5e6bb3 | ||
|
|
a6f52df8b6 | ||
|
|
6c5e666f4b | ||
|
|
c5e119f6be | ||
|
|
456c4c43e7 | ||
|
|
beb11eda5e | ||
|
|
88bb2b99cf | ||
|
|
69843086f9 | ||
|
|
45664fc956 | ||
|
|
56f5d18bd8 | ||
|
|
767742ba12 | ||
|
|
85cbeceea9 | ||
|
|
8527f75bf8 | ||
|
|
5a076e3226 | ||
|
|
c969d3e051 | ||
|
|
baf9715570 | ||
|
|
4aaf89d977 | ||
|
|
221135cd9b | ||
|
|
93f3a43b34 | ||
|
|
f37da21462 | ||
|
|
fdd59093db | ||
|
|
55a8d21e0f | ||
|
|
d14d9e77a5 | ||
|
|
d46cdb7e7f | ||
|
|
c535864ee7 | ||
|
|
82f305cea6 | ||
|
|
c9c07165e0 | ||
|
|
e967773bd6 | ||
|
|
2215da939d | ||
|
|
fa0ea56e1a | ||
|
|
470a9d25d0 | ||
|
|
937868dd85 | ||
|
|
884c8f2e48 | ||
|
|
32b9bc6a15 | ||
|
|
f8ef9c821c | ||
|
|
f950abdc4a | ||
|
|
137a51eb76 | ||
|
|
ba8f4e9a92 | ||
|
|
8e4e3a7586 | ||
| 657aba3cf3 | |||
|
|
229c259e7c | ||
|
|
f5a0b45f6f | ||
|
|
cb3160541d | ||
|
|
1d7448ba75 | ||
|
|
5d9591e5ea | ||
|
|
97216db0c0 | ||
|
|
8b30658275 | ||
|
|
54dc7a31d3 | ||
|
|
fc83306a81 | ||
|
|
491dc57144 | ||
| 9f797d814c | |||
|
|
a817229ae1 | ||
|
|
17b6631310 | ||
|
|
fe544ccdd3 | ||
|
|
a700412773 | ||
|
|
92068f1cce | ||
|
|
9d6daf7afd | ||
|
|
c206fc28e4 | ||
|
|
ad023843e9 | ||
|
|
3386969655 | ||
|
|
b50043020d | ||
|
|
e57709a9ee | ||
|
|
8dc6ee362f | ||
|
|
98a9d1dffe | ||
|
|
2673a3377a | ||
|
|
6f7f05baf2 | ||
|
|
f4af38d8ef | ||
|
|
14967a06c6 | ||
|
|
18ed6aa599 | ||
|
|
8e3e6e25a4 | ||
|
|
e797381da6 | ||
|
|
ee00fecdc8 | ||
|
|
9101d61045 | ||
|
|
830701e670 | ||
|
|
235c7e4d24 | ||
|
|
4c8c557e7b | ||
|
|
acecf9998f | ||
|
|
81d5f818cc | ||
|
|
637fe4a4f4 | ||
|
|
a61e0a2196 | ||
| fd62fac10e | |||
|
|
7a1f25f5d1 | ||
|
|
1e3220a95f | ||
|
|
9c338571f0 | ||
|
|
5540f901de | ||
|
|
1b9987c4ee | ||
|
|
4bc9236bd7 | ||
|
|
9129f04d0e | ||
|
|
54a9e31ee5 | ||
|
|
fc4ce4d017 | ||
|
|
5335751323 | ||
|
|
b899286639 | ||
|
|
698c10c8c0 | ||
|
|
bef9587a34 | ||
|
|
195311a395 | ||
|
|
3faff25fba | ||
|
|
208c929650 | ||
|
|
32e9623411 | ||
|
|
c4320e2239 | ||
|
|
6af959f92d | ||
|
|
757dd492b9 | ||
|
|
91287899a4 | ||
|
|
314fefc9b6 | ||
|
|
e8482e4e2d | ||
|
|
d47de98c22 | ||
|
|
896ca8073b | ||
|
|
d63a66f4cf | ||
|
|
e28a6a6d19 | ||
|
|
c521d68ad7 | ||
|
|
8fa727702e | ||
|
|
0c3b5b50b1 | ||
|
|
ff0ff0b5ae | ||
|
|
704fd743a0 | ||
|
|
66595c7767 | ||
|
|
13d49930cc | ||
|
|
78958d9de6 | ||
|
|
e92f9c15d3 | ||
|
|
9ea8e74394 | ||
|
|
da23b95cfd | ||
|
|
e6498cdcb3 | ||
|
|
182526cdc8 | ||
|
|
bc13c5b065 | ||
|
|
bdabfcf935 | ||
|
|
e8063a2b13 | ||
|
|
875658a661 | ||
|
|
4460475fb0 | ||
|
|
6c284a94db | ||
|
|
8edd3badfc | ||
|
|
2cd3bba98d | ||
|
|
f373892870 | ||
|
|
371b542d44 | ||
|
|
f197df6dbe | ||
|
|
ad047cc717 | ||
|
|
ba4a8bfad8 | ||
|
|
50f650fef6 | ||
|
|
ddd9c387d3 | ||
|
|
d6b9e46ec1 | ||
|
|
dbe7720a27 | ||
|
|
fcf5815468 | ||
|
|
aa068147ab | ||
|
|
c4326421fd | ||
|
|
fd6ccb729c | ||
|
|
3128bca4ad | ||
|
|
c82130a616 | ||
|
|
571c5ed825 | ||
| b401ea2906 | |||
|
|
77768cc85f | ||
|
|
b746d6e3f9 | ||
|
|
2aa4032e1f | ||
|
|
ef755bac81 | ||
|
|
8dece735d1 | ||
|
|
8e848894fa | ||
|
|
39e1155bf9 | ||
|
|
4153cae9c4 | ||
|
|
afa61deaa6 | ||
|
|
859c4787cf | ||
|
|
cce62bf9f7 | ||
|
|
2f2f1227ec | ||
|
|
d397b31ca4 | ||
|
|
15955a13f4 | ||
|
|
76fae36178 | ||
|
|
2519ed8650 | ||
|
|
bcb4ffa465 | ||
|
|
a15789a1b4 | ||
|
|
63ffd7e37a | ||
|
|
8d80168bda | ||
|
|
a0e58e029a | ||
|
|
4441618d0f | ||
|
|
ac70d8926f | ||
|
|
b216a67c3b | ||
|
|
2b268fe168 | ||
|
|
bea3058004 | ||
|
|
8883a55df6 | ||
|
|
9f36d786d0 | ||
|
|
80f21648ce | ||
|
|
51c8f461c0 | ||
|
|
9c9ad63793 | ||
|
|
12417a9be7 | ||
|
|
7de05794e6 | ||
|
|
0b52f34940 | ||
|
|
9fc21568e7 | ||
|
|
f77f6697d1 | ||
|
|
de14b8418f | ||
|
|
b5274b6e68 | ||
|
|
ac56bc4aec | ||
|
|
a848c05afa | ||
|
|
d3fd6f6ed1 | ||
|
|
c12539d933 | ||
|
|
f8a4d53851 |
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
[submodule "firmware/components/libgit2/vendor"]
|
||||
path = firmware/components/libgit2/vendor
|
||||
url = https://github.com/libgit2/libgit2.git
|
||||
@@ -6,6 +6,7 @@
|
||||
"arguments": []
|
||||
},
|
||||
"initialization_options": {
|
||||
"linkedProjects": ["firmware/Cargo.toml"],
|
||||
"cargo": {
|
||||
"target": "xtensa-esp32s3-espidf",
|
||||
"allTargets": false
|
||||
|
||||
54
CONTEXT.md
@@ -2,7 +2,8 @@
|
||||
|
||||
A single-purpose writing appliance: e-ink + mechanical keyboard + ESP32-S3. The
|
||||
user opens the lid, writes Markdown, and (when they choose) publishes to a git
|
||||
remote. This glossary fixes the language of that workflow.
|
||||
remote. This glossary fixes the language of that workflow, and of the screen
|
||||
the writer looks at while doing it.
|
||||
|
||||
**Related docs:**
|
||||
[`README.md`](README.md) — project overview, hardware, macro roadmap.
|
||||
@@ -14,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
|
||||
@@ -33,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**:
|
||||
@@ -55,6 +75,32 @@ into user-facing language).
|
||||
> prompt. A **Publish** is the only user-observable unit of "shipping work";
|
||||
> internal commits are an implementation detail of that.
|
||||
|
||||
### Screen regions
|
||||
|
||||
**Writing column**:
|
||||
The left region of the panel showing the text being edited — the _only_ region
|
||||
that repaints per keystroke. A 63-col region split into a **line-number gutter**
|
||||
(absolute numbers, 2–4 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 (~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
|
||||
bottom status band are both collapsed into this one right-hand region); sidebar.
|
||||
Do not write bare **panel**: it collides with the **transient panel** (the
|
||||
modal full-screen help/config view that swaps in over the editor — a later
|
||||
release, see [`docs/spikes.md`](docs/spikes.md) Spike 11). Always qualify:
|
||||
_side panel_ vs _transient panel_.
|
||||
|
||||
## Relationships
|
||||
|
||||
- A **File** belongs to exactly one scope (**Tracked** or **Local**), fixed at
|
||||
@@ -68,7 +114,7 @@ into user-facing language).
|
||||
|
||||
> **Dev:** "If I'm in a **Local** file and I press `Ctrl-G`, what happens?"
|
||||
> **Domain expert:** "Nothing — **Publish** is unavailable in **Local**. The
|
||||
> status line says so. There is no path from **Local** to the remote."
|
||||
> side panel says so. There is no path from **Local** to the remote."
|
||||
> **Dev:** "So if I want to publish something that started as a journal entry,
|
||||
> I have to copy-paste it into a **Tracked** file?"
|
||||
> **Domain expert:** "Yes, deliberately. Promotion is a manual gesture, not a
|
||||
@@ -94,7 +140,7 @@ into user-facing language).
|
||||
the remote was cloned on, and never switches.
|
||||
- **Durability before delivery.** A **Publish**'s user-meaningful moment is
|
||||
when the local commit lands (~0.2 s), not when the push completes
|
||||
(~5–10 s). The status line surfaces the commit-landed state as soon as
|
||||
(~5–10 s). The side panel surfaces the commit-landed state as soon as
|
||||
it exists; the remaining push time is the transport of an already-safe
|
||||
thing. Long-form rationale:
|
||||
[`docs/notes/ctrl-g-perceived-latency.md`](docs/notes/ctrl-g-perceived-latency.md).
|
||||
|
||||
244
MEMORY_AUDIT.md
Normal 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 9–10 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.
|
||||
207
README.md
@@ -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: ~12 lines of edit area, ~95 cols. 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,78 +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.3 editing :v03, after v02, 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.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`, status 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
|
||||
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
@@ -0,0 +1,2 @@
|
||||
/target
|
||||
/Cargo.lock
|
||||
10
display/Cargo.toml
Normal 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
@@ -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
@@ -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
@@ -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. |
|
||||
161
docs/adr.md
@@ -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 →
|
||||
@@ -152,7 +152,7 @@ as the price of those properties.
|
||||
|
||||
### Consequences
|
||||
|
||||
- Visible edit area on this panel is ~11 lines. UI must embrace the
|
||||
- Visible writing column on this panel is ~13 lines. UI must embrace the
|
||||
constraint — no multi-pane, no large headers. See
|
||||
[v0.1 product → screen layout](v0.1-mvp-product.md#screen-layout).
|
||||
- Framebuffer is ~27 KB; keeps PSRAM free for git pack data — a top-3
|
||||
@@ -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.
|
||||
@@ -227,6 +227,19 @@ risk table).
|
||||
Implementation: [v0.1 technical → `git` module](v0.1-mvp-technical.md#module-breakdown)
|
||||
and [risks table](v0.1-mvp-technical.md#risks-and-how-well-know-they-bit-us).
|
||||
|
||||
### Outcome — Spike 7, 2026-07-05: kill-switch fired
|
||||
|
||||
`gix` was ruled out for v0.1 and the fallback taken. gitoxide supports push only
|
||||
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/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
|
||||
its HTTP(S) push lands upstream before v0.1 ships.
|
||||
|
||||
---
|
||||
|
||||
## ADR-005: Auth — HTTPS + GitHub Personal Access Token
|
||||
@@ -264,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.
|
||||
|
||||
@@ -342,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.
|
||||
|
||||
---
|
||||
|
||||
@@ -376,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--).
|
||||
|
||||
---
|
||||
|
||||
@@ -462,6 +494,125 @@ retry). Failure surfaces as a single retry-able outcome in the status line.
|
||||
|
||||
---
|
||||
|
||||
## ADR-011: Credential provisioning — how the PAT reaches the device and is protected at rest
|
||||
|
||||
**Status:** Proposed / **Open** — 2026-07-07 (deferred beyond v0.1)
|
||||
**Scope:** Gates the first distribution to any non-dev user.
|
||||
|
||||
### Context
|
||||
|
||||
[ADR-005](#adr-005-auth--https--github-personal-access-token) decided the auth
|
||||
model (HTTPS + PAT) and sketched an endgame ("from v0.9 the PAT moves to
|
||||
encrypted storage with an eFuse-derived key"), but left the *mechanics* open:
|
||||
**how does a token get onto a device, and how is it protected once there?**
|
||||
|
||||
Spike 7 made this concrete. The PAT is currently **baked into the firmware image
|
||||
at build time** (`build.rs` → `env!()` → a `const` in the `.bin`) — ADR-005's
|
||||
deliberate v0.1 shortcut. On a real device that means:
|
||||
|
||||
- the token sits in **plaintext in flash** — anyone with physical access can
|
||||
`esptool read_flash` and extract a working GitHub PAT (push/pull as the user);
|
||||
- it is the **same token on every unit** built from that image — no per-device
|
||||
revocation;
|
||||
- **rotation requires a reflash**.
|
||||
|
||||
This is fine for the dev's own bench unit (it's their token, their device) and is
|
||||
why the [Spike 7 postmortem](postmortems/2026-07-05-spike7-gix-https-push.md)
|
||||
lists it as the *last standing shortcut*. It is not fine for a unit in anyone
|
||||
else's hands. Resolving it needs a **provisioning path**, which the current design
|
||||
(["build-time only, no provisioning module"](v0.1-mvp-technical.md#provisioning--build-time-only-no-module-on-device))
|
||||
deliberately omits.
|
||||
|
||||
### Options considered
|
||||
|
||||
| Option | Pros | Cons |
|
||||
| --- | --- | --- |
|
||||
| **Build-time bake** (current, ADR-005 v0.1) | Zero UX; nothing to build. | Plaintext in flash; same token per unit; reflash to rotate. **Dev-bench only.** |
|
||||
| **On-device paste → NVS (plaintext)** | No reflash; per-device token. | Still plaintext at rest; needs a first-run entry UI (captive portal / keyboard). |
|
||||
| **On-device paste → NVS encrypted (eFuse key)** | ADR-005's stated target; a flash dump alone doesn't yield the token; per-device + rotatable. | Needs NVS encryption + eFuse key derivation + first-run UI — the whole provisioning module. |
|
||||
| **USB provisioning tool** (host writes NVS over serial) | No on-device text entry; scriptable at assembly. | Needs a host-side tool; token still needs at-rest protection (combine with encryption). |
|
||||
| **Per-device fine-grained PAT** (orthogonal) | Least blast radius; per-device revoke; repo-scoped. | User mints one PAT per device; pairs with any storage option above. |
|
||||
| **GitHub App installation token** | Strongest, rotating creds. | Heavy for a single-user appliance — rejected in ADR-005 for overhead. |
|
||||
|
||||
### Decision
|
||||
|
||||
**Open — deferred.** v0.1 stays on the build-time bake (dev-bench only). Before
|
||||
shipping to any non-dev user, pick a provisioning mechanism — the likely shape is
|
||||
**on-device paste → eFuse-encrypted NVS** (ADR-005's target) **+ a per-device
|
||||
fine-grained PAT** to bound the blast radius. That requires the provisioning
|
||||
module currently deferred, NVS encryption, and eFuse key derivation, and ties
|
||||
into [ADR-007](#adr-007-storage-split--fat-on-sd-for-working-copy-littlefs-on-flash-for-config)
|
||||
(where config lives).
|
||||
|
||||
### Consequences
|
||||
|
||||
- **Blocks nothing in v0.1** — the bench unit runs on the baked PAT.
|
||||
- **Gates first non-dev distribution** — this ADR must flip to Accepted (with a
|
||||
chosen mechanism) before a unit leaves the dev's hands.
|
||||
- Whatever is chosen, the token must never be logged or written into the working
|
||||
copy — already enforced in the git module (PAT lives only in libgit2's
|
||||
credential callback).
|
||||
- Rotation UX and the first-run flow are the real work here, not the crypto.
|
||||
|
||||
---
|
||||
|
||||
## 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 26–32,
|
||||
octal PSRAM 33–37, strapping 0/3/45/46, USB 19/20, RGB 38/48, EPD 4–7/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
@@ -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)).
|
||||
281
docs/kaizen/real-repo-sync.md
Normal file
@@ -0,0 +1,281 @@
|
||||
# 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, 0]
|
||||
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; the
|
||||
after column has no bar until the step-6 measurement lands.
|
||||
|
||||
## 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 ~700–900 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 → ~2–3 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 ~150–250 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` ≈ **9–10 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
@@ -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 2–3 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
@@ -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 5–10 s `Ctrl-G` push feel instant. |
|
||||
| [`git-sync-images-and-repo-size.md`](git-sync-images-and-repo-size.md) | Why we don't shrink the notes repo — its 153 MB of media is remanso's image CDN, so rewriting history to slim the on-device clone breaks the web app. |
|
||||
| [`boot-time-budget.md`](boot-time-budget.md) | Where the ~4.3 s to cursor goes — and why the ≤ 3 s v1.0 target is hard: one ~1.9 s full refresh is unavoidable at cold boot, so the splash is nearly free. |
|
||||
| [`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. |
|
||||
73
docs/notes/boot-time-budget.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# Boot-time budget — where the ~4.3 s to cursor goes
|
||||
|
||||
> **Measured 2026-07-11:** cold boot is **4258 ms** power-on → cursor (the
|
||||
> `boot: cursor ready` log prefix). That clears the **≤ 5 s v0.1 gate** with
|
||||
> ~742 ms to spare. This note breaks the number down, and argues that the
|
||||
> **≤ 3 s v1.0 target** is hard on this panel because one ~1.9 s full refresh is
|
||||
> architecturally unavoidable at cold boot.
|
||||
>
|
||||
> Notes index: [`README.md`](README.md). Docs index:
|
||||
> [`../README.md`](../README.md). Backs the boot-time acceptance criterion in
|
||||
> [`../v0.1-mvp-product.md`](../v0.1-mvp-product.md#acceptance-criteria) and the
|
||||
> v1.0 goal in [`../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.
|
||||
@@ -17,8 +17,8 @@ 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 50–200 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 5–10 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 5–10 seconds is _transport of an already-safe thing_.
|
||||
|
||||
Surface that moment in the status line 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.
|
||||
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.
|
||||
|
||||
**Durability before delivery.** The moment that matters to the user is the moment durability is achieved, not the moment delivery completes. Once you see that, the slow operations stop feeling slow.
|
||||
|
||||
148
docs/notes/git-sync-images-and-repo-size.md
Normal 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.
|
||||
90
docs/notes/sync-latency.md
Normal 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 ~2–4 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.
|
||||
214
docs/postmortems/2026-07-05-spike3-sd-cmd59.md
Normal file
@@ -0,0 +1,214 @@
|
||||
# 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: **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),
|
||||
> storage split [ADR-007](../adr.md#adr-007-storage-split--fat-on-sd-for-working-copy-littlefs-on-flash-for-config),
|
||||
> 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
|
||||
shared SPI2 bus, mount FAT, and prove the persistence module's atomic-save
|
||||
pattern (write `*.tmp` → fsync → rename → read-back). The card **never mounts**:
|
||||
SD init gets cleanly through CMD0 and CMD8 (the card even identifies as an
|
||||
SDHC/SDXC v2 card), then fails at **CMD59 (CRC_ON_OFF)**, which the card rejects
|
||||
as an illegal command (`ESP_ERR_NOT_SUPPORTED`, `0x106`). `sdmmc_card_init`
|
||||
treats that as fatal.
|
||||
|
||||
Root cause is the **card**, not our wiring or code: the bench card is a 133 GB
|
||||
SDXC that doesn't implement CMD59 in SPI mode — common on large / cheap /
|
||||
counterfeit SDXC cards. It works fine on the Mac because macOS uses native SD
|
||||
mode, never the SPI fallback the ESP32 uses.
|
||||
|
||||
**Decision:** keep CRC required (don't disable it to limp the bad card along —
|
||||
see below) and reject incompatible cards with a clear message. Resume when a
|
||||
genuine ≤32 GB card is on the bench.
|
||||
|
||||
## Bench wiring (shared SPI2, proven good)
|
||||
|
||||
| Signal | GPIO | Shared with EPD? |
|
||||
| ------ | ---- | ---------------- |
|
||||
| SCK | 12 | yes (epd.rs) |
|
||||
| MOSI | 11 | yes (epd.rs) |
|
||||
| MISO | 13 | **no** — new line; the EPD is write-only and never used MISO |
|
||||
| SD CS | 10 | no — EPD CS is 7 |
|
||||
|
||||
The SD sits on the same SPI2 bus as the panel, on its own chip-select. The spike
|
||||
runs **SD-only** (see "EPD bus lock" below).
|
||||
|
||||
## Timeline
|
||||
|
||||
1. First flash → `sdmmc_send_cmd_crc_on_off returned 0x106`, `sdmmc_card_init
|
||||
failed (0x106)`. Cryptic; could be wiring, pull-ups, speed, or card.
|
||||
2. Added internal pull-ups on SCK/MOSI/MISO/CS and drove the EPD CS (GPIO 7)
|
||||
high (deselect the panel on the shared bus). **No change** — identical
|
||||
failure at the same command. A deterministic failure at one command argues
|
||||
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 *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.
|
||||
5. Raised the compile-time log ceiling (`CONFIG_LOG_MAXIMUM_LEVEL_DEBUG`) and
|
||||
bumped the `sdmmc_*` / `sdspi_*` tags to DEBUG at runtime. The dump was
|
||||
decisive:
|
||||
- `cmd=52` / `cmd=5` fail (`0x107`/`0x106`) — **normal**: those are SDIO
|
||||
probes (CMD52/CMD5) a memory card doesn't answer.
|
||||
- `sdmmc_sd: SDHC/SDXC card` — printed **only when CMD8 succeeds**. So CMD0
|
||||
and CMD8 returned clean, correct responses (right 0xAA echo, no error bit).
|
||||
- `cmd=59, R1 response: command not supported` — only CMD59 is rejected.
|
||||
6. Confirmed the breakout is a bare microSD→pin adapter (no level shifter), so
|
||||
the 3.3 V SPI path is clean.
|
||||
|
||||
## Root cause
|
||||
|
||||
The card responds correctly to CMD0 and CMD8 (proving wiring, signal integrity,
|
||||
pull-ups, bus sharing, and the hand-built FFI mount path are all correct) but
|
||||
**rejects CMD59 (CRC_ON_OFF) as an illegal command**. CMD59 is mandatory per the
|
||||
SD spec; some large/counterfeit SDXC cards don't implement it in SPI mode. Since
|
||||
esp-idf's `sdmmc_init_spi_crc` hard-fails when CMD59 fails (and there is **no**
|
||||
Kconfig or host flag to skip it), the mount aborts.
|
||||
|
||||
## What it was *not*
|
||||
|
||||
- **Not wiring / a swap** — CMD0/CMD8 responses are clean and correct.
|
||||
- **Not signal integrity** — internal pull-ups changed nothing; failure is
|
||||
deterministic at one command; init runs at 400 kHz where the jumpers are fine.
|
||||
- **Not the filesystem** — failure is at SD *protocol* init, before any FAT
|
||||
access. The card is FAT32 and mounts on the Mac.
|
||||
- **Not a level-shifter breakout** — it's a bare 3.3 V adapter.
|
||||
- **Not our FFI** — the hand-rolled `SDSPI_HOST_DEFAULT()` descriptors work; the
|
||||
driver reaches and drives the card correctly.
|
||||
|
||||
## Decision: keep CRC required, reject bad cards clearly
|
||||
|
||||
CMD59's job is to enable **CRC on data transfers**. The only way to mount this
|
||||
card is to tolerate CMD59 failing, i.e. run with data CRC **off**. For a device
|
||||
whose entire value is not losing the user's writing, giving up integrity
|
||||
checking — precisely on the cards that are already the sketchy ones — is the
|
||||
wrong trade. It would also require patching a vendored esp-idf function that
|
||||
re-applies on every update.
|
||||
|
||||
So `sd_fat` now maps `ESP_ERR_NOT_SUPPORTED` from the mount to an actionable
|
||||
message ("card rejected CMD59… use a genuine card, ideally ≤32 GB…") instead of
|
||||
a raw code. This is the behavior the real `persistence` module should carry too:
|
||||
a bad card at boot should say "swap the SD," not hang on a hex code.
|
||||
|
||||
A CRC-off patch remains *possible* if bench work ever needs this exact card, but
|
||||
it is explicitly **not recommended** and not applied.
|
||||
|
||||
## Other findings worth keeping
|
||||
|
||||
- **EPD bus lock forces SD-only (for now).** The EPD driver uses esp-idf-hal's
|
||||
`SpiBusDriver`, whose constructor calls `spi_device_acquire_bus(BLOCK)` and
|
||||
holds that **exclusive** lock for its whole lifetime (it needs CS held across
|
||||
a cmd→data sequence while DC toggles). While held, any other device on SPI2 —
|
||||
the SD — blocks. So the EPD and an arbitrated SD device can't both be live on
|
||||
one host as things stand. This spike proves the SD stack; the shared-bus
|
||||
**arbitration decision** (release/re-acquire around EPD ops, vs. the
|
||||
risk-table fallback of giving the SD its own SPI3) is still open and is what a
|
||||
follow-up must settle before integration.
|
||||
- **FatFS long filenames are required.** The atomic-save temp name
|
||||
(`notes.md.tmp`, two dots) is not a valid 8.3 name, and FatFS defaults to
|
||||
8.3-only. `CONFIG_FATFS_LFN_HEAP=y` is needed by this spike **and** the real
|
||||
persistence path.
|
||||
- **`SDSPI_*_DEFAULT()` macros are bindgen-invisible.** Built the `sdmmc_host_t`
|
||||
/ `sdspi_device_config_t` descriptors by hand; the `SDMMC_HOST_FLAG_*` values
|
||||
are `BIT(n)` macros bindgen can't fold, so they're inlined (`BIT(3)`/`BIT(5)`).
|
||||
- **Conservative SD clock.** `SD_FREQ_KHZ = 10_000` (vs. the 20 MHz default),
|
||||
since the EPD needed 4 MHz on these bench jumpers. Init runs at 400 kHz
|
||||
regardless, so this only affects post-init throughput — revisit on a real PCB.
|
||||
- **Diagnostic technique.** Raising the log ceiling + `esp_log_level_set` to read
|
||||
the driver's per-command R1 bytes turned a cryptic error into a one-glance
|
||||
root cause. Reusable for any esp-idf init failure.
|
||||
|
||||
## Where the end-to-end (clone → SD → edit → push) stands
|
||||
|
||||
- **Clone is out-of-band in v0.1** — the dev clones the remote onto the mounted
|
||||
SD from a laptop; there is no first-clone on device
|
||||
([ADR-007](../adr.md#adr-007-storage-split--fat-on-sd-for-working-copy-littlefs-on-flash-for-config),
|
||||
technical doc "Provisioning"). The bench card mounts on the Mac, so that step
|
||||
is not blocked.
|
||||
- **Edit → save-to-SD** becomes testable the moment a compliant card mounts
|
||||
(Spike 3 + the existing editor's atomic save). Closest milestone.
|
||||
- **SD → push** needs **Spike 7 (gitoxide over HTTPS+PAT)**, which is unbuilt and
|
||||
needs **PSRAM enabled first** (not yet done). Spike 6 (Wi-Fi + TLS) proved the
|
||||
network/TLS gate. So the full loop is not testable yet even with a good card.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- [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)
|
||||
|
||||
- `firmware/src/bin/sd_fat.rs` — the spike (mount + atomic round-trip + clear
|
||||
rejection + debug logging).
|
||||
- `firmware/Cargo.toml` — `[[bin]] sd_fat`.
|
||||
- `firmware/justfile` — `build-sd` / `flash-sd` / `monitor-sd`.
|
||||
- `firmware/sdkconfig.defaults` — `CONFIG_FATFS_LFN_HEAP=y` (keep) and
|
||||
`CONFIG_LOG_MAXIMUM_LEVEL_DEBUG=y` (temporary, strip when green).
|
||||
457
docs/postmortems/2026-07-05-spike7-gix-https-push.md
Normal file
@@ -0,0 +1,457 @@
|
||||
# Spike 7 (git push) — the ADR-004 kill-switch fired: gix can't push over HTTPS
|
||||
|
||||
> Date: 2026-07-05 (on-device push completed 2026-07-06)
|
||||
> Status: **DONE** — gix ruled out for the push path; pivoted to `libgit2`
|
||||
> (`git2`), proved the git mechanics on desktop, then landed the full
|
||||
> `init → commit → push` over mbedTLS HTTPS **on hardware** (2026-07-06). See
|
||||
> "On-device push COMPLETE" below.
|
||||
>
|
||||
> Context: Spike 7 in
|
||||
> [`../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/README.md).
|
||||
|
||||
## Summary
|
||||
|
||||
Spike 7 was written as the kill-switch for [ADR-004](../adr.md): *"the
|
||||
smart-HTTP path is validated in spike 7 before we commit to integration; if it
|
||||
fails on the device, we fall back to `libgit2-sys`."* It never needed a device
|
||||
to fire. Before writing any gix code, gitoxide's own crate-status doc settles
|
||||
the question: `gix` has send-pack/receive-pack **plumbing** (report-status,
|
||||
sideband, delete-refs, atomic pushes) but supports push as a **workflow** only
|
||||
over `file://` and `ssh://`. **Push over HTTP(S) is not implemented** — push is
|
||||
still listed under "workflows that still need plumbing." (Clone/fetch, by
|
||||
contrast, are robust over HTTP(S) — which is why Spike 6's TLS GET passed but
|
||||
does not carry over to push.)
|
||||
|
||||
Because [ADR-005](../adr.md) fixes auth as **HTTPS + PAT**, `gix` cannot satisfy
|
||||
the push path today. gix *can* push over `ssh://`, but that would (a) revisit
|
||||
ADR-005 and (b) still die on device — gix's SSH transport spawns the external
|
||||
`ssh` program, which does not exist on the ESP32. So the kill-switch condition
|
||||
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/README.md)).
|
||||
|
||||
## Why not the alternatives
|
||||
|
||||
| Option | Verdict |
|
||||
| ------ | ------- |
|
||||
| **gix + HTTPS** (as ADR-004 intended) | Blocked — gix has no HTTP(S) push. |
|
||||
| **gix + SSH push** | gix supports it, but revisits ADR-005 *and* gix's SSH transport shells out to an `ssh` binary absent on ESP32 → dead on device. |
|
||||
| **gix-protocol send-pack + custom HTTPS transport** | Pure-Rust, no ADR change, but not smoke-test-sized: hand-wiring send-pack over an mbedtls HTTP transport is real work and unproven upstream. Reconsider only if the libgit2 cross-compile (below) turns out worse. |
|
||||
| **libgit2 (`git2`)** ← chosen | The ADR's named fallback. Trivial on desktop; the risk becomes the on-device cross-compile. |
|
||||
|
||||
## What the desktop spike proves
|
||||
|
||||
Run live against a local `file://` bare remote (no credentials), exercising the
|
||||
exact v0.1 `git` module contract:
|
||||
|
||||
- **first commit + push** from an unborn `HEAD` (fresh clone of an empty repo)
|
||||
→ the commit lands in origin. Message is an ISO-8601 timestamp.
|
||||
- **nothing to publish** → short-circuits when the staged tree matches `HEAD`.
|
||||
- **divergence** → a second clone advances origin; the first clone's push is
|
||||
rejected, `pull --no-edit` merges cleanly (different files), the retry push
|
||||
succeeds, and origin ends with a correct two-parent merge commit.
|
||||
|
||||
Also confirmed 2026-07-05 against a **real GitHub repo** (`jcalixte/typoena-test`)
|
||||
over HTTPS with a fine-grained PAT: `committed → push accepted by remote`, the
|
||||
commit landed on GitHub. So the TLS handshake + PAT auth + smart-HTTP push all
|
||||
work through libgit2's vendored stack (desktop links `openssl-sys` for TLS). The
|
||||
one path still unexercised live is a **non-fast-forward rejection over HTTPS**
|
||||
(the `push_update_reference` callback) — the `file://` transport surfaced that as
|
||||
a `push()` error instead, and the GitHub push was a clean fast-forward.
|
||||
|
||||
Implementation notes that carry into the real module:
|
||||
|
||||
- **`git add --all` semantics.** libgit2's `index.add_all(["*"], DEFAULT)` stages
|
||||
new + modified + **deleted** paths, unlike a naive `git add .`. v0.5 file-delete
|
||||
needs removals to reach the next Publish's staged set — this is that behavior.
|
||||
- **Push rejection is not always a `push()` error.** A non-fast-forward can come
|
||||
back as a transport `Err` (local transport did this) *or* silently via the
|
||||
`push_update_reference` callback with a status string while `push()` returns
|
||||
`Ok` (the HTTPS/GitHub path). The spike handles both and routes either to the
|
||||
pull-and-retry. The callback path is coded for but not yet exercised live.
|
||||
- **PAT hygiene.** The token is handed only to libgit2's credential callback
|
||||
(`Cred::userpass_plaintext`) and never logged — matches ADR-005.
|
||||
|
||||
## What it does *not* prove — the next gate
|
||||
|
||||
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:
|
||||
|
||||
> Can `libgit2` (`git2` / `libgit2-sys`) cross-compile to
|
||||
> `xtensa-esp32s3-espidf` and use esp-idf's **mbedtls** as its TLS backend?
|
||||
|
||||
`libgit2-sys` vendors libgit2 and, on desktop, pulled `openssl-sys` for TLS —
|
||||
there is no openssl on esp-idf, so the device build will need libgit2 pointed at
|
||||
mbedtls (its `MbedTLS` backend) via the esp-idf sysroot, which is unproven. This
|
||||
is the on-device Spike 7 and it also depends on:
|
||||
|
||||
- **PSRAM** (`CONFIG_SPIRAM`) enabled — still off (only ~339 KB internal heap;
|
||||
see firmware README / Spike 6 note). libgit2's pack working set needs it.
|
||||
- **A working SD card** (Spike 3, currently
|
||||
[paused on a CMD59-incompatible card](2026-07-05-spike3-sd-cmd59.md)) for the
|
||||
`/sd/repo` working copy.
|
||||
|
||||
So the full **SD → push** loop is still not testable on hardware; this spike
|
||||
retired the *library/API* risk and replaced it with a *cross-compile* risk to
|
||||
tackle once PSRAM + SD are unblocked.
|
||||
|
||||
## On-device probe — 2026-07-05
|
||||
|
||||
Two moves toward the on-device gate, decoupled from the (still-blocked) SD card:
|
||||
|
||||
**PSRAM enabled.** `sdkconfig.defaults` gained `CONFIG_SPIRAM=y` +
|
||||
`CONFIG_SPIRAM_MODE_OCT=y` (the N16R8 is *octal* PSRAM — quad mode would fail
|
||||
init) + `CONFIG_SPIRAM_USE_MALLOC=y` (adds PSRAM to the heap so large Rust allocs
|
||||
land there). Speed left at 40 MHz for a safe first enable. Octal PSRAM uses
|
||||
GPIO 33–37; the EPD/SD pins (4–13) avoid that range, so no wiring conflict.
|
||||
**Confirmed on hardware 2026-07-05:** boot log shows `Found 8MB PSRAM device`
|
||||
(vendor AP, gen-3, 64 Mbit die), `SPI SRAM memory test OK`, and `Adding pool of
|
||||
8192K of PSRAM memory to heap allocator` — the full 8 MB joins the heap on top of
|
||||
~372 KB internal DRAM. The editor boots and types normally, so PSRAM broke
|
||||
nothing. The ~1.5 MB git working-set budget now has headroom.
|
||||
|
||||
**libgit2 cross-compile probe.** Added `git2` (default-features off → no
|
||||
openssl/ssh, isolating the C build from the TLS question) + a throwaway
|
||||
`git_probe` bin, and built for `xtensa-esp32s3-espidf`. Result reframes the risk
|
||||
in a *more* encouraging direction than expected:
|
||||
|
||||
- **The C cross-compiles.** `xtensa-esp-elf-gcc` ran on libgit2's sources and
|
||||
several files built with `exit status: 0`. The feared "cmake can't target
|
||||
xtensa / no toolchain" failure did **not** happen.
|
||||
- **The wall is missing esp-idf networking headers.** libgit2's core
|
||||
`git2_util.h` does `#include <arpa/inet.h>`, and the build died with
|
||||
`arpa/inet.h: No such file or directory`. Root cause: `libgit2-sys` builds
|
||||
vendored libgit2 as a **standalone `cc` library**, so it never inherits
|
||||
esp-idf's include paths — esp-idf's BSD-socket headers (lwIP) live under the
|
||||
esp-idf component tree, not the bare toolchain sysroot the `cc` invocation
|
||||
used. (`arpa/inet.h` *does* exist in esp-idf, via lwIP's POSIX compat layer;
|
||||
it just wasn't on the `-I` path.)
|
||||
|
||||
So the on-device libgit2 question is **not** "impossible," it's "needs esp-idf
|
||||
integration": get the vendored C build to see esp-idf's lwIP/newlib includes.
|
||||
Candidate paths, roughly in order of effort:
|
||||
|
||||
1. **Inject esp-idf include dirs into the `cc` build** via
|
||||
`CFLAGS_xtensa-esp32s3-espidf` (point `-I` at esp-idf's lwIP POSIX-compat
|
||||
headers). Cheapest to try; risk is a *cascade* — `arpa/inet.h` is likely the
|
||||
first of several missing headers, and then missing lwIP/pthread symbols at
|
||||
final link.
|
||||
2. **Build libgit2 as a proper esp-idf component** (CMake component pulled into
|
||||
the esp-idf build so it inherits all component includes/libs). The "right"
|
||||
way; more plumbing, via esp-idf-sys's extra-components mechanism.
|
||||
3. **Patch/fork `libgit2-sys`** to be esp-idf-aware (read `DEP_ESP_IDF_*` and add
|
||||
the include paths). Upstreamable but the most work.
|
||||
|
||||
The util-layer `arpa/inet.h` include is unconditional (not gated on the transport
|
||||
backend), so path 1/2/3 is needed before even a transport-less build links.
|
||||
|
||||
### Path 1 attempted — 2026-07-05: confirmed a dead end
|
||||
|
||||
Injected esp-idf's lwIP + generated-config include dirs via
|
||||
`CFLAGS_xtensa_esp32s3_espidf` and rebuilt the probe. `arpa/inet.h` **resolved**
|
||||
— then the build immediately hit the next wall: `lwipopts.h → sys/ioctl.h: No
|
||||
such file`, a header from a *different* esp-idf component (vfs/newlib), not lwIP.
|
||||
That is the whole problem in one line: **path 1 peels the esp-idf component
|
||||
include graph one component at a time**, with fragile absolute `-I` paths into
|
||||
build-output dirs (the config-dir hash even changed between two builds). It does
|
||||
not converge without effectively reconstructing esp-idf's entire per-component
|
||||
include environment by hand.
|
||||
|
||||
### Decision: go straight to path 2 (libgit2 as an esp-idf component)
|
||||
|
||||
Path 2 isn't just the robust include fix — it **solves the TLS backend at the
|
||||
same time**. libgit2 the C library *does* support mbedTLS (`USE_HTTPS=mbedTLS`);
|
||||
only the `libgit2-sys` Rust wrapper lacks it. Building libgit2 as an esp-idf
|
||||
CMake component lets us (a) inherit every component's includes + link (kills the
|
||||
cascade) and (b) set `USE_HTTPS=mbedTLS` against esp-idf's own mbedtls — which
|
||||
would make a **custom Rust subtransport unnecessary**. Two birds. Sketch:
|
||||
|
||||
- Add libgit2 via esp-idf-sys's extra-components mechanism (a `components/`
|
||||
dir + `CMakeLists.txt` declaring `REQUIRES lwip mbedtls pthread newlib vfs`,
|
||||
wrapping libgit2's own CMake with `USE_HTTPS=mbedTLS`, `USE_SSH=OFF`).
|
||||
- Bind to it from Rust — either `libgit2-sys` in *system* mode pointing at the
|
||||
component-built lib, or hand-rolled bindings for the handful of calls the
|
||||
`git` module needs.
|
||||
|
||||
This is a real, multi-step chunk (component CMake + bindings + link), not a
|
||||
flag-flip — scoped as the next work item, gated behind nothing now that PSRAM is
|
||||
up.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- [x] Enable PSRAM (`CONFIG_SPIRAM`) — **done + hardware-verified 2026-07-05**
|
||||
(octal, USE_MALLOC): 8 MB detected, memory-tested, added to heap; editor
|
||||
still runs.
|
||||
- [x] On-device Spike 7 libgit2 build — **probed + path 1 attempted 2026-07-05**:
|
||||
the C cross-compiles; the include cascade does not converge via CFLAGS
|
||||
injection (path 1 dead end). Decision: **path 2** (libgit2 as an esp-idf
|
||||
component with `USE_HTTPS=mbedTLS`).
|
||||
- [x] Path 2: add libgit2 as an esp-idf component — **compiles AND links
|
||||
2026-07-05** (Gate A + Gate B). libgit2 1.9.4 built as a component with
|
||||
`REQUIRES mbedtls lwip pthread vfs newlib`; the include cascade vanished as
|
||||
predicted. mbedTLS wired directly (`GIT_MBEDTLS` + `GIT_SHA1/256_MBEDTLS`).
|
||||
A `git_smoke` bin calling `git_libgit2_init/version/shutdown` links clean:
|
||||
538 `git_*` functions in the ELF, +514 KB text. See "Path 2 result" below.
|
||||
- [x] Flash the PSRAM build and confirm the SPIRAM heap region — **done
|
||||
2026-07-05**: 8192K pool added to heap, memory test OK.
|
||||
- [x] Run the desktop spike against a real GitHub test repo — **done 2026-07-05**
|
||||
(`jcalixte/typoena-test`, fine-grained PAT): HTTPS handshake + PAT auth +
|
||||
push confirmed. Still open: the `push_update_reference` rejection path over
|
||||
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").
|
||||
- [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 — **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
|
||||
`Builder::stack_size`; main joins it), and `CONFIG_ESP_MAIN_TASK_STACK_SIZE`
|
||||
dropped 98304 → **12288** (the Spike-6 value proven with the editor +
|
||||
TLS-on-main). On-device push succeeded off-main — no panic/overflow, no
|
||||
ENOMEM on the spawn — retiring the "time()-only-works-on-main" misdiagnosis.
|
||||
|
||||
## Path 2 result — libgit2 compiles and links on xtensa (Gate A + Gate B)
|
||||
|
||||
The bet paid off. libgit2 **1.9.4** (the exact version `libgit2-sys 0.18.5`
|
||||
vendors, chosen so `git2`'s safe Rust API can bind it in system mode later)
|
||||
builds as an esp-idf component and links into a real image.
|
||||
|
||||
**Why a component beat Path 1.** Registering libgit2 with
|
||||
`REQUIRES mbedtls lwip pthread vfs newlib` makes it inherit those components'
|
||||
include + link graph. Path 1's manual CFLAGS injection died because resolving
|
||||
one component's headers exposes the next (`arpa/inet.h` → `sys/ioctl.h` → …).
|
||||
The component model walks that graph for us — the cascade never appeared.
|
||||
|
||||
**mbedTLS, not OpenSSL.** The libgit2-sys wrapper only offers
|
||||
openssl/securetransport/winhttp, but the C library has an mbedTLS backend
|
||||
(`streams/mbedtls.c`, `hash/mbedtls.c`). A hand-written `git2_features.h` selects
|
||||
`GIT_HTTPS` + `GIT_MBEDTLS` + `GIT_SHA1_MBEDTLS` + `GIT_SHA256_MBEDTLS`, so TLS
|
||||
and hashing reuse the mbedtls esp-idf already ships (and Spike 6 validated).
|
||||
|
||||
**The port surface was small** — four shims, libgit2 sources untouched (so we
|
||||
never fork 1.9.4):
|
||||
|
||||
| Gap on esp-idf (picolibc + VFS) | Shim |
|
||||
|---|---|
|
||||
| no top-level `<poll.h>` (only `<sys/poll.h>`) | forwarding `poll.h` on the include path |
|
||||
| `lstat` absent (no symlinks) | `#define lstat stat`, force-included via `esp_port.h` |
|
||||
| `<sys/mman.h>` absent | `esp_map.c` — `p_mmap` via `git__malloc` + `read` (pack pages land in PSRAM) |
|
||||
| `getuid`/`geteuid`/`getgid`/`getppid`/`getpgid`/`getsid`/`getpwuid_r`/`readlink`/`utimes` declared but not implemented | `esp_stubs.c` — single-root-user, no-user-db, no-symlink answers |
|
||||
|
||||
Also: gcc 14 promoted `-Wimplicit-function-declaration` /
|
||||
`-Wincompatible-pointer-types` to hard errors; this pre-gcc14 C trips them
|
||||
benignly, so the component downgrades them to warnings. `unix/process.c`
|
||||
(fork/`sys/wait.h`) is excluded — only the SSH-exec transport we don't enable
|
||||
uses it.
|
||||
|
||||
**Verification.** A throwaway `git_smoke` bin (`git_libgit2_init` /
|
||||
`_version` / `_shutdown` via three hand externs) links with **zero undefined
|
||||
references**: `nm` shows **538 `git_*` text symbols** in the ELF (`git_index_*`,
|
||||
`git_repository_*`, `git_commit_*`, `git_remote_*`), the four shims present,
|
||||
+514 KB `.text` (negligible against 16 MB flash).
|
||||
|
||||
**Gate C — RAN ON HARDWARE 2026-07-05.** Flashed `git_smoke` to the S3; the
|
||||
linked library reports `1.9.4`, `git_libgit2_init() -> 1` (global init ran —
|
||||
registers the mbedTLS stream + HTTP transport + hash backends),
|
||||
`git_libgit2_shutdown() -> 0`, clean. No crash/assert/hang. So libgit2 +
|
||||
mbedTLS **compiles, links, and executes** on the ESP32-S3 — the full Path 2
|
||||
de-risk. Still unproven: an actual `repository_init` → `commit` → `push` over
|
||||
mbedTLS HTTPS (needs Wi-Fi/SNTP from Spike 6 + a working-copy location).
|
||||
|
||||
**Build mechanics learned.** The component is wired via
|
||||
`[[package.metadata.esp-idf-sys.extra_components]]` `component_dirs`, pointed at
|
||||
on-disk source through a `LIBGIT2_SRC` env var (probe stage — not yet vendored).
|
||||
esp-idf-sys emits no `rerun-if-*`, so editing the *root* Cargo.toml or the
|
||||
component doesn't retrigger its build script once it has succeeded; forcing a
|
||||
reconfigure means `rm -rf target/**/.fingerprint/esp-idf-sys-*` (cheap — the
|
||||
159 MB cmake cache in the OUT_DIR persists, so only the changed component
|
||||
recompiles).
|
||||
|
||||
**Gate D — `git2` safe-API binding LINKS 2026-07-05.** Replaced the hand
|
||||
externs with the real path: the `git2` crate (default-features off, so no
|
||||
openssl-sys/libssh2-sys) bound to our component via `libgit2-sys` in **system
|
||||
mode** (`LIBGIT2_NO_VENDOR=1`). The trick: we don't want libgit2-sys to build
|
||||
*or* link anything — esp-idf already links `liblibgit2.a` inside its component
|
||||
group (verified in `build.ninja`: `esp-idf/libgit2/liblibgit2.a` sits in the
|
||||
`libespidf.elf` `LINK_LIBRARIES`, and the group is repeated ~6× so libgit2's
|
||||
refs to mbedtls/lwip resolve). So a **fake pkg-config with empty `Libs`**
|
||||
(`firmware/pkgconfig/{libgit2,zlib}.pc`, found via `PKG_CONFIG_LIBDIR` +
|
||||
`PKG_CONFIG_ALLOW_CROSS=1`) makes both libgit2-sys's and libz-sys's probes
|
||||
succeed while emitting nothing; the symbols come from the component. `git_smoke`
|
||||
now uses `git2::Version` + `Oid::hash_object` and links with zero undefined refs
|
||||
— `nm` confirms `git_odb_hash`, `git_oid_tostr`, `git_error_last`, and
|
||||
`mbedtls_sha1_starts` all **defined** in the ELF.
|
||||
|
||||
**Build gotcha (important):** esp-idf-sys forwards the app's link args only when
|
||||
its build script reruns, and emits no `rerun-if-*`. After the component set
|
||||
changes, the forwarded args go stale — `rm -rf
|
||||
target/**/.fingerprint/esp-idf-sys-*` before building forces a fresh forward
|
||||
(that was why the first git2 link failed with undefined `git_*`).
|
||||
|
||||
**Build-gating done:** `git2` is an optional dep behind the `git` feature, and
|
||||
`git_smoke` has `required-features = ["git"]`, so the editor build never pulls
|
||||
libgit2-sys/pkg-config. The component's CMake now registers *empty* when
|
||||
`LIBGIT2_SRC` is unset, so `just build` (no env) still works.
|
||||
|
||||
**Open decisions before commit** (deliberately not done yet):
|
||||
|
||||
1. **Vendoring** — the component points at `~/.cargo`'s unpacked source via
|
||||
`LIBGIT2_SRC`; not reproducible. Needs a submodule pinned to `v1.9.4` (or a
|
||||
vendored copy). This also lets the `just flash-git` recipe drop the env vars.
|
||||
2. **Component build burden** — `extra_components` still compiles all ~200
|
||||
libgit2 files on a clean build even for the editor (cached after; Rust side
|
||||
is already gated). Accept, or gate the C compile too.
|
||||
3. ~~Runtime (Gate D on HW)~~ — **DONE 2026-07-05.** `just flash-git` on the S3:
|
||||
`git2 crate is talking to libgit2 1.9.4`, then `sha1(blob "hello") =
|
||||
b6fc4c620b67d95f953a5c1c1230aaab5db5a1b0` + "hash matches" — i.e. git2 →
|
||||
libgit2 → mbedTLS SHA1 all ran correctly on device. Full chain proven.
|
||||
4. ~~**The real thing** — `repository_init` → `commit` → `push` over mbedTLS
|
||||
HTTPS~~ — **DONE + hardware-verified 2026-07-06** (flash-FAT working copy);
|
||||
see "On-device push COMPLETE" below.
|
||||
|
||||
## On-device push COMPLETE — 2026-07-06
|
||||
|
||||
The real thing runs on hardware. `just flash-git-push` (bin
|
||||
`firmware/src/bin/git_push.rs`, build `@a15789a`) did the whole loop on the
|
||||
ESP32-S3 and pushed to GitHub over HTTPS:
|
||||
|
||||
```
|
||||
init OK at /spiflash/wc-1783370910
|
||||
wrote device.md
|
||||
staged + tree written
|
||||
committed to master
|
||||
origin set; pushing refs/heads/master:refs/heads/device/1783370910
|
||||
cert-check BYPASSED for github.com
|
||||
push accepted by remote
|
||||
✅ Spike 7 complete — pushed master → origin/device/1783370910 over mbedTLS HTTPS
|
||||
```
|
||||
|
||||
Verified from both ends: the device logged `push accepted`, and `git ls-remote
|
||||
https://github.com/jcalixte/typoena-test.git` independently shows
|
||||
`refs/heads/device/1783370910` at commit `a96a7996`. So Wi-Fi/SNTP → mount
|
||||
flash-FAT → `repository_init` → `add_all` → `commit` → smart-HTTP push + pack
|
||||
upload + PAT auth all work on-device through libgit2 + esp-idf mbedTLS.
|
||||
|
||||
**Heap:** started at 8.44 MB free, min-ever **6.85 MB** — the whole TLS handshake
|
||||
+ packfile build cost ~1.6 MB, all served from PSRAM; internal DRAM was never
|
||||
stressed. **Timing:** first-boot FATFS format of the working-copy dir ~7.7 s,
|
||||
commit sub-second, TLS handshake→accept ~6 s.
|
||||
|
||||
### Three bugs stood between "links + runs" and "pushes"
|
||||
|
||||
Gate D proved SHA1 on device; getting from there to a real push took three fixes
|
||||
on the flash-FAT + FATFS path, each found on hardware. All are committed (5
|
||||
microcommits through `a15789a`).
|
||||
|
||||
1. **Main task stack 12 KB → 96 KB.** libgit2 is stack-hungry: nearly every
|
||||
function puts a `char path[GIT_PATH_MAX]` (4 KB) buffer on the stack, and the
|
||||
`repository_init → config-write → FATFS → wear-leveling` chain nests ~10 of
|
||||
them — a *trivial config write* measured ~67 KB of stack. At 48 KB it
|
||||
overflowed and smashed an adjacent newlib lock handle → `LoadProhibited` in
|
||||
`xQueueGenericSend`. **This corrected an earlier misdiagnosis:** the "`time()`
|
||||
only works on the main task, not a std::thread" conclusion from the first
|
||||
on-device attempt was wrong — that thread had the *default 4 KB* stack, so the
|
||||
same deep chain just overflowed sooner. It was always stack depth, not
|
||||
thread-vs-main. (This stack has since moved: `sdkconfig.defaults` is shared
|
||||
with the editor build, so git was later given its OWN 96 KB `std::thread` and
|
||||
the main-task stack dropped back to 12 KB — see the follow-ups. The misdiagnosis
|
||||
is now doubly retired: the push runs fine off the main task.)
|
||||
|
||||
2. **`p_rename` = remove-then-rename** (`esp_stubs.c`). FATFS `f_rename` fails
|
||||
`EEXIST` if the target exists and FAT has no hardlinks, so libgit2's own
|
||||
`p_rename` (link-then-rename in `posix.c`) can't overwrite the
|
||||
`config`/`refs`/`HEAD`/`index` files its lock→commit sequence depends on. Ours
|
||||
drops the target then renames; `posix.c`'s original is compiled under a
|
||||
throwaway name via a file-scoped CMake `COMPILE_DEFINITIONS`, so ours is the
|
||||
`p_rename` every caller links. (Not crash-atomic, but FAT offers no atomic
|
||||
replace — acceptable for the working copy.) Verified on hardware: cleared the
|
||||
`failed to rename lockfile to '.git/config'` error.
|
||||
|
||||
3. **`utimes` existence-gate — the killer.** This one silently defeated every
|
||||
object write. Our first `utimes` stub returned `0` unconditionally ("VFS can't
|
||||
set times; ignore"). But libgit2's `git_futils_touch()` → `p_utimes()` is how
|
||||
the loose ODB's `freshen` probe answers *"does this object already exist?"*,
|
||||
and `git_odb_write()` (`odb.c:1629`) **skips the write entirely** when freshen
|
||||
succeeds. So a blanket `return 0` made freshen always report "exists" →
|
||||
libgit2 believed every object was already on disk → **every** blob/tree/commit
|
||||
write was silently dropped. `.git/objects/` stayed empty (only `info/` +
|
||||
`pack/`), and `write_tree` failed with `invalid object specified - device.md`.
|
||||
Fix: `stat`-gate the stub — present → `0` (setting the time is a cosmetic
|
||||
no-op we skip), absent → `-1`/`ENOENT`, so freshen correctly reports "not
|
||||
found" and the real write proceeds.
|
||||
|
||||
Diagnosed with an in-binary A/B/C/D ODB probe — write an in-memory blob, a
|
||||
file blob, run `add_all`, then walk `.git/objects` — which showed `exists =
|
||||
false` for every OID and an empty objects dir, isolating it to the *write*
|
||||
path (not read, not mmap, not the index). The vendored `odb.c` /
|
||||
`odb_loose.c` / `futils.c` source then pinned it to the freshen→touch→utimes
|
||||
chain. **Lesson:** a "harmless" no-op POSIX stub is actively dangerous when a
|
||||
caller reads its return value as a semantic signal.
|
||||
|
||||
### Shortcuts — status
|
||||
|
||||
- **Cert verification — DONE, hardware-verified 2026-07-06 (commit `2519ed8`).**
|
||||
Was: `certificate_check` blanket-accepted the peer cert (MITM-open). Now: embed
|
||||
GitHub's roots (`firmware/src/bin/github_roots.pem` — USERTrust ECC/RSA +
|
||||
DigiCert G2/Global Root CA, extracted from the macOS root store), write them to
|
||||
`/spiflash/ca.pem`, and load them via `git2::opts::set_ssl_cert_file`
|
||||
(`GIT_OPT_SET_SSL_CERT_LOCATIONS`; `CONFIG_MBEDTLS_FS_IO=y` lets mbedtls fopen
|
||||
the file). The callback returns `CertificatePassthrough`, which the transport
|
||||
maps to `is_valid ? 0 : -1` (`httpclient.c:805`) → **fail-closed**. The push
|
||||
still landed, proving the chain validates against the embedded USERTrust ECC
|
||||
root on-device. Caveat: roots must be refreshed if GitHub rotates CAs; a product
|
||||
would prefer esp-idf's bundle via a custom subtransport (it can't reach
|
||||
libgit2's private mbedtls config without touching libgit2 sources).
|
||||
- **PAT baked into flash — STILL STANDING** (ADR-005 spike shortcut). `build.rs`
|
||||
embeds `TW_PAT` in the git_push/git_sync image via `env!()` — plaintext in
|
||||
flash, extractable with `esptool read_flash`. Fine for the dev's bench unit,
|
||||
not for a shipped one. The provisioning + at-rest-hardening decision is now
|
||||
tracked as an **open** ADR: [ADR-011](../adr.md#adr-011-credential-provisioning--how-the-pat-reaches-the-device-and-is-protected-at-rest)
|
||||
(likely on-device paste → eFuse-encrypted NVS + a per-device fine-grained PAT).
|
||||
- **Product sync transport — DECIDED 2026-07-06: HTTPS + PAT.** On-device libgit2
|
||||
is HTTPS-only (mbedTLS build; no ssh client, libssh2 unported), and the proven
|
||||
path is HTTPS+PAT, so the product keeps ADR-005 rather than porting SSH. The
|
||||
real project remote (`git@github.com:jcalixte/typewriter.git`, SSH) stays for
|
||||
desktop/human use; the device publishes over an HTTPS remote + token (stored
|
||||
securely, not in flash). No libssh2 port needed.
|
||||
|
||||
## Artifacts (this session)
|
||||
|
||||
- `spikes/spike7-git-push/` — the desktop spike crate (`src/main.rs`,
|
||||
`Cargo.toml`, `README.md`, `.env.example`).
|
||||
- `firmware/components/libgit2/` — the esp-idf component (uncommitted probe):
|
||||
`CMakeLists.txt`, `git2_features.h`, `poll.h`, `esp_port.h`, `esp_map.c`,
|
||||
`esp_stubs.c`.
|
||||
- `firmware/src/bin/git_smoke.rs` + Cargo.toml `[[bin]]`/`extra_components`
|
||||
(uncommitted probe wiring).
|
||||
- ADR-004 — outcome note appended (kill-switch fired → libgit2).
|
||||
- `docs/v0.1-mvp-technical.md` — risk-table row updated (gix push → libgit2).
|
||||
@@ -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.
|
||||
15
docs/postmortems/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Postmortems
|
||||
|
||||
> Bench + bring-up debugging write-ups: what broke, how we found the root cause,
|
||||
> and the decisions that came out of it. One file per incident, named
|
||||
> `YYYY-MM-DD-<slug>.md`. These capture *why* a spike stalled or a design turned
|
||||
> — the kind of context that's expensive to reconstruct later.
|
||||
>
|
||||
> Project overview: [`../../README.md`](../../README.md). Bring-up spikes:
|
||||
> [`../v0.1-mvp-technical.md`](../v0.1-mvp-technical.md#hardware-bring-up-order).
|
||||
|
||||
| Date | Incident | Status |
|
||||
| ---------- | ------------------------------------------------------------------------ | ------ |
|
||||
| 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 |
|
||||
821
docs/qfd.md
@@ -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.2–v1.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\\(0–5, 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 (~2–4 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 "~200–300 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 ~200–300 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 (0–5): 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 1–10 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 "+5–10 min" pricing, the target moves
|
||||
before the runtime decision does.
|
||||
|
||||
### Perception scores (guessed)
|
||||
|
||||
Five products on the 0–5 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
|
||||
40–100 %, 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 40–100 % (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 ~10–15 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 2–7 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,10 +1174,50 @@ 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 ~200–300 ms floor); ≤ 150 ms sat below what the panel
|
||||
can deliver.
|
||||
|
||||
The minor variance between README's "~12 lines" and product/[ADR-003]'s
|
||||
"~11 lines" of edit area is within rounding for a 14 px glyph in a 240 px
|
||||
tall edit region and is not load-bearing.
|
||||
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
|
||||
the top header and bottom status bars (metadata moved into the **side panel**),
|
||||
so the **writing column** spans the full panel height — ~13 lines at the
|
||||
editor's 20 px font (`FONT_10X20`, `editor.rs` `ROWS = HEIGHT / 20 = 13`).
|
||||
README, the product/technical docs, and [ADR-003] are all updated to ~13 lines
|
||||
(writing column).
|
||||
|
||||
---
|
||||
|
||||
@@ -489,8 +1230,8 @@ tall edit region and is not load-bearing.
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 0–5 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\\(0–5, 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 0–5 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
|
||||
40–100 %, 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 40–100 % (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 ~10–15 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 1–10 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.
|
||||
108
docs/roadmap.md
@@ -1,108 +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 status line
|
||||
- [ ] 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
|
||||
|
||||
## 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>`
|
||||
- [ ] Status line shows file path, dirty flag, mode
|
||||
|
||||
## 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)
|
||||
- [ ] `Ctrl-G` is disabled / hidden when the current buffer is local-scope
|
||||
- [ ] Status line 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 status line
|
||||
|
||||
## 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
|
||||
155
docs/spikes.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# Rendering & UX spikes
|
||||
|
||||
> Scope: bench experiments that retire **display/UX** risk, run after the v0.1
|
||||
> hardware bring-up spikes (1–7) in
|
||||
> [`v0.1-mvp-technical.md`](v0.1-mvp-technical.md#hardware-bring-up-order).
|
||||
> Unlike 1–7, these are **not** a v0.1 integration gate, and each feeds a
|
||||
> different release (noted per spike).
|
||||
>
|
||||
> 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: [`macroplan.md`](macroplan.md).
|
||||
|
||||
These prove display/UX risks, not stack risks, so they sit outside the 1–7
|
||||
"prove before integration" gate. **Run Spike 8 first:** it partitions the panel
|
||||
into the writing column and the side panel that the later spikes (scrollbar,
|
||||
line-number gutter, status readouts) draw within and around. Each feeds a
|
||||
different release, but the bench experiment is worth running now to retire the
|
||||
risk early.
|
||||
|
||||
| Spike | Feeds |
|
||||
| ----------------------------------------- | ---------------------------------------------------- |
|
||||
| 8 — Layout: side panel + writing column | v0.1 |
|
||||
| 9 — Boot splash bitmap | v0.1 |
|
||||
| 10 — Dark / light theme | v1.0 |
|
||||
| 11 — Transient panel (help / config) | v0.4 `:` · v0.5 palette · v0.9 settings (mechanism) |
|
||||
| 12 — Scroll position indicator | reading / `View`-mode UX |
|
||||
| 13 — Line-number gutter | v0.2 |
|
||||
| 14 — Multi-file navigation | v0.5 |
|
||||
|
||||
8. **Spike 8 — Layout: side panel + writing column.** Split the 792×272 landscape
|
||||
into a full-height **writing column** (~60 cols, left) and an always-visible
|
||||
**side panel** (~150 px / ~20 cols, right), per the
|
||||
[Screen layout](v0.1-mvp-product.md#screen-layout). Today the editor fills the
|
||||
full 79-col width with text and a cramped 12 px bottom status
|
||||
([`firmware/src/editor.rs`](../firmware/src/editor.rs), `COLS`/`ROWS` +
|
||||
`draw_status`); this spike narrows the text to a legible measure and moves all
|
||||
metadata into the panel, retiring the header/status bands entirely. Two driver
|
||||
facts shape it. First, narrowing does *not* speed up typing: `update_part`
|
||||
already drives both controllers full width, windowed only in Y, because "the
|
||||
waveform time dominates, not the data clock-out"
|
||||
([`firmware/src/epd.rs`](../firmware/src/epd.rs)) — the win is line length and
|
||||
persistent info, not latency. Second, since every partial refresh spans the full
|
||||
width, a keystroke's windowed-Y band repaints the panel's pixels on that row
|
||||
*for free* (redrawn identically), but a panel field that changes on a *different*
|
||||
row than the cursor costs a **second** windowed-Y band — burning the "20 partials
|
||||
→ forced full refresh" ghosting counter twice as fast (render module). That sorts
|
||||
what the panel may hold: static (filename, dirty), event-driven (mode, Wi-Fi,
|
||||
keyboard-disconnect, publish state), and throttled (clock, word count, session)
|
||||
fields only — nothing that repaints per keystroke, so no live cursor *column*.
|
||||
The panel sits entirely in the master half (right of the `x = 396` seam), so its
|
||||
glyphs never split the seam; the writing column still straddles it, as today.
|
||||
Prove: render column + panel; type and confirm the windowed-Y refresh stays a
|
||||
single band (panel redraws in place, no second region); then update a throttled
|
||||
field (word count on a typing pause) and a discrete one (mode flip) and confirm
|
||||
each is one isolated band that forces no extra full refresh. Decides the
|
||||
column/panel split in pixels, the panel's field layout and update cadence, and
|
||||
whether the ghosting counter needs per-region accounting. (Feeds v0.1's screen
|
||||
layout — the writing column + side panel the product doc now draws.)
|
||||
|
||||
9. **Spike 9 — Boot splash bitmap.** Embed the Typoena logo as a 1-bit asset and
|
||||
blit it, with the build tag, at boot before the editor opens. Spike 2 already
|
||||
proved vector + font rendering, so the only new risk is the image-asset
|
||||
pipeline (embedding + blit) and that a large near-full-frame image takes a
|
||||
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
|
||||
waveforms stress the panel differently — and (b) whether partial-refresh
|
||||
ghosting accumulates faster in dark mode, forcing a more frequent full
|
||||
refresh. Prove: legible inverted text, acceptable ghosting, and
|
||||
partial-refresh latency unchanged from Spike 5. (Feeds v1.0's light/dark
|
||||
theme.)
|
||||
|
||||
11. **Spike 11 — Transient panel (help / config).** Prove a modal full-screen
|
||||
view that swaps in over the editor and restores the buffer exactly on exit —
|
||||
the shared primitive behind a help screen, the v0.9 settings screen, the v0.5
|
||||
file palette (`Ctrl-P`), and the v0.4 `:` command line. The risk is
|
||||
e-ink-specific: entering/leaving with partial refresh leaves ghosting, and
|
||||
the editor viewport underneath must return artifact-free. Prove with a static
|
||||
help screen (trivial content): a full refresh on enter and on exit restores
|
||||
the prior text exactly; measure in/out latency. Decides overlay-box vs.
|
||||
full-screen swap — recommend the swap, since a dimmed partial overlay is both
|
||||
slow and ghost-prone at ~630 ms. Panel *content* (which config keys, help
|
||||
text) is feature work deferred to the owning release; this spike proves only
|
||||
the mechanism.
|
||||
|
||||
12. **Spike 12 — Scroll position indicator.** The buffer already scrolls (`View`
|
||||
mode `j`/`k`/`space`); this spike proves how to *show* position without
|
||||
wrecking latency. Measure two affordances on the bench: (a) a right-edge
|
||||
scrollbar repainted each scroll step, and (b) a compact side-panel readout
|
||||
(`34/128` or `27%`). Risk: the scrollbar is a tall pixel column repainted on
|
||||
every scroll — measure its partial-refresh cost against the small
|
||||
side-panel field, on the windowed-Y path, during held-key scroll. Prove:
|
||||
pick the affordance whose refresh cost is acceptable and confirm it forces no
|
||||
extra full refreshes. (Feeds the reading/`View`-mode UX; the a-vs-b choice is
|
||||
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. **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),
|
||||
and filename entry reuses the v0.4 `:` command line — so this spike proves
|
||||
neither, and depends on both. What is genuinely new is **persistence and
|
||||
buffer lifecycle**, not rendering: enumerate a FAT directory across *both*
|
||||
`/sd/repo` and `/sd/local`, keep ≤ 3 ropes resident, and on switch swap the
|
||||
active rope + cursor + soft-wrap after an atomic save-of-current
|
||||
(persistence module). Two sharp edges. **New file** (`:enew`) must prompt
|
||||
for scope — tracked (`/sd/repo`, pushed) vs. local (`/sd/local`, `Ctrl-G`
|
||||
disabled). **Delete** extends v0.5 scope (the roadmap lists `:enew` but not
|
||||
delete): removing the currently-open buffer must close it and fall back to
|
||||
another resident buffer or an empty one, and — the load-bearing check — the
|
||||
FAT unlink must reach the next Publish's *staged* set, which the git
|
||||
module's `git add .`-equivalent staging may not catch for removals (needs
|
||||
`git rm` / `add -A` semantics —
|
||||
[`git` module](v0.1-mvp-technical.md#git--commit--push)). Prove:
|
||||
open/switch/new/delete across both roots with a correct ≤ 3-buffer
|
||||
lifecycle, a delete that propagates into the following commit, and an
|
||||
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
|
||||
[macroplan](macroplan.md#v05--file-palette--multi-file--) v0.5.)
|
||||
14
docs/tradeoff-curves/README.md
Normal 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 ~2–2.8 s and ships. Holds the full measurement trail plus the firmware plumbing plan (merged from the retired handoff note). |
|
||||
117
docs/tradeoff-curves/epd-refresh-latency.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# E-ink refresh latency vs rows driven
|
||||
|
||||
> **Model:** on this GDEY0579T93 (SSD1683 dual-controller) panel, refresh time is
|
||||
> set by **how many gate lines (rows, the Y axis) are driven** and **which
|
||||
> waveform LUT runs** — *not* by the pixel column width. Three refresh modes fall
|
||||
> out of that: full refresh (~1870 ms), full-area partial (~630 ms), and
|
||||
> windowed-Y partial (~100–130 ms for one text line). This is the cost model
|
||||
> behind per-keystroke typing, the boot splash→editor swap
|
||||
> ([`../notes/boot-time-budget.md`](../notes/boot-time-budget.md)), and the
|
||||
> scroll/gutter spikes.
|
||||
>
|
||||
> Tradeoff-curves index: [`README.md`](README.md). Docs index:
|
||||
> [`../README.md`](../README.md). Driver:
|
||||
> [`../../firmware/src/epd.rs`](../../firmware/src/epd.rs). Bench origin:
|
||||
> [`../spikes.md`](../spikes.md) (Spikes 5 + 8).
|
||||
|
||||
## The model
|
||||
|
||||
A refresh is three serial costs:
|
||||
|
||||
```
|
||||
set RAM window → clock the pixels out over SPI → run the update waveform
|
||||
(fixed) (scales with bytes = rows) (scales with gate lines
|
||||
AND with which LUT)
|
||||
```
|
||||
|
||||
Two things do **not** help, and one thing does:
|
||||
|
||||
- **Column width (X) is free to keep full.** The panel is a master (`0x00`) +
|
||||
slave (`0x80`) pair with the framebuffer split at the seam; every refresh
|
||||
drives *both* controllers full width so the seam/mirror math stays intact
|
||||
(`update_part` / `write_frame_bank` in
|
||||
[`epd.rs`](../../firmware/src/epd.rs)). Narrowing the *column* saves nothing —
|
||||
"the waveform time dominates, not the data clock-out."
|
||||
- **Row count (Y) is the knob.** E-paper drive time scales with the number of
|
||||
gate lines transitioned, so restricting the refresh to a horizontal band of
|
||||
rows is the real win — a one-line band is far cheaper than the whole panel.
|
||||
- **The LUT sets the tier.** A *partial* update (`0x22`←`0xFF`) runs a short
|
||||
waveform that only nudges pixels that changed. A *full* update (`0x22`←`0xD7`,
|
||||
the fast-full LUT) runs ~3× as many frames per pixel to fully clear-and-set —
|
||||
slower, but it erases ghosting and re-establishes a known image.
|
||||
|
||||
Within partial mode the latency is roughly linear in rows:
|
||||
|
||||
```
|
||||
t_partial(rows) ≈ 90 ms + 2 ms · rows
|
||||
└ floor ┘ └ per-gate-line slope ┘
|
||||
```
|
||||
|
||||
Floor ≈ fixed SPI setup + border/VCOM commands + waveform ramp; slope ≈ the
|
||||
per-row waveform drive. Full refresh sits in its own flat tier (~1870 ms, all 272
|
||||
rows, full-clear LUT) and **cannot be windowed** — the clear waveform needs the
|
||||
whole panel.
|
||||
|
||||
## The curve
|
||||
|
||||
```
|
||||
Partial-refresh latency vs rows driven (Y-band) t ≈ 90 ms + 2 ms · rows
|
||||
|
||||
ms
|
||||
1870 |========================================= FULL refresh — separate tier:
|
||||
| all 272 rows, full-clear LUT,
|
||||
| un-windowable. ~3× the partial
|
||||
| waveform. De-ghosts + seeds a
|
||||
| known image.
|
||||
|
|
||||
630 | * ← full-area partial (272 rows)
|
||||
| . ·
|
||||
| . ·
|
||||
| . · slope ≈ 2 ms / gate line
|
||||
300 | . ·
|
||||
| . ·
|
||||
110 | * ← one text line (~10 rows): the per-keystroke path
|
||||
90 | * ← floor (SPI setup + border/VCOM + waveform ramp)
|
||||
+----+----+----+----+----+----+----+----+----+----+----+---
|
||||
0 25 50 75 100 125 150 175 200 225 250 272 rows
|
||||
```
|
||||
|
||||
| Mode | Rows driven | Latency | LUT | Used for |
|
||||
| --- | ---: | ---: | --- | --- |
|
||||
| Full refresh | 272 (all) | **~1870 ms** (measured) | full-clear | first cold-boot image; periodic de-ghost (every `FULL_REFRESH_EVERY` = 64 updates) |
|
||||
| Full-area partial | 272 | **~630 ms** (measured; 680 ms at boot) | partial | deletes, caret moves, mode switches, the snackbar, and the boot splash→editor swap |
|
||||
| Windowed-Y partial | ~10 (1 line) | **~100–130 ms** (estimated¹) | partial | additive per-keystroke typing |
|
||||
|
||||
¹ The single-line windowed figure is projected from the floor+slope model and the
|
||||
Spike 5 full-area measurement; the exact bench number is still to be confirmed
|
||||
from the on-device refresh log (`{mode} refresh #N … {ms} ms` in
|
||||
[`main.rs`](../../firmware/src/main.rs)). The 1870 ms full and 630 ms full-area
|
||||
figures are measured.
|
||||
|
||||
## Two things that bound it
|
||||
|
||||
**Ghosting caps the partial streak.** Partial updates leave faint residue, so a
|
||||
full refresh every 64 updates resets clarity and panel state. You can't "always
|
||||
partial" — the ~1870 ms tier is a periodic tax paid for longevity, not a mode you
|
||||
can retire.
|
||||
|
||||
**The first cold-boot image must be a full refresh.** After power-on the `0x26`
|
||||
"previous" bank holds garbage, and a partial refresh *diffs against it* — so the
|
||||
very first clean paint has to be the full tier. This is why boot pays exactly one
|
||||
unavoidable ~1.9 s full refresh, and why the splash (which rides it) is nearly
|
||||
free while the *editor's* first frame can be a cheap partial on top. Full
|
||||
derivation: [`../notes/boot-time-budget.md`](../notes/boot-time-budget.md).
|
||||
|
||||
## What it decides
|
||||
|
||||
- **Per-keystroke typing → windowed-Y partial.** Only the touched line's band is
|
||||
driven (~100–130 ms), so typing keeps up with the keyboard; the panel never
|
||||
repaints per keystroke off that line.
|
||||
- **Boot splash→editor → full-area partial (~630 ms), not a second full refresh
|
||||
(~1870 ms).** The splash already seeded the baseline, so the editor rides in on
|
||||
a partial — the ~1.25 s cold-boot win recorded in the boot-time budget.
|
||||
- **Deletes, caret moves, mode flips, snackbar → full-area partial.** These erase
|
||||
ink or change the panel off the cursor line, which a windowed band would ghost,
|
||||
so they take the whole-panel partial rather than a windowed one.
|
||||
- **Splash + periodic → full refresh.** The unavoidable first image and the
|
||||
every-64 de-ghost.
|
||||
640
docs/tradeoff-curves/sync-commit-staging.md
Normal 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 ~2–2.8 s against 611 s/OOM for every alternative ships: a full
|
||||
> cold real-repo `:sync` lands at ~9–10 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
|
||||
~700–900 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 8–25×. 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) ≈ **8–9 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 ~8–10 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: **~7–8 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 ~150–250 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 1–3) — 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
|
||||
+10–15 % 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 ~2–2.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 **9–10 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).
|
||||
128
docs/tradeoff-curves/wifi-auto-sync.md
Normal 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 ~15–20 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 ~15–20 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: 5–10 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
@@ -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
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -52,11 +52,11 @@ at build time:
|
||||
a `.env` file gitignored locally) and embedded as `const &'static str`s in
|
||||
the binary.
|
||||
- The dev clones the git working copy onto the SD card from their laptop
|
||||
(`git clone` directly onto the mounted SD), so `/sd/local/notes.md` is
|
||||
(`git clone` directly onto the mounted SD), so `/sd/repo/notes.md` is
|
||||
present at first boot.
|
||||
- Re-configuring = re-build + re-flash. For a single-dev MVP, that's fine.
|
||||
|
||||
If the firmware boots and finds no `/sd/local` or no `/sd/local/notes.md`, it
|
||||
If the firmware boots and finds no `/sd/repo` or no `/sd/repo/notes.md`, it
|
||||
shows a one-line error on the splash and stops. No recovery flow ships in
|
||||
v0.1 — the dev re-mounts the SD and reboots.
|
||||
|
||||
@@ -75,7 +75,7 @@ Auth model rationale: [ADR-005](adr.md#adr-005-auth--https--github-personal-acce
|
||||
Boot (~3 s): Wi-Fi stays off (brought up on demand by `Ctrl-G`)
|
||||
│
|
||||
▼
|
||||
Open /sd/local/notes.md, place cursor at end of file
|
||||
Open /sd/repo/notes.md, place cursor at end of file
|
||||
│
|
||||
▼
|
||||
[EDITING] ← keypresses, partial refresh per edit
|
||||
@@ -99,49 +99,60 @@ strip aspect (~2.9:1) — biases the UX toward "current line and recent
|
||||
context," not a full page. Hardware rationale: [ADR-003](adr.md#adr-003-display-medium--e-ink-gdey0579t93-panel).
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ notes.md ● 14:02:33 │ ← header
|
||||
├─────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ Lorem ipsum dolor sit amet, consectetur adipiscing 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 ex ea commodo consequat. │
|
||||
│ │
|
||||
│ Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu │
|
||||
│ fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in │ ← edit area
|
||||
│ culpa qui officia deserunt mollit anim id est laborum. │ (~11 lines)
|
||||
│ │
|
||||
│ The quick brown fox jumps over the lazy▎ │ (cursor block)
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
├─────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ INSERT • unsaved • Wi-Fi — │ ← status
|
||||
└─────────────────────────────────────────────────────────────────────────────────────┘
|
||||
┌───────────────────────────────────────────────────────┬─────────────────────┐
|
||||
│ Lorem ipsum dolor sit amet, consectetur adipiscing │ notes.md ● │
|
||||
│ 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. │ │
|
||||
│ │ 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 — │
|
||||
│ │ -- INSERT -- │
|
||||
└───────────────────────────────────────────────────────┴─────────────────────┘
|
||||
writing column (~60 cols, full height) side panel (~20 ch)
|
||||
```
|
||||
|
||||
- Header: filename, dirty indicator (●/○), wall clock if SNTP has set time.
|
||||
- Status: mode (always `INSERT` in v0.1), save state, Wi-Fi state
|
||||
(`—` = off, `✓` = connected, `✗` = failed; default is `—` because the
|
||||
radio is on-demand). `Ctrl-S` briefly flashes "saved at HH:MM" and
|
||||
returns. `Ctrl-G` transitions through `✓ committed abc1234 · pushing…`
|
||||
at ~0.2 s — the commit-landed moment, when the user's work is durable
|
||||
(see "Durability before delivery" in [`CONTEXT.md`](../CONTEXT.md)) —
|
||||
and ends at `✓ pushed abc1234` (or `publish failed: <reason>`, with the
|
||||
local commit preserved either way).
|
||||
- Cursor: solid block at the insertion point. **No blink.**
|
||||
- **Writing column** (left, ~60 cols, full panel height): only the text and a
|
||||
solid-block cursor at the insertion point. **No blink.** The seam at `x = 396`
|
||||
runs through it invisibly (the driver's split-and-mirror blit handles it).
|
||||
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 (●/○). 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
|
||||
commit-landed moment, when the user's work is durable (see "Durability before
|
||||
delivery" in [`CONTEXT.md`](../CONTEXT.md)) — and ends at `✓ pushed abc1234`
|
||||
(or `publish failed: <reason>`, with the local commit preserved either way).
|
||||
- **Deliberately not shown:** any cursor-position readout (*line* or *column*),
|
||||
character count, or reading-time estimate — each repaints a panel row on cursor
|
||||
movement or per keystroke, on a row apart from the cursor, doubling the ghosting
|
||||
rate for little writerly value. v0.1 ships no positional readout at all.
|
||||
|
||||
Fonts: one mono font, embedded in firmware. Target sizing: a 14 px mono
|
||||
glyph at roughly 8 px advance gives ~95 columns × ~11 visible lines of edit
|
||||
area after header (16 px) and status (16 px) bars. Final size tuned during
|
||||
spike 2 against real legibility on the panel.
|
||||
Fonts: one mono font for the writing column, a smaller one for the panel, both
|
||||
embedded in firmware. Sizing target: ~60 columns of writing (down from the
|
||||
full-width ~79 — too wide for a comfortable line length) across the full ~13
|
||||
visible lines, now that metadata has moved off the top/bottom bars into the side
|
||||
panel. Final size tuned during spike 2 against real legibility on the panel.
|
||||
|
||||
**UX consequence of the strip aspect:** the cursor stays near the bottom of
|
||||
the visible area during normal typing; the edit area scrolls one line at a
|
||||
the visible area during normal typing; the writing column scrolls one line at a
|
||||
time. There is no "page" — just a continuously advancing strip. Embrace it,
|
||||
don't fight it.
|
||||
|
||||
**What scroll looks like on e-ink:** every line-scroll is a partial refresh
|
||||
of the full edit area — every line moves, there is no small dirty rect for
|
||||
of the full writing column — every line moves, there is no small dirty rect for
|
||||
scroll — so each scroll costs ~200–300 ms. Per-keystroke partial refreshes
|
||||
accumulate ghosting; the render module clears it by upgrading every 20th
|
||||
refresh to a full refresh, deferred to the next idle pause ≥ 1 s so it
|
||||
@@ -156,15 +167,16 @@ a save.
|
||||
| Failure | What the user sees |
|
||||
| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| No SD card on boot | "no SD card — insert one and reboot" |
|
||||
| SD card mounted but `/sd/local` missing | "missing /sd/local — re-provision and reboot" (dev re-clones onto SD) |
|
||||
| Wi-Fi credentials wrong (build-time env vars) | status shows `Wi-Fi ✗`; dev rebuilds with correct env vars and re-flashes |
|
||||
| Wi-Fi won't connect | status shows `Wi-Fi ✗`; editing still works; `Ctrl-G` shows "no network" |
|
||||
| `Ctrl-G` publish fails (auth, network, merge conflict) | status shows reason; local commit is preserved; user can retry — non-fast-forward auto-recovers via pull-then-push and is not surfaced as a failure unless the pull itself conflicts |
|
||||
| File write fails | status flashes "save failed"; buffer stays dirty |
|
||||
| Keyboard disconnects | header shows `⌨ ✗`; editing pauses; reconnects automatically |
|
||||
| SD card mounted but `/sd/repo` missing | "missing /sd/repo — re-provision and reboot" (dev re-clones onto SD) |
|
||||
| Wi-Fi credentials wrong (build-time env vars) | panel Wi-Fi field shows `✗`; dev rebuilds with correct env vars and re-flashes |
|
||||
| Wi-Fi won't connect | panel Wi-Fi field shows `✗`; editing still works; `Ctrl-G`'s publish-state field shows "no network" |
|
||||
| `Ctrl-G` publish fails (auth, network, merge conflict) | panel publish-state field shows the reason; local commit is preserved; user can retry — non-fast-forward auto-recovers via pull-then-push and is not surfaced as a failure unless the pull itself conflicts |
|
||||
| File write fails | panel save field flashes "save failed"; buffer stays dirty |
|
||||
| Keyboard disconnects | panel shows `⌨ ✗` (blank once reconnected); editing pauses; reconnects automatically |
|
||||
|
||||
No modal dialogs. Errors live in the status line. The editor is never blocked
|
||||
from accepting keystrokes (except during the partial-refresh frame itself).
|
||||
No modal dialogs. Errors live in the side panel's ambient-state fields. The
|
||||
editor is never blocked from accepting keystrokes (except during the
|
||||
partial-refresh frame itself).
|
||||
|
||||
## Out of scope for v0.1
|
||||
|
||||
@@ -193,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
|
||||
@@ -203,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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -48,7 +48,7 @@ blocked by I/O.
|
||||
| `wifi_task` | 0 | 8 KB | On-demand station: off by default, brought up by `Ctrl-G` |
|
||||
| `ui_task` | 1 | 16 KB | Consume `KeyEvent`s, mutate editor state, enqueue render |
|
||||
| `render_task` | 1 | 12 KB | Drain render queue, do partial/full refresh on EPD |
|
||||
| `git_task` | 1 | 32 KB | Triggered by `Ctrl-G`; runs gitoxide commit + push |
|
||||
| `git_task` | 1 | 96 KB | Triggered by `Ctrl-G`; runs libgit2 commit + push |
|
||||
|
||||
All inter-task communication is via `crossbeam-channel` or `std::sync::mpsc`
|
||||
bounded queues. The editor state is `Arc<Mutex<EditorState>>`; the lock is
|
||||
@@ -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,20 +92,31 @@ 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
|
||||
refresh per character, measure end-to-end latency.
|
||||
6. **Spike 6 — Wi-Fi + TLS.** Connect to home Wi-Fi, do an HTTPS GET to
|
||||
`api.github.com`, validate cert chain.
|
||||
7. **Spike 7 — gitoxide push.** Smoke test: from desktop-Rust first, then on
|
||||
device, push a single commit to a test repo over HTTPS+PAT.
|
||||
7. **Spike 7 — git push.** Smoke test: from desktop-Rust first, then on
|
||||
device, push a single commit to a test repo over HTTPS+PAT. **Done** — the
|
||||
gitoxide fall-back to **libgit2** (`git2`) fired here; push is proven on
|
||||
hardware ([postmortem](postmortems/2026-07-05-spike7-gix-https-push.md)).
|
||||
|
||||
Only after spike 7 do we start integration. Any spike that fails forces a
|
||||
stack decision (e.g. fall back to libgit2; switch to a separate SD SPI bus).
|
||||
|
||||
### Rendering spikes (post-bring-up)
|
||||
|
||||
The display/UX spikes (8–14) — panel layout, boot splash, theme, transient
|
||||
panel, scroll indicator, line-number gutter, multi-file navigation — are **not**
|
||||
part of the v0.1 integration gate and span several releases, so they live in
|
||||
their own log: [`spikes.md`](spikes.md). Spikes **8 (layout)** and **9 (splash)**
|
||||
feed v0.1; the rest feed later releases. Run Spike 8 first — it partitions the
|
||||
panel into the writing column and side panel the others draw within.
|
||||
|
||||
## Module breakdown
|
||||
|
||||
### `editor` — text buffer + cursor
|
||||
@@ -135,9 +149,11 @@ Custom widget layer on `embedded-graphics` —
|
||||
Owns the top-ranked engineering functions (latency, refresh-region area)
|
||||
in [qfd.md §3](qfd.md#3-house-of-quality--whats--hows).
|
||||
|
||||
- A render request is a `RenderOp { Lines(range) | Status(text) | FullRefresh }`.
|
||||
- A render request is a `RenderOp { Lines(range) | Panel(field, text) | FullRefresh }`.
|
||||
The **side panel** is multi-field (Spike 8), so the old single `Status(text)`
|
||||
op becomes a per-field write; the exact field enum is what Spike 8 decides.
|
||||
- The render thread maintains a shadow of the current screen contents per
|
||||
region (header / edit area lines / status).
|
||||
region (**writing-column** lines / **side-panel** fields).
|
||||
- For `Lines(range)`: clear the region's bounding box, draw glyphs, partial
|
||||
refresh that rect.
|
||||
- Glyph cache: rasterised mono font kept in PSRAM; one entry per (codepoint).
|
||||
@@ -150,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
|
||||
|
||||
@@ -165,7 +196,7 @@ Storage split rationale:
|
||||
[qfd.md H13](qfd.md#6-critical-performance-budget).
|
||||
- **Bring-up on `Ctrl-G`.** `wifi_task` transitions `Off → Associating →
|
||||
Connected`. Association timeout: 10 s. If association fails, the local
|
||||
commit is preserved and the status line shows `⌁ ✗`; no background retry.
|
||||
commit is preserved and the side panel's Wi-Fi field shows `✗`; no background retry.
|
||||
- **Grace window** of ~90 s after a Publish completes. A `Ctrl-G` inside
|
||||
the window reuses the connection (no second association cost). If no
|
||||
Publish fires before the window expires, the radio tears down.
|
||||
@@ -185,26 +216,46 @@ to v0.9. v0.1 reads all config from the binary at build time — see
|
||||
|
||||
### `git` — commit + push
|
||||
|
||||
`gitoxide` choice and kill-switch:
|
||||
[ADR-004](adr.md#adr-004-git-implementation--gitoxide-gix).
|
||||
Auth model: [ADR-005](adr.md#adr-005-auth--https--github-personal-access-token).
|
||||
Library + kill-switch:
|
||||
[ADR-004](adr.md#adr-004-git-implementation--gitoxide-gix) — the gitoxide bet was
|
||||
**retired in spike 7** (gix has no HTTP(S) push at all), so the module is built on
|
||||
**libgit2 via the `git2` crate**.
|
||||
Auth + transport are settled: **HTTPS + PAT over esp-idf's mbedTLS**
|
||||
([ADR-005](adr.md#adr-005-auth--https--github-personal-access-token)) — no custom
|
||||
transport and no `reqwest`/`rustls` layer are needed; libgit2's own smart-HTTP
|
||||
client speaks TLS through the mbedTLS stream it is compiled against. The full
|
||||
`init → commit → push` path is **proven on hardware** — see the
|
||||
[spike 7 postmortem](postmortems/2026-07-05-spike7-gix-https-push.md).
|
||||
PSRAM heap during push is a top-3 watched metric — see
|
||||
[qfd.md §6](qfd.md#6-critical-performance-budget).
|
||||
|
||||
- `gix` with the smart-HTTP transport backed by `esp-idf` mbedtls (via a
|
||||
custom transport impl, or `gix-transport` with `reqwest`+`rustls-mbedtls`
|
||||
if that path is cleaner — decided in spike 7).
|
||||
- libgit2 **1.9.4** is vendored as an esp-idf component
|
||||
(`firmware/components/libgit2`) built with `GIT_HTTPS + GIT_MBEDTLS`; `git2`
|
||||
binds it in system mode. A short `esp_stubs.c` supplies the POSIX calls
|
||||
picolibc/FATFS lack (`utimes` existence-gate, `p_rename` replace, uid/symlink).
|
||||
- The server cert chain is verified against an embedded GitHub root bundle
|
||||
(`GIT_OPT_SET_SSL_CERT_LOCATIONS`); the `certificate_check` callback returns
|
||||
PASSTHROUGH so libgit2's own verdict stands → **fail-closed** on an untrusted /
|
||||
MITM cert. A product should refresh those roots (or reuse esp-idf's bundle) at
|
||||
provisioning.
|
||||
- Runs on a **dedicated ~96 KB thread**, not the shared UI/main stack: libgit2's
|
||||
init→push chain nests ~10 `GIT_PATH_MAX` (4 KB) buffers (~67 KB measured).
|
||||
- Operations needed in v0.1 (the [`gct` shell function](../CONTEXT.md#user-facing-actions) is the reference):
|
||||
- `gix::open` the existing working copy at `/sd/repo`
|
||||
- stage everything under the working copy (`git add .` equivalent)
|
||||
- open a **persistent clone** at `/sd/repo` (spike 7 proved the flow on a
|
||||
fresh per-boot init pushing a throwaway `device/<unix>` branch, to isolate
|
||||
the transport from history reconciliation; the product keeps one clone and
|
||||
fast-forwards — storage is [ADR-007](adr.md), still SD-vs-flash-FAT while SD
|
||||
is blocked)
|
||||
- stage everything under the working copy (`git add --all`, incl. deletions)
|
||||
- short-circuit return if nothing is staged — status: "nothing to publish"
|
||||
- commit with author from config, message `"<ISO-8601 timestamp>"` (no `wip`
|
||||
prefix; the timestamp _is_ the message)
|
||||
- push HEAD to `origin/<current branch>`
|
||||
- push HEAD to `origin/<current branch>` (normally a clean fast-forward)
|
||||
- on push failure: `git pull --no-edit` (merge), then retry the push once.
|
||||
Only surface failure to the status line if the pull conflicts or the
|
||||
second push also fails.
|
||||
- The PAT is loaded into the Authorization header per request; never logged.
|
||||
Only surface failure to the side panel's publish-state field if the pull
|
||||
conflicts or the second push also fails.
|
||||
- The PAT is handed to libgit2's credential callback (`USER_PASS_PLAINTEXT`);
|
||||
never logged, never persisted to the working copy.
|
||||
- The whole sequence is atomic from the user's view — see
|
||||
[`CONTEXT.md` → Publish](../CONTEXT.md#user-facing-actions).
|
||||
|
||||
@@ -243,7 +294,7 @@ ESP32-S3-N16R8: 512 KB SRAM + 8 MB PSRAM. We budget conservatively.
|
||||
| Internal SRAM | ~80 KB | mbedtls runtime (TLS handshake working set) |
|
||||
| PSRAM | ~128 KB | EPD framebuffer (792×272×1 ≈ 27 KB) + shadow + glyph cache |
|
||||
| PSRAM | ~512 KB | rope buffer + edit history headroom |
|
||||
| PSRAM | ~1.5 MB | gitoxide working set during push (pack delta, etc.) |
|
||||
| PSRAM | ~1.5 MB | libgit2 working set during push (pack delta, etc.) |
|
||||
| PSRAM | rest | heap headroom |
|
||||
|
||||
PSRAM is the default for `Box::new` via a custom allocator wrapper; DMA-able
|
||||
@@ -305,11 +356,11 @@ Mirrored as live conflicts in
|
||||
|
||||
| Risk | Symptom we'd see | Fallback |
|
||||
| ---------------------------------------------------------- | ------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| `gix` smart-HTTP push doesn't work on `esp-idf-rs` mbedtls | spike 7 fails | switch to `libgit2-sys` (C, well-trodden) for v0.1 only |
|
||||
| ~~`gix` smart-HTTP push doesn't work on `esp-idf-rs` mbedtls~~ **fired 2026-07-05**: gix has no HTTP(S) push at all (only `file://`/`ssh://`) | spike 7 (desktop) | **switched to `libgit2` (`git2`)** — mechanics proven on desktop; on-device libgit2/mbedtls cross-compile is the new gate. See [postmortem](postmortems/2026-07-05-spike7-gix-https-push.md). |
|
||||
| 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
@@ -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.
|
||||
25
docs/v0.2.5-international-input.md
Normal 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
@@ -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.
|
||||
34
docs/v0.4-visual-and-ex.md
Normal 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)
|
||||
286
docs/v0.5-palette-and-multi-file.md
Normal 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
|
||||
60–70 KB, competing with Wi-Fi/TLS. Decision rule: **~60–70 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 5–10 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
@@ -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`→`$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.)
|
||||
14
docs/v0.7-search-and-git.md
Normal 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).
|
||||
12
docs/v0.8-battery-and-sleep.md
Normal 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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,2 @@
|
||||
/target
|
||||
/Cargo.lock
|
||||
15
editor/Cargo.toml
Normal 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
@@ -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]
|
||||
|
||||
25
firmware/.env.example
Normal file
@@ -0,0 +1,25 @@
|
||||
# Build-time config for the network spikes (6/7). Copy to `.env` and fill in.
|
||||
# `.env` is gitignored; `just` loads it automatically (dotenv-load) so the
|
||||
# values reach build.rs, which emits them as compile-time env for the binary.
|
||||
#
|
||||
# cp .env.example .env # then edit
|
||||
# just flash-wifi # Spike 6 — Wi-Fi + TLS
|
||||
#
|
||||
# Only the editor build (`just flash`) needs none of these.
|
||||
|
||||
# Home Wi-Fi (2.4 GHz — the ESP32-S3 has no 5 GHz radio).
|
||||
TW_WIFI_SSID=your-ssid
|
||||
TW_WIFI_PASS=your-password
|
||||
# Note: the spike assumes WPA2-Personal. Open network → leave TW_WIFI_PASS empty.
|
||||
# A WPA3-only AP needs AuthMethod::WPA3Personal in src/bin/wifi_tls.rs.
|
||||
|
||||
# Spike 7 — on-device git push (`just flash-git-push`, src/bin/git_push.rs).
|
||||
# HTTPS + PAT auth (ADR-005); baked into the git_push image at build time and
|
||||
# never logged. Point TW_REMOTE_URL at a THROWAWAY test repo — the spike pushes
|
||||
# a fresh `device/<unix>` branch on every boot.
|
||||
TW_REMOTE_URL=https://github.com/you/typoena-test.git
|
||||
TW_GH_USER=your-github-username
|
||||
TW_PAT=ghp_xxxxxxxxxxxxxxxxxxxx
|
||||
# Commit author (message is a timestamp).
|
||||
TW_AUTHOR_NAME=Typoena
|
||||
TW_AUTHOR_EMAIL=typoena@example.com
|
||||
@@ -1,15 +1,93 @@
|
||||
[package]
|
||||
name = "firmware"
|
||||
version = "0.1.0"
|
||||
version = "0.6.0"
|
||||
authors = ["Julien Calixte <juliencalixte@gmail.com>"]
|
||||
edition = "2024"
|
||||
resolver = "2"
|
||||
rust-version = "1.85"
|
||||
# Declare every binary explicitly so a spike program under src/bin/ doesn't get
|
||||
# an auto-discovered target with the default test harness (which trips up
|
||||
# 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"
|
||||
harness = false # do not use the built-in cargo test harness -> resolve rust-analyzer errors
|
||||
|
||||
# Spike 6 — Wi-Fi + TLS. Standalone bench program, kept separate from the
|
||||
# editor firmware. Flash with `just flash-wifi`.
|
||||
[[bin]]
|
||||
name = "wifi_tls"
|
||||
path = "src/bin/wifi_tls.rs"
|
||||
harness = false
|
||||
|
||||
# Spike 3 — SD card (FAT) over the shared SPI2 bus. Standalone bench program.
|
||||
# Flash with `just flash-sd`.
|
||||
[[bin]]
|
||||
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).
|
||||
[[bin]]
|
||||
name = "git_smoke"
|
||||
path = "src/bin/git_smoke.rs"
|
||||
harness = false
|
||||
required-features = ["git"]
|
||||
|
||||
# Spike 7 Path 2 finish — on-device init/commit/push over mbedTLS HTTPS. Wi-Fi +
|
||||
# SNTP + flash-FAT + libgit2 in one bench binary. Gated behind `git` like
|
||||
# git_smoke. Build/flash with `just flash-git-push` (needs the git TW_* vars in
|
||||
# .env; flashes partitions.csv so the `storage` FAT partition exists).
|
||||
[[bin]]
|
||||
name = "git_push"
|
||||
path = "src/bin/git_push.rs"
|
||||
harness = false
|
||||
required-features = ["git"]
|
||||
|
||||
# Milestone #2A — persistent-clone publish cycle (clone + open + fast-forward
|
||||
# push), the product's real git flow. Same `git` gate + TW_* env + partition
|
||||
# table as git_push. Build/flash with `just flash-git-sync`.
|
||||
[[bin]]
|
||||
name = "git_sync"
|
||||
path = "src/bin/git_sync.rs"
|
||||
harness = false
|
||||
required-features = ["git"]
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
|
||||
@@ -21,13 +99,45 @@ esp-idf-sys = { git = "https://github.com/esp-rs/esp-idf-sys.git" }
|
||||
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]
|
||||
# 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`
|
||||
embassy-time = { version = "0.5", features = ["generic-queue-8"] }
|
||||
embedded-graphics = "0.8"
|
||||
# Traits shared with esp-idf-svc (Spike 6 uses `embedded_svc::http::Method`).
|
||||
# Already in the tree via esp-idf-svc; pinned here so the spike names it directly.
|
||||
embedded-svc = "0.29"
|
||||
|
||||
[build-dependencies]
|
||||
embuild = "0.33"
|
||||
|
||||
# TEMPORARY — Spike 7 Path 2 Gate A probe. Builds libgit2 as an esp-idf
|
||||
# component (see firmware/components/libgit2/). Compiled against on-disk source
|
||||
# via LIBGIT2_SRC. Revert if the probe fails; promote (vendor the source) if it
|
||||
# passes.
|
||||
[[package.metadata.esp-idf-sys.extra_components]]
|
||||
component_dirs = ["components/libgit2"]
|
||||
|
||||
@@ -42,6 +42,62 @@ the `$` / end-of-line block caret sits one cell past the last char, `iw` / `aw`
|
||||
are whitespace-delimited (like vim's `iW` / `aW`), and `cw` isn't special-cased
|
||||
to `ce`.
|
||||
|
||||
**Spike 6 — Wi-Fi + TLS: verified 2026-07-05.** A separate binary —
|
||||
[`src/bin/wifi_tls.rs`](src/bin/wifi_tls.rs), flashed with `just flash-wifi` —
|
||||
kept apart from the editor firmware. It brings up the station, syncs the clock
|
||||
over SNTP (mbedtls validates the server cert against wall time, so the 1970 RTC
|
||||
has to be corrected first), then does an HTTPS GET to `https://api.github.com/`
|
||||
with cert-chain validation against the esp-idf certificate bundle
|
||||
(`esp_crt_bundle_attach`), and logs status, a body preview, and free heap around
|
||||
the handshake (TLS heap pressure is a watched risk). A validated GET is the gate
|
||||
for Spike 7 (gitoxide push over HTTPS + PAT).
|
||||
|
||||
Bench result (WPA2-PSK AP, 2.4 GHz): associate ~3 s → DHCP → SNTP first sync →
|
||||
`esp-x509-crt-bundle: Certificate validated` → `HTTPS GET … → 200`, reading real
|
||||
GitHub JSON. **TLS handshake cost ≈ 35 KB heap** (265 → 229 KB, recovered
|
||||
after), clean and repeatable across reboots. Note: PSRAM is **not** enabled yet
|
||||
(only ~339 KB internal heap) — TLS fits, but Spike 7's gitoxide working set will
|
||||
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 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
|
||||
@@ -94,7 +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, gitoxide push; 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
|
||||
@@ -116,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)
|
||||
|
||||
@@ -141,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
|
||||
|
||||
@@ -21,6 +21,32 @@ fn main() {
|
||||
.unwrap_or_else(|| "unknown".into());
|
||||
println!("cargo:rustc-env=BUILD_GIT={git}");
|
||||
println!("cargo:rustc-env=BUILD_TIME={time}Z");
|
||||
|
||||
// Wi-Fi credentials for the network spikes (6/7) and, later, the runtime.
|
||||
// Read at build time and emitted as compile-time env so a binary can pull
|
||||
// them in with env!(). Empty when unset: the network spike checks at
|
||||
// runtime and prints a clear message, so the *editor* build never has to
|
||||
// carry Wi-Fi creds. Source them from firmware/.env (loaded by `just`).
|
||||
//
|
||||
// The TW_REMOTE_URL / TW_GH_USER / TW_PAT / TW_AUTHOR_* vars back Spike 7's
|
||||
// on-device push (src/bin/git_push.rs). env!() embeds a value only in a
|
||||
// binary that references it, so the editor binary carries none of these.
|
||||
// NOTE: TW_PAT ends up in the git_push image — fine for the bench spike, but
|
||||
// a product must not bake the PAT into flash (ADR-005).
|
||||
for var in [
|
||||
"TW_WIFI_SSID",
|
||||
"TW_WIFI_PASS",
|
||||
"TW_REMOTE_URL",
|
||||
"TW_GH_USER",
|
||||
"TW_PAT",
|
||||
"TW_AUTHOR_NAME",
|
||||
"TW_AUTHOR_EMAIL",
|
||||
] {
|
||||
let val = std::env::var(var).unwrap_or_default();
|
||||
println!("cargo:rustc-env={var}={val}");
|
||||
println!("cargo:rerun-if-env-changed={var}");
|
||||
}
|
||||
|
||||
// Pointing rerun-if-changed at a file that never exists forces this
|
||||
// script to rerun on every build, keeping BUILD_TIME fresh.
|
||||
println!("cargo:rerun-if-changed=.force-build-stamp");
|
||||
|
||||
131
firmware/components/libgit2/CMakeLists.txt
Normal file
@@ -0,0 +1,131 @@
|
||||
# libgit2 as an ESP-IDF component (Spike 7, Path 2 — Gate A probe).
|
||||
#
|
||||
# Builds libgit2 1.9.4 (the exact version libgit2-sys 0.18.5 expects, so git2's
|
||||
# safe Rust API can bind it in system mode later) from an on-disk source tree
|
||||
# pointed to by $ENV{LIBGIT2_SRC}, configured for mbedTLS.
|
||||
#
|
||||
# Why a component and not CFLAGS injection (Path 1): registering as a component
|
||||
# makes libgit2 inherit the lwip / mbedtls / pthread / vfs / newlib include and
|
||||
# link graph. Path 1 died because manually injecting one component's includes
|
||||
# cascades into the next (arpa/inet.h -> sys/ioctl.h -> ...). The component
|
||||
# model resolves that graph for us.
|
||||
#
|
||||
# GATE A SCOPE: prove libgit2's C compiles under the esp-idf/xtensa toolchain.
|
||||
# Not wired to any Rust caller yet — esp-idf builds all components (COMPONENTS
|
||||
# is empty in esp-idf-sys's project template), so this compiles regardless.
|
||||
|
||||
set(LG2 $ENV{LIBGIT2_SRC})
|
||||
if(NOT LG2 OR NOT EXISTS "${LG2}/include/git2.h")
|
||||
# No source provided → register an empty component and bail. This keeps the
|
||||
# editor build (`just build`, no env) working: esp-idf builds every
|
||||
# component, but with nothing to compile this one is a no-op. Only `git`
|
||||
# builds set LIBGIT2_SRC. (Once libgit2 is vendored into the repo this
|
||||
# branch becomes dead — LIBGIT2_SRC will always resolve.)
|
||||
message(STATUS "libgit2 component: LIBGIT2_SRC unset/invalid — empty component (git feature off)")
|
||||
idf_component_register()
|
||||
return()
|
||||
endif()
|
||||
|
||||
# Source list mirrors libgit2-sys's build.rs, with two deltas:
|
||||
# - hashing/TLS = mbedtls (one hash file) instead of sha1dc + openssl
|
||||
# - bundled zlib compiled in (deps/zlib) instead of linking libz-sys
|
||||
# The transports/ and streams/ dirs are compiled wholesale; each file guards
|
||||
# itself on the feature macros in git2_features.h.
|
||||
file(GLOB LG2_SRCS
|
||||
"${LG2}/src/libgit2/*.c"
|
||||
"${LG2}/src/libgit2/transports/*.c"
|
||||
"${LG2}/src/libgit2/streams/*.c"
|
||||
"${LG2}/src/util/*.c"
|
||||
"${LG2}/deps/llhttp/*.c"
|
||||
"${LG2}/deps/xdiff/*.c"
|
||||
"${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"
|
||||
"${LG2}/src/util/unix/realpath.c"
|
||||
"${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.
|
||||
)
|
||||
|
||||
list(LENGTH LG2_SRCS LG2_NSRC)
|
||||
if(LG2_NSRC LESS 50)
|
||||
message(FATAL_ERROR "libgit2 glob returned only ${LG2_NSRC} files — LIBGIT2_SRC likely wrong")
|
||||
endif()
|
||||
message(STATUS "libgit2 component: ${LG2_NSRC} source files from ${LG2}")
|
||||
|
||||
idf_component_register(
|
||||
SRCS ${LG2_SRCS}
|
||||
INCLUDE_DIRS "${LG2}/include"
|
||||
PRIV_INCLUDE_DIRS
|
||||
"${CMAKE_CURRENT_LIST_DIR}" # our hand-written git2_features.h
|
||||
"${LG2}/src/libgit2"
|
||||
"${LG2}/src/util"
|
||||
"${LG2}/src/util/hash"
|
||||
"${LG2}/deps/llhttp"
|
||||
"${LG2}/deps/xdiff"
|
||||
"${LG2}/deps/pcre"
|
||||
"${LG2}/deps/zlib"
|
||||
REQUIRES mbedtls lwip pthread vfs newlib
|
||||
)
|
||||
|
||||
# PCRE (bundled regex backend) compile-time config — copied from libgit2-sys's
|
||||
# build.rs. Global -D because libgit2 shares one config.h across the tree.
|
||||
target_compile_definitions(${COMPONENT_LIB} PRIVATE
|
||||
GIT_REGEX_BUILTIN=1
|
||||
HAVE_STDINT_H=1
|
||||
HAVE_MEMMOVE=1
|
||||
NO_RECURSE=1
|
||||
NEWLINE=10
|
||||
POSIX_MALLOC_THRESHOLD=10
|
||||
LINK_SIZE=2
|
||||
PARENS_NEST_LIMIT=250
|
||||
MATCH_LIMIT=10000000
|
||||
MATCH_LIMIT_RECURSION=MATCH_LIMIT
|
||||
MAX_NAME_SIZE=32
|
||||
MAX_NAME_COUNT=10000
|
||||
)
|
||||
|
||||
# Force-include our platform shims into every libgit2 TU (lstat==stat, etc.).
|
||||
target_compile_options(${COMPONENT_LIB} PRIVATE
|
||||
"-include" "${CMAKE_CURRENT_LIST_DIR}/esp_port.h"
|
||||
)
|
||||
|
||||
# gcc 14 promoted these from warnings to hard errors; this vendored C (last
|
||||
# tested on gcc <14) trips them benignly (int32_t/long pointer aliasing on a
|
||||
# 32-bit target; internal iterator callback casts). Downgrade to warnings.
|
||||
# Missing POSIX functions surface as implicit-declaration warnings, then as
|
||||
# unresolved symbols at link — that's how we enumerate the shims still needed.
|
||||
target_compile_options(${COMPONENT_LIB} PRIVATE
|
||||
-Wno-error=implicit-function-declaration
|
||||
-Wno-error=incompatible-pointer-types
|
||||
-Wno-error=implicit-int
|
||||
)
|
||||
|
||||
# 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;p_open=libgit2_unused_p_open;p_creat=libgit2_unused_p_creat"
|
||||
)
|
||||
123
firmware/components/libgit2/esp_map.c
Normal file
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* esp_map.c — p_mmap/p_munmap for libgit2 on esp-idf (Spike 7, Path 2).
|
||||
*
|
||||
* Replaces src/util/unix/map.c, which needs <sys/mman.h> (absent on
|
||||
* picolibc/esp-idf). libgit2 uses p_mmap read-only, to view pack files and the
|
||||
* index. We emulate it by allocating a buffer and reading the range into it.
|
||||
* 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.
|
||||
*
|
||||
* 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"
|
||||
#include "map.h"
|
||||
|
||||
#include <unistd.h>
|
||||
#include <string.h>
|
||||
#include <errno.h>
|
||||
#include <stdint.h>
|
||||
|
||||
int git__page_size(size_t *page_size)
|
||||
{
|
||||
*page_size = 4096;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int git__mmap_alignment(size_t *alignment)
|
||||
{
|
||||
*alignment = 4096;
|
||||
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;
|
||||
|
||||
GIT_UNUSED(prot);
|
||||
GIT_UNUSED(flags);
|
||||
GIT_MMAP_VALIDATE(out, len, prot, flags);
|
||||
|
||||
out->data = NULL;
|
||||
out->len = 0;
|
||||
|
||||
data = git__malloc(len);
|
||||
GIT_ERROR_CHECK_ALLOC(data);
|
||||
if (read_range(fd, offset, len, data) < 0) {
|
||||
git__free(data);
|
||||
return -1;
|
||||
}
|
||||
g_maps++;
|
||||
g_read_bytes += len;
|
||||
g_live_bytes += len;
|
||||
|
||||
out->data = data;
|
||||
out->len = len;
|
||||
return 0;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
498
firmware/components/libgit2/esp_mbedtls_stream.c
Normal 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
|
||||
20
firmware/components/libgit2/esp_port.h
Normal file
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* esp_port.h — platform shims for building libgit2 on esp-idf, force-included
|
||||
* into every libgit2 TU via `-include` (Spike 7, Path 2).
|
||||
*
|
||||
* esp-idf's C library is picolibc and its filesystem layer is the VFS, which
|
||||
* has no symlink concept. So POSIX calls libgit2 assumes on "unix" are either
|
||||
* absent or degenerate here. Shimming them in a force-included header keeps
|
||||
* libgit2's own sources untouched (important: we don't want to fork 1.9.4).
|
||||
*/
|
||||
#ifndef LIBGIT2_ESPIDF_PORT_H
|
||||
#define LIBGIT2_ESPIDF_PORT_H
|
||||
|
||||
#include <sys/stat.h>
|
||||
|
||||
/* No symlinks on FAT/LittleFS: lstat is just stat. */
|
||||
#ifndef lstat
|
||||
#define lstat stat
|
||||
#endif
|
||||
|
||||
#endif
|
||||
146
firmware/components/libgit2/esp_stubs.c
Normal file
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
* esp_stubs.c — POSIX identity/symlink stubs for libgit2 on esp-idf
|
||||
* (Spike 7, Path 2).
|
||||
*
|
||||
* picolibc *declares* these in <unistd.h>/<pwd.h> but does not implement them:
|
||||
* esp-idf has no users, groups, processes, or symlinks. libgit2 calls them
|
||||
* while resolving config paths, ownership checks, and temp names. We provide
|
||||
* definitions that model "a single root user, no user database, no symlinks",
|
||||
* which is the truthful shape of the device's flat filesystem.
|
||||
*/
|
||||
|
||||
#include <sys/types.h>
|
||||
#include <unistd.h>
|
||||
#include <pwd.h>
|
||||
#include <errno.h>
|
||||
#include <sys/time.h>
|
||||
#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; }
|
||||
uid_t geteuid(void) { return 0; }
|
||||
gid_t getgid(void) { return 0; }
|
||||
gid_t getegid(void) { return 0; }
|
||||
|
||||
/* No process hierarchy. */
|
||||
pid_t getppid(void) { return 0; }
|
||||
pid_t getpgid(pid_t pid) { (void)pid; return 0; }
|
||||
pid_t getsid(pid_t pid) { (void)pid; return 0; }
|
||||
|
||||
/* No user database: report "no such user" so libgit2 falls back to $HOME. */
|
||||
int getpwuid_r(uid_t uid, struct passwd *pwd, char *buf, size_t buflen,
|
||||
struct passwd **result)
|
||||
{
|
||||
(void)uid;
|
||||
(void)pwd;
|
||||
(void)buf;
|
||||
(void)buflen;
|
||||
*result = NULL;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* No symlinks on FAT/LittleFS: nothing is ever a symbolic link. */
|
||||
ssize_t readlink(const char *path, char *buf, size_t bufsiz)
|
||||
{
|
||||
(void)path;
|
||||
(void)buf;
|
||||
(void)bufsiz;
|
||||
errno = EINVAL; /* POSIX: EINVAL == "named file is not a symbolic link" */
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* No symlinks on FAT: creating one is unsupported. libgit2 calls this only in
|
||||
* its "does this filesystem support symlinks?" probe (fs_path.c) and while
|
||||
* copying trees (futils.c) — a clean failure is the honest answer, and libgit2
|
||||
* treats the target as a normal file. (git_push referenced it; git_smoke, which
|
||||
* only touched the ODB, did not — hence it surfaces now.) */
|
||||
int symlink(const char *target, const char *linkpath)
|
||||
{
|
||||
(void)target;
|
||||
(void)linkpath;
|
||||
errno = ENOSYS;
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* FATFS/VFS can't set file times — but utimes() MUST still fail for a path that
|
||||
* doesn't exist. libgit2's git_futils_touch() is p_utimes(), and that is how the
|
||||
* loose ODB's `freshen` probe answers "does this object already exist?":
|
||||
* git_odb_write() SKIPS the write entirely when freshen succeeds (odb.c:1629).
|
||||
* A blanket `return 0` made every freshen succeed, so libgit2 believed every
|
||||
* object was already on disk and silently dropped ALL loose-object writes —
|
||||
* blobs/trees/commits never persisted, and write_tree then failed with
|
||||
* "invalid object specified". Gate success on existence: present → 0 (actually
|
||||
* setting the time is a cosmetic no-op we skip), absent → -1/ENOENT so freshen
|
||||
* reports "not found" and the real write proceeds. */
|
||||
int utimes(const char *path, const struct timeval times[2])
|
||||
{
|
||||
struct stat st;
|
||||
(void)times;
|
||||
if (stat(path, &st) != 0)
|
||||
return -1; /* stat set errno (ENOENT for a missing object) */
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* lwip implements getaddrinfo() but not gai_strerror(); libgit2's socket stream
|
||||
* (streams/socket.c) uses it only to format a connect-error message, so a
|
||||
* constant string is sufficient. Deliberately no <netdb.h> include: the symbol
|
||||
* is undefined at link (no macro/decl shadows it), so a plain definition is
|
||||
* safe, and the ABI (pointer vs. implicit-int return) matches on xtensa. */
|
||||
const char *gai_strerror(int ecode)
|
||||
{
|
||||
(void)ecode;
|
||||
return "getaddrinfo failure";
|
||||
}
|
||||
|
||||
/* POSIX rename() atomically replaces an existing target; FATFS f_rename does
|
||||
* NOT (it fails with EEXIST) and FAT has no hardlinks, so libgit2's own
|
||||
* p_rename (link-then-rename, in posix.c) can't overwrite config/refs/HEAD/index
|
||||
* during their lock→commit. Provide replace semantics: drop the target, then
|
||||
* rename. Not crash-atomic (a crash between the two loses `to`), but FAT offers
|
||||
* no atomic replace — acceptable for the working copy. posix.c's original is
|
||||
* compiled as libgit2_unused_p_rename (see the component CMakeLists), so this is
|
||||
* the p_rename every caller links against. */
|
||||
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);
|
||||
}
|
||||
37
firmware/components/libgit2/git2_features.h
Normal file
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* git2_features.h — hand-written feature config for building libgit2 as an
|
||||
* ESP-IDF component (Spike 7, Path 2).
|
||||
*
|
||||
* libgit2's CMake normally generates this from git2_features.h.in. We write it
|
||||
* by hand so the build is driven by ESP-IDF's component CMake instead. The one
|
||||
* substantive choice vs. the libgit2-sys vendored build: TLS + hashing use
|
||||
* *mbedTLS* (which ESP-IDF already ships and uses for its own TLS), not
|
||||
* OpenSSL/SecureTransport. That backend exists in the C library but the
|
||||
* libgit2-sys Rust wrapper never exposes it — the reason for Path 2.
|
||||
*/
|
||||
#ifndef INCLUDE_features_h__
|
||||
#define INCLUDE_features_h__
|
||||
|
||||
#define GIT_THREADS 1
|
||||
#define GIT_TRACE 1
|
||||
#define GIT_ARCH_32 1
|
||||
|
||||
/* Bundled backends: no system deps to hunt for on the device. */
|
||||
#define GIT_REGEX_BUILTIN 1
|
||||
#define GIT_HTTPPARSER_BUILTIN 1
|
||||
#define GIT_COMPRESSION_BUILTIN 1
|
||||
|
||||
/* TLS via ESP-IDF's mbedTLS (streams/mbedtls.c). */
|
||||
#define GIT_HTTPS 1
|
||||
#define GIT_MBEDTLS 1
|
||||
|
||||
/* Hashing via mbedTLS too (util/hash/mbedtls.c) — one crypto backend, and it
|
||||
* lets us skip the sha1dc collision-detection sources. */
|
||||
#define GIT_SHA1_MBEDTLS 1
|
||||
#define GIT_SHA256_MBEDTLS 1
|
||||
|
||||
/* Socket readiness via poll()/select() (both provided by lwip + vfs). */
|
||||
#define GIT_IO_POLL 1
|
||||
#define GIT_IO_SELECT 1
|
||||
|
||||
#endif
|
||||
12
firmware/components/libgit2/poll.h
Normal file
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* poll.h shim for the libgit2 esp-idf component (Spike 7, Path 2).
|
||||
*
|
||||
* libgit2's posix.h does `#include <poll.h>` when GIT_IO_POLL is set, but
|
||||
* esp-idf's newlib ships poll() under <sys/poll.h> only — there is no
|
||||
* top-level <poll.h>. This forwarding header (on the component's private
|
||||
* include path) bridges the gap without touching libgit2's sources.
|
||||
*/
|
||||
#ifndef LIBGIT2_ESPIDF_POLL_SHIM_H
|
||||
#define LIBGIT2_ESPIDF_POLL_SHIM_H
|
||||
#include <sys/poll.h>
|
||||
#endif
|
||||
1
firmware/components/libgit2/vendor
Submodule
BIN
firmware/docs/sd-card.jpg
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
@@ -2,25 +2,161 @@
|
||||
# Recipes source ~/export-esp.sh themselves (LIBCLANG_PATH + Xtensa GCC),
|
||||
# so no per-shell setup is needed before calling just.
|
||||
|
||||
# Load firmware/.env (Wi-Fi creds for the network spikes) into recipe env.
|
||||
# Absent .env is fine — the editor build ignores TW_* entirely.
|
||||
set dotenv-load := true
|
||||
|
||||
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"
|
||||
|
||||
# Custom partition table (adds the `storage` FAT partition for the git working
|
||||
# copy). Only the git-push flash applies it — the editor flash keeps the default
|
||||
# single-app layout, so this change is scoped to Spike 7's finish.
|
||||
partition_table := justfile_directory() + "/partitions.csv"
|
||||
|
||||
# Spike 7 Path 2 — env for the git2/libgit2 build. LIBGIT2_SRC points at the
|
||||
# vendored libgit2 submodule (v1.9.4, matches libgit2-sys 0.18.5). pkgconfig/
|
||||
# holds fake .pc files so libgit2-sys + libz-sys run in system mode and the
|
||||
# symbols come from the esp-idf libgit2 component. Only the git recipes set this,
|
||||
# so the editor build never compiles libgit2.
|
||||
libgit2_src := justfile_directory() + "/components/libgit2/vendor"
|
||||
git_env := "LIBGIT2_SRC=" + libgit2_src + " LIBGIT2_NO_VENDOR=1 PKG_CONFIG_ALLOW_CROSS=1 PKG_CONFIG_LIBDIR=" + justfile_directory() + "/pkgconfig"
|
||||
|
||||
# list recipes
|
||||
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}} cargo build --release
|
||||
{{esp_env}} {{git_env}} cargo build --release --bin firmware --features git
|
||||
|
||||
# build + flash + open serial monitor
|
||||
# build + flash + open serial monitor (full firmware, see `build`)
|
||||
flash:
|
||||
{{esp_env}} cargo run --release
|
||||
{{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
|
||||
|
||||
# light build + flash + open serial monitor (see `build-light`)
|
||||
flash-light:
|
||||
{{esp_env}} cargo run --release --bin firmware
|
||||
|
||||
# serial monitor only, with decoded backtraces
|
||||
monitor:
|
||||
espflash monitor --elf {{elf}}
|
||||
|
||||
# Spike 6 — build the Wi-Fi + TLS spike (needs TW_WIFI_SSID / TW_WIFI_PASS in .env)
|
||||
build-wifi:
|
||||
{{esp_env}} cargo build --release --bin wifi_tls
|
||||
|
||||
# Spike 6 — flash + monitor the Wi-Fi + TLS spike
|
||||
flash-wifi:
|
||||
{{esp_env}} cargo run --release --bin wifi_tls
|
||||
|
||||
# serial monitor for the Wi-Fi spike, with decoded backtraces
|
||||
monitor-wifi:
|
||||
espflash monitor --elf {{elf_wifi}}
|
||||
|
||||
# Spike 3 — build the SD/FAT spike (no .env needed)
|
||||
build-sd:
|
||||
{{esp_env}} cargo build --release --bin sd_fat
|
||||
|
||||
# Spike 3 — flash + monitor the SD/FAT spike
|
||||
flash-sd:
|
||||
{{esp_env}} cargo run --release --bin sd_fat
|
||||
|
||||
# serial monitor for the SD spike, with decoded backtraces
|
||||
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
|
||||
|
||||
# Spike 7 Path 2 — flash + monitor the git2/libgit2 smoke
|
||||
flash-git:
|
||||
{{esp_env}} {{git_env}} cargo run --release --bin git_smoke --features git
|
||||
|
||||
# serial monitor for the git smoke, with decoded backtraces
|
||||
monitor-git:
|
||||
espflash monitor --elf {{elf_git}}
|
||||
|
||||
# Spike 7 finish — build the on-device git push (Wi-Fi + SNTP + flash-FAT + libgit2)
|
||||
build-git-push:
|
||||
{{esp_env}} {{git_env}} cargo build --release --bin git_push --features git
|
||||
|
||||
# Spike 7 finish — flash + monitor. espflash applies the custom partition table
|
||||
# (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 --baud 921600 --partition-table {{partition_table}} --flash-size 16mb {{elf_git_push}}
|
||||
|
||||
# serial monitor for the git push, with decoded backtraces
|
||||
monitor-git-push:
|
||||
espflash monitor --elf {{elf_git_push}}
|
||||
|
||||
# Milestone #2A — build the persistent-clone publish cycle (clone + fast-forward push)
|
||||
build-git-sync:
|
||||
{{esp_env}} {{git_env}} cargo build --release --bin git_sync --features git
|
||||
|
||||
# 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 --baud 921600 --partition-table {{partition_table}} --flash-size 16mb {{elf_git_sync}}
|
||||
|
||||
# serial monitor for the git sync, with decoded backtraces
|
||||
monitor-git-sync:
|
||||
espflash monitor --elf {{elf_git_sync}}
|
||||
|
||||
# detect board, print chip/MAC/flash size
|
||||
info:
|
||||
espflash board-info
|
||||
@@ -28,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
|
||||
|
||||
19
firmware/partitions.csv
Normal file
@@ -0,0 +1,19 @@
|
||||
# Typoena partition table — 16 MB flash (ESP32-S3-WROOM-1-N16R8).
|
||||
#
|
||||
# Adds a `storage` data partition of subtype `fat` so the on-device git working
|
||||
# copy (Spike 7) lives on flash-FAT: `esp_vfs_fat_spiflash_mount_rw_wl` finds it
|
||||
# by the label below and mounts it at /spiflash.
|
||||
#
|
||||
# esp-idf-rs does NOT compile this table (it builds `libespidf` as a static lib,
|
||||
# not a full app project) — espflash applies it at flash time via
|
||||
# `--partition-table partitions.csv`. Only `just flash-git-push` passes that flag,
|
||||
# so the editor flash is unaffected and keeps its default single-app layout.
|
||||
#
|
||||
# factory is 3 MB — generous headroom for the libgit2-linked binary (the editor
|
||||
# is ~1 MB; git_push adds Wi-Fi + FAT + libgit2). storage is 4 MB. Total ~7.4 MB.
|
||||
#
|
||||
# Name, Type, SubType, Offset, Size, Flags
|
||||
nvs, data, nvs, 0x9000, 0x6000,
|
||||
phy_init, data, phy, 0xf000, 0x1000,
|
||||
factory, app, factory, 0x10000, 0x300000,
|
||||
storage, data, fat, 0x310000, 0x400000,
|
||||
|
14
firmware/pkgconfig/libgit2.pc
Normal file
@@ -0,0 +1,14 @@
|
||||
# Fake pkg-config file for the libgit2 esp-idf component (Spike 7, Path 2).
|
||||
#
|
||||
# libgit2-sys in system mode (LIBGIT2_NO_VENDOR=1) probes for a system libgit2
|
||||
# via pkg-config; without one it aborts, with one it emits link flags. We don't
|
||||
# want it to build OR link anything — esp-idf already builds liblibgit2.a and
|
||||
# puts it in the final link group (that's how git_smoke's 538 symbols resolved).
|
||||
# So this .pc makes the probe *succeed* (right version, in range [1.9.4,1.10.0))
|
||||
# while emitting NOTHING (empty Libs/Cflags). The symbols come from the
|
||||
# component; libgit2-sys supplies only its hand-written Rust bindings.
|
||||
Name: libgit2
|
||||
Description: libgit2 built as an esp-idf component (mbedTLS)
|
||||
Version: 1.9.4
|
||||
Libs:
|
||||
Cflags:
|
||||
16
firmware/pkgconfig/zlib.pc
Normal file
@@ -0,0 +1,16 @@
|
||||
# Fake pkg-config file for zlib (Spike 7, Path 2).
|
||||
#
|
||||
# libz-sys is a non-optional dependency of libgit2-sys. Left to itself it would
|
||||
# either grab the host (macOS) zlib or vendor-build its own libz.a — and either
|
||||
# way its zlib symbols would collide with the zlib we bundle *inside* the
|
||||
# libgit2 component (deps/zlib, which lives in esp-idf's link group where the
|
||||
# ordering is safe). This .pc makes libz-sys's probe succeed while emitting
|
||||
# NOTHING, so it contributes no symbols and the component's bundled zlib wins.
|
||||
#
|
||||
# PKG_CONFIG_LIBDIR is pointed at this directory only, so neither probe can fall
|
||||
# through to the host's real zlib.pc.
|
||||
Name: zlib
|
||||
Description: zlib bundled into the libgit2 esp-idf component
|
||||
Version: 1.3.1
|
||||
Libs:
|
||||
Cflags:
|
||||
@@ -1,12 +1,85 @@
|
||||
# Rust often needs a bit of an extra main task stack size compared to C (the default is 3K)
|
||||
# You might have to increase this further if you allocate large stack variables in the main task
|
||||
CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192
|
||||
# You might have to increase this further if you allocate large stack variables in the main task.
|
||||
# Bumped for the TLS handshake (Spike 6): mbedtls runs on the calling task and
|
||||
# wants several KB of stack on top of the app's own usage. 12 KB ran the editor
|
||||
# AND a full TLS-on-main handshake on hardware — the last-known-good value for
|
||||
# this shared stack.
|
||||
#
|
||||
# Spike 7 briefly bumped this to 96 KB because libgit2's push chain is far deeper
|
||||
# (~67 KB measured for a trivial config write: nearly every function puts a 4 KB
|
||||
# `char path[GIT_PATH_MAX]` on the stack and init→config→FATFS→wear-leveling
|
||||
# nests ~10 deep). But this sdkconfig is SHARED with the editor build, so that
|
||||
# forced the editor to over-reserve ~80 KB for nothing. Git now runs on its own
|
||||
# large-stack thread instead (git_push.rs — GIT_STACK via
|
||||
# std::thread::Builder::stack_size), so this drops back to the Spike-6 value.
|
||||
# (The "time() only works on the main task" theory was a misdiagnosis: that
|
||||
# iteration ran git on a std::thread with the DEFAULT 4 KB stack and just
|
||||
# overflowed sooner — it was always stack depth, never thread-vs-main.)
|
||||
CONFIG_ESP_MAIN_TASK_STACK_SIZE=12288
|
||||
|
||||
# Increase a bit these stack sizes as they are also a bit too small by default
|
||||
CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE=4096
|
||||
CONFIG_FREERTOS_IDLE_TASK_STACKSIZE=4096
|
||||
|
||||
# You might have to increase this further if you spawn your own Rust threads
|
||||
# that allocate large stack variables; or better yet - use
|
||||
# You might have to increase this further if you spawn your own Rust threads
|
||||
# that allocate large stack variables; or better yet - use
|
||||
# `std::thread::Builder::new().stack_size(XXX)` for spawning
|
||||
CONFIG_PTHREAD_TASK_STACK_SIZE_DEFAULT=4096
|
||||
|
||||
# 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.
|
||||
CONFIG_FATFS_LFN_HEAP=y
|
||||
|
||||
# PSRAM (Spike 7 — the git working set needs it; the docs memory plan budgets
|
||||
# ~1.5 MB PSRAM for pack/delta work, and the rope buffer lives there too). This
|
||||
# board is an ESP32-S3-WROOM-1-N16R8 → 8 MB *octal* PSRAM, so OCT mode is
|
||||
# required (quad would fail to init). USE_MALLOC adds PSRAM to the heap
|
||||
# allocator, so large allocations (Box, Rope, libgit2 buffers) land there
|
||||
# instead of the ~339 KB internal DRAM Spike 6 measured. Octal PSRAM occupies
|
||||
# GPIO 33–37; the EPD/SD pins (4–13) deliberately avoid that range, so no wiring
|
||||
# conflict. Speed left at the 40 MHz default (safe first-enable; 80 MHz needs
|
||||
# matching flash-freq config and is a later tuning step).
|
||||
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
|
||||
|
||||
58
firmware/snippets-catalog/prose.json
Normal 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"
|
||||
}
|
||||
}
|
||||
27
firmware/snippets-catalog/structure.json
Normal 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": "$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"
|
||||
}
|
||||
}
|
||||
37
firmware/snippets-catalog/symbols.json
Normal 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)"
|
||||
}
|
||||
}
|
||||
371
firmware/src/bin/git_bench.rs
Normal 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]),
|
||||
);
|
||||
}
|
||||
389
firmware/src/bin/git_push.rs
Normal file
@@ -0,0 +1,389 @@
|
||||
//! Spike 7 — Path 2 finish: on-device `init → commit → push` over mbedTLS HTTPS.
|
||||
//!
|
||||
//! Gate D proved the `git2` safe API links and runs on device (SHA1 via
|
||||
//! mbedTLS). This is the real prize: the full publish path the editor's `git`
|
||||
//! module will run, end to end on hardware —
|
||||
//!
|
||||
//! 1. Wi-Fi assoc + SNTP. A valid wall clock is needed twice: mbedTLS checks
|
||||
//! the server cert's validity window, and the commit signature is stamped
|
||||
//! with the current time.
|
||||
//! 2. Mount flash-FAT at /spiflash (partition `storage`, see partitions.csv).
|
||||
//! The working copy lives here — the ADR-007 storage question is settled
|
||||
//! *for the spike* as flash-FAT (sidesteps the still-unresolved SD card);
|
||||
//! SD stays the product plan of record.
|
||||
//! 3. `git init` a fresh working copy, write a file, stage it, commit with the
|
||||
//! configured author (message = a timestamp), and push HEAD to a fresh
|
||||
//! per-boot branch `device/<unix>` on the HTTPS remote, PAT in the
|
||||
//! credential callback (never logged).
|
||||
//!
|
||||
//! Why push to a *fresh* branch and not `add` onto a clone: a fresh `init` each
|
||||
//! boot has an unrelated history, so pushing onto an existing branch would be a
|
||||
//! non-fast-forward and drag in merge-unrelated-histories handling. A unique
|
||||
//! `device/<unix>` branch is always a clean create — it isolates the actual
|
||||
//! unknown (does the push transport work on device) from history reconciliation.
|
||||
//! The product will hold a *persistent clone* so real publishes fast-forward;
|
||||
//! proving clone/fetch on device is a clean follow-up.
|
||||
//!
|
||||
//! ## Cert verification
|
||||
//!
|
||||
//! libgit2's mbedTLS stream verifies the server chain against whatever CA is set
|
||||
//! via `GIT_OPT_SET_SSL_CERT_LOCATIONS`: the handshake is `VERIFY_OPTIONAL`, then
|
||||
//! `verify_server_cert` turns a bad/untrusted chain into `GIT_ECERTIFICATE`. We
|
||||
//! embed GitHub's root CAs (`github_roots.pem`), write them to `/spiflash/ca.pem`
|
||||
//! and point libgit2 there (`install_tls_trust_store`). The `certificate_check`
|
||||
//! callback then returns PASSTHROUGH — "honor libgit2's own result" — which the
|
||||
//! http transport maps to `is_valid ? 0 : -1` (httpclient.c:805), so an
|
||||
//! untrusted / MITM cert FAILS the push. Fail-closed, no blanket-accept.
|
||||
//!
|
||||
//! Scope note: esp-idf ships a full CA bundle (Spike 6), but it's attached to
|
||||
//! esp-idf's own mbedtls config, not libgit2's private one — bridging it would
|
||||
//! mean touching libgit2 sources. Embedding GitHub's roots is the minimal,
|
||||
//! source-clean trust store; refresh it if GitHub rotates CAs (a product would
|
||||
//! prefer esp-idf's bundle via a custom subtransport, or fetch roots at
|
||||
//! provisioning). See ADR-005 / the Spike 7 postmortem.
|
||||
//!
|
||||
//! Build/flash with `just flash-git-push` (needs the git TW_* vars in .env).
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::ffi::CStr;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::rc::Rc;
|
||||
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::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::{BlockingWifi, EspWifi};
|
||||
use firmware::net::connect_wifi;
|
||||
use git2::{
|
||||
CertificateCheckStatus, Cred, CredentialType, IndexAddOption, PushOptions, RemoteCallbacks,
|
||||
Repository, Signature,
|
||||
};
|
||||
|
||||
/// Injected by build.rs so serial output identifies the exact build.
|
||||
const BUILD_TAG: &str = concat!("build ", env!("BUILD_TIME"), " @", env!("BUILD_GIT"));
|
||||
|
||||
// Baked in at build time from firmware/.env (see build.rs). Empty when unset;
|
||||
// checked at runtime so the editor build never depends on them.
|
||||
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");
|
||||
|
||||
/// flash-FAT partition (partitions.csv) and its VFS mount point.
|
||||
const FAT_LABEL: &CStr = c"storage";
|
||||
const MOUNT: &CStr = c"/spiflash";
|
||||
const MOUNT_STR: &str = "/spiflash";
|
||||
|
||||
/// GitHub's root CAs, embedded so the push can verify the server's TLS chain
|
||||
/// (see the "Cert verification" module docs). Written to FAT at runtime and
|
||||
/// handed to libgit2 via GIT_OPT_SET_SSL_CERT_LOCATIONS.
|
||||
const GITHUB_ROOTS_PEM: &str = include_str!("github_roots.pem");
|
||||
/// Where install_tls_trust_store drops the bundle for libgit2's mbedTLS stream
|
||||
/// to parse (needs a real path — CONFIG_MBEDTLS_FS_IO is on).
|
||||
const CA_BUNDLE_PATH: &str = "/spiflash/ca.pem";
|
||||
|
||||
/// SNTP first-sync budget (same as Spike 6).
|
||||
const SNTP_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
||||
/// Stack for the dedicated git thread (see run()). libgit2's init→push chain
|
||||
/// measured ~67 KB on hardware and the push is deeper; 96 KB is the value proven
|
||||
/// on the main task before git moved off it. Sizing the thread — not the shared
|
||||
/// main-task stack — is the whole point of this task: the editor build no longer
|
||||
/// reserves it. Allocated from internal DRAM only while a push is running.
|
||||
const GIT_STACK: usize = 96 * 1024;
|
||||
|
||||
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 7 Path 2 finish (on-device git push), {BUILD_TAG}");
|
||||
|
||||
if let Err(e) = run() {
|
||||
log::error!("❌ Spike 7 setup failed: {e:?}");
|
||||
}
|
||||
|
||||
// Reached only on a setup error (run() idles forever on the happy path).
|
||||
loop {
|
||||
FreeRtos::delay_ms(1000);
|
||||
}
|
||||
}
|
||||
|
||||
fn run() -> Result<()> {
|
||||
if WIFI_SSID.is_empty() {
|
||||
bail!("TW_WIFI_SSID is empty — set the network + git TW_* vars in firmware/.env");
|
||||
}
|
||||
if REMOTE_URL.is_empty() || GH_USER.is_empty() || PAT.is_empty() {
|
||||
bail!("TW_REMOTE_URL / TW_GH_USER / TW_PAT must all be set in firmware/.env");
|
||||
}
|
||||
|
||||
let peripherals = Peripherals::take()?;
|
||||
let sys_loop = EspSystemEventLoop::take()?;
|
||||
let nvs = EspDefaultNvsPartition::take()?;
|
||||
|
||||
// Wi-Fi is bound here and, on the happy path, NEVER dropped: run() idles at
|
||||
// the end rather than returning. Dropping EspWifi runs wifi_deinit, whose
|
||||
// free() asserts if the git work left the heap in a bad state — an earlier
|
||||
// revision crashed exactly there (tlsf_free in wifi_deinit), which masked
|
||||
// the git-thread logs. Keeping the radio up surfaces the real result.
|
||||
let _wifi = {
|
||||
let mut wifi = BlockingWifi::wrap(
|
||||
EspWifi::new(peripherals.modem, sys_loop.clone(), Some(nvs))?,
|
||||
sys_loop,
|
||||
)?;
|
||||
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
|
||||
};
|
||||
|
||||
sync_clock()?;
|
||||
mount_fat().context("mounting flash-FAT")?;
|
||||
install_tls_trust_store().context("installing TLS trust store")?;
|
||||
|
||||
// Git runs on a DEDICATED large-stack thread, not the main task. libgit2's
|
||||
// call chain is deeply stack-hungry (see GIT_STACK), and sizing the *shared*
|
||||
// main-task stack for it made the editor build over-reserve ~80 KB. Its own
|
||||
// thread lets the main task stay small (sdkconfig.defaults, back to 12 KB).
|
||||
//
|
||||
// NB: an earlier iteration ran git on a std::thread and appeared to fail,
|
||||
// which we wrongly blamed on a "newlib time() lock that only works on main".
|
||||
// The real cause was the default 4 KB pthread stack overflowing — the same
|
||||
// chain just smashed the stack sooner. An explicit stack_size fixes it; there
|
||||
// is no thread-vs-main limitation. (Verified: the push below runs mbedTLS +
|
||||
// FATFS timestamping off-main.)
|
||||
//
|
||||
// Errors are LOGGED, not propagated, so the radio stays up (_wifi is held on
|
||||
// this task) and the monitor shows the result. join() blocks main until git
|
||||
// finishes; a panic almost certainly means GIT_STACK is too small.
|
||||
let git = std::thread::Builder::new()
|
||||
.name("git".into())
|
||||
.stack_size(GIT_STACK)
|
||||
.spawn(git_publish)
|
||||
.context("spawning git thread")?;
|
||||
match git.join() {
|
||||
Ok(Ok(summary)) => log::info!("✅ Spike 7 complete — {summary}"),
|
||||
Ok(Err(e)) => log::error!("❌ git_publish failed: {e:?}"),
|
||||
Err(_) => log::error!("❌ git thread panicked — likely stack overflow, raise GIT_STACK"),
|
||||
}
|
||||
|
||||
log::info!("idling with Wi-Fi up — press reset to re-run");
|
||||
loop {
|
||||
FreeRtos::delay_ms(1000);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<()> {
|
||||
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(())
|
||||
}
|
||||
|
||||
/// Mount the flash-FAT `storage` partition at /spiflash, formatting on first
|
||||
/// boot. Unlike the SD spike (which must never reformat a user's card),
|
||||
/// `format_if_mount_failed = true` is correct here: `storage` is our own blank
|
||||
/// partition, and a fresh flash needs an initial FAT.
|
||||
fn mount_fat() -> Result<()> {
|
||||
let cfg = sys::esp_vfs_fat_mount_config_t {
|
||||
format_if_mount_failed: true,
|
||||
max_files: 16, // libgit2 opens several files at once (index, refs, objects)
|
||||
allocation_unit_size: 4096,
|
||||
disk_status_check_enable: false,
|
||||
use_one_fat: false,
|
||||
};
|
||||
// SAFETY: valid C strings + config; the driver fills `wl` on success.
|
||||
let mut wl: sys::wl_handle_t = 0;
|
||||
esp!(unsafe {
|
||||
sys::esp_vfs_fat_spiflash_mount_rw_wl(MOUNT.as_ptr(), FAT_LABEL.as_ptr(), &cfg, &mut wl)
|
||||
})
|
||||
.context("esp_vfs_fat_spiflash_mount_rw_wl (is the `storage` partition flashed?)")?;
|
||||
|
||||
let (mut total, mut free) = (0u64, 0u64);
|
||||
// Best-effort usage report.
|
||||
if unsafe { sys::esp_vfs_fat_info(MOUNT.as_ptr(), &mut total, &mut free) } == sys::ESP_OK {
|
||||
log::info!(
|
||||
"flash-FAT mounted at {MOUNT_STR} — {} KiB total, {} KiB free",
|
||||
total / 1024,
|
||||
free / 1024
|
||||
);
|
||||
} else {
|
||||
log::info!("flash-FAT mounted at {MOUNT_STR}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write the embedded GitHub root CAs to FAT and point libgit2's mbedTLS stream
|
||||
/// at them (GIT_OPT_SET_SSL_CERT_LOCATIONS). Must run after the FAT mount and
|
||||
/// before the push. Once set, libgit2 verifies the server chain itself, so the
|
||||
/// push fails closed on an untrusted cert (the push callback returns PASSTHROUGH
|
||||
/// to honor that result — see the module docs).
|
||||
fn install_tls_trust_store() -> Result<()> {
|
||||
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(())
|
||||
}
|
||||
|
||||
/// The whole publish (runs on the dedicated git thread — see run()): init a fresh
|
||||
/// working copy, write a file, commit, and push to a fresh remote branch. Returns
|
||||
/// a one-line summary.
|
||||
fn git_publish() -> Result<String> {
|
||||
log::info!("git_publish started — free heap {}", free_heap());
|
||||
let unix = now_unix();
|
||||
|
||||
// Fresh working copy per boot in a UNIQUE dir — never deleted. libgit2
|
||||
// writes loose objects read-only, and FATFS refuses to f_unlink a read-only
|
||||
// file (→ EACCES), so a wipe-and-reinit strategy can't clean a prior repo.
|
||||
// Unique dirs sidestep that; the 4 MB partition holds many tiny repos, and
|
||||
// each boot pushes to its own branch anyway. (Cleanup of old dirs is a
|
||||
// product concern, not a spike one.)
|
||||
let repo_dir = format!("{MOUNT_STR}/wc-{unix}");
|
||||
let repo = Repository::init(&repo_dir).context("git init working copy")?;
|
||||
log::info!("init OK at {repo_dir} — free heap {}", free_heap());
|
||||
|
||||
// One tracked file. Content is disposable; it just makes a non-empty tree.
|
||||
let path = format!("{repo_dir}/device.md");
|
||||
let body = format!("# Typoena on-device publish\n\nunix: {unix}\n{BUILD_TAG}\n");
|
||||
fs::File::create(&path)
|
||||
.and_then(|mut f| f.write_all(body.as_bytes()))
|
||||
.context("writing device.md")?;
|
||||
log::info!("wrote device.md");
|
||||
|
||||
// Stage (add --all semantics) and commit with the configured author. Message
|
||||
// is the timestamp — the product's `git` module will use a proper ISO-8601
|
||||
// string (desktop spike uses chrono); unix seconds keep this bench binary
|
||||
// dependency-free.
|
||||
let mut index = repo.index().context("opening index")?;
|
||||
index
|
||||
.add_all(["*"], IndexAddOption::DEFAULT, None)
|
||||
.context("staging (add --all)")?;
|
||||
index.write().context("writing index")?;
|
||||
let tree = repo.find_tree(index.write_tree().context("writing tree")?)?;
|
||||
log::info!("staged + tree written — free heap {}", free_heap());
|
||||
|
||||
let sig = Signature::now(AUTHOR_NAME, AUTHOR_EMAIL).context("building signature")?;
|
||||
let message = format!("Typoena on-device publish — unix {unix}");
|
||||
repo.commit(Some("HEAD"), &sig, &sig, &message, &tree, &[])
|
||||
.context("creating commit")?;
|
||||
let local = repo
|
||||
.head()?
|
||||
.shorthand()
|
||||
.context("HEAD has no branch shorthand")?
|
||||
.to_string();
|
||||
log::info!("committed to {local} — free heap {}", free_heap());
|
||||
|
||||
// Point origin at the HTTPS remote and push to a fresh per-boot branch.
|
||||
let remote_branch = format!("device/{unix}");
|
||||
let refspec = format!("refs/heads/{local}:refs/heads/{remote_branch}");
|
||||
repo.remote("origin", REMOTE_URL)
|
||||
.context("creating origin remote")?;
|
||||
log::info!("origin set; pushing {refspec} — free heap {}", free_heap());
|
||||
|
||||
push(&repo, &refspec).with_context(|| format!("pushing {refspec}"))?;
|
||||
|
||||
log::info!(
|
||||
"push returned — free heap {}, min-ever {}",
|
||||
free_heap(),
|
||||
min_free_heap()
|
||||
);
|
||||
Ok(format!("pushed {local} → origin/{remote_branch} over mbedTLS HTTPS"))
|
||||
}
|
||||
|
||||
/// Push `refspec` to origin over HTTPS. Binds the PAT credential + the (spike)
|
||||
/// cert-accept callback, and surfaces a server-side ref rejection as an error.
|
||||
fn push(repo: &Repository, refspec: &str) -> Result<()> {
|
||||
let mut remote = repo.find_remote("origin")?;
|
||||
|
||||
// Server-side per-ref status arrives via a callback, NOT as a push() error.
|
||||
// An Rc<RefCell<…>> lets the callback own a handle while we read the result
|
||||
// after push() returns (the desktop spike uses the same shape).
|
||||
let rejection: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
|
||||
let mut cbs = RemoteCallbacks::new();
|
||||
|
||||
cbs.credentials(|_url, _user_from_url, allowed| {
|
||||
// GitHub over HTTPS asks for USER_PASS_PLAINTEXT: the PAT is the
|
||||
// password. The PAT is handed to libgit2 here and never logged.
|
||||
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",
|
||||
))
|
||||
});
|
||||
|
||||
// Real verification: libgit2's mbedTLS stream checks the server chain
|
||||
// against the CA bundle install_tls_trust_store() loaded. PASSTHROUGH tells
|
||||
// libgit2 to honor that result — the http transport maps it to
|
||||
// `is_valid ? 0 : -1` (httpclient.c:805), so an untrusted cert FAILS the
|
||||
// push (fail-closed). No blanket-accept.
|
||||
cbs.certificate_check(|_cert, host| {
|
||||
log::info!("verifying {host} TLS chain against embedded GitHub CA bundle");
|
||||
Ok(CertificateCheckStatus::CertificatePassthrough)
|
||||
});
|
||||
|
||||
{
|
||||
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)).context("push transport")?;
|
||||
|
||||
if let Some(msg) = rejection.borrow().clone() {
|
||||
bail!("remote rejected ref: {msg}");
|
||||
}
|
||||
log::info!("push accepted by remote");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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() }
|
||||
}
|
||||
|
||||
fn min_free_heap() -> u32 {
|
||||
unsafe { sys::esp_get_minimum_free_heap_size() }
|
||||
}
|
||||
45
firmware/src/bin/git_smoke.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
//! Spike 7 — Path 2 Gate D: the `git2` safe API on device.
|
||||
//!
|
||||
//! Gates A/B/C proved libgit2 compiles, links, and runs via hand externs. This
|
||||
//! replaces those with the real integration path: the `git2` crate's safe Rust
|
||||
//! API, bound to our esp-idf-built libgit2 through `libgit2-sys` in system mode
|
||||
//! (LIBGIT2_NO_VENDOR=1 + the fake pkg-config in firmware/pkgconfig/). If this
|
||||
//! links and runs, the desktop spike's add/commit/push code transfers to device.
|
||||
//!
|
||||
//! Two checks, both in-memory (no filesystem or network needed yet):
|
||||
//! 1. git2::Version — proves the safe wrapper reaches the linked library.
|
||||
//! 2. Oid::hash_object — computes a blob SHA1 through libgit2's ODB, which
|
||||
//! runs the mbedTLS hash backend. The expected hash is known, so a correct
|
||||
//! value proves the whole path (git2 -> libgit2 -> mbedtls) end to end.
|
||||
|
||||
use esp_idf_svc::sys;
|
||||
use git2::{ObjectType, Oid};
|
||||
|
||||
fn main() {
|
||||
sys::link_patches();
|
||||
esp_idf_svc::log::EspLogger::initialize_default();
|
||||
|
||||
log::info!("Typoena — Spike 7 Path 2 Gate D (git2 safe API on device)");
|
||||
|
||||
let (major, minor, patch) = git2::Version::get().libgit2_version();
|
||||
log::info!("git2 crate is talking to libgit2 {major}.{minor}.{patch}");
|
||||
|
||||
// `git hash-object` of the 5 bytes "hello" is a fixed, well-known value.
|
||||
match Oid::hash_object(ObjectType::Blob, b"hello") {
|
||||
Ok(oid) => {
|
||||
log::info!("sha1(blob \"hello\") = {oid}");
|
||||
if oid.to_string() == "b6fc4c620b67d95f953a5c1c1230aaab5db5a1b0" {
|
||||
log::info!("hash matches `git hash-object` — mbedTLS SHA1 backend correct");
|
||||
} else {
|
||||
log::warn!("hash MISMATCH — mbedTLS SHA1 backend produced the wrong digest");
|
||||
}
|
||||
}
|
||||
Err(e) => log::error!("Oid::hash_object failed: {e}"),
|
||||
}
|
||||
|
||||
log::info!("✅ git2 safe API linked and ran on device");
|
||||
|
||||
loop {
|
||||
std::thread::sleep(std::time::Duration::from_secs(60));
|
||||
}
|
||||
}
|
||||
487
firmware/src/bin/git_sync.rs
Normal file
@@ -0,0 +1,487 @@
|
||||
//! Milestone #2, increment A — the PERSISTENT-CLONE publish cycle.
|
||||
//!
|
||||
//! `git_push.rs` proved the transport by doing a *fresh* `git init` each boot and
|
||||
//! pushing an unrelated per-boot branch `device/<unix>` — deliberately sidestepping
|
||||
//! history reconciliation. That is NOT how the editor publishes. The product keeps
|
||||
//! ONE clone of the user's repo on the device and fast-forwards it on every
|
||||
//! `Ctrl-G`. This binary proves the piece `git_push` never exercised on hardware:
|
||||
//! `clone` + persistent `open` + a real fast-forward `push`, over mbedTLS HTTPS+PAT.
|
||||
//!
|
||||
//! Flow: Wi-Fi + SNTP + flash-FAT + TLS trust store (identical to `git_push`) →
|
||||
//! `open` `/spiflash/repo` if it exists, else `clone` the remote → append a line
|
||||
//! to a tracked `notes.md` (a real content change, unlike `git_push`'s throwaway
|
||||
//! file) → stage + commit (parent = current HEAD, or none on an empty remote) →
|
||||
//! push HEAD to its own branch as a fast-forward, with a fetch-and-reconcile on a
|
||||
//! rejected push.
|
||||
//!
|
||||
//! ## Scope (increment A)
|
||||
//!
|
||||
//! The reconcile handles *up-to-date* and *fast-forward*. A true **divergence**
|
||||
//! (a second writer pushed in between) needs a real merge commit written to
|
||||
//! FATFS — the riskiest untested path — and is deferred to increment B; here it
|
||||
//! bails with a clear message. The single-writer appliance case (one device
|
||||
//! publishing) is always a fast-forward, so the happy path never hits it.
|
||||
//!
|
||||
//! Functions are kept standalone (config via the baked `TW_*` consts, no editor
|
||||
//! coupling) so increment C can lift `open_or_clone`/`publish`/`push_with_retry`
|
||||
//! into a reusable `git` module the editor's `Ctrl-G` calls.
|
||||
//!
|
||||
//! Build/flash with `just flash-git-sync` (same TW_* `.env` + partition table as
|
||||
//! `git_push`). Git runs on a dedicated 96 KB thread (see `git_push.rs` / #3).
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::ffi::CStr;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::rc::Rc;
|
||||
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::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::{BlockingWifi, EspWifi};
|
||||
use firmware::net::connect_wifi;
|
||||
use git2::build::{CheckoutBuilder, RepoBuilder};
|
||||
use git2::{
|
||||
CertificateCheckStatus, Commit, Cred, CredentialType, FetchOptions, IndexAddOption,
|
||||
PushOptions, RemoteCallbacks, Repository, Signature,
|
||||
};
|
||||
|
||||
/// Injected by build.rs so serial output identifies the exact build.
|
||||
const BUILD_TAG: &str = concat!("build ", env!("BUILD_TIME"), " @", env!("BUILD_GIT"));
|
||||
|
||||
// Baked in at build time from firmware/.env (see build.rs). Empty when unset;
|
||||
// checked at runtime so the editor build never depends on them.
|
||||
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");
|
||||
|
||||
/// flash-FAT partition (partitions.csv) and its VFS mount point.
|
||||
const FAT_LABEL: &CStr = c"storage";
|
||||
const MOUNT: &CStr = c"/spiflash";
|
||||
const MOUNT_STR: &str = "/spiflash";
|
||||
|
||||
/// The persistent clone. Unlike `git_push`'s per-boot `wc-<unix>` dirs, this one
|
||||
/// dir survives reboots (flash-FAT is only formatted on a failed mount): boot 1
|
||||
/// clones it, every later boot opens it and fast-forwards — the product model.
|
||||
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.
|
||||
const GITHUB_ROOTS_PEM: &str = include_str!("github_roots.pem");
|
||||
const CA_BUNDLE_PATH: &str = "/spiflash/ca.pem";
|
||||
|
||||
/// SNTP first-sync budget (same as Spike 6).
|
||||
const SNTP_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
||||
/// Stack for the dedicated git thread — clone/checkout is at least as deep as the
|
||||
/// init→push chain `git_push` measured (~67 KB), so keep the proven 96 KB. See #3.
|
||||
const GIT_STACK: usize = 96 * 1024;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
esp_idf_svc::sys::link_patches();
|
||||
esp_idf_svc::log::EspLogger::initialize_default();
|
||||
|
||||
log::info!("Typoena — persistent-clone publish cycle (milestone #2A), {BUILD_TAG}");
|
||||
|
||||
if let Err(e) = run() {
|
||||
log::error!("❌ git_sync setup failed: {e:?}");
|
||||
}
|
||||
|
||||
// Reached only on a setup error (run() idles forever on the happy path).
|
||||
loop {
|
||||
FreeRtos::delay_ms(1000);
|
||||
}
|
||||
}
|
||||
|
||||
fn run() -> Result<()> {
|
||||
if WIFI_SSID.is_empty() {
|
||||
bail!("TW_WIFI_SSID is empty — set the network + git TW_* vars in firmware/.env");
|
||||
}
|
||||
if REMOTE_URL.is_empty() || GH_USER.is_empty() || PAT.is_empty() {
|
||||
bail!("TW_REMOTE_URL / TW_GH_USER / TW_PAT must all be set in firmware/.env");
|
||||
}
|
||||
|
||||
let peripherals = Peripherals::take()?;
|
||||
let sys_loop = EspSystemEventLoop::take()?;
|
||||
let nvs = EspDefaultNvsPartition::take()?;
|
||||
|
||||
// Wi-Fi is bound here and never dropped on the happy path (run() idles at the
|
||||
// end): dropping EspWifi runs wifi_deinit, which can assert if git left the
|
||||
// heap in a bad state and mask the real logs. Keeping the radio up surfaces
|
||||
// the true result. (Same rationale as git_push.rs.)
|
||||
let _wifi = {
|
||||
let mut wifi = BlockingWifi::wrap(
|
||||
EspWifi::new(peripherals.modem, sys_loop.clone(), Some(nvs))?,
|
||||
sys_loop,
|
||||
)?;
|
||||
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
|
||||
};
|
||||
|
||||
sync_clock()?;
|
||||
mount_fat().context("mounting flash-FAT")?;
|
||||
install_tls_trust_store().context("installing TLS trust store")?;
|
||||
|
||||
// Git runs on a dedicated 96 KB thread, not the shared main task (see #3 /
|
||||
// git_push.rs). Errors are LOGGED, not propagated, so the radio stays up and
|
||||
// the monitor shows the result.
|
||||
let git = std::thread::Builder::new()
|
||||
.name("git".into())
|
||||
.stack_size(GIT_STACK)
|
||||
.spawn(publish)
|
||||
.context("spawning git thread")?;
|
||||
match git.join() {
|
||||
Ok(Ok(summary)) => log::info!("✅ publish cycle complete — {summary}"),
|
||||
Ok(Err(e)) => log::error!("❌ publish failed: {e:?}"),
|
||||
Err(_) => log::error!("❌ git thread panicked — likely stack overflow, raise GIT_STACK"),
|
||||
}
|
||||
|
||||
log::info!("idling with Wi-Fi up — press reset to publish again");
|
||||
loop {
|
||||
FreeRtos::delay_ms(1000);
|
||||
}
|
||||
}
|
||||
|
||||
/// The publish cycle (on the dedicated git thread): open-or-clone the persistent
|
||||
/// repo, record a change, commit on top of the current branch, and fast-forward
|
||||
/// push it. Returns a one-line summary.
|
||||
fn publish() -> Result<String> {
|
||||
log::info!("publish started — free heap {}", free_heap());
|
||||
|
||||
let (repo, cloned) = open_or_clone()?;
|
||||
log::info!(
|
||||
"repo ready ({}) — free heap {}",
|
||||
if cloned { "cloned" } else { "opened" },
|
||||
free_heap()
|
||||
);
|
||||
|
||||
// Append a line to a tracked file: a real content change (the editor will
|
||||
// write the user's notes here). Create-or-append so it works on the first
|
||||
// publish and every one after.
|
||||
let unix = now_unix();
|
||||
let path = format!("{REPO_DIR}/{NOTES_FILE}");
|
||||
let line = format!("- publish @unix {unix} ({BUILD_TAG})\n");
|
||||
{
|
||||
let mut f = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.with_context(|| format!("opening {path}"))?;
|
||||
f.write_all(line.as_bytes()).context("appending note")?;
|
||||
}
|
||||
log::info!("appended a line to {NOTES_FILE}");
|
||||
|
||||
// Stage everything (add --all: also stages deletions, which v0.x note-delete
|
||||
// needs) and build the tree.
|
||||
let mut index = repo.index().context("opening index")?;
|
||||
index
|
||||
.add_all(["*"], IndexAddOption::DEFAULT, None)
|
||||
.context("staging (add --all)")?;
|
||||
index.write().context("writing index")?;
|
||||
let tree = repo.find_tree(index.write_tree().context("writing tree")?)?;
|
||||
|
||||
// Commit on top of the current branch tip (None on an empty/unborn remote).
|
||||
let parent = repo.head().ok().and_then(|h| h.peel_to_commit().ok());
|
||||
|
||||
// Nothing-to-publish short-circuit (product spec): if the staged tree matches
|
||||
// the parent's, there is nothing new to push.
|
||||
if let Some(p) = &parent {
|
||||
if p.tree_id() == tree.id() {
|
||||
return Ok(format!("nothing to publish (tree unchanged @ {})", short(p.id())));
|
||||
}
|
||||
}
|
||||
|
||||
let sig = Signature::now(AUTHOR_NAME, AUTHOR_EMAIL).context("building signature")?;
|
||||
let message = format!("Typoena publish — unix {unix}");
|
||||
let parents: Vec<&Commit> = parent.iter().collect();
|
||||
let oid = repo
|
||||
.commit(Some("HEAD"), &sig, &sig, &message, &tree, &parents)
|
||||
.context("creating commit")?;
|
||||
let branch = repo
|
||||
.head()?
|
||||
.shorthand()
|
||||
.context("HEAD has no branch shorthand")?
|
||||
.to_string();
|
||||
log::info!("committed {} to {branch} — free heap {}", short(oid), free_heap());
|
||||
|
||||
push_with_retry(&repo, &branch)?;
|
||||
|
||||
log::info!(
|
||||
"push done — free heap {}, min-ever {}",
|
||||
free_heap(),
|
||||
min_free_heap()
|
||||
);
|
||||
Ok(format!(
|
||||
"published {} → origin/{branch} (persistent clone at {REPO_DIR})",
|
||||
short(oid)
|
||||
))
|
||||
}
|
||||
|
||||
/// Open the persistent clone, or clone it on first boot. Returns the repo and
|
||||
/// 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(_) => {
|
||||
// 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")?;
|
||||
Ok((repo, true))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Push `branch` to origin; on a rejected push, fetch origin and reconcile, then
|
||||
/// retry once. A true divergence (two writers) needs a merge commit and is
|
||||
/// deferred to increment B — `fetch_and_integrate` bails there.
|
||||
fn push_with_retry(repo: &Repository, branch: &str) -> Result<()> {
|
||||
let refspec = format!("refs/heads/{branch}:refs/heads/{branch}");
|
||||
match try_push(repo, &refspec) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(first) => {
|
||||
log::warn!("push rejected ({first}); fetching origin to reconcile");
|
||||
fetch_and_integrate(repo, branch).context("reconciling after a rejected push")?;
|
||||
log::info!("reconciled with origin; retrying push");
|
||||
try_push(repo, &refspec).context("push after reconcile")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One push attempt over HTTPS. Binds the PAT credential + the cert-verify
|
||||
/// callback, and surfaces a server-side ref rejection (e.g. non-fast-forward) as
|
||||
/// an error (it arrives via `push_update_reference`, not as a `push()` error).
|
||||
fn try_push(repo: &Repository, refspec: &str) -> Result<()> {
|
||||
let mut remote = repo.find_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))
|
||||
.context("push transport")?;
|
||||
|
||||
if let Some(msg) = rejection.borrow().clone() {
|
||||
bail!("remote rejected ref: {msg}");
|
||||
}
|
||||
log::info!("push accepted by remote");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch origin and integrate `branch` into the local branch. Handles up-to-date
|
||||
/// and fast-forward (the common single-writer cases). A real divergence needs a
|
||||
/// merge commit written to FATFS — deferred to increment B — so it bails.
|
||||
fn fetch_and_integrate(repo: &Repository, branch: &str) -> Result<()> {
|
||||
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")?;
|
||||
let theirs = repo.reference_to_annotated_commit(&fetch_head)?;
|
||||
let (analysis, _) = repo.merge_analysis(&[&theirs])?;
|
||||
|
||||
if analysis.is_up_to_date() {
|
||||
log::info!("already up to date with origin/{branch}");
|
||||
return Ok(());
|
||||
}
|
||||
if analysis.is_fast_forward() {
|
||||
log::info!("fast-forwarding local {branch} to origin");
|
||||
let refname = format!("refs/heads/{branch}");
|
||||
repo.find_reference(&refname)?
|
||||
.set_target(theirs.id(), "fast-forward to origin")?;
|
||||
repo.set_head(&refname)?;
|
||||
repo.checkout_head(Some(CheckoutBuilder::new().force()))?;
|
||||
return Ok(());
|
||||
}
|
||||
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.
|
||||
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
|
||||
}
|
||||
|
||||
/// First 8 hex chars of an OID, for readable logs.
|
||||
fn short(oid: git2::Oid) -> String {
|
||||
oid.to_string()[..8].to_string()
|
||||
}
|
||||
|
||||
/// 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<()> {
|
||||
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(())
|
||||
}
|
||||
|
||||
/// Mount the flash-FAT `storage` partition at /spiflash, formatting only on a
|
||||
/// failed mount (so the persistent clone survives reboots). Mirrors git_push.
|
||||
fn mount_fat() -> Result<()> {
|
||||
let cfg = sys::esp_vfs_fat_mount_config_t {
|
||||
format_if_mount_failed: true,
|
||||
max_files: 16, // libgit2 opens several files at once (index, refs, objects)
|
||||
allocation_unit_size: 4096,
|
||||
disk_status_check_enable: false,
|
||||
use_one_fat: false,
|
||||
};
|
||||
let mut wl: sys::wl_handle_t = 0;
|
||||
esp!(unsafe {
|
||||
sys::esp_vfs_fat_spiflash_mount_rw_wl(MOUNT.as_ptr(), FAT_LABEL.as_ptr(), &cfg, &mut wl)
|
||||
})
|
||||
.context("esp_vfs_fat_spiflash_mount_rw_wl (is the `storage` partition flashed?)")?;
|
||||
|
||||
let (mut total, mut free) = (0u64, 0u64);
|
||||
if unsafe { sys::esp_vfs_fat_info(MOUNT.as_ptr(), &mut total, &mut free) } == sys::ESP_OK {
|
||||
log::info!(
|
||||
"flash-FAT mounted at {MOUNT_STR} — {} KiB total, {} KiB free",
|
||||
total / 1024,
|
||||
free / 1024
|
||||
);
|
||||
} else {
|
||||
log::info!("flash-FAT mounted at {MOUNT_STR}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write the embedded GitHub root CAs to FAT and point libgit2's mbedTLS stream
|
||||
/// at them. Must run after the FAT mount and before any TLS. Mirrors git_push.
|
||||
fn install_tls_trust_store() -> Result<()> {
|
||||
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(())
|
||||
}
|
||||
|
||||
/// 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() }
|
||||
}
|
||||
|
||||
fn min_free_heap() -> u32 {
|
||||
unsafe { sys::esp_get_minimum_free_heap_size() }
|
||||
}
|
||||
110
firmware/src/bin/github_roots.pem
Normal file
@@ -0,0 +1,110 @@
|
||||
# GitHub HTTPS trust anchors for the on-device git push (Spike 7).
|
||||
# Extracted from the macOS system root store 2026-07-06.
|
||||
# github.com currently chains: leaf -> Sectigo E36 -> Sectigo E46 ->
|
||||
# USERTrust ECC. The others cover GitHub's known RSA / DigiCert chains.
|
||||
# Roots only: the server sends the intermediates during the handshake.
|
||||
#
|
||||
# Regenerate: security find-certificate -a -c <CN> -p \
|
||||
# /System/Library/Keychains/SystemRootCertificates.keychain
|
||||
|
||||
# USERTrust ECC Certification Authority
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIICjzCCAhWgAwIBAgIQXIuZxVqUxdJxVt7NiYDMJjAKBggqhkjOPQQDAzCBiDEL
|
||||
MAkGA1UEBhMCVVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNl
|
||||
eSBDaXR5MR4wHAYDVQQKExVUaGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMT
|
||||
JVVTRVJUcnVzdCBFQ0MgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMTAwMjAx
|
||||
MDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBiDELMAkGA1UEBhMCVVMxEzARBgNVBAgT
|
||||
Ck5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNleSBDaXR5MR4wHAYDVQQKExVUaGUg
|
||||
VVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMTJVVTRVJUcnVzdCBFQ0MgQ2VydGlm
|
||||
aWNhdGlvbiBBdXRob3JpdHkwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQarFRaqflo
|
||||
I+d61SRvU8Za2EurxtW20eZzca7dnNYMYf3boIkDuAUU7FfO7l0/4iGzzvfUinng
|
||||
o4N+LZfQYcTxmdwlkWOrfzCjtHDix6EznPO/LlxTsV+zfTJ/ijTjeXmjQjBAMB0G
|
||||
A1UdDgQWBBQ64QmG1M8ZwpZ2dEl23OA1xmNjmjAOBgNVHQ8BAf8EBAMCAQYwDwYD
|
||||
VR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAwNoADBlAjA2Z6EWCNzklwBBHU6+4WMB
|
||||
zzuqQhFkoJ2UOQIReVx7Hfpkue4WQrO/isIJxOzksU0CMQDpKmFHjFJKS04YcPbW
|
||||
RNZu9YO6bVi9JNlWSOrvxKJGgYhqOkbRqZtNyWHa0V1Xahg=
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
# USERTrust RSA Certification Authority
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIF3jCCA8agAwIBAgIQAf1tMPyjylGoG7xkDjUDLTANBgkqhkiG9w0BAQwFADCB
|
||||
iDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0pl
|
||||
cnNleSBDaXR5MR4wHAYDVQQKExVUaGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNV
|
||||
BAMTJVVTRVJUcnVzdCBSU0EgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMTAw
|
||||
MjAxMDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBiDELMAkGA1UEBhMCVVMxEzARBgNV
|
||||
BAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNleSBDaXR5MR4wHAYDVQQKExVU
|
||||
aGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMTJVVTRVJUcnVzdCBSU0EgQ2Vy
|
||||
dGlmaWNhdGlvbiBBdXRob3JpdHkwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIK
|
||||
AoICAQCAEmUXNg7D2wiz0KxXDXbtzSfTTK1Qg2HiqiBNCS1kCdzOiZ/MPans9s/B
|
||||
3PHTsdZ7NygRK0faOca8Ohm0X6a9fZ2jY0K2dvKpOyuR+OJv0OwWIJAJPuLodMkY
|
||||
tJHUYmTbf6MG8YgYapAiPLz+E/CHFHv25B+O1ORRxhFnRghRy4YUVD+8M/5+bJz/
|
||||
Fp0YvVGONaanZshyZ9shZrHUm3gDwFA66Mzw3LyeTP6vBZY1H1dat//O+T23LLb2
|
||||
VN3I5xI6Ta5MirdcmrS3ID3KfyI0rn47aGYBROcBTkZTmzNg95S+UzeQc0PzMsNT
|
||||
79uq/nROacdrjGCT3sTHDN/hMq7MkztReJVni+49Vv4M0GkPGw/zJSZrM233bkf6
|
||||
c0Plfg6lZrEpfDKEY1WJxA3Bk1QwGROs0303p+tdOmw1XNtB1xLaqUkL39iAigmT
|
||||
Yo61Zs8liM2EuLE/pDkP2QKe6xJMlXzzawWpXhaDzLhn4ugTncxbgtNMs+1b/97l
|
||||
c6wjOy0AvzVVdAlJ2ElYGn+SNuZRkg7zJn0cTRe8yexDJtC/QV9AqURE9JnnV4ee
|
||||
UB9XVKg+/XRjL7FQZQnmWEIuQxpMtPAlR1n6BB6T1CZGSlCBst6+eLf8ZxXhyVeE
|
||||
Hg9j1uliutZfVS7qXMYoCAQlObgOK6nyTJccBz8NUvXt7y+CDwIDAQABo0IwQDAd
|
||||
BgNVHQ4EFgQUU3m/WqorSs9UgOHYm8Cd8rIDZsswDgYDVR0PAQH/BAQDAgEGMA8G
|
||||
A1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQEMBQADggIBAFzUfA3P9wF9QZllDHPF
|
||||
Up/L+M+ZBn8b2kMVn54CVVeWFPFSPCeHlCjtHzoBN6J2/FNQwISbxmtOuowhT6KO
|
||||
VWKR82kV2LyI48SqC/3vqOlLVSoGIG1VeCkZ7l8wXEskEVX/JJpuXior7gtNn3/3
|
||||
ATiUFJVDBwn7YKnuHKsSjKCaXqeYalltiz8I+8jRRa8YFWSQEg9zKC7F4iRO/Fjs
|
||||
8PRF/iKz6y+O0tlFYQXBl2+odnKPi4w2r78NBc5xjeambx9spnFixdjQg3IM8WcR
|
||||
iQycE0xyNN+81XHfqnHd4blsjDwSXWXavVcStkNr/+XeTWYRUc+ZruwXtuhxkYze
|
||||
Sf7dNXGiFSeUHM9h4ya7b6NnJSFd5t0dCy5oGzuCr+yDZ4XUmFF0sbmZgIn/f3gZ
|
||||
XHlKYC6SQK5MNyosycdiyA5d9zZbyuAlJQG03RoHnHcAP9Dc1ew91Pq7P8yF1m9/
|
||||
qS3fuQL39ZeatTXaw2ewh0qpKJ4jjv9cJ2vhsE/zB+4ALtRZh8tSQZXq9EfX7mRB
|
||||
VXyNWQKV3WKdwrnuWih0hKWbt5DHDAff9Yk2dDLWKMGwsAvgnEzDHNb842m1R0aB
|
||||
L6KCq9NjRHDEjf8tM7qtj3u1cIiuPhnPQCjY/MiQu12ZIvVS5ljFH4gxQ+6IHdfG
|
||||
jjxDah2nGN59PRbxYvnKkKj9
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
# DigiCert Global Root G2
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDjjCCAnagAwIBAgIQAzrx5qcRqaC7KGSxHQn65TANBgkqhkiG9w0BAQsFADBh
|
||||
MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3
|
||||
d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBH
|
||||
MjAeFw0xMzA4MDExMjAwMDBaFw0zODAxMTUxMjAwMDBaMGExCzAJBgNVBAYTAlVT
|
||||
MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j
|
||||
b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IEcyMIIBIjANBgkqhkiG
|
||||
9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuzfNNNx7a8myaJCtSnX/RrohCgiN9RlUyfuI
|
||||
2/Ou8jqJkTx65qsGGmvPrC3oXgkkRLpimn7Wo6h+4FR1IAWsULecYxpsMNzaHxmx
|
||||
1x7e/dfgy5SDN67sH0NO3Xss0r0upS/kqbitOtSZpLYl6ZtrAGCSYP9PIUkY92eQ
|
||||
q2EGnI/yuum06ZIya7XzV+hdG82MHauVBJVJ8zUtluNJbd134/tJS7SsVQepj5Wz
|
||||
tCO7TG1F8PapspUwtP1MVYwnSlcUfIKdzXOS0xZKBgyMUNGPHgm+F6HmIcr9g+UQ
|
||||
vIOlCsRnKPZzFBQ9RnbDhxSJITRNrw9FDKZJobq7nMWxM4MphQIDAQABo0IwQDAP
|
||||
BgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAdBgNVHQ4EFgQUTiJUIBiV
|
||||
5uNu5g/6+rkS7QYXjzkwDQYJKoZIhvcNAQELBQADggEBAGBnKJRvDkhj6zHd6mcY
|
||||
1Yl9PMWLSn/pvtsrF9+wX3N3KjITOYFnQoQj8kVnNeyIv/iPsGEMNKSuIEyExtv4
|
||||
NeF22d+mQrvHRAiGfzZ0JFrabA0UWTW98kndth/Jsw1HKj2ZL7tcu7XUIOGZX1NG
|
||||
Fdtom/DzMNU+MeKNhJ7jitralj41E6Vf8PlwUHBHQRFXGU7Aj64GxJUTFy8bJZ91
|
||||
8rGOmaFvE7FBcf6IKshPECBV1/MUReXgRPTqh5Uykw7+U0b6LJ3/iyK5S9kJRaTe
|
||||
pLiaWN0bfVKfjllDiIGknibVb63dDcY3fe0Dkhvld1927jyNxF1WW6LZZm6zNTfl
|
||||
MrY=
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
# DigiCert Global Root CA
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDrzCCApegAwIBAgIQCDvgVpBCRrGhdWrJWZHHSjANBgkqhkiG9w0BAQUFADBh
|
||||
MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3
|
||||
d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBD
|
||||
QTAeFw0wNjExMTAwMDAwMDBaFw0zMTExMTAwMDAwMDBaMGExCzAJBgNVBAYTAlVT
|
||||
MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j
|
||||
b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IENBMIIBIjANBgkqhkiG
|
||||
9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4jvhEXLeqKTTo1eqUKKPC3eQyaKl7hLOllsB
|
||||
CSDMAZOnTjC3U/dDxGkAV53ijSLdhwZAAIEJzs4bg7/fzTtxRuLWZscFs3YnFo97
|
||||
nh6Vfe63SKMI2tavegw5BmV/Sl0fvBf4q77uKNd0f3p4mVmFaG5cIzJLv07A6Fpt
|
||||
43C/dxC//AH2hdmoRBBYMql1GNXRor5H4idq9Joz+EkIYIvUX7Q6hL+hqkpMfT7P
|
||||
T19sdl6gSzeRntwi5m3OFBqOasv+zbMUZBfHWymeMr/y7vrTC0LUq7dBMtoM1O/4
|
||||
gdW7jVg/tRvoSSiicNoxBN33shbyTApOB6jtSj1etX+jkMOvJwIDAQABo2MwYTAO
|
||||
BgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUA95QNVbR
|
||||
TLtm8KPiGxvDl7I90VUwHwYDVR0jBBgwFoAUA95QNVbRTLtm8KPiGxvDl7I90VUw
|
||||
DQYJKoZIhvcNAQEFBQADggEBAMucN6pIExIK+t1EnE9SsPTfrgT1eXkIoyQY/Esr
|
||||
hMAtudXH/vTBH1jLuG2cenTnmCmrEbXjcKChzUyImZOMkXDiqw8cvpOp/2PV5Adg
|
||||
06O/nVsJ8dWO41P0jmP6P6fbtGbfYmbW0W5BjfIttep3Sp+dWOIrWcBAI+0tKIJF
|
||||
PnlUkiaY4IBIqDfv8NZ5YBberOgOzW6sRBc4L0na4UU+Krk2U886UAb3LujEV0ls
|
||||
YSEY1QSteDwsOoBrp+uvFRTp2InBuThs4pFsiv9kuXclVzDAGySj4dzp30d8tbQk
|
||||
CAUw7C29C79Fv1C5qfPrmAESrciIxpg0X40KPMbp1ZWVbd4=
|
||||
-----END CERTIFICATE-----
|
||||
212
firmware/src/bin/sd_bench.rs
Normal 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]),
|
||||
);
|
||||
}
|
||||
117
firmware/src/bin/sd_fat.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
//! Spike 3 — SD card (FAT) on its own SPI3 host, now a thin on-device harness
|
||||
//! over the real [`firmware::persistence`] module.
|
||||
//!
|
||||
//! 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. [`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.
|
||||
//!
|
||||
//! Flash with `just flash-sd`. Needs no `.env`.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use esp_idf_svc::hal::delay::FreeRtos;
|
||||
|
||||
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"));
|
||||
|
||||
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 via firmware::persistence), {BUILD_TAG}");
|
||||
|
||||
match run() {
|
||||
Ok(()) => log::info!("✅ Spike 3 complete — persistence::Storage mounts and round-trips"),
|
||||
Err(e) => log::error!("❌ Spike 3 failed: {e:?}"),
|
||||
}
|
||||
|
||||
// Idle instead of returning, so the result stays on the monitor.
|
||||
loop {
|
||||
FreeRtos::delay_ms(1000);
|
||||
}
|
||||
}
|
||||
|
||||
fn run() -> Result<()> {
|
||||
let storage = Storage::mount().context("mounting SD via persistence module")?;
|
||||
|
||||
let (max_khz, real_khz) = storage.negotiated_khz();
|
||||
log::info!("card clock — max {max_khz} kHz, negotiated {real_khz} kHz");
|
||||
|
||||
let (total, free) = storage.usage().context("reading FAT usage")?;
|
||||
log::info!(
|
||||
"FAT usage — {} MiB total, {} MiB free",
|
||||
total / (1024 * 1024),
|
||||
free / (1024 * 1024)
|
||||
);
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
/// 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}"))?;
|
||||
}
|
||||
// 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",
|
||||
payload.len(),
|
||||
back.len()
|
||||
);
|
||||
}
|
||||
log::info!(
|
||||
"round-trip OK — {} bytes: save {NOTES} (tmp→fsync→unlink→rename) → load identical",
|
||||
payload.len()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
72
firmware/src/bin/splash.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
174
firmware/src/bin/wifi_tls.rs
Normal file
@@ -0,0 +1,174 @@
|
||||
//! Spike 6 — Wi-Fi + TLS.
|
||||
//!
|
||||
//! A small standalone bench program (separate binary from the editor firmware)
|
||||
//! that proves the networking + TLS stack end to end:
|
||||
//!
|
||||
//! 1. Bring up the station and associate with the home AP.
|
||||
//! 2. Sync the clock over SNTP — mbedtls checks the server cert's
|
||||
//! not-before/not-after against wall time, so without this the 1970 RTC
|
||||
//! makes every handshake fail with "certificate is not valid yet".
|
||||
//! 3. HTTPS GET https://api.github.com/ with cert-chain validation against
|
||||
//! the esp-idf certificate bundle (esp_crt_bundle_attach), and read the
|
||||
//! response body.
|
||||
//!
|
||||
//! A validated GET is the whole point: it's the gate for Spike 7 (gitoxide
|
||||
//! push over HTTPS + PAT). Free heap is logged around the handshake because
|
||||
//! TLS heap pressure on this chip is a top-3 watched risk (see qfd.md §6).
|
||||
//!
|
||||
//! Credentials come from build-time env (build.rs → env!): set TW_WIFI_SSID /
|
||||
//! TW_WIFI_PASS in firmware/.env and run `just flash-wifi`.
|
||||
|
||||
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use embedded_svc::http::Method;
|
||||
use esp_idf_svc::eventloop::EspSystemEventLoop;
|
||||
use esp_idf_svc::hal::delay::FreeRtos;
|
||||
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::{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"));
|
||||
|
||||
/// Wi-Fi credentials, baked in at build time from firmware/.env. Empty when
|
||||
/// unset — checked at runtime so the editor build never depends on them.
|
||||
const WIFI_SSID: &str = env!("TW_WIFI_SSID");
|
||||
const WIFI_PASS: &str = env!("TW_WIFI_PASS");
|
||||
|
||||
/// The validated endpoint. Root of the GitHub REST API: returns JSON, needs a
|
||||
/// User-Agent, and is served over a normal public CA chain — a faithful
|
||||
/// stand-in for the api.github.com host Spike 7 will push through.
|
||||
const TEST_URL: &str = "https://api.github.com/";
|
||||
|
||||
/// SNTP first-sync budget. Home networks resolve pool.ntp.org and answer well
|
||||
/// within this; failing past it is a real problem worth surfacing.
|
||||
const SNTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
|
||||
|
||||
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 6 (Wi-Fi + TLS), {BUILD_TAG}");
|
||||
|
||||
match run() {
|
||||
Ok(()) => log::info!("✅ Spike 6 complete — Wi-Fi assoc + SNTP + validated HTTPS GET"),
|
||||
Err(e) => log::error!("❌ Spike 6 failed: {e:?}"),
|
||||
}
|
||||
|
||||
// Idle instead of returning, so the result stays on screen and Wi-Fi/other
|
||||
// tasks keep running for inspection rather than the app winding down.
|
||||
loop {
|
||||
FreeRtos::delay_ms(1000);
|
||||
}
|
||||
}
|
||||
|
||||
fn run() -> Result<()> {
|
||||
if WIFI_SSID.is_empty() {
|
||||
bail!("TW_WIFI_SSID is empty — set it in firmware/.env (see .env.example) and rebuild");
|
||||
}
|
||||
|
||||
let peripherals = Peripherals::take()?;
|
||||
let sys_loop = EspSystemEventLoop::take()?;
|
||||
let nvs = EspDefaultNvsPartition::take()?;
|
||||
|
||||
let mut wifi = BlockingWifi::wrap(
|
||||
EspWifi::new(peripherals.modem, sys_loop.clone(), Some(nvs))?,
|
||||
sys_loop,
|
||||
)?;
|
||||
|
||||
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);
|
||||
|
||||
sync_clock()?;
|
||||
|
||||
https_get(TEST_URL)?;
|
||||
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<()> {
|
||||
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 cert validity would fail");
|
||||
}
|
||||
FreeRtos::delay_ms(100);
|
||||
}
|
||||
|
||||
let unix = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
// A synced clock lands well past this (2023-11-14); anything below means the
|
||||
// RTC never actually advanced and TLS would reject on validity.
|
||||
if unix < 1_700_000_000 {
|
||||
bail!("clock still at {unix} after SNTP sync — refusing TLS with a bad wall clock");
|
||||
}
|
||||
log::info!("clock synced — unix {unix}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// HTTPS GET with cert-chain validation against the esp-idf certificate bundle.
|
||||
/// Logs status, the first chunk of the body, and free heap around the request.
|
||||
fn https_get(url: &str) -> Result<()> {
|
||||
let heap_before = unsafe { esp_idf_svc::sys::esp_get_free_heap_size() };
|
||||
|
||||
let mut conn = EspHttpConnection::new(&HttpConfig {
|
||||
// Validate the server chain against the bundled roots. If this is None,
|
||||
// the handshake skips verification — which would defeat the spike.
|
||||
crt_bundle_attach: Some(esp_idf_svc::sys::esp_crt_bundle_attach),
|
||||
..Default::default()
|
||||
})
|
||||
.context("creating the HTTPS connection (TLS init)")?;
|
||||
|
||||
// GitHub rejects requests without a User-Agent.
|
||||
let headers = [
|
||||
("User-Agent", "typoena-spike6"),
|
||||
("Accept", "application/vnd.github+json"),
|
||||
];
|
||||
conn.initiate_request(Method::Get, url, &headers)
|
||||
.context("TLS handshake / request send failed")?;
|
||||
conn.initiate_response()?;
|
||||
|
||||
let status = conn.status();
|
||||
log::info!("HTTPS GET {url} → {status}");
|
||||
|
||||
// Preview the first chunk, then drain the rest so we log the real byte count
|
||||
// (proves the encrypted stream reads back cleanly, not just the handshake).
|
||||
let mut buf = [0u8; 512];
|
||||
let first = conn.read(&mut buf)?;
|
||||
log::info!(
|
||||
"body[..{first}]: {}",
|
||||
String::from_utf8_lossy(&buf[..first]).replace('\n', " ")
|
||||
);
|
||||
let mut total = first;
|
||||
loop {
|
||||
let n = conn.read(&mut buf)?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
total += n;
|
||||
}
|
||||
|
||||
let heap_after = unsafe { esp_idf_svc::sys::esp_get_free_heap_size() };
|
||||
log::info!(
|
||||
"read {total} bytes; free heap {heap_before} → {heap_after} (Δ {} B during TLS)",
|
||||
heap_before as i64 - heap_after as i64
|
||||
);
|
||||
|
||||
if !(200..300).contains(&status) {
|
||||
bail!("unexpected HTTP status {status} (TLS validated, but the request was not OK)");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,778 +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`].
|
||||
|
||||
use embedded_graphics::mono_font::ascii::{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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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, **~2–2.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
@@ -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;
|
||||
@@ -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
@@ -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 2–3 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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,2 @@
|
||||
# generated mesh exports — regenerate with `just stl`
|
||||
*.stl
|
||||
206
hardware/case/README.md
Normal 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.
|
||||
|
||||

|
||||

|
||||
|
||||
## 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.
|
||||
|
||||

|
||||
|
||||
### Screen (bezel lip + foam + screwed bracket)
|
||||
|
||||
```
|
||||
front face the sandwich, front → back:
|
||||
┌───────────────┐ 1. deck BEZEL LIP (overlaps ~4–5 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.2–0.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"`):
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
Or inspect each half alone — `plan_up` (the deck/lid, shown from below) and
|
||||
`plan_down` (the cavity):
|
||||
|
||||

|
||||

|
||||
|
||||
### 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.** 18–22° is typewriter-shallow; raise `Hb`
|
||||
toward ~28–35° 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
|
||||
|
||||

|
||||
|
||||
- **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 3–4 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 `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
@@ -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 & 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 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 & 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 mm panel drops into a rebate behind a bezel lip that overlaps ~6 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; 28–35° 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>
|
||||
BIN
hardware/case/renders/assembled.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
hardware/case/renders/baseplate.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
hardware/case/renders/body.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
hardware/case/renders/bracket.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
hardware/case/renders/front34.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
hardware/case/renders/nameplate.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
hardware/case/renders/plan-down.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
hardware/case/renders/plan-up.png
Normal file
|
After Width: | Height: | Size: 29 KiB |