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