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