diff --git a/display/src/lib.rs b/display/src/lib.rs index 1b1ab3b..b76a3a9 100644 --- a/display/src/lib.rs +++ b/display/src/lib.rs @@ -6,8 +6,12 @@ //! 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}; pub const WIDTH: u16 = 792; pub const HEIGHT: u16 = 272; @@ -32,6 +36,43 @@ impl Frame { 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 } diff --git a/docs/roadmap.md b/docs/roadmap.md index f033e6f..b19ccf5 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -10,13 +10,22 @@ into are tracked in [`qfd.md`](qfd.md). 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. No release has shipped, but v0.1 is close: SD storage, save, and -**git publish are all wired into the app binary and hardware-verified -2026-07-11** (`:sync` commits on the SD `/sd/repo` and pushes to a test repo), -leaving the **boot splash as the last v0.1 gate** — and v0.2 navigation, -**v0.2.5 international input (hardware-verified 2026-07-11)**, and most of v0.6 -Markdown already run. Version numbers are unchanged — they track shippable device -releases, not core progress. +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 measured ~5.5 s** (power-on → cursor, instrumented +2026-07-11 — the 3.5 s eyeball undercounted; `esp_timer` sees only the ~4.1 s +app-side slice, missing the bootloader + ~0.74 s PSRAM memtest). **Over the ≤ 5 s +gate**; **fix implemented 2026-07-11** — the first editor render is now a +full-area partial (~630 ms) rather than a second full refresh (~1.9 s), expected +~4.2 s, pending a reflash to re-measure + check ghosting (PSRAM memtest kept on). +The 1-hour soak is attested from real use; boot time joins the +remaining post-ship acceptance checks (power-pull recovery, 1000-word no-drop, +and `Ctrl-G`'s not-yet-built pull-then-retry → v0.9). Beyond v0.1, v0.2 navigation, **v0.2.5 international input (hardware-verified +2026-07-11)**, and most of v0.6 Markdown already run. Version numbers are +unchanged — they track shippable device releases, not core progress. Marks: `[x]` done in core · `[~]` partially done · `[ ]` not started. An inline `(✓)` marks the done half of a split item. @@ -34,8 +43,8 @@ title = "Typoena — macro plan" name = "v0.1 it writes, it pushes" start = 2026-06-01 original = 2026-06-29 -status = "at-risk" -note = "Overdue — core editing, SD storage, and on-device git push all proven in spikes; SD mount + save now wired into main.rs. Remaining: boot splash and wiring git publish into main.rs." +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" @@ -106,19 +115,29 @@ requires = ["v0.1 it writes, it pushes"] --- -## v0.1 — MVP: "it writes, it pushes" — [~] +## v0.1 — MVP: "it writes, it pushes" — [x] The minimum thing that justifies the hardware existing. Full design: [product](v0.1-mvp-product.md) · [technical](v0.1-mvp-technical.md). -**Status:** core editing + partial refresh run on device, and **SD mount + save -are now wired into `main.rs`** (Spike 3 resolved — a genuine ≤32 GB card mounts, -verified on its own SPI3 host per ADR-012). **Git publish is now wired too** -(`:sync` → commit + fast-forward push on the SD `/sd/repo`, hardware-verified -2026-07-11 against a test repo). Remaining v0.1 integration: the boot splash -(Spike 9). +**Status:** SHIPPED 2026-07-11 (late vs the 2026-06-29 baseline). Core editing + +partial refresh run on device; **SD mount + save are wired into `main.rs`** +(Spike 3 resolved — a genuine ≤32 GB card mounts, verified on its own SPI3 host +per ADR-012); **git publish is wired** (`:sync` → commit + fast-forward push on +the SD `/sd/repo`, hardware-verified against a test repo); and the **boot splash +(Spike 9) is confirmed on the panel** — [`Frame::splash`](../display/src/lib.rs) +shows a vector `typoena`-in-a-circle at startup while the SD mounts, then the +editor comes up. Cold boot measured ~5.5 s (power-on → cursor, 2026-07-11) — **over the ≤ 5 s +gate**: the 3.5 s eyeball undercounted, and `esp_timer` catches only the ~4.1 s +app-side slice (it starts after the bootloader + the ~0.74 s PSRAM memtest). Fix +implemented 2026-07-11: first editor render is now a full-area partial (~4.2 s +expected, pending a reflash to verify); PSRAM memtest kept on. The 1-hour soak +is attested from real use; boot time joins the remaining post-ship acceptance +checks (power-pull recovery, 1000-word no-drop, `Ctrl-G` pull-then-retry → v0.9) — +see [product → acceptance](v0.1-mvp-product.md#acceptance-criteria). -- [~] ESP32-S3 boots (✓); e-ink shows Typoena splash + boot log — splash pending Spike 9 +- [x] ESP32-S3 boots (✓); e-ink shows Typoena splash (✓ Spike 9, confirmed on + panel 2026-07-11); boot status surfaces via the panel snackbar (no serial on device) - [x] USB host enumerates the Nuphy, key events reach the editor (Spike 4) - [x] One hard-coded file (`/sd/repo/notes.md`) opens on boot — **wired in `main.rs`** (`boot_storage` mounts the SD and loads the note; a missing @@ -166,7 +185,7 @@ verified on its own SPI3 host per ADR-012). **Git publish is now wired too** rather than a timer — a timed auto-dismiss would cost a ~630 ms full-area e-ink flash purely to erase text, which the panel deliberately avoids (cf. the dropped pending-accent marker in v0.2.5). -- [~] Partial refresh on edits (✓ Spike 5); save now wired (full-area partial +- [x] Partial refresh on edits (✓ Spike 5); save wired (full-area partial repaint on `:w`) Out of scope: Vim, palette, multiple files, branches, conflict handling. diff --git a/docs/spikes.md b/docs/spikes.md index f6a87eb..add1981 100644 --- a/docs/spikes.md +++ b/docs/spikes.md @@ -64,6 +64,22 @@ risk early. clean full refresh. Mostly a feature — kept as a spike only to prove the asset path. (Feeds v0.1's "e-ink shows Typoena splash + boot log".) + **Built 2026-07-11 as a *vector* splash**, not a bitmap. The frame is + [`display::Frame::splash`](../display/src/lib.rs) — the `typoena` wordmark + centred inside a stroked `Circle`, drawn with `embedded-graphics`, one clean + full refresh. It is shared by two callers: the + [`splash`](../firmware/src/bin/splash.rs) bench binary (`just flash-splash`) + and **`main.rs`'s boot path**, which shows it right after EPD init — replacing + the old white-clear baseline — while the SD mounts and the note loads, then a + second full refresh brings up the editor. Nothing is embedded, so the + image-asset pipeline named above is deliberately **left unproven** — an + acceptable trade because Spike 2 already covered vector + font rendering. + **Proposition, deferred to end-of-project polish (v1.0):** replace the vector + mark with an embedded 1-bit raster logo, which is when the asset-embed/blit + path would finally be exercised. **Confirmed on the panel 2026-07-11** — the + splash renders cleanly at boot, then the editor comes up. This closed the last + v0.1 display gate; v0.1 shipped the same day. + 10. **Spike 10 — Dark / light theme.** Invert the 1-bit framebuffer (white on black) and refresh. The invert is trivial (XOR at blit); the real unknowns are (a) ghosting and refresh time on a predominantly-black panel — full-black diff --git a/docs/v0.1-mvp-product.md b/docs/v0.1-mvp-product.md index d723b23..1697fe6 100644 --- a/docs/v0.1-mvp-product.md +++ b/docs/v0.1-mvp-product.md @@ -205,7 +205,25 @@ 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. +> **Status 2026-07-11:** v0.1 was declared **delivered**, with acceptance +> criteria run as **post-ship hardening**. The 1-hour soak is attested from +> sustained real use (below). Two criteria are known **not** to pass yet, tracked +> as follow-ups rather than silent passes: cold boot measured **~5.5 s** +> (instrumented 2026-07-11, over the 5 s gate — fix identified below), and +> `Ctrl-G`'s pull-then-retry path is **not yet implemented** (deferred to v0.9). + - [ ] After a cold boot with valid pre-flashed config, cursor is ready in ≤ 5 s. + — **MEASURED 2026-07-11: ~5.5 s power-on → cursor**, over the gate (the + `boot: cursor ready` log prefix, 5508 ms, from `esp_log_timestamp` ≈ since + power-on; the 4091 ms my first instrument printed was only the app-side + slice — `esp_timer` doesn't start until ~1.4 s in, after the 2nd-stage + bootloader + the ~0.74 s PSRAM memtest, so it excludes them). **Fix + implemented 2026-07-11** (pending a reflash to re-measure + confirm no + ghosting): the first editor render is now a full-area partial (~630 ms) + instead of a second full refresh (~1.9 s), which should bring boot to + ~4.2 s. The two boot full refreshes (~3.9 s) were the structural cost; the + PSRAM memtest (~0.74 s, left on — a real HW sanity check on a hand-wired + board) is a further lever, and both feed the v1.0 ≤ 3 s target. - [ ] 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 @@ -215,8 +233,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 diff --git a/firmware/Cargo.toml b/firmware/Cargo.toml index 0a30651..593a5da 100644 --- a/firmware/Cargo.toml +++ b/firmware/Cargo.toml @@ -36,6 +36,14 @@ name = "sd_fat" path = "src/bin/sd_fat.rs" harness = false +# 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). diff --git a/firmware/docs/sd-card.jpg b/firmware/docs/sd-card.jpg new file mode 100644 index 0000000..825aa95 Binary files /dev/null and b/firmware/docs/sd-card.jpg differ diff --git a/firmware/justfile b/firmware/justfile index 2e4fe40..c4051f8 100644 --- a/firmware/justfile +++ b/firmware/justfile @@ -10,6 +10,7 @@ esp_env := ". ~/export-esp.sh &&" elf := "target/xtensa-esp32s3-espidf/release/firmware" elf_wifi := "target/xtensa-esp32s3-espidf/release/wifi_tls" elf_sd := "target/xtensa-esp32s3-espidf/release/sd_fat" +elf_splash := "target/xtensa-esp32s3-espidf/release/splash" elf_git := "target/xtensa-esp32s3-espidf/release/git_smoke" elf_git_push := "target/xtensa-esp32s3-espidf/release/git_push" elf_git_sync := "target/xtensa-esp32s3-espidf/release/git_sync" @@ -79,6 +80,18 @@ flash-sd: monitor-sd: espflash monitor --elf {{elf_sd}} +# 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 @@ -180,9 +193,9 @@ provision sd_volume="": # Resolve the target card volume. Prints the /Volumes/ path as the last # stdout line (callers capture it); all diagnostics go to stderr. Prefers an -# explicit name; else auto-detects exactly one ejectable + external volume, and -# refuses on 0 or >1 — a wrong guess here means rsync --delete wipes the wrong -# disk's repo/. +# 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 @@ -195,8 +208,17 @@ _card sd_volume="": for v in /Volumes/*; do [ -d "$v" ] || continue info="$(diskutil info "$v" 2>/dev/null || true)" - if echo "$info" | grep -qiE 'Ejectable:[[:space:]]+Yes' \ - && echo "$info" | grep -qiE 'Device Location:[[:space:]]+External|Removable Media:[[:space:]]+(Removable|Yes)'; then + # 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 diff --git a/firmware/src/bin/splash.rs b/firmware/src/bin/splash.rs new file mode 100644 index 0000000..d9ba5f0 --- /dev/null +++ b/firmware/src/bin/splash.rs @@ -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::, + &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); + } +} diff --git a/firmware/src/git_sync.rs b/firmware/src/git_sync.rs new file mode 100644 index 0000000..bdbb82c --- /dev/null +++ b/firmware/src/git_sync.rs @@ -0,0 +1,390 @@ +//! 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 `notes.md` before `:sync` signals us, +//! so we just stage + commit + push what's on disk. +//! +//! 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::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, IndexAddOption, + PushOptions, RemoteCallbacks, Repository, Signature, +}; + +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 note is already saved to `/sd/repo/notes.md` by the +/// UI task before this is sent, so the request carries no payload (a future +/// multi-file publish can grow one). +pub struct PublishRequest; + +/// 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, + tx: Sender, +) { + // Lazily initialised on the first request, then reused across publishes. + let mut wifi: Option>> = None; + let mut modem = Some(modem); + let mut nvs = Some(nvs); + let mut clock_synced = false; + let mut tls_ready = false; + + while rx.recv().is_ok() { + let outcome = publish_cycle( + &sys_loop, + &mut wifi, + &mut modem, + &mut nvs, + &mut clock_synced, + &mut tls_ready, + ); + 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>>, + modem: &mut Option>, + nvs: &mut Option, + clock_synced: &mut bool, + tls_ready: &mut bool, +) -> Result { + 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"); + } + + // Bring Wi-Fi up once (on-demand: the radio stays off until the first :sync). + if wifi.is_none() { + 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); + } + if !*clock_synced { + sync_clock()?; + *clock_synced = true; + } + if !*tls_ready { + install_tls_trust_store()?; + *tls_ready = true; + } + + publish_once() +} + +/// Open `/sd/repo`, stage the working tree, commit on top of the current branch, +/// and fast-forward push. Never clones or wipes: a `/sd/repo` that isn't a valid +/// repo is a provisioning error, surfaced as such. +fn publish_once() -> Result { + log::info!("publish started — free heap {}", free_heap()); + let repo = Repository::open(REPO_DIR).with_context(|| { + format!("opening git repo at {REPO_DIR} — provision the card with a clone (just init) whose origin is your remote") + })?; + + // Stage everything (add --all also stages deletions, for a future note-delete) + // and build the tree from what the editor saved. + let mut index = repo.index().context("opening index")?; + index + .add_all(["*"], IndexAddOption::DEFAULT, None) + .context("staging (add --all)")?; + index.write().context("writing index")?; + let tree = repo.find_tree(index.write_tree().context("writing tree")?)?; + + // Commit on top of the current branch tip (None on an empty/unborn remote). + let parent = repo.head().ok().and_then(|h| h.peel_to_commit().ok()); + + // Nothing-to-publish short-circuit: staged tree identical to the parent's. + if let Some(p) = &parent { + if p.tree_id() == tree.id() { + log::info!("nothing to publish — tree unchanged @ {}", short(p.id())); + return Ok(PublishOutcome::UpToDate); + } + } + + let sig = Signature::now(AUTHOR_NAME, AUTHOR_EMAIL).context("building signature")?; + let message = format!("Typoena publish — unix {}", now_unix()); + let parents: Vec<&Commit> = parent.iter().collect(); + let oid = repo + .commit(Some("HEAD"), &sig, &sig, &message, &tree, &parents) + .context("creating commit")?; + let 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(PublishOutcome::Pushed(short(oid))) +} + +/// Push `branch` to origin; on a rejected push, fetch origin and reconcile, then +/// retry once. A true divergence (two writers) needs a merge commit and is +/// deferred to increment B — `fetch_and_integrate` bails there. A single-writer +/// appliance always fast-forwards, so the happy path never hits it. +fn push_with_retry(repo: &Repository, branch: &str) -> Result<()> { + let refspec = format!("refs/heads/{branch}:refs/heads/{branch}"); + match try_push(repo, &refspec) { + Ok(()) => Ok(()), + Err(first) => { + log::warn!("push rejected ({first}); fetching origin to reconcile"); + fetch_and_integrate(repo, branch).context("reconciling after a rejected push")?; + log::info!("reconciled with origin; retrying push"); + try_push(repo, &refspec).context("push after reconcile") + } + } +} + +/// 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>> = 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(git2::build::CheckoutBuilder::new().force()))?; + return Ok(()); + } + bail!("origin/{branch} diverged from local — a real merge commit is needed (increment B, deferred)") +} + +/// 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::()) +} + +/// 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() } +} + +fn min_free_heap() -> u32 { + unsafe { sys::esp_get_minimum_free_heap_size() } +} diff --git a/firmware/src/lib.rs b/firmware/src/lib.rs index dd0f5ef..f77daa5 100644 --- a/firmware/src/lib.rs +++ b/firmware/src/lib.rs @@ -8,6 +8,15 @@ //! `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; diff --git a/firmware/src/main.rs b/firmware/src/main.rs index 4e548d7..8526340 100644 --- a/firmware/src/main.rs +++ b/firmware/src/main.rs @@ -1,4 +1,3 @@ -mod epd; mod usb_kbd; use std::time::Instant; @@ -10,8 +9,9 @@ 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 editor::{Editor, Effect, Mode, CH}; -use epd::Epd; +use firmware::epd::{self, Epd}; use firmware::persistence::{Storage, NOTES}; /// Injected by build.rs so serial output identifies the exact build. @@ -56,7 +56,11 @@ 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 @@ -68,6 +72,33 @@ fn main() -> anyhow::Result<()> { // Bring up the USB keyboard in the background; keys arrive via next_key(). usb_kbd::start()?; + // 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::(); + let (res_tx, res_rx) = std::sync::mpsc::channel::(); + 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. let mut ed = Editor::with_text(saved); @@ -89,9 +120,26 @@ fn main() -> anyhow::Result<()> { ed.set_keyboard_present(last_kbd); ed.refresh_stats(); - // First render is full (establishes the on-screen baseline for partials). + // 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), @@ -107,14 +155,26 @@ fn main() -> anyhow::Result<()> { keys += 1; } - // Carry out any host-side effect a `:` command asked for. The SD save is - // wired (fast, inline). Publishing (`:sync` → git push) lives behind the - // `git` Cargo feature via `publish()`, so the default light build carries - // no libgit2 / git2 at all — see the note on `publish`. + // Carry out any host-side effect a `:` command asked for. Save is inline + // (fast). `:sync` persists, then hands off to the git thread — the push + // is behind the `git` feature, so a light build carries no libgit2/git2. match effect { Effect::None => {} Effect::Save => save_note(&storage, &mut ed), - Effect::Publish => publish(&storage, &mut ed), + Effect::Publish => { + // Publishing an unsaved buffer is meaningless, so persist first. + save_note(&storage, &mut ed); + // Then signal the git thread — 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). + #[cfg(feature = "git")] + match git_tx.send(firmware::git_sync::PublishRequest) { + Ok(()) => ed.set_notice("syncing..."), + Err(_) => ed.set_notice("sync: git thread down"), + } + #[cfg(not(feature = "git"))] + log::info!(":sync — saved; light build (no `git` feature) — push skipped"); + } } // Keyboard attach/detach feeds the panel's disconnect flag. @@ -124,6 +184,23 @@ fn main() -> anyhow::Result<()> { 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::*; + ed.set_notice(match outcome { + Pushed(oid) => format!("synced {oid}"), + UpToDate => "up to date".to_string(), + Failed(reason) => reason, + }); + let f = ed.draw(true); + epd.display_frame_partial_window(f.bytes(), 0, epd::HEIGHT)?; + 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 { @@ -271,36 +348,6 @@ fn save_note(storage: &Storage, ed: &mut Editor) { } } -/// `:sync` — persist, then publish. Publishing (git push) is gated behind the -/// `git` Cargo feature. The nominal build (`just build`/`just flash`) turns it -/// on (libgit2 + git2). A **light** build (`just build-light`/`just flash-light`) -/// leaves it off — no `git2` crate and, since the justfile only sets -/// `LIBGIT2_SRC` for the full recipes, no libgit2/mbedTLS component either — so -/// `:sync` just saves locally. -/// -/// This `#[cfg]` is the seam that keeps the light build light: the git-only code -/// path is only ever compiled under `--features git`, so wiring publish in later -/// can never drag libgit2 into a light build. -fn publish(storage: &Storage, ed: &mut Editor) { - // Publishing an unsaved buffer is meaningless, so save first in both builds. - // (`save_note` posts the "saved N B" snackbar; the git path will overwrite it - // with a push result once wired.) - save_note(storage, ed); - - #[cfg(feature = "git")] - { - // TODO(v0.1): signal the dedicated git thread here (channel, not an - // inline blocking push) once `git_sync` is graduated from its spike bin - // into a module. The feature is on but that integration hasn't landed, - // so for now this still just saves. - log::info!(":sync — saved; `git` feature ON, but the publish module isn't wired yet"); - } - #[cfg(not(feature = "git"))] - { - log::info!(":sync — saved; built without the `git` feature (light build) — push skipped"); - } -} - /// 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.