feat(firmware): wire SD persistence + git publish into the editor

Land the v0.1 editor integration: the git_sync module (libgit2 on the SD
/sd/repo, dedicated 96KB git thread, lazy Wi-Fi, :sync push with
synced/up-to-date/failed snackbars), the boot splash (Spike 9) plus its bin
and justfile recipes, and a power-on→cursor boot-timing log. Also re-syncs
the roadmap/spikes/v0.1-product status and adds the SD hardware reference
photo.
This commit is contained in:
Julien Calixte
2026-07-11 15:30:43 +02:00
parent 98a9d1dffe
commit 8dc6ee362f
11 changed files with 710 additions and 65 deletions

View File

@@ -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
}

View File

@@ -10,13 +10,22 @@ into are tracked in [`qfd.md`](qfd.md).
The editor **core** has been built 23 versions ahead of the device
**releases**, and is now **extracted into a host-testable `editor` crate** (plus
a `display` crate for the panel framebuffer) so `cargo test` exercises it off the
xtensa target. 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.

View File

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

View File

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

View File

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@@ -10,6 +10,7 @@ esp_env := ". ~/export-esp.sh &&"
elf := "target/xtensa-esp32s3-espidf/release/firmware"
elf_wifi := "target/xtensa-esp32s3-espidf/release/wifi_tls"
elf_sd := "target/xtensa-esp32s3-espidf/release/sd_fat"
elf_splash := "target/xtensa-esp32s3-espidf/release/splash"
elf_git := "target/xtensa-esp32s3-espidf/release/git_smoke"
elf_git_push := "target/xtensa-esp32s3-espidf/release/git_push"
elf_git_sync := "target/xtensa-esp32s3-espidf/release/git_sync"
@@ -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/<name> 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

View File

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

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

@@ -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<PublishRequest>,
tx: Sender<PublishOutcome>,
) {
// 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 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<BlockingWifi<EspWifi<'static>>>,
modem: &mut Option<Modem<'static>>,
nvs: &mut Option<EspDefaultNvsPartition>,
clock_synced: &mut bool,
tls_ready: &mut bool,
) -> 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");
}
// 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<PublishOutcome> {
log::info!("publish started — free heap {}", free_heap());
let repo = Repository::open(REPO_DIR).with_context(|| {
format!("opening git repo at {REPO_DIR} — provision the card with a clone (just init) whose origin is your remote")
})?;
// Stage everything (add --all also stages deletions, for a future note-delete)
// and build the tree from what the editor saved.
let mut index = repo.index().context("opening index")?;
index
.add_all(["*"], IndexAddOption::DEFAULT, None)
.context("staging (add --all)")?;
index.write().context("writing index")?;
let tree = repo.find_tree(index.write_tree().context("writing tree")?)?;
// Commit on top of the current branch tip (None on an empty/unborn remote).
let parent = repo.head().ok().and_then(|h| h.peel_to_commit().ok());
// Nothing-to-publish short-circuit: staged tree identical to the parent's.
if let Some(p) = &parent {
if p.tree_id() == tree.id() {
log::info!("nothing to publish — tree unchanged @ {}", short(p.id()));
return Ok(PublishOutcome::UpToDate);
}
}
let sig = Signature::now(AUTHOR_NAME, AUTHOR_EMAIL).context("building signature")?;
let message = format!("Typoena publish — unix {}", now_unix());
let parents: Vec<&Commit> = parent.iter().collect();
let oid = repo
.commit(Some("HEAD"), &sig, &sig, &message, &tree, &parents)
.context("creating commit")?;
let 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<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(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::<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() }
}
fn min_free_heap() -> u32 {
unsafe { sys::esp_get_minimum_free_heap_size() }
}

View File

@@ -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;

View File

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