Commit Graph

39 Commits

Author SHA1 Message Date
Julien Calixte
4c8c557e7b feat(firmware): gate :sync publish behind the git feature
Route :sync through publish(), whose git-push path is #[cfg(feature =
"git")]. Git code in the firmware binary is now only ever compiled with
--features git, so the default build stays a light editor build with no
git2 crate and (since the justfile only sets LIBGIT2_SRC for git recipes)
no libgit2 component. Feature is off by default; :sync saves locally.
2026-07-11 12:39:24 +02:00
Julien Calixte
637fe4a4f4 feat(firmware): boot-load and save the note via persistence
Seed the editor from Storage::load at boot and persist on :w/:sync
through Storage::save. A missing card/repo/unreadable note halts boot
with the reason on the panel rather than starting empty and clobbering
the note on the next save. Save errors are logged, buffer kept in RAM.

The git-push half of :sync stays a TODO — it needs git_sync graduated
into a module on the dedicated git thread; :sync saves for now.
2026-07-11 12:17:58 +02:00
Julien Calixte
1e3220a95f refactor(sd): drive Spike 3 harness through persistence module
sd_fat is now a thin on-device harness over firmware::persistence instead of
duplicating the mount FFI. It mounts, reports FAT usage + negotiated clock,
loads notes.md (non-destructive), and only runs the write round-trip when
notes.md is empty so a provisioned card is never clobbered. Verified on
hardware 2026-07-11: mount at 10 MHz, 91-byte round-trip byte-identical.
2026-07-11 12:02:22 +02:00
Julien Calixte
9c338571f0 feat(persistence): add SD mount + atomic save/load module
Graduate the proven Spike 3 storage stack into firmware::persistence so the
editor and the spike share one implementation. Storage::mount brings up the
dedicated SPI3 bus (ADR-012) and mounts FAT with format_if_mount_failed=false
(never reformat the user's card). save() does the FAT-safe atomic write
(tmp -> fsync -> unlink -> rename); recover() reconciles a leftover *.tmp at
boot, keeping the committed file when the crash point is ambiguous and only
promoting the tmp when the target was already unlinked. load() caps reads at
256 KiB.
2026-07-11 12:02:15 +02:00
Julien Calixte
1b9987c4ee fix(sd): correct stale shared-bus log strings to SPI3
The boot banner, success line, and round-trip payload still said
"shared SPI2" / "shared bus" after the move to SPI3. Cosmetic only —
serial output now matches the dedicated-bus wiring.
2026-07-11 11:43:34 +02:00
Julien Calixte
9129f04d0e feat(sd): move the SD spike to its own SPI3 host
The EPD's SpiBusDriver holds an exclusive SPI2 lock for its lifetime, so
an SD on SPI2 is locked out while the panel driver is alive. Give the SD
its own SPI3 (SCK 14, MOSI 15; MISO 13 / CS 10 unchanged) and drop the
now-pointless EPD-CS deselect. See ADR-012.
2026-07-11 11:28:16 +02:00
Julien Calixte
b899286639 feat(sd): reformat a fresh card on mount failure in the spike
Flip format_if_mount_failed to true so a fresh exFAT/unformatted bench card is
reformatted to FAT on the device instead of failing — no Mac-side prep for the
spike. Fires only on a filesystem mount failure, not the CMD59 protocol
rejection. The real persistence module must keep this false.
2026-07-11 11:14:15 +02:00
Julien Calixte
698c10c8c0 fix(sd): unlink target before f_rename to overwrite on FAT
FatFS's f_rename returns FR_EXIST on an existing destination — unlike POSIX
rename(2) it won't replace it, so re-running the round-trip failed at rename.
Unlink the target first (tolerating a missing one for the first save). This
opens a crash window the real persistence module must close with *.tmp boot
recovery (ADR-007).
2026-07-11 11:14:04 +02:00
Julien Calixte
bef9587a34 chore(sd): drop Spike 3 CMD59 diagnostic logging
The per-command sdmmc/sdspi DEBUG logs only existed to diagnose the CMD59
init failure, now resolved (verified 2026-07-11). Remove the build-time log
ceiling and the runtime esp_log_level_set block.
2026-07-11 11:13:42 +02:00
Julien Calixte
da23b95cfd feat(editor): add :w/:sync commands as host save/publish effects
The pure, no-IO editor core can't persist or push, so `handle()` now
returns an `Effect` (None/Save/Publish) that the firmware actions. `:w`
signals Save, `:sync` signals Publish (save then git push); `:wq`/`:x`
alias Save with vim's "quit" half dropped, and the `:q` family stays
absent — an always-on appliance has nothing to quit to. `:fmt` remains
in-core and yields no effect.

The main loop keeps the batch's last effect and matches on it. Actual
SD-write and git-push are the v0.1 gate and still stubbed as log lines.
2026-07-11 01:24:46 +02:00
Julien Calixte
bc13c5b065 feat(usb-kbd): compose dead-key accents before enqueuing keys
Route decoded keys through keymap::Composer, so on device `'`+e yields é
(grave/acute/circumflex/diaeresis/tilde, plus ç), and reset it on
keyboard detach. Enabling it is now safe because the editor buffer is
UTF-8-correct. Not verified on the xtensa build in this environment.
2026-07-11 00:30:38 +02:00
Julien Calixte
e8063a2b13 refactor(editor): extract editor + display into host-testable crates
Move the editor core out of the firmware crate (pinned to the xtensa
target, so it can't run `cargo test`) into a standalone `editor` crate,
with the panel framebuffer + geometry split into a `display` crate. Both
depend only on embedded-graphics + keymap, so the editor is now
host-buildable and unit-tested (8 characterization tests). Firmware links
them and re-exports the geometry from epd.rs; behaviour is unchanged.

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

Extract the logic into firmware::net (new lib target) and rewire the
wifi_tls / git_push / git_sync spikes to it, removing three duplicated
copies. main.rs reuses this when Wi-Fi lands there instead of adding a
fourth copy.
2026-07-10 23:44:02 +02:00
Julien Calixte
8edd3badfc fix(usb-kbd): quiesce in-flight transfer before free; guard hot-plug leaks
Adopt the keymap crate (drops the PREV_KEYS/CAPS_USED statics for one
owned Decoder) and harden device teardown:

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

Decode correctness is covered by the keymap tests; the teardown paths
are FFI and still need an on-device hot-plug run to confirm.
2026-07-10 10:36:47 +01:00
Julien Calixte
f197df6dbe style(editor): drop the mode line to the panel's bottom edge
Move the mode indicator (and the NO KBD flag above it) down so the
mode sits flush at the bottom-left with a 2 px margin, reclaiming the
dead space that was left below it.
2026-07-08 22:01:10 +02:00
Julien Calixte
ba4a8bfad8 feat(editor): put the mode indicator at the side-panel bottom-left
Move the mode + pending-command echo from the panel's top to its
bottom-left (vim `-- INSERT --` style); word count takes the top slot
and the NO KBD flag sits just above the mode. Also drop the
words-this-session field (not wanted) and its session_base_words state.
2026-07-08 21:48:21 +02:00
Julien Calixte
50f650fef6 feat(editor): split display into writing column and side panel
Carve the panel into a ~60-col writing column and a right-hand side
panel (per CONTEXT.md screen regions); the divider sits right of the
x=396 driver seam. The mode indicator and pending-command echo move
into the panel, while the ':' command line stays in the bottom strip.

The panel also shows word count and words-this-session (a snapshot
refreshed on a typing pause / non-Insert action, never per keystroke,
so ordinary typing keeps the fast windowed refresh) and a "NO KBD"
flag while the keyboard is dropped.
2026-07-08 21:33:41 +02:00
Julien Calixte
ddd9c387d3 feat(usb): track keyboard connection state
Add a persistent KBD_PRESENT atomic (set on setup, cleared on unplug)
and a keyboard_present() accessor. DEV_GONE is a one-shot detach event
the client loop consumes, not a state the panel can read.
2026-07-08 21:33:32 +02:00
Julien Calixte
b746d6e3f9 feat(editor): add Markdown affordances and a :fmt formatter
v0.6 affordances plus the v0.4 command-line mechanism, all in editor.rs:

- soft-wrap display lines at word boundaries (layout() no longer cuts
  mid-word; a single word wider than the panel still hard-breaks)
- Enter continues Markdown lists: -/*/+ repeat, N. increments, an empty
  item exits the list; only at end-of-line, indentation preserved
- heading lines (# .. ######) render faux-bold via a 1px double-strike
  (no bold Latin-9 font); checks the logical line so wrapped headings stay bold
- new `:` command mode (Command variant + status-strip echo, Esc/Backspace
  cancel) with one command, `:fmt`: align pipe tables (honoring :--: colons),
  collapse runs of blank lines to one, strip trailing whitespace; caret lands
  on roughly the same line afterwards
2026-07-07 23:44:46 +02:00
Julien Calixte
8dece735d1 fix(firmware): delete repo dir via path-based recursion, not remove_dir_all
std::fs::remove_dir_all deletes race-free via the openat/unlinkat/fdopendir
family; esp-idf's path-based FATFS VFS doesn't implement those, so it fails
with EACCES on the first entry. That looked like a read-only-attribute
problem but wasn't. A plain read_dir -> remove_file/remove_dir walk
(remove_tree) uses only what FATFS supports.

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

The p_open/p_creat shim (39e1155) stays: it keeps libgit2's objects writable
(ro=false), so neither this delete nor libgit2's own gc/repack/fetch-prune
is ever blocked by a read-only file on FAT.
2026-07-07 22:48:17 +02:00
Julien Calixte
8e848894fa feat(firmware): add RECLONE_EACH_BOOT toggle to git_sync
Debug/recovery knob (ships false): when true, wipe /spiflash/repo and re-clone
every boot instead of opening the persistent clone. Doubles as the validation
harness for the FAT read-only-delete fix — the wipe deletes libgit2-created
objects, which only succeeds once they are writable.
2026-07-07 09:33:08 +02:00
Julien Calixte
afa61deaa6 fix(firmware): recover git_sync from a leftover clone dir
859c478 aborted if /spiflash/repo already existed — libgit2 refuses to
clone into a non-empty dir (code=Exists). Remove a writable stale/partial
clone and re-clone.

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

Known limit: the remove only works on a *writable* leftover. libgit2 marks
objects/packs read-only and FATFS won't f_unlink a read-only file (EACCES);
POSIX chmod does not clear AM_RDO on esp-idf's FATFS VFS (verified). So
re-cloning over a run that wrote objects needs an AM_RDO-clearing unlink
shim in esp_stubs.c (increment B, also needed for fetch/repack); until then
the fallback is a one-time `espflash erase-region 0x310000 0x400000`.
2026-07-07 08:34:09 +02:00
Julien Calixte
859c4787cf feat(firmware): add persistent-clone git sync spike (git_sync)
Milestone #2 increment A: the product's real publish flow, distinct from
git_push's fresh-init-per-boot throwaway branch. open-or-clone a persistent
/spiflash/repo, append to a tracked notes.md, commit on top of the branch,
and fast-forward push over mbedTLS HTTPS+PAT. Reconcile handles up-to-date
and fast-forward; a true divergence (merge commit on FATFS) is deferred to
increment B. Runs on the dedicated 96 KB git thread; gated behind `git`.

Compiles clean; on-device clone/open+fast-forward not yet hardware-verified
(clone/checkout is deeper than the proven init path — the flash may need a
GIT_STACK bump).
2026-07-07 00:06:13 +02:00
Julien Calixte
15955a13f4 refactor(firmware): run git on a dedicated large-stack thread
libgit2's init→push chain is deeply stack-hungry (~67 KB measured for a
trivial config write; the push is deeper). Spike 7 sized the shared main
task stack at 96 KB for it, which forced the editor build to over-reserve
~80 KB.

Move git_publish onto its own std::thread (GIT_STACK = 96 KB via
Builder::stack_size) and join it from main. This lets
CONFIG_ESP_MAIN_TASK_STACK_SIZE drop back to 12288 — the Spike-6 value
proven to run the editor plus a TLS-on-main handshake — so the editor no
longer pays for git's stack.

Hardware-verified: the push ran off-main (mbedTLS + FATFS) with no panic,
no stack overflow, and no ENOMEM on the 96 KB spawn. This retires the
earlier "time() only works on the main task" misdiagnosis for good — it
was always the default 4 KB pthread stack overflowing, never thread-vs-main.
2026-07-06 23:46:55 +02:00
Julien Calixte
2519ed8650 feat(firmware): verify git-push TLS chain against embedded GitHub CAs
Retire the cert-check bypass. Embed GitHub's root CAs (github_roots.pem:
USERTrust ECC/RSA + DigiCert G2/Global Root CA), write them to /spiflash/ca.pem
at boot, and point libgit2's mbedTLS stream at them via
GIT_OPT_SET_SSL_CERT_LOCATIONS (CONFIG_MBEDTLS_FS_IO is on). The push callback
now returns CertificatePassthrough instead of CertificateOk, so the http
transport maps it to `is_valid ? 0 : -1` (httpclient.c:805) -- an untrusted or
MITM cert fails the push (fail-closed), no blanket-accept.

Hardware-verified 2026-07-06: `TLS trust store installed`, chain validated
against the embedded USERTrust ECC root, push accepted (branch
device/1783372683 on jcalixte/typoena-test). Roots must be refreshed if GitHub
rotates CAs; a product would prefer esp-idf's bundle via a custom subtransport.
2026-07-06 23:19:24 +02:00
Julien Calixte
a15789a1b4 feat(firmware): add on-device git push spike (git_push)
Wi-Fi + SNTP + flash-FAT + libgit2 in one bench binary: init a fresh working
copy, commit, and push a per-boot device/<unix> branch over mbedTLS HTTPS with
PAT auth. Gated behind the `git` feature; built/flashed via `just flash-git-push`.

Status: local git init verified on hardware; the ODB-write fix and the full
push are not yet confirmed end-to-end on device. Cert verification is bypassed
in the push callback (spike shortcut) -- real trust-store wiring must land
before this leaves the bench.
2026-07-06 00:18:30 +02:00
Julien Calixte
2b268fe168 feat(firmware): wire libgit2 component + bind git2 in system mode
Adds the extra_components metadata, git2 as an optional dep behind the
`git` feature (default-features off -> no openssl-sys/libssh2-sys), and a
git_smoke bin gated on it. libgit2-sys/libz-sys run in system mode against
fake pkg-config files (empty Libs) so they emit no link flags -- symbols
come from the esp-idf component. Proven on device: git2 version + a blob
SHA1 through the mbedTLS backend.
2026-07-05 21:28:47 +02:00
Julien Calixte
9fc21568e7 feat(firmware): add SD/FAT spike (Spike 3)
Standalone bench program (src/bin/sd_fat.rs, `just flash-sd`) that mounts
FAT over the EPD's shared SPI2 bus and proves the persistence module's
atomic save: write .tmp, fsync, rename, read back and compare.

Runs SD-only: the EPD's SpiBusDriver holds an exclusive bus lock for its
lifetime, so an arbitrated SD device can't be live alongside it yet. Keeps
CRC required and maps a card that rejects CMD59 to a clear "use a genuine
card" message rather than running the user's notes over an unchecked bus.

sdkconfig gains CONFIG_FATFS_LFN_HEAP (the atomic-save .tmp name isn't valid
8.3) and, temporarily, CONFIG_LOG_MAXIMUM_LEVEL_DEBUG to read the drivers'
per-command R1 bytes during bring-up.
2026-07-05 18:16:42 +02:00
Julien Calixte
de14b8418f feat(editor): use ISO-8859-15 font for accented glyphs
Swap the mono font from the ascii subset to iso_8859_15 (Latin-9) so
à é ê ç … œ € have glyphs. Cell size is identical, so ASCII rendering is
byte-for-byte unchanged; groundwork for v0.2.5 international input.
2026-07-05 17:57:29 +02:00
Julien Calixte
f8a4d53851 feat(firmware): add Wi-Fi + TLS spike (Spike 6)
Standalone `wifi_tls` binary: station assoc, SNTP, then a validated
HTTPS GET to api.github.com against the esp-idf cert bundle. Gates
Spike 7 (gitoxide push). Creds come from firmware/.env via build.rs.
2026-07-05 09:22:31 +02:00
Julien Calixte
0665f2d5fb feat(firmware): add vim text objects and the change operator
Generalise the normal-mode command state machine into d/c operators that
compose with motions (dw, d$, …), doubled forms (dd, cc), and text
objects: iw/aw plus nesting-aware bracket pairs (i(, a{, …) and quotes
(i", i'). ciw/daw/di( and friends now work; pending op/object shows in
the status strip.
2026-07-05 01:41:23 +02:00
Julien Calixte
c88389b623 chore: update package meta data 2026-07-05 01:31:33 +02:00
Julien Calixte
00b57bac55 perf(firmware): window partial refresh to the edited rows
Per keystroke, diff the new frame against the last shown one to find the
changed row band and partial-refresh only those rows instead of all 272.
E-paper update time scales with the gate rows driven, so a single edited
text line refreshes far faster. Windowed in Y only (full width, both
controllers), so the seam/mirroring logic is unchanged. Also drop the
periodic full refresh to every 64 updates — the panel stays visually
clean, so it's now mainly for longevity.
2026-07-05 00:08:14 +02:00
Julien Calixte
25665561cd feat(firmware): type on the e-paper panel with partial refresh
Spike 5 — first spike where keyboard input and panel output run
together. usb_kbd changes from a blocking run() into start(): it brings
the USB host stack up on background threads and pushes edge-detected,
US-layout-translated key-downs onto a queue drained via next_key().
main.rs owns the panel, maintains a wrapped/scrolling text buffer, and
partial-refreshes per keystroke batch with a periodic full refresh to
clear ghosting. Logs per-refresh latency.
2026-07-05 00:00:11 +02:00
Julien Calixte
784dcb7666 feat(firmware): add partial refresh to the EPD driver
display_frame_partial writes the new image to bank 0x24, runs GxEPD2's
partial waveform (_Update_Part: 0x3C/0x80, 0x21/0x00,0x10, 0x22/0xFF,
0x20), then syncs bank 0x26 so the next partial has a correct baseline.
Like GxEPD2 for this dual-controller panel, the update covers the full
panel with the partial waveform — no windowing, since the waveform time
dominates. Much faster than a full refresh and without the flashing.
2026-07-05 00:00:02 +02:00
Julien Calixte
8b71e0d9ec feat(firmware): read keycodes from a USB HID boot keyboard
Spike 4. Drive the ESP-IDF USB Host Library directly through the raw
esp-idf-sys bindings (the convenience HID class driver is a managed
component not vendored in mainline, and a boot keyboard doesn't need
it). On attach, src/usb_kbd.rs enumerates the device and dumps its
descriptors, claims the boot-keyboard interface, sends SET_PROTOCOL(boot)
and SET_IDLE(0), then polls the interrupt-IN endpoint and decodes each
8-byte report into modifiers + keycodes logged over UART.

main.rs becomes the Spike 4 harness; the epd module is kept compiled
(allow(dead_code)) so it doesn't bit-rot. Verified on hardware with a
19f5:3255 keyboard: letters, digits, modifiers, and rollover all decode.
2026-07-04 19:28:53 +02:00
Julien Calixte
6145d27405 feat(firmware): drive GDEY0579T93 EPD via dual-SSD1683 driver
Add a thin dual-SSD1683 driver (src/epd.rs, ported from GxEPD2's
GxEPD2_579_GDEY0579T93) for the 792x272 e-paper panel: reset/init,
RAM addressing, the split-and-mirror full-frame blit across the
master/slave seam at x=396, and full-refresh waveform. Frame
implements embedded-graphics' DrawTarget.

Replace the Spike 1 blink harness in main.rs with the Spike 2 test:
SPI at 4 MHz over SCK 12 / DIN 11 / CS 7 / DC 6 / RST 5 / BUSY 4,
alternating normal/inverted frames with a seam-straddling circle,
"Typoena", and the build tag. Verified on hardware 2026-07-04.
2026-07-04 18:58:42 +02:00
Julien Calixte
e91f3be26e feat(firmware): blink on-board WS2812 alongside GPIO 2
The DevKitC-1 has no discrete LED on GPIO 2, so the Spike 1 blink was
invisible without external wiring. Drive the on-board addressable LED
(WS2812 on GPIO 48) each cycle for visible confirmation on the bench.
2026-07-04 16:49:14 +02:00
Julien Calixte
55ba0db0f6 chore(firmware): scaffold Spike 1 Blink crate
Generated from esp-rs/esp-idf-template for the ESP32-S3 std target.
src/main.rs toggles GPIO 2 every 500 ms and logs `blink N` over USB-
serial — the minimum bring-up surface called out in
docs/v0.1-mvp-technical.md (Spike 1: confirm toolchain, flash, and basic
GPIO). edition=2024 with rust-version=1.85.

No editor/render/git/usb/fs modules yet; those land per the spike
methodology when later spikes need them.
2026-05-23 14:36:51 +02:00