diff --git a/Cargo.lock b/Cargo.lock index 769f34637..bff1e4127 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3911,6 +3911,7 @@ dependencies = [ "libm", "littlefs-rust", "log", + "lp-bootctl", "lp-collection 1.0.0", "lp-gfx-lpvm", "lp-perf", @@ -5270,6 +5271,10 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "lp-bootctl" +version = "1.0.0" + [[package]] name = "lp-cli" version = "40.0.0" @@ -5645,10 +5650,13 @@ dependencies = [ "espflash", "fw-host", "js-sys", + "littlefs-rust", + "lp-bootctl", "lpa-client", "lpc-model", "lpc-wire", "lpfs", + "md-5", "serde", "serde-wasm-bindgen", "serde_json", @@ -5703,6 +5711,7 @@ dependencies = [ "async-trait", "futures-util", "js-sys", + "littlefs-rust", "log", "lp-gfx-lpvm", "lpa-agent", diff --git a/Cargo.toml b/Cargo.toml index 3d5cb6aac..a827c5d81 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "lp-base/lp-perf", "lp-base/lp-collection", "lp-base/lp-recovery", + "lp-base/lp-bootctl", "lp-base/lpfs", # lp-app workspace members "lp-core/lpc-history", @@ -97,6 +98,7 @@ resolver = "2" default-members = [ # lp-base workspace members "lp-base/lp-recovery", + "lp-base/lp-bootctl", "lp-base/lpfs", # lp-app workspace members "lp-core/lpc-history", @@ -204,6 +206,7 @@ cranelift-interpreter = { git = "https://github.com/light-player/lp-cranelift", regalloc2 = { git = "https://github.com/light-player/lp-regalloc2", branch = "lightplayer", default-features = false, features = ["std"] } lp-collection = { path = "lp-base/lp-collection", default-features = false } lp-recovery = { path = "lp-base/lp-recovery", default-features = false } +lp-bootctl = { path = "lp-base/lp-bootctl", default-features = false } # Common dependencies diff --git a/docs/adr/2026-07-30-boot-control-sector.md b/docs/adr/2026-07-30-boot-control-sector.md new file mode 100644 index 000000000..e9dc286eb --- /dev/null +++ b/docs/adr/2026-07-30-boot-control-sector.md @@ -0,0 +1,167 @@ +# ADR: Boot-control sector + +- **Status:** Accepted +- **Date:** 2026-07-30 +- **Deciders:** Photomancer +- **Supersedes:** None +- **Superseded by:** None + +## Context + +A project can make its own device unrecoverable. The originating case: a +project bright enough to brown the board out at power-up. The LEDs come on, +the rail collapses, the board resets, auto-loads the same project, and loops — +never staying up long enough for Studio to connect and fix it. + +`lp-recovery` already has a boot-loop ladder that skips project auto-load +after repeated incomplete boots, but it does not fire here, for **two +independent reasons** (both left for a follow-up plan): + +1. `mark_boot_complete()` fires on the **first rendered frame**, so a board + that renders one bright frame and then dies looks like a successful boot + and resets the counter every time. +2. `ResetCause::Brownout` is excluded from `blames_code()`, so brownouts never + increment the counter even when the counter is reached. + +And a third problem that no amount of ladder tuning fixes: **the recovery +region lives in RTC fast RAM, which is wiped on `PowerOn`.** The instinctive +human response to a misbehaving board is to unplug it — which erases the +evidence that it was looping at all. A latch a user destroys by doing the +obvious thing is not a latch. + +Separately, recovery tooling needs to tell a device "come up without loading a +project" while the device is in ROM download mode — i.e. while no firmware is +running to receive a message. The only channel available there is flash +itself. + +These are the same requirement seen from two directions. + +## Decision + +Add a dedicated **4 KB `bootctl` flash partition** carrying a small record +that the firmware reads before it auto-loads a project. The format lives in a +new `no_std`, zero-alloc crate, `lp-base/lp-bootctl`, shared by every writer +and reader so they cannot disagree. + +**Placement: the 4 KB comes out of `nvs`,** which shrinks from `0x6000` to +`0x5000`, putting `bootctl` at `0xe000`. Nothing reads NVS — no LightPlayer +code references it, and `esp-radio`'s `NVS` is a 15-word **RAM** array +(`common_adapter.rs`) plus stubbed ESP-IDF shims, not this partition. This +placement moves **no other offset**, so `lpfs` stays at `0x310000` and +existing devices' filesystem images remain valid. Both boards keep +byte-identical layouts so the offset can be a constant rather than a +partition-table lookup. + +**Two writers, one format.** The host writes the record over esptool while +the device sits in ROM download mode — the path that works on a board that +cannot boot. The firmware will later write it too, to latch its own degraded +state across a power cycle (follow-up plan; today the firmware only reads and +consumes). + +**Blank is safe.** A device that has never seen this feature reads `0xFF` +bytes. That, a bad magic, a bad CRC, a future format version, a short read, +and a torn write all decode to "boot normally". There is exactly one way to +get a non-default boot: a fully valid record that asks for one. A corrupt +sector can never *cause* a degraded boot — only fail to prevent one. + +**Torn writes: one write, integrity by checksum.** The record is written to +an erased sector in a single operation, and its integrity rests on the magic +and CRC rather than on write ordering. + +This is deliberately *not* the discipline `lp-recovery` uses. That crate +publishes RTC-RAM structures by flipping one visibility word last, and the +flash-native mirror — write the payload, then the magic — was the original +design here. **It does not work.** Every flash-write API that can reach this +sector issues the ESP ROM/stub `FLASH_BEGIN`, which *erases the sectors it is +about to write*; that is true of `espflash::write_bin_to_flash` and of +`esptool-js`'s `writeFlash` alike. A second write publishing a first would +erase it instead, leaving a valid magic over an erased payload — which fails +the CRC, boots normally, and makes the feature silently inoperative. + +The CRC covers the magic, so every prefix of a partial write fails one of the +two checks. `no_partial_write_is_ever_honored` asserts that for all 16 +truncation points. + +**Consume on read, not on use.** The firmware erases a valid record the +moment it reads it, before anything acts on it. That makes the instruction +one-shot — the user asked for one recovery boot, not a permanent mode — and +means a crash *during* the recovery boot cannot make it sticky. The failure +mode traded for is an erase that fails, which strands the device reachable +with no project loaded: the safe direction, and itself recoverable. + +**Unknown flag bits are ignored, not rejected.** A newer host asking for +something this firmware cannot apply still gets the instructions it does +understand. + +**Amendment 2026-07-31 — the safe-mode clamp bits are assigned.** Bits +`8..16` now carry a safe-mode output clamp level (`0` = none, else a +brightness ceiling out of 255), with a precedence rule defined in the +format: *a firmware that implements the clamp loads the project dimmed and +ignores the skip bit* — a dim, visible board is a strictly better +degradation than a dark one. Studio's "Start in safe mode" writes BOTH the +skip and a dim clamp, so the same record means "nothing loaded" on firmware +that predates the clamp and upgrades to "project running dim" when the +clamp lands, with no format bump and no Studio change. Firmware consumption +of the clamp remains with the follow-up plan (it shares the output path +with the fixture mA limiter). + +## Consequences + +- **This is a breaking flash-layout change.** A device flashed with the old + table has no `bootctl` partition; the region at `0xe000` is inside its + `nvs`. Reading it yields whatever NVS had there — which decodes as `Blank` + or `Invalid`, so an un-reflashed device simply never takes a boot-control + instruction. Deployed boards must be reflashed with the new table to gain + the feature, and that is safe because nothing reads NVS. +- `lpfs` is untouched, so reflashing does **not** cost the user their files. +- The firmware gains a flash read (and conditionally a 4 KB erase) on the boot + path, before the filesystem mounts. Measured cost: image grew well within + budget — 2 866 928 B of 3 145 728 B, 278 800 B headroom against a 65 536 B + CI margin. +- `lp-bootctl` hardcodes the offset, so the two boards' partition tables must + stay identical. `lp-base/lp-bootctl/tests/partition_layout.rs` enforces + that, along with no-overlap, the 4 MB bound, and that no pre-existing + partition moved. Those guards live in `lp-bootctl` rather than `fw-esp32c6` + because the latter is RV32-only and excluded from host builds, so tests + there would never run. +- Two skip-auto-load reasons now exist (boot-control record, and the + RTC-resident incomplete-boot ladder). The boot log always names which one + applied. + +## Alternatives Considered + +- **Shrink `lpfs` instead of `nvs`.** Rejected: changing the partition size + changes littlefs's block count, invalidating every existing filesystem + image. Recovery tooling that costs users their files is self-defeating. +- **Append after `lpfs`.** Impossible: the layout ends at exactly `0x400000` + on a 4 MB part. +- **Store the flag in NVS proper.** Rejected: writing ESP-IDF's NVS format + from a host over esptool means implementing that format host-side, which is + far more work than a 16-byte record in a raw sector, for no benefit. +- **Store the flag in `lpfs`.** Rejected on two counts: the host would have + to build and rewrite a littlefs image just to set one flag, and the flag + would live in the same structure whose corruption is one of the things it + needs to survive. +- **Keep the latch in the RTC recovery region.** Rejected: wiped on + `PowerOn`, and unplugging the board is the single most likely user action + in the scenario this exists for. +- **A `consumed` marker word instead of erasing.** Bit-clearing a word is + cheaper than a 4 KB erase and avoids an erase cycle per recovery boot. + Rejected for simplicity: it doubles the states to reason about in a + safety-critical primitive, and the erase happens at most once per recovery + boot, where tens of milliseconds are irrelevant. +- **Numeric partition subtype `0x40`.** Rejected because it does not work: + espflash's `esp-idf-part` panics on any `data` subtype outside its enum. + Used `undefined` (`0x06`), ESP-IDF's designated user-data subtype. + +## Follow-ups + +- The firmware as a *writer*: latch degraded state across a power cycle after + repeated failures. Needs the boot-complete redefinition and the brownout + blame policy fixed first — both deferred to the follow-up plan. +- The graduated output clamp that reserved flag bits `8..16` exist for. +- Mirroring a last-crash summary into the sector, so post-mortem is readable + from a board that never boots (the RTC crash record is not). +- `lpfs` is declared with subtype `spiffs` but is littlefs; `esp-idf-part` + does support a `littlefs` subtype. Left alone deliberately — cosmetic, and + changing it would alter the partition-table binary for no functional gain. diff --git a/docs/adr/2026-07-30-bootloader-mode-detection.md b/docs/adr/2026-07-30-bootloader-mode-detection.md new file mode 100644 index 000000000..ebda1542b --- /dev/null +++ b/docs/adr/2026-07-30-bootloader-mode-detection.md @@ -0,0 +1,101 @@ +# ADR: Bootloader-mode detection is handshake-authoritative + +- **Status:** Accepted +- **Date:** 2026-07-30 +- **Deciders:** Photomancer +- **Supersedes:** None +- **Superseded by:** None + +## Context + +Recovery tooling has to know what is on the other end of the wire: LightPlayer +firmware running the app protocol, a ROM/stub bootloader waiting to be +flashed, or nothing useful at all. "Flash firmware", "erase", and "write a +boot-control record" only work against a bootloader; the app protocol only +works against the app. + +Studio has never had to answer this directly. Readiness is granted by exactly +one thing — a proto-matching `ServerHello` — and everything else was +*diagnosis*: `BootLineClassifier` watches non-protocol serial lines and +explains why a device is not ready. That is enough to render an error, but +not to decide which operations to offer. + +## Decision + +**Enumeration data cannot answer this, and the code says so.** ESP32-C6 and +ESP32-S3 use the chip's native USB-Serial-JTAG peripheral, and the ROM +bootloader uses *that same peripheral*: the device enumerates as +`303A:1001` whether it is running our firmware or sitting in download mode. +Boards behind a CP2102/CH340 bridge are worse — they always report the +bridge's identifiers, never the chip's state. VID/PID matching is not merely +a weaker signal here; it is structurally incapable of distinguishing the two +states. It is recorded as rejected so it is not re-proposed. + +Mode is therefore established by **talking to the device**, in three tiers of +evidence: + +1. **A proto-matching `ServerHello` ⇒ `App`.** Readiness is untouched: + `DeviceLinkMode` classifies, it does not promote. The hello remains the + only thing that grants readiness. +2. **ROM download-mode boot lines ⇒ `Bootloader`, corroborating.** Strong + when present, meaningless when absent — a board *already* in download mode + printed its banner before Studio attached, so silence proves nothing. This + is why absence of the signature classifies as `Unknown`, never as "not a + bootloader". +3. **An esptool SYNC handshake ⇒ `Bootloader`, authoritative**, plus the chip + identity for free. + +Anything else is `Unknown`, which means "no evidence", not "nothing is there". + +**The probe is mode-exclusive because it reboots the device.** The handshake +drives DTR/RTS to enter download mode, and on USB-Serial-JTAG that reset +drops USB enumeration and invalidates the port handle. So +`DeviceSession::probe_link_mode` takes `DeviceMode::Management`, releases the +link, probes, and rebuilds — the same discipline as `manage`. It is never +part of the routine connect ladder. `DeviceLinkMode::probe_would_help()` +returns `false` for `App` for exactly this reason: probing a healthy board +costs a reboot and buys nothing. + +**An unanswered probe is not proof of `Unknown`.** A device happily running +the app ignores SYNC too. On probe failure the session reports the *passive* +classification of the rebuilt link rather than asserting `Unknown` — the +honest answer instead of a guess. + +## Consequences + +- Studio can offer bootloader-only operations on evidence rather than hope, + and can say *which* of the three states it sees. +- Chip identity arrives free with an authoritative probe, which M8 needs to + refuse cross-chip whole-flash restores. +- The probe costs a device reboot, so it must stay behind an explicit user + action or an explicit escalation. Any future caller that runs it + speculatively will reboot healthy boards; `probe_would_help()` exists to + make the right thing easy. +- `BootLineClassifier` remains the single boot-line classifier in the app + layer. A previous duplicate in `lpa-studio-core` was demoted and deleted + (see `2026-07-15-device-session-model`); this ADR does not reintroduce one. +- Providers without a bootloader concept (host process, browser worker sim) + report the probe as unsupported rather than fabricating an answer. + +## Alternatives Considered + +- **Match USB VID/PID.** Rejected as impossible, not merely unreliable — see + above. This is the alternative most likely to be re-proposed by someone who + has not hit the USB-Serial-JTAG detail, which is why it is stated first. +- **Trust boot-line classification alone.** Rejected: its absence carries no + information, so it can never distinguish "bootloader" from "silent". Kept + as corroboration, where its presence is genuinely strong. +- **Probe on every connect** and cache the answer. Rejected: it reboots every + healthy device on every connect, and on USB-Serial-JTAG it also drops the + port handle mid-ladder. +- **Treat an unanswered probe as `Unknown`.** Rejected: an app-mode device + ignores SYNC, so this would report a working board as broken. + +## Follow-ups + +- Surfacing the mode in the UI, and the "waiting for a device in bootloader + mode" confirmation that makes the BOOT-button ritual learnable, is M5 of + the device-recovery plan. +- A flapping-device heuristic (enumeration-drop counting) is M9 and is + deliberately offer-only: it must never trigger a probe on its own, since + that would reboot a device that may just have a loose cable. diff --git a/docs/adr/2026-07-31-device-backup-archive-format.md b/docs/adr/2026-07-31-device-backup-archive-format.md new file mode 100644 index 000000000..93c75b92d --- /dev/null +++ b/docs/adr/2026-07-31-device-backup-archive-format.md @@ -0,0 +1,91 @@ +# ADR: Device backup archive format + +- **Status:** Accepted +- **Date:** 2026-07-31 +- **Deciders:** Photomancer +- **Supersedes:** None +- **Superseded by:** None + +## Context + +M6 of the device-recovery plan gives Studio a device backup: the raw `lpfs` +partition read off the board over the bootloader — deliberately workable on a +device that cannot boot — mounted in wasm by the same littlefs implementation +the firmware uses, and handed to the user as a ZIP. + +The moment M7 (restore) reads these archives back, the layout stops being an +implementation detail and becomes a contract: an archive written today must +restore correctly tomorrow, and a future selective restore ("put just this +one project back") must be able to recover device paths without reversing a +scheme. That crossing — from artifact to contract — is what warrants an ADR. +The full field-level reference lives with the code +(`lp-app/lpa-studio-core/src/app/device/filesystem_backup/README.md`); this +records the decisions and their reasons. + +## Decision + +**Posture: support-facing, shaped as if public.** The format is not promised +to users or documented outside the repo, but it is designed as though it +might be — that is what keeps the promotion path free. This mirrors the +product's alpha wire posture rather than fighting it. + +**Versioning: version and refuse, never migrate** — the same alpha rule as +share envelopes (`2026-07-28-share-envelopes.md`). `manifest.json` carries +`formatVersion`; a reader meeting an unknown version says so and stops. No +dual-format decode paths, no shims. + +**Layout: device paths mirrored verbatim under one `files/` root.** + +``` +manifest.json +files/.lp/device.json +files/projects/porch/project.json +``` + +- Nothing flattened, renamed, or restructured: recovering a device path is + `strip_prefix("files/")`, not the reversal of a scheme. This is the + property selective restore depends on. +- The `files/` prefix exists for exactly one reason: `manifest.json` cannot + collide with a file the device kept at its filesystem root. +- Entries sorted by device path, manifest written first: the same device + state produces byte-stable archives, and a streaming reader learns what it + is holding before the content. +- Deflate compression; the content is small and mostly text. + +**The manifest carries what restore and forensics need**: `formatVersion`, +capture time, the `deviceUid` found *in the captured image* (absent for a +never-named board), the chip the bootloader named itself as, the partition +offset/length and littlefs block size the image was read at, and file +count/total bytes. The chip and partition fields are what let M8 refuse +cross-chip restores and let M7 detect a cross-device restore before it +happens. + +## Consequences + +- M7 restore and future selective restore have a stable substrate; promoting + the format to user-facing later costs documentation, not redesign. +- The uid living inside the archive means restore MUST implement the + identity guard (preserve the target's own `/.lp/device.json`) — the + archive faithfully carries the hazard, and the reader owns the safety. +- Byte-stable output makes archives diffable and testable by fixture. +- A `formatVersion` bump strands older Studio builds by design; acceptable + under the alpha posture, revisit when devices ship. + +## Alternatives Considered + +- **Raw partition image instead of a ZIP.** Rejected as the user-facing + artifact: opaque, unbrowsable, ties the backup to one partition geometry + (the C6 and S3 already differ). Whole-flash imaging remains M8's separate, + explicitly image-shaped feature. +- **A flattened or manifest-indexed layout.** Rejected: any scheme that must + be reversed to recover a device path is a scheme somebody will reverse + wrongly. +- **Migrating old archives on read.** Rejected by the standing alpha rule: + version and refuse. + +## Follow-ups + +- M7 (restore) reads this format and owns the identity guard and + verify-before-write. +- If the format is promoted to user-facing, document it outside the repo and + add fixture archives per version to `schemas/`-style history. diff --git a/docs/adr/README.md b/docs/adr/README.md index 6891c2993..6f1453074 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -122,6 +122,13 @@ holds the full context. | Classic-ESP32 (LX6) address windows for the Xtensa backtrace walk (the ABI is shared, the memory map is not) | `2026-07-30-xtensa-backtrace-window-spill` | A classic-ESP32 firmware target needs frames in its crash reports | | Xtensa exception-frame walking (crashes arriving through the exception vector rather than `panic!`; `walk_frames_from` already takes an explicit `(ra, sp)` for it) | `2026-07-30-xtensa-backtrace-window-spill` | The S3 needs backtraces for hardware faults, not just panics | | Emitter peephole for the Xtensa integer-div-by-zero guard (`Movi`+`BranchRr` → a single `BranchZ(Beqz)`; `MOVEQZ`/`MOVNEZ` fusion, both already encoded/decoded/emulated in `lp-xt-inst`/`lp-xt-emu`) | `2026-07-30-integer-division-never-traps` | Xtensa code-size or instruction-count pressure makes trimming the guard worth it | +| Firmware as a *writer* of the boot-control sector (latch degraded state across a power cycle; today it only reads and consumes) | `2026-07-30-boot-control-sector` | The boot-complete redefinition and brownout blame policy land | +| Firmware consumption of the boot-control safe-mode clamp (bits `8..16` are now ASSIGNED — Studio writes skip+clamp and the format defines clamp-overrides-skip precedence; firmware still only honors the skip) | `2026-07-30-boot-control-sector` | The safe-clamp / fixture mA-limiter work begins | +| Last-crash summary mirrored into the boot-control sector (the RTC crash record is unreadable from a board that never boots) | `2026-07-30-boot-control-sector` | Post-mortem from a non-booting board is needed | +| `lpfs` partition subtype is `spiffs` but the filesystem is littlefs (`esp-idf-part` supports `littlefs`) | `2026-07-30-boot-control-sector` | The partition table is being changed for another reason anyway | +| Surfacing link mode in the UI + the "waiting for a device in bootloader mode" confirmation that makes the BOOT-button ritual learnable | `2026-07-30-bootloader-mode-detection` | M5 of the device-recovery plan | +| Flapping-device heuristic (enumeration-drop counting), deliberately offer-only — it must never trigger a probe, which would reboot a device that may just have a loose cable | `2026-07-30-bootloader-mode-detection` | M9 of the device-recovery plan | +| Backup-format promotion to user-facing (external docs + per-version fixture archives) | `2026-07-31-device-backup-archive-format` | The format is promised outside the repo | | C6 migration to `lp-ws281x` (`docs/debt/c6-on-legacy-ws281x-driver.md`) | `2026-07-31-lp-ws281x-multi-channel-driver-adoption` | A second C6 channel is wanted, a `lp-ws281x` fix needs to reach the C6, or maintaining two drivers becomes its own tax | | `MAX_LEDS` silent truncation and duplication (`docs/debt/output-channel-led-cap-silent-truncation.md`) | `2026-07-31-lp-ws281x-multi-channel-driver-adoption` | A long strip is authored and the cap bites with no diagnostic | | Float→int through the soft-float ABI (`__fixsfsi`/`__fixunssfsi` are skipped because the ABI leaves out-of-range and NaN undefined) | `2026-07-31-soft-float-via-compiler-builtins` | The C6 harness's probe data shows the ROM matching `compiler_builtins` at those edges | diff --git a/docs/debt/README.md b/docs/debt/README.md index c68002e82..bbdfd5e21 100644 --- a/docs/debt/README.md +++ b/docs/debt/README.md @@ -64,3 +64,5 @@ stay in place when retired; the log is the history). | [s3-frame-cost-scales-per-fixture](s3-frame-cost-scales-per-fixture.md) | carried | 2026-07-31 | lpc-engine resolver + lpc-hardware registry | Frame cost is flat ~8.4 ms/fixture: per-frame dataflow re-resolution + per-frame endpoint-status recomputation; the shader JIT is ~1%, sends 11% | | [c6-on-legacy-ws281x-driver](c6-on-legacy-ws281x-driver.md) | carried | 2026-07-31 | lp-fw/fw-esp32c6/src/output | C6 still runs its own single-channel WS281x driver, not `lp-ws281x`; the two now diverge in features and both hardcode `MAX_LEDS` | | [example-shaders-not-compile-gated](example-shaders-not-compile-gated.md) | carried | 2026-07-29 | examples GLSL + CI + lps-filetests | an example shader can compile on the host yet fail on 4 of 5 targets; the break surfaces only when a human opens it in Studio | +| [safe-mode-dim-boot-unproven](safe-mode-dim-boot-unproven.md) | retired | 2026-08-01 | fw-esp32c6/bootctl + lpc-engine safe clamp | RETIRED 2026-08-01: dim boot seen on silicon (serial: record found/consumed/clamped 26/255; heartbeat outputClamp; eyes: dim) | +| [studio-no-reconnect-after-replug](studio-no-reconnect-after-replug.md) | carried | 2026-07-31 | lpa-link/browser-serial + studio device cards | every bootloader-mode op ends on a replug Studio cannot see; the op card waits forever and the user reloads the tab | diff --git a/docs/debt/safe-mode-dim-boot-unproven.md b/docs/debt/safe-mode-dim-boot-unproven.md new file mode 100644 index 000000000..6ebcbdf19 --- /dev/null +++ b/docs/debt/safe-mode-dim-boot-unproven.md @@ -0,0 +1,68 @@ +--- +status: retired +since: 2026-08-01 # first hardware walk of the arm-safe-mode chain +logged: 2026-08-01 +area: fw-esp32c6/bootctl + lpc-engine safe clamp +related: + [ + "web-serial-js-untestable.md", + "studio-no-reconnect-after-replug.md", + "../adr/2026-07-30-boot-control-sector.md", + ] +--- +# Safe-mode dim boot is unproven on silicon + +**Shape** — The browser arm chain is hardware-verified end to end up to +the last leg: Studio writes the 16-byte boot-control record and it is +readback-verified in flash at 0xE000 (`LPBC` magic + CRC, byte-exact). +But no one has ever observed the *consequence*: a board booting with +the safe-mode output clamp applied (~10% brightness). On the one bench +walk (2026-08-01), the next boot came up full brightness. + +The firmware read path (`fw-esp32c6/src/bootctl.rs` `read_and_consume`, +called from `main.rs`, gated `#[cfg(not(feature = "memory_fs"))]`) and +the clamp application (`BootAction::LoadClamped` → +`Engine::safe_output_clamp_q16`) are covered by host tests, but the +silicon path — record present at boot → consumed → clamp visibly +applied — has never been seen working. + +**Carrying cost** — The feature's origin story ("a project too bright +to survive boot") is unproven end to end. If the boot leg is broken, +the recovery UX above it is a working ritual with a dead payload, and +we find out during a real rescue. + +**Workarounds** — +- The arm/write side is trustworthy (readback gate); only the consume + side needs the next bench session. +- Boot outcomes are currently unobservable after the fact — the boot + log scrolls by on serial and nothing records the `[BOOTCTL]` + decision. Capture serial during the walk, or land boot-log capture + in the fw hello/status first. + +**Incident log** +- 2026-08-01 — bench walk: record verified in flash; next boot full + brightness. Whether a dim boot happened unobserved between replugs + is unknown. +- 2026-08-01 (later) — host read of 0xE000 on the desk C6 found the + sector blank (all 0xFF), which would mean the firmware *did* consume + the record — but the board's MAC (`a0:f2:62:87:b4:8c`) did not match + the bench board from the walk (`a0:f2:62:85:85:d8`), so the read may + have been of a never-armed board. Evidence inconclusive. +- 2026-08-01 (evening) — closing walk PASSED; see Resolution. + +**Resolution (2026-08-01 evening)** — Exit criteria met on the bench: +merged-branch firmware flashed to the desk C6, the Studio-identical +record host-written and readback-verified at 0xE000, physical replug. +Serial capture shows the full chain: `[BOOTCTL] record found: +flags=0x00001a01` → `record consumed` → `SAFE MODE: output clamped to +26/255 — loading the project dimmed`; the heartbeat reports +`"outputClamp":26` (the new wire field); Yona confirmed the LEDs came +up dim. The first walk's full-brightness boot was never reproduced — +most consistent with an unobserved dim boot between replugs, which is +exactly the observability gap the heartbeat field now closes. + +**Exit criteria** — One bench session: arm safe mode from Studio, +replug, and either see the LEDs come up dim or capture the serial boot +log showing the `[BOOTCTL]` decision. If consumed-but-not-applied, +chase `BootAction::LoadClamped` application; if never consumed, chase +the feature gating of `read_and_consume` in the shipped image. diff --git a/docs/debt/story-capture-pipeline.md b/docs/debt/story-capture-pipeline.md index 7a59cddfd..0ab22d8c2 100644 --- a/docs/debt/story-capture-pipeline.md +++ b/docs/debt/story-capture-pipeline.md @@ -396,3 +396,31 @@ escalate rather than widening the threshold. that has wedged runs since 2026-07-08), and **diff the bytes before designing a fix** — this pipeline has now produced several "obviously a settling race" diagnoses that the pixels overturned. + +- 2026-07-31 — **`just studio-story-pull` was broken, and the guard step's + message points at it.** `story-apply-refresh.mjs` parsed `process.argv` and + called `process.exit(2)` at *module scope*, so `story-pull.mjs` — which + imports `applyRefresh` from it — exited 2 before doing anything. The + documented manual fallback for drift has therefore been dead since the two + scripts were split, and the CI failure message ("`just studio-story-pull` + is the manual fallback") sends you straight into it. Argument parsing now + lives inside the `import.meta.url === argv[1]` guard; both the CLI and the + import work. + Found while chasing a story-job failure on PR #207 that was **neither + drift nor a capture crash: it was a wasm compile error.** A new + `LinkManagementRequest` variant left a non-exhaustive match in + `browser_serial_esp32/provider.rs`, which is feature-gated to wasm — so + `just check` and even `cargo check -p lpa-link --all-features` on the host + both passed, and `dx build ... [wasm32-unknown-unknown]` failed. This is + the [wasm gap](../../docs/debt/README.md) biting through the story job, and + it presents *identically* to a wedge: no capture ran, so the + `.check-complete` sentinel refused to treat it as drift (correctly), the + upload/auto-commit steps skipped, `Capture crash summary` fired, and the + guard step announced "unresolved story drift". + **Reading the signal:** step conclusions distinguish the three cases, the + step *name* does not. Skipped upload steps + fired crash summary means "no + usable capture" — which is a build failure at least as often as a wedge. + Read the tail of the `Story check` step before assuming a wedge; a + compile error is at the top of the step, thousands of lines above the + failure line. Local prophylactic for studio-touching changes: + `cargo check -p lpa-studio-web --target wasm32-unknown-unknown`. diff --git a/docs/debt/studio-no-reconnect-after-replug.md b/docs/debt/studio-no-reconnect-after-replug.md new file mode 100644 index 000000000..986ef3ab7 --- /dev/null +++ b/docs/debt/studio-no-reconnect-after-replug.md @@ -0,0 +1,38 @@ +--- +status: carried +since: 2026-07-31 # boot_safe_once began ending on the replug instruction +logged: 2026-08-01 +area: lpa-link/browser-serial + studio device cards +related: + [ + "web-serial-js-untestable.md", + "safe-mode-dim-boot-unproven.md", + ] +--- +# Studio never reconnects after a physical replug + +**Shape** — Bootloader-mode ops (arm safe mode, wipe) end on "Unplug +the board and plug it back in to start it" — a *physical* replug is the +only way a C6 over USB-Serial-JTAG leaves download mode. But nothing on +the Studio side re-attaches when the device re-enumerates: +`browser_serial.js` registers a serial `connect` listener, yet the +awaiting op card sits at "Waiting for device output…" forever, and no +`[device]` lines appear after the replug. The awaiting op is also never +cleared by a later successful connect (verify who clears +`device_card_op` before building on it). + +**Carrying cost** — Every recovery flow ends on an instruction the app +cannot confirm was followed. The user replugs, the board boots, and +Studio looks broken. This also blocks proving the dim-boot leg from +inside Studio (see `safe-mode-dim-boot-unproven.md`). + +**Workarounds** — Reload the Studio tab after replugging and reconnect +through the normal device picker; the Web Serial permission grant +survives, so this is clicks, not re-pairing. + +**Exit criteria** — After a bootloader-mode op ends awaiting replug, a +re-enumerated granted port is re-opened automatically (or one click), +the op card resolves, and `[device]` output flows. Note Web Serial +`connect` events only fire for previously-granted ports — the grant +retention in `browser_serial.js` is the hook. Testing falls under the +`web-serial-js-untestable.md` gap: expect this to need a hardware walk. diff --git a/lp-app/lpa-link/Cargo.toml b/lp-app/lpa-link/Cargo.toml index 68743ecdf..80924f23f 100644 --- a/lp-app/lpa-link/Cargo.toml +++ b/lp-app/lpa-link/Cargo.toml @@ -15,11 +15,25 @@ espflash = { version = "3.3", default-features = false, features = [ "serialport", ], optional = true } fw-host = { path = "../../lp-fw/fw-host", optional = true } +# Not optional: the boot-control record's format is shared vocabulary between +# every provider that writes it and the firmware that reads it, and +# `LinkManagementRequest::boot_safe_once` needs it in every build. `no_std`, +# no dependencies of its own, so wasm builds pay nothing for it. +lp-bootctl = { path = "../../lp-base/lp-bootctl", default-features = false } lpa-client = { path = "../lpa-client", optional = true, default-features = false } lpc-model = { path = "../../lp-core/lpc-model", optional = true } lpc-wire = { path = "../../lp-core/lpc-wire", optional = true } +# The fake device's raw-filesystem read returns a REAL littlefs image (see +# `providers/fake_device/fake_filesystem_image.rs`) — a fake that answered +# with arbitrary bytes would hide the only failure that matters. +littlefs-rust = { version = "0.1.0", default-features = false, features = [ + "std", +], optional = true } lpfs = { path = "../../lp-base/lpfs", optional = true, features = ["std"] } js-sys = { version = "0.3", optional = true } +# Verifies the MD5 the ROM/stub sends after a raw flash read. Host-only: +# the browser path gets its checksum from esptool-js. +md-5 = { version = "0.10", optional = true } serde = { workspace = true, features = ["derive"] } serde-wasm-bindgen = { version = "0.6", optional = true } serde_json = { workspace = true, optional = true } @@ -71,6 +85,7 @@ device-session-host = ["device-session", "lpa-client/host"] fake-device = [ "device-session-host", "dep:fw-host", + "dep:littlefs-rust", "lpa-client/serial", "dep:lpc-model", "dep:lpfs", @@ -86,6 +101,7 @@ host-serial-esp32 = [ "lpa-client/serial", "dep:espflash", "dep:lpc-model", + "dep:md-5", "dep:serde_json", "dep:serialport", "dep:tokio", @@ -95,5 +111,9 @@ host-serial-esp32 = [ name = "manage_smoke" required-features = ["host-serial-esp32"] +[[example]] +name = "bootctl_smoke" +required-features = ["host-serial-esp32"] + [lints] workspace = true diff --git a/lp-app/lpa-link/README.md b/lp-app/lpa-link/README.md index 1780e722b..3ac41b97a 100644 --- a/lp-app/lpa-link/README.md +++ b/lp-app/lpa-link/README.md @@ -78,10 +78,31 @@ The current implemented management operations are: manifest/images to the device. - `EraseDeviceFlash`: erase the whole device flash so the ESP32 returns to a blank, unprovisioned state. - -Raw filesystem image erase/read/write are modeled as link-level operations but -are future work. They should operate on direct device/LittleFS image bytes below -the server, not on the server filesystem API used for normal project upload. +- `SetBootControl`: write the `lp-bootctl` sector — an instruction to the + device's NEXT boot, delivered through flash because a device that cannot + boot has no other channel. +- `ReadRawFilesystem`: read the device's `lpfs` partition back to the host, + verbatim, as littlefs image bytes. + +`ReadRawFilesystem` takes no region. The partition is per board (the C6's +`lpfs` is at `0x310000`, the S3's at `0x610000`), and which board this is only +becomes knowable when the esptool SYNC handshake answers — a device that will +not boot cannot be asked what it is, and that is precisely the case this +operation exists for. Providers therefore resolve the region from the chip +they detect, through `LinkFlashRegion::lpfs_for_chip`, and report both the +region and the chip on the result so a backup's manifest can record them. +An unrecognized chip is refused rather than guessed at: a wrong region +produces a plausible-looking archive of the wrong bytes. + +`lpa-link` does not parse the image. Turning littlefs bytes into files is the +concern of the layer that builds the archive (`lpa-studio-core`'s +`app/device/filesystem_backup`); this crate moves bytes off a board. + +Raw filesystem WRITE (restore) is modeled as a link-level operation but is +future work, and no provider advertises it — `LinkCapabilities` has a +read-only `with_raw_filesystem_read()` alongside the paired +`with_raw_filesystem()` exactly so the UI is never offered a write that every +provider answers with `unsupported`. ## Server Connections diff --git a/lp-app/lpa-link/examples/bootctl_smoke.rs b/lp-app/lpa-link/examples/bootctl_smoke.rs new file mode 100644 index 000000000..2a633c643 --- /dev/null +++ b/lp-app/lpa-link/examples/bootctl_smoke.rs @@ -0,0 +1,127 @@ +//! Manual hardware smoke for the boot-control sector, end to end. +//! +//! Drives a real board through the recovery escape the way Studio does: +//! connect → Ready → `SetBootControl(skip project auto-load)` → the device +//! reboots and comes up reachable with nothing loaded → reboot again → the +//! record is gone and the project loads normally. +//! +//! That last step is the point. A boot-control record is one-shot; the +//! firmware consumes it as it reads it, so "safe once" must not become "safe +//! forever". It is also the step easiest to skip by hand. +//! +//! ```sh +//! cargo run -p lpa-link --features host-serial-esp32 --example bootctl_smoke -- \ +//! /dev/cu.usbmodem1101 +//! ``` +//! +//! NON-DESTRUCTIVE: writes a 16-byte record into the `bootctl` partition and +//! reboots. Nothing is erased, and the device's project survives. +//! +//! Companion to `manage_smoke`, which covers the erase/flash/reset cycle and +//! IS destructive. + +use std::rc::Rc; +use std::time::Duration; + +use lpa_link::providers::host_serial_esp32::{ + HostSerialEsp32Options, HostSerialEsp32Provider, label_for_port, +}; +use lpa_link::{ + DeviceDeadlines, DeviceEvent, DeviceEventSink, DeviceSession, DeviceTimers, LinkConnector, + LinkManagementRequest, LinkManagementResult, +}; + +fn event_printer() -> DeviceEventSink { + DeviceEventSink::new(|event| match event { + DeviceEvent::LogLine { line, .. } => println!(" | {line}"), + DeviceEvent::State { state } => println!(" * state: {state:?}"), + DeviceEvent::Progress { label, percent } => match percent { + Some(percent) => println!(" % {label}: {percent}%"), + None => println!(" % {label}"), + }, + }) +} + +fn main() { + let port = std::env::args() + .nth(1) + .expect("usage: bootctl_smoke "); + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime"); + let local = tokio::task::LocalSet::new(); + runtime.block_on(local.run_until(run(port))); +} + +async fn run(port: String) { + let provider = HostSerialEsp32Provider::with_options(HostSerialEsp32Options { + reset_after_open: true, + ..HostSerialEsp32Options::default() + }); + let endpoint_id = provider.create_endpoint_for_port(&port, label_for_port(&port)); + let connector = Rc::new(LinkConnector::HostSerialEsp32(provider)); + let timers = DeviceTimers::new(|duration| Box::pin(tokio::time::sleep(duration))) + .with_deadlines(DeviceDeadlines { + ready: Duration::from_secs(30), + ..DeviceDeadlines::default() + }); + + println!("== connect =="); + let session = DeviceSession::connect(connector, &endpoint_id, timers, event_printer()) + .await + .expect("device session connect"); + let state = session.wait_ready().await; + assert!( + state.is_ready(), + "expected Ready after connect, got {state:?}" + ); + + println!("\n== manage: SetBootControl (safe mode) =="); + let outcome = session + .manage(LinkManagementRequest::start_safe_mode(), event_printer()) + .await + .expect("write boot-control record"); + match &outcome.result { + LinkManagementResult::SetBootControl(result) => { + println!( + "wrote flags {:#010x} to {:?}", + result.flags, result.chip_name + ); + assert_ne!(result.flags, 0, "the request must carry an instruction"); + } + other => panic!("expected SetBootControl result, got {other:?}"), + } + assert!( + outcome.state.is_ready(), + "the device must come back reachable after a boot-control write \ + (nothing was erased), got {:?}", + outcome.state + ); + + println!( + "\n>> Expect the device's boot log to carry:\n\ + >> [BOOTCTL] record found: flags=0x00000001\n\ + >> [BOOTCTL] record consumed\n\ + >> [BOOTCTL] SAFE BOOT: boot-control record — skipping project auto-load" + ); + + println!("\n== manage: ResetRuntime (second boot — the record must be GONE) =="); + let outcome = session + .manage(LinkManagementRequest::ResetRuntime, event_printer()) + .await + .expect("reset runtime"); + assert!( + outcome.state.is_ready(), + "expected Ready after the second reboot, got {:?}", + outcome.state + ); + println!( + "\n>> The second boot log must NOT carry a [BOOTCTL] SAFE BOOT line.\n\ + >> If it does, the one-shot consume is broken and 'safe once' has\n\ + >> become 'safe forever'." + ); + + println!("\n== bootctl smoke complete =="); +} diff --git a/lp-app/lpa-link/src/device_session/device_link_mode.rs b/lp-app/lpa-link/src/device_session/device_link_mode.rs new file mode 100644 index 000000000..1255aa189 --- /dev/null +++ b/lp-app/lpa-link/src/device_session/device_link_mode.rs @@ -0,0 +1,233 @@ +//! Which of three things is on the other end of the wire. +//! +//! # Why this cannot be answered from enumeration data +//! +//! The obvious approach — read the USB vendor/product id — **cannot work** +//! on the boards LightPlayer targets. ESP32-C6 and ESP32-S3 use the chip's +//! native USB-Serial-JTAG peripheral, and the ROM bootloader uses *that same +//! peripheral*: the device enumerates as `303A:1001` whether it is running +//! our firmware or sitting in download mode waiting to be flashed. Boards +//! behind a CP2102/CH340 bridge are worse — they always report the bridge's +//! ids, never the chip's state. +//! +//! So the mode has to be established by *talking* to the device. See +//! `docs/adr/2026-07-30-bootloader-mode-detection.md`. +//! +//! # The evidence, cheapest first +//! +//! 1. A proto-matching `ServerHello` ⇒ [`App`](DeviceLinkMode::App). This is +//! the existing readiness gate and stays the only thing that grants +//! readiness; this module classifies, it does not promote. +//! 2. Boot lines matching a ROM download-mode signature ⇒ corroboration for +//! [`Bootloader`](DeviceLinkMode::Bootloader). Strong when present, +//! meaningless when absent: a board already sitting in download mode +//! printed its banner before anyone attached, so silence proves nothing. +//! 3. An esptool SYNC handshake that answers ⇒ `Bootloader`, authoritatively, +//! plus the chip identity for free. +//! 4. Nothing answers ⇒ [`Unknown`](DeviceLinkMode::Unknown). +//! +//! # The probe is not free +//! +//! Step 3 resets the device (DTR/RTS), and on USB-Serial-JTAG that reset +//! drops USB enumeration and invalidates the port handle. **Probing a +//! healthy device reboots it.** That is why the probe is an explicit +//! `DeviceMode::Management` escalation rather than a step in the routine +//! connect ladder — see [`crate::device_session::device_mode`]. + +use super::device_readiness::{BootLineClassifier, NoFirmwareReason}; + +/// What is on the other end of the wire. +#[derive(Clone, Debug, Eq, PartialEq, Default)] +pub enum DeviceLinkMode { + /// LightPlayer firmware is running and speaking the wire protocol. + App, + /// A ROM or stub bootloader is listening — the device can be flashed, + /// erased, or handed a boot-control record, but it is not running the + /// app. + Bootloader { + /// Chip identity, when a SYNC probe supplied it. `None` when the + /// classification rests on boot lines alone. + chip_name: Option, + /// How this was established, for UX that needs to say why. + evidence: BootloaderEvidence, + }, + /// Neither answered: no power, a charge-only cable, the wrong port, or + /// foreign firmware that speaks neither protocol. + #[default] + Unknown, +} + +/// How a [`DeviceLinkMode::Bootloader`] classification was reached. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BootloaderEvidence { + /// An esptool SYNC handshake answered. Authoritative. + SyncHandshake, + /// Boot output carried a ROM download-mode signature. Corroborating: + /// its presence is strong, its absence means nothing. + BootLines, +} + +impl DeviceLinkMode { + /// Classify from boot lines alone, without probing. + /// + /// This is the passive read, safe to run on every connect. It can reach + /// `App` and `Bootloader { evidence: BootLines }`, and otherwise answers + /// `Unknown` — which means "no *passive* evidence", not "nothing is + /// there". Escalate to a SYNC probe to tell those apart. + pub fn from_boot_lines(classifier: &BootLineClassifier, hello_seen: bool) -> Self { + if hello_seen { + return Self::App; + } + if classifier.no_firmware_detected() + && classifier.no_firmware_reason() == NoFirmwareReason::RomDownloadMode + { + return Self::Bootloader { + chip_name: None, + evidence: BootloaderEvidence::BootLines, + }; + } + Self::Unknown + } + + /// Promote to an authoritative `Bootloader` after a SYNC probe answered. + pub fn from_sync_probe(chip_name: Option) -> Self { + Self::Bootloader { + chip_name, + evidence: BootloaderEvidence::SyncHandshake, + } + } + + pub fn is_app(&self) -> bool { + matches!(self, Self::App) + } + + pub fn is_bootloader(&self) -> bool { + matches!(self, Self::Bootloader { .. }) + } + + /// Whether a SYNC probe would add anything. + /// + /// False for `App` — probing a healthy device reboots it for no gain — + /// and false once a probe has already answered. + pub fn probe_would_help(&self) -> bool { + match self { + Self::App => false, + Self::Bootloader { evidence, .. } => *evidence != BootloaderEvidence::SyncHandshake, + Self::Unknown => true, + } + } + + pub fn as_str(&self) -> &'static str { + match self { + Self::App => "app", + Self::Bootloader { .. } => "bootloader", + Self::Unknown => "unknown", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn classifier_with(lines: &[&str]) -> BootLineClassifier { + let mut classifier = BootLineClassifier::new(); + for line in lines { + classifier.observe_line(*line); + } + classifier + } + + #[test] + fn a_hello_means_app_mode_whatever_the_boot_lines_said() { + // Boot lines are only ever corroboration; a hello settles it. + let classifier = classifier_with(&["ESP-ROM:esp32c6-20220919", "waiting for download"]); + assert_eq!( + DeviceLinkMode::from_boot_lines(&classifier, true), + DeviceLinkMode::App + ); + } + + #[test] + fn download_mode_boot_lines_classify_as_bootloader() { + let classifier = classifier_with(&["ESP-ROM:esp32c6-20220919", "waiting for download"]); + assert_eq!( + DeviceLinkMode::from_boot_lines(&classifier, false), + DeviceLinkMode::Bootloader { + chip_name: None, + evidence: BootloaderEvidence::BootLines, + } + ); + } + + #[test] + fn silence_is_unknown_not_bootloader() { + // A board ALREADY in download mode printed its banner before anyone + // attached. Absence of the signature must not be read as absence of + // a bootloader. + let classifier = classifier_with(&[]); + assert_eq!( + DeviceLinkMode::from_boot_lines(&classifier, false), + DeviceLinkMode::Unknown + ); + assert!(DeviceLinkMode::Unknown.probe_would_help()); + } + + #[test] + fn blank_flash_is_unknown_until_a_probe_answers() { + // Blank flash is a no-firmware signature but NOT a download-mode one: + // the chip may be in either state, and only a probe can say. + let classifier = + classifier_with(&["ESP-ROM:esp32c6-20220919", "invalid header: 0xffffffff"]); + assert!(classifier.no_firmware_detected()); + assert_eq!( + DeviceLinkMode::from_boot_lines(&classifier, false), + DeviceLinkMode::Unknown + ); + } + + #[test] + fn a_sync_probe_is_authoritative_and_carries_chip_identity() { + let mode = DeviceLinkMode::from_sync_probe(Some("ESP32-C6".to_string())); + assert_eq!( + mode, + DeviceLinkMode::Bootloader { + chip_name: Some("ESP32-C6".to_string()), + evidence: BootloaderEvidence::SyncHandshake, + } + ); + assert!(mode.is_bootloader()); + assert!(!mode.probe_would_help(), "a probe that answered is final"); + } + + #[test] + fn probing_a_healthy_device_is_never_worth_it() { + // The probe resets the device and drops USB enumeration. On App mode + // that is pure cost. + assert!(!DeviceLinkMode::App.probe_would_help()); + } + + #[test] + fn boot_line_evidence_can_still_be_upgraded_by_a_probe() { + let mode = DeviceLinkMode::Bootloader { + chip_name: None, + evidence: BootloaderEvidence::BootLines, + }; + assert!( + mode.probe_would_help(), + "boot lines give no chip identity; a probe adds it" + ); + } + + #[test] + fn modes_have_stable_log_names() { + assert_eq!(DeviceLinkMode::App.as_str(), "app"); + assert_eq!(DeviceLinkMode::Unknown.as_str(), "unknown"); + assert_eq!(DeviceLinkMode::from_sync_probe(None).as_str(), "bootloader"); + } + + #[test] + fn unknown_is_the_default() { + assert_eq!(DeviceLinkMode::default(), DeviceLinkMode::Unknown); + } +} diff --git a/lp-app/lpa-link/src/device_session/device_manage.rs b/lp-app/lpa-link/src/device_session/device_manage.rs index ca775cbd6..59bbb2b20 100644 --- a/lp-app/lpa-link/src/device_session/device_manage.rs +++ b/lp-app/lpa-link/src/device_session/device_manage.rs @@ -33,6 +33,7 @@ use crate::provider::management_result::LinkManagementResult; use crate::{LinkError, LinkProvider}; use super::device_event::{DeviceEvent, DeviceEventSink, DeviceLineOrigin}; +use super::device_link_mode::DeviceLinkMode; use super::device_session::DeviceSession; use super::device_state::DeviceState; @@ -106,6 +107,68 @@ impl DeviceSession { Ok(DeviceManageOutcome { result, state }) } + /// Escalate to an **authoritative** link-mode determination by asking + /// the device directly. + /// + /// Runs the esptool SYNC handshake, which only a ROM/stub bootloader + /// answers. This is the one way to distinguish "in download mode" from + /// "not responding": USB-Serial-JTAG parts present identical enumeration + /// data in both states, so nothing short of talking to the chip can tell + /// them apart. + /// + /// **This reboots the device.** The handshake drives DTR/RTS to enter + /// download mode, and on USB-Serial-JTAG that reset drops USB + /// enumeration. It therefore takes [`DeviceMode::Management`] and + /// rebuilds the link afterwards, exactly like [`Self::manage`] — never + /// call it speculatively on a healthy board. `DeviceLinkMode::App` + /// already answers `probe_would_help() == false` for this reason. + /// + /// A probe that goes unanswered is NOT proof of `Unknown`: a device + /// happily running the app ignores SYNC too. So on probe failure this + /// reports whatever the rebuilt link's PASSIVE classification says, + /// which is the honest answer rather than a guess. + /// + /// [`DeviceMode::Management`]: super::device_mode::DeviceMode::Management + pub async fn probe_link_mode( + &self, + sink: DeviceEventSink, + ) -> Result { + let _mode = self.try_begin_management()?; + let session_id = self.shared.session_id(); + self.shared.release_link().await; + let probed = self + .shared + .connector() + .probe_target(&session_id, fold_into_device_events(&sink)) + .await; + + // The probe rebooted the device whether or not it answered, so the + // link must be rebuilt either way before anything can be said about + // the device's state. + if let Err(error) = self.shared.rebuild_link().await { + sink.emit(DeviceEvent::LogLine { + line: format!("device link rebuild failed after probe: {error}"), + origin: DeviceLineOrigin::Link, + }); + // A probe that answered is still authoritative; the rebuild + // failing does not unsay it. + return probed.map(DeviceLinkMode::from_sync_probe); + } + let state = self.shared.drive_readiness().await; + + match probed { + Ok(chip_name) => Ok(DeviceLinkMode::from_sync_probe(chip_name)), + Err(error) => { + sink.emit(DeviceEvent::LogLine { + line: format!("no bootloader answered ({error}); classifying passively"), + origin: DeviceLineOrigin::Link, + }); + let _ = state; + Ok(self.shared.passive_link_mode()) + } + } + } + /// Rebuild the link on the same endpoint and re-run readiness: recovery /// from `Gone` (device unplugged/rebooted) and the way out of every /// sticky terminal state. Refused while a management operation holds the diff --git a/lp-app/lpa-link/src/device_session/device_session.rs b/lp-app/lpa-link/src/device_session/device_session.rs index f973b0e64..0df9aa926 100644 --- a/lp-app/lpa-link/src/device_session/device_session.rs +++ b/lp-app/lpa-link/src/device_session/device_session.rs @@ -29,6 +29,7 @@ use crate::{ use super::device_client_io::DeviceClientIo; use super::device_event::{DeviceEvent, DeviceEventSink, DeviceLineOrigin}; +use super::device_link_mode::DeviceLinkMode; use super::device_mode::{ChannelUseGuard, DeviceMode, DeviceModeGuard}; use super::device_readiness::{BootLineClassifier, HelloGate, gate_first_frame}; use super::device_snapshot::DeviceSnapshot; @@ -103,11 +104,13 @@ impl DeviceSession { pub fn snapshot(&self) -> DeviceSnapshot { let state = self.shared.state(); let session = self.shared.session.borrow().clone(); + let link_mode = self.shared.passive_link_mode(); DeviceSnapshot { endpoint_status: DeviceSnapshot::derive_endpoint_status(&state, &session), state, session, recent_lines: self.shared.classifier.borrow().recent_lines().to_vec(), + link_mode, } } @@ -219,6 +222,13 @@ pub(crate) struct DeviceShared { } impl DeviceShared { + /// The passive link-mode read: what boot lines and the hello say, + /// without probing. See [`DeviceLinkMode::from_boot_lines`]. + pub(super) fn passive_link_mode(&self) -> DeviceLinkMode { + let classifier = self.classifier.borrow(); + DeviceLinkMode::from_boot_lines(&classifier, self.state().is_ready()) + } + pub(crate) fn state(&self) -> DeviceState { self.state.borrow().clone() } diff --git a/lp-app/lpa-link/src/device_session/device_snapshot.rs b/lp-app/lpa-link/src/device_session/device_snapshot.rs index f80adea11..b2caaced9 100644 --- a/lp-app/lpa-link/src/device_session/device_snapshot.rs +++ b/lp-app/lpa-link/src/device_session/device_snapshot.rs @@ -2,6 +2,7 @@ use crate::{LinkEndpointStatus, LinkSession, LinkSessionStatus}; +use super::device_link_mode::DeviceLinkMode; use super::device_state::DeviceState; /// Point-in-time view of a [`DeviceSession`]: the state machine position, @@ -21,6 +22,16 @@ pub struct DeviceSnapshot { /// failed, `Connected` when ready, `Available` after a clean close. pub endpoint_status: LinkEndpointStatus, pub recent_lines: Vec, + /// What is on the other end of the wire, as far as PASSIVE evidence can + /// tell: a hello means `App`, ROM download-mode boot lines mean + /// `Bootloader`, and anything else is `Unknown`. + /// + /// `Unknown` here means "no passive evidence", not "nothing is there" — + /// a board already sitting in download mode printed its banner before + /// Studio attached. Escalate with a SYNC probe + /// (`DeviceSession::probe_link_mode`) to tell those apart; that probe + /// reboots the device, which is why it is never automatic. + pub link_mode: DeviceLinkMode, } impl DeviceSnapshot { diff --git a/lp-app/lpa-link/src/device_session/mod.rs b/lp-app/lpa-link/src/device_session/mod.rs index e705a890f..1d98c78bd 100644 --- a/lp-app/lpa-link/src/device_session/mod.rs +++ b/lp-app/lpa-link/src/device_session/mod.rs @@ -29,6 +29,7 @@ mod device_client_io; mod device_event; +mod device_link_mode; mod device_manage; mod device_mode; mod device_readiness; @@ -42,6 +43,7 @@ mod device_wire; mod tests; pub use device_event::{DeviceEvent, DeviceEventSink, DeviceLineOrigin}; +pub use device_link_mode::{BootloaderEvidence, DeviceLinkMode}; pub use device_manage::DeviceManageOutcome; pub use device_mode::{DeviceMode, DeviceModeGuard}; pub use device_readiness::{ diff --git a/lp-app/lpa-link/src/device_session/tests.rs b/lp-app/lpa-link/src/device_session/tests.rs index 0725d20ec..dc833f89d 100644 --- a/lp-app/lpa-link/src/device_session/tests.rs +++ b/lp-app/lpa-link/src/device_session/tests.rs @@ -782,3 +782,90 @@ fn recorded_states(events: &Rc>>) -> Vec { }) .collect() } + +#[tokio::test] +async fn a_ready_session_classifies_as_app_mode_without_probing() { + let (connector, endpoint_id, _device) = fake_device_connector(FakeDeviceScript::new( + FakeBootState::LightPlayer(FakeLightPlayerState::new()), + )); + let session = DeviceSession::connect( + connector, + &endpoint_id, + test_timers(), + DeviceEventSink::noop(), + ) + .await + .unwrap(); + assert!(session.wait_ready().await.is_ready()); + + let snapshot = session.snapshot(); + assert_eq!(snapshot.link_mode, DeviceLinkMode::App); + assert!( + !snapshot.link_mode.probe_would_help(), + "probing a healthy device reboots it for nothing" + ); +} + +#[tokio::test] +async fn probe_link_mode_is_authoritative_and_carries_chip_identity() { + // A device the fake reports a bootloader for: the SYNC handshake answers, + // which is the ONLY way to tell "in download mode" from "not responding" + // — enumeration data is identical in both states. + let (connector, endpoint_id, _device) = fake_device_connector(FakeDeviceScript::new( + FakeBootState::LightPlayer(FakeLightPlayerState::new()), + )); + let session = DeviceSession::connect( + connector, + &endpoint_id, + test_timers(), + DeviceEventSink::noop(), + ) + .await + .unwrap(); + assert!(session.wait_ready().await.is_ready()); + + let mode = session + .probe_link_mode(DeviceEventSink::noop()) + .await + .unwrap(); + assert_eq!( + mode, + DeviceLinkMode::Bootloader { + chip_name: Some("ESP32-C6 (fake)".to_string()), + evidence: BootloaderEvidence::SyncHandshake, + } + ); + assert!( + !mode.probe_would_help(), + "an answered probe is final — re-probing only costs another reboot" + ); +} + +#[tokio::test] +async fn probe_link_mode_is_refused_while_management_holds_the_wire() { + // The probe reboots the device, so it must obey the same exclusivity as + // flash/erase rather than racing them on one wire. + let (connector, endpoint_id, _device) = fake_device_connector(FakeDeviceScript::new( + FakeBootState::LightPlayer(FakeLightPlayerState::new()), + )); + let session = DeviceSession::connect( + connector, + &endpoint_id, + test_timers(), + DeviceEventSink::noop(), + ) + .await + .unwrap(); + assert!(session.wait_ready().await.is_ready()); + + let guard = session.try_begin_management().unwrap(); + let error = session + .probe_link_mode(DeviceEventSink::noop()) + .await + .unwrap_err(); + assert!( + error.to_string().contains("management"), + "probe should refuse while the wire is held: {error}" + ); + drop(guard); +} diff --git a/lp-app/lpa-link/src/lib.rs b/lp-app/lpa-link/src/lib.rs index 8721b7915..3a89ac12a 100644 --- a/lp-app/lpa-link/src/lib.rs +++ b/lp-app/lpa-link/src/lib.rs @@ -14,8 +14,9 @@ pub mod stream; #[cfg(feature = "device-session")] pub use device_session::{ - DeviceDeadlines, DeviceEvent, DeviceEventSink, DeviceLineOrigin, DeviceManageOutcome, - DeviceMode, DeviceSession, DeviceSnapshot, DeviceState, DeviceTimers, IncompatibleReason, + BootloaderEvidence, DeviceDeadlines, DeviceEvent, DeviceEventSink, DeviceLineOrigin, + DeviceLinkMode, DeviceManageOutcome, DeviceMode, DeviceSession, DeviceSnapshot, DeviceState, + DeviceTimers, IncompatibleReason, }; #[cfg(feature = "device-session-host")] pub use provider::connection::{LinkClientTransport, LinkServerConnection}; @@ -25,6 +26,7 @@ pub use provider::endpoint::LinkEndpoint; pub use provider::endpoint::LinkEndpointId; pub use provider::endpoint::LinkEndpointStatus; pub use provider::error::LinkError; +pub use provider::flash_region::LinkFlashRegion; pub use provider::log::{LinkLogEntry, LinkLogLevel}; pub use provider::management_event::{ LinkManagementEvent, LinkManagementEventSink, emit_management_result_events, @@ -32,8 +34,8 @@ pub use provider::management_event::{ pub use provider::management_progress::LinkManagementProgress; pub use provider::management_request::LinkManagementRequest; pub use provider::management_result::{ - LinkEraseDeviceResult, LinkFirmwareFlashResult, LinkFirmwareManifest, LinkManagementResult, - LinkRawFilesystemEraseResult, + LinkBootControlResult, LinkEraseDeviceResult, LinkFirmwareFlashResult, LinkFirmwareManifest, + LinkManagementResult, LinkRawFilesystemEraseResult, LinkRawFilesystemReadResult, }; pub use provider::operation::{LinkCapabilities, LinkOperation}; pub use provider::provider::LinkProvider; diff --git a/lp-app/lpa-link/src/provider/flash_region.rs b/lp-app/lpa-link/src/provider/flash_region.rs new file mode 100644 index 000000000..a5ad079a0 --- /dev/null +++ b/lp-app/lpa-link/src/provider/flash_region.rs @@ -0,0 +1,162 @@ +//! Raw flash regions a link operation can address, per chip. +//! +//! A raw read is meaningless without an offset and a length, and those are +//! **per board**: the C6's `lpfs` sits at `0x310000` for 960 KB, the S3's at +//! `0x610000` for 1.5 MB (its 8 MB partition floor — +//! `docs/adr/2026-07-30-esp32s3-partition-floor.md`). Hardcoding the C6's +//! numbers would silently read the wrong 960 KB off an S3 and hand the user +//! a "backup" of somebody else's partition. +//! +//! **The chip is discovered, not declared.** A device that cannot boot cannot +//! tell Studio which board it is — that is the whole recovery scenario (see +//! the M5 plan correction in the recovery plan's notes). What *can* answer is +//! the esptool SYNC handshake both providers already perform before any flash +//! operation, so the region is resolved from the chip name that handshake +//! returns, at the moment of the read. +//! +//! The names arrive in two shapes: espflash's `Chip` renders `esp32c6`, while +//! esptool-js reports something like `ESP32-C6 (QFN32) (revision v0.2)`. +//! [`LinkFlashRegion::lpfs_for_chip`] normalizes both. + +use serde::{Deserialize, Serialize}; + +/// A contiguous span of device flash, in bytes from the start of the chip. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub struct LinkFlashRegion { + pub offset: u32, + pub length: u32, +} + +impl LinkFlashRegion { + /// The `lpfs` partition on `chip_name`, or `None` for a chip this build + /// has no partition table for. + /// + /// Returning `None` rather than guessing is deliberate: a wrong region + /// produces a plausible-looking archive of the wrong bytes, which is + /// worse than a refusal in exactly the situation where the user is + /// trying to rescue their work. + pub fn lpfs_for_chip(chip_name: &str) -> Option { + let key = normalize_chip_name(chip_name); + LPFS_PARTITIONS + .iter() + .find(|(chip, _)| key.contains(chip)) + .map(|(_, region)| *region) + } + + /// Block count for a littlefs mount over this region at `block_size`. + pub fn block_count(&self, block_size: u32) -> u32 { + self.length / block_size + } +} + +/// The `lpfs` partition of every board LightPlayer ships a partition table +/// for. Guarded against the tables themselves by the tests below. +/// +/// Keys are matched by substring against the normalized chip name, so a new +/// entry must not be a substring of another one (`esp32` alone would swallow +/// every board); today's keys are disjoint. +const LPFS_PARTITIONS: &[(&str, LinkFlashRegion)] = &[ + ( + "esp32c6", + LinkFlashRegion { + offset: 0x0031_0000, + length: 0x000F_0000, + }, + ), + ( + "esp32s3", + LinkFlashRegion { + offset: 0x0061_0000, + length: 0x0018_0000, + }, + ), +]; + +/// Reduce a reported chip name to lowercase alphanumerics so `esp32c6`, +/// `ESP32-C6 (QFN32) (revision v0.2)` and `ESP32-C6` all answer the same. +fn normalize_chip_name(chip_name: &str) -> String { + chip_name + .chars() + .filter(|ch| ch.is_ascii_alphanumeric()) + .map(|ch| ch.to_ascii_lowercase()) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The hand-maintained agreement between this table and the firmware + /// partition tables — the same guard `lp-bootctl` keeps over its sector + /// offset, for the same reason: nothing else would notice the drift, and + /// the failure mode is a silently wrong backup. + #[test] + fn lpfs_regions_match_every_boards_partition_table() { + for (board, csv) in [ + ( + "esp32c6", + include_str!("../../../../lp-fw/fw-esp32c6/partitions.csv"), + ), + ( + "esp32s3", + include_str!("../../../../lp-fw/fw-esp32s3/partitions.csv"), + ), + ] { + let (offset, size) = lpfs_row(csv); + let region = LinkFlashRegion::lpfs_for_chip(board) + .unwrap_or_else(|| panic!("{board} has an lpfs region")); + assert_eq!(region.offset, offset, "{board}: lpfs offset drifted"); + assert_eq!(region.length, size, "{board}: lpfs size drifted"); + } + } + + #[test] + fn chip_names_normalize_across_both_reporters() { + // espflash's `Chip` Display, and esptool-js's chatty banner. + let expected = LinkFlashRegion::lpfs_for_chip("esp32c6").unwrap(); + for reported in [ + "esp32c6", + "ESP32-C6", + "ESP32-C6 (QFN32) (revision v0.2)", + "esp32-c6", + ] { + assert_eq!( + LinkFlashRegion::lpfs_for_chip(reported), + Some(expected), + "{reported} should resolve to the C6 lpfs region" + ); + } + } + + #[test] + fn an_unknown_chip_refuses_rather_than_guessing() { + assert_eq!(LinkFlashRegion::lpfs_for_chip("ESP32-C3"), None); + assert_eq!(LinkFlashRegion::lpfs_for_chip(""), None); + } + + #[test] + fn block_count_covers_the_whole_region() { + let c6 = LinkFlashRegion::lpfs_for_chip("esp32c6").unwrap(); + assert_eq!(c6.block_count(4096), 240); + let s3 = LinkFlashRegion::lpfs_for_chip("esp32s3").unwrap(); + assert_eq!(s3.block_count(4096), 384); + } + + fn lpfs_row(csv: &str) -> (u32, u32) { + csv.lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .map(|line| line.split(',').map(str::trim).collect::>()) + .find(|fields| fields.first() == Some(&"lpfs")) + .map(|fields| (parse_hex(fields[3]), parse_hex(fields[4]))) + .expect("partitions.csv declares lpfs") + } + + fn parse_hex(text: &str) -> u32 { + let digits = text + .strip_prefix("0x") + .or_else(|| text.strip_prefix("0X")) + .unwrap_or(text); + u32::from_str_radix(digits, 16).unwrap_or_else(|_| panic!("{text:?} is not hex")) + } +} diff --git a/lp-app/lpa-link/src/provider/management_event.rs b/lp-app/lpa-link/src/provider/management_event.rs index cfe79d15c..2e5f98f2f 100644 --- a/lp-app/lpa-link/src/provider/management_event.rs +++ b/lp-app/lpa-link/src/provider/management_event.rs @@ -58,21 +58,31 @@ pub fn emit_management_result_events( } impl LinkManagementResult { - fn logs(&self) -> &[String] { + /// The operation's log lines, uniformly across variants. + /// + /// Public so consumers can replay a result without matching every + /// variant — a match that has to be extended for each new operation is a + /// build break waiting to happen, and each copy of it drifts. + pub fn logs(&self) -> &[String] { match self { Self::ResetRuntime => &[], Self::FlashFirmware(result) => &result.logs, Self::EraseDeviceFlash(result) => &result.logs, Self::EraseRawFilesystem(result) => &result.logs, + Self::ReadRawFilesystem(result) => &result.logs, + Self::SetBootControl(result) => &result.logs, } } - fn progress(&self) -> &[LinkManagementProgress] { + /// The operation's progress steps, uniformly across variants. + pub fn progress(&self) -> &[LinkManagementProgress] { match self { Self::ResetRuntime => &[], Self::FlashFirmware(result) => &result.progress, Self::EraseDeviceFlash(result) => &result.progress, Self::EraseRawFilesystem(result) => &result.progress, + Self::ReadRawFilesystem(result) => &result.progress, + Self::SetBootControl(result) => &result.progress, } } } diff --git a/lp-app/lpa-link/src/provider/management_request.rs b/lp-app/lpa-link/src/provider/management_request.rs index 5d8ab1cd5..c6469b598 100644 --- a/lp-app/lpa-link/src/provider/management_request.rs +++ b/lp-app/lpa-link/src/provider/management_request.rs @@ -13,15 +13,91 @@ pub enum LinkManagementRequest { EraseDeviceFlash, /// Erase the raw device filesystem partition below the running server. EraseRawFilesystem, + /// Read the raw device filesystem partition back to the host, verbatim. + /// + /// Takes no region: the partition is per board, and the board is + /// **discovered** by the SYNC handshake the provider performs anyway (see + /// [`crate::LinkFlashRegion`]). A device that cannot boot cannot be asked + /// what it is, which is exactly when this operation matters. + /// + /// Works from ROM download mode, so it is the one way to get a user's + /// work off a board whose own project prevents it from running. + ReadRawFilesystem, + /// Write the boot-control sector, instructing the device's next boot. + /// + /// `flags` are `lp_bootctl::BootFlags` bits, carried as a plain `u32` so + /// this wire type stays independent of the on-flash format crate; + /// providers convert with `BootFlags::from_bits` at the point of + /// encoding. Prefer [`Self::boot_safe_once`] over assembling bits by hand. + /// + /// This is a request to the *next* boot, not an immediate effect. The + /// device applies it when it restarts and consumes it as it does, so the + /// instruction is one-shot. + SetBootControl { flags: u32 }, } impl LinkManagementRequest { + /// Ask the device to start once in **safe mode**. + /// + /// The recovery escape for a project that prevents its own device from + /// running — too bright, too power-hungry, or hanging the watchdog. + /// + /// Sets BOTH the skip-autoload bit and a dim output-clamp level, and the + /// format's precedence rule (see [`lp_bootctl::BootFlags`]) picks the + /// best behavior the firmware can deliver: clamp-aware firmware loads + /// the project dimmed; older firmware ignores the clamp bits and comes + /// up with nothing loaded. Either way the board is reachable and cannot + /// brown itself out, and the boot after that is normal again. + pub fn start_safe_mode() -> Self { + Self::SetBootControl { + flags: lp_bootctl::BootFlags::SKIP_PROJECT_AUTOLOAD + .with_safe_clamp(lp_bootctl::BootFlags::DEFAULT_SAFE_CLAMP) + .bits(), + } + } + pub fn operation(&self) -> LinkOperation { match self { Self::ResetRuntime => LinkOperation::Reset, Self::FlashFirmware => LinkOperation::FlashFirmware, Self::EraseDeviceFlash => LinkOperation::EraseDeviceFlash, Self::EraseRawFilesystem => LinkOperation::WriteRawFilesystem, + Self::ReadRawFilesystem => LinkOperation::ReadRawFilesystem, + Self::SetBootControl { .. } => LinkOperation::WriteBootControl, } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn start_safe_mode_sets_the_skip_bit_and_a_dim_clamp() { + let LinkManagementRequest::SetBootControl { flags } = + LinkManagementRequest::start_safe_mode() + else { + panic!("expected SetBootControl"); + }; + let decoded = lp_bootctl::BootFlags::from_bits(flags); + // Both halves of the precedence design: the skip for firmware that + // predates the clamp, the clamp for firmware that has it. + assert!(decoded.contains(lp_bootctl::BootFlags::SKIP_PROJECT_AUTOLOAD)); + assert_eq!( + decoded.safe_clamp(), + Some(lp_bootctl::BootFlags::DEFAULT_SAFE_CLAMP) + ); + assert!( + !decoded.has_unknown_bits(), + "the convenience constructor must not set reserved bits" + ); + } + + #[test] + fn set_boot_control_maps_to_the_write_operation() { + assert_eq!( + LinkManagementRequest::start_safe_mode().operation(), + LinkOperation::WriteBootControl + ); + } +} diff --git a/lp-app/lpa-link/src/provider/management_result.rs b/lp-app/lpa-link/src/provider/management_result.rs index 45695af11..0b068207e 100644 --- a/lp-app/lpa-link/src/provider/management_result.rs +++ b/lp-app/lpa-link/src/provider/management_result.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -use crate::LinkManagementProgress; +use crate::{LinkFlashRegion, LinkManagementProgress}; /// Firmware image summary reported by a provider management operation. #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] @@ -37,6 +37,39 @@ pub struct LinkRawFilesystemEraseResult { pub progress: Vec, } +/// Result of reading the raw device filesystem partition back to the host. +/// +/// `image` is the partition's bytes verbatim — a littlefs image, not files. +/// Parsing it is deliberately somebody else's job: `lpa-link` moves bytes off +/// a board that may not boot, and the filesystem format is the concern of the +/// layer that turns the image into an archive. +/// +/// `region` and `chip_name` ride along because the read RESOLVED them (the +/// SYNC handshake names the chip; the chip picks the partition), and a +/// backup's manifest has to record which partition of which board it is or a +/// later restore cannot tell. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub struct LinkRawFilesystemReadResult { + pub image: Vec, + pub region: LinkFlashRegion, + pub chip_name: Option, + pub logs: Vec, + pub progress: Vec, +} + +/// Result of writing the boot-control sector. +/// +/// `flags` echoes what was written so callers can report the instruction +/// that actually landed rather than the one they asked for. The device does +/// not act on it until it next restarts. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub struct LinkBootControlResult { + pub flags: u32, + pub chip_name: Option, + pub logs: Vec, + pub progress: Vec, +} + /// Provider-neutral result from a link management operation. #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] pub enum LinkManagementResult { @@ -44,4 +77,6 @@ pub enum LinkManagementResult { FlashFirmware(LinkFirmwareFlashResult), EraseDeviceFlash(LinkEraseDeviceResult), EraseRawFilesystem(LinkRawFilesystemEraseResult), + ReadRawFilesystem(LinkRawFilesystemReadResult), + SetBootControl(LinkBootControlResult), } diff --git a/lp-app/lpa-link/src/provider/mod.rs b/lp-app/lpa-link/src/provider/mod.rs index 934231c1b..d01b7c26b 100644 --- a/lp-app/lpa-link/src/provider/mod.rs +++ b/lp-app/lpa-link/src/provider/mod.rs @@ -13,6 +13,7 @@ pub mod connection; pub mod diagnostic; pub mod endpoint; pub mod error; +pub mod flash_region; pub mod log; pub mod management_event; pub mod management_progress; diff --git a/lp-app/lpa-link/src/provider/operation.rs b/lp-app/lpa-link/src/provider/operation.rs index dc1e46581..f3f5aa46b 100644 --- a/lp-app/lpa-link/src/provider/operation.rs +++ b/lp-app/lpa-link/src/provider/operation.rs @@ -19,6 +19,10 @@ pub enum LinkOperation { ReadRawFilesystem, /// Write the raw filesystem image below the running server. WriteRawFilesystem, + /// Write the boot-control sector: an instruction to the device's next + /// boot, delivered through flash because a device that cannot boot has no + /// other channel. See `lp-bootctl`. + WriteBootControl, /// Read low-level logs from the endpoint/link. ReadLogs, /// Read low-level diagnostics from the endpoint/link. @@ -76,9 +80,25 @@ impl LinkCapabilities { self } + /// Advertise the raw-filesystem READ only. + /// + /// Split out of [`Self::with_raw_filesystem`] (M6): backup ships before + /// restore, and advertising the write half early would let the UI offer + /// an operation every provider answers with `unsupported`. The paired + /// builder stays for M7, which completes the other half. + pub fn with_raw_filesystem_read(mut self) -> Self { + self.operations.insert(LinkOperation::ReadRawFilesystem); + self + } + pub fn with_raw_filesystem(mut self) -> Self { self.operations.insert(LinkOperation::ReadRawFilesystem); self.operations.insert(LinkOperation::WriteRawFilesystem); self } + + pub fn with_boot_control(mut self) -> Self { + self.operations.insert(LinkOperation::WriteBootControl); + self + } } diff --git a/lp-app/lpa-link/src/providers/browser_serial_esp32/browser_esp32_flash.js b/lp-app/lpa-link/src/providers/browser_serial_esp32/browser_esp32_flash.js index 78f494eba..eda867f85 100644 --- a/lp-app/lpa-link/src/providers/browser_serial_esp32/browser_esp32_flash.js +++ b/lp-app/lpa-link/src/providers/browser_serial_esp32/browser_esp32_flash.js @@ -189,6 +189,204 @@ export async function eraseDeviceFlash(portId, esptoolModulePath, onEvent) { } } +/** + * Write the boot-control record — an instruction to the device's next boot, + * delivered through flash because a device that cannot boot has no other + * channel. + * + * `record` arrives already encoded (magic, version, flags, CRC) from + * `lp-bootctl` on the Rust side. Do NOT reconstruct it here: the firmware + * that reads these bytes cannot renegotiate the format at runtime, so one + * implementation of it is the point. + * + * One `writeFlash` call, deliberately. Its FLASH_BEGIN erases the sectors it + * is about to write, so splitting the record across two writes would have the + * second erase the first. + */ +export async function writeBootControl(portId, esptoolModulePath, address, record, onEvent) { + if (!isSupported()) { + throw new Error("Web Serial boot-control write is not supported in this browser."); + } + + const logs = []; + const progress = []; + const terminal = terminalFor(logs, "esp32-bootctl", onEvent); + try { + const port = getPort(portId); + await releasePort(portId); + + const { ESPLoader, Transport } = await loadEsptoolModule(esptoolModulePath); + const transport = new Transport(port, ESPTOOL_TRANSPORT_TRACING); + const loader = new ESPLoader({ + transport, + baudrate: 115200, + terminal, + debugLogging: false, + }); + + try { + const chipName = await loader.main(); + pushProgress(progress, onEvent, { + label: "Connected to ESP32 bootloader", + completedSteps: 1, + totalSteps: 2, + percent: 25, + }); + await loader.writeFlash({ + fileArray: [{ data: new Uint8Array(record), address }], + flashSize: "keep", + flashMode: "keep", + flashFreq: "keep", + eraseAll: false, + // MUST be true: esptool-js 0.6.0 implements ONLY deflate writes and + // throws "Yet to handle Non Compressed Writes" otherwise — found on + // the bench (2026-07-31) as "Arming safe mode failed". The ROM/stub + // accepts FLASH_DEFL_BEGIN in download mode; flashFirmware above has + // always used it. + compress: true, + }); + // Verify by READBACK, not by esptool's flash-ID warning. On the bench + // (2026-07-31, ESP32-C6 rev 2 over USB-Serial-JTAG) the ID probe reads + // 0 and esptool prints "Failed to communicate with the flash chip" — + // while actual stub reads AND writes work fine (the 2.9 MB firmware + // write and the filesystem backup both succeeded on the same plug + // session). The ID probe drives per-chip SPI registers; the stub's + // FLASH_DEFL_*/READ_FLASH commands are a different path. Gating on the + // warning blocked every boot-control write on that board; comparing + // the record byte-for-byte in flash is the guarantee we actually want. + const readBack = await loader.readFlash(address, record.byteLength ?? record.length); + const written = new Uint8Array(record); + const matches = + readBack && + readBack.length === written.length && + written.every((byte, i) => readBack[i] === byte); + if (!matches) { + throw new Error( + `Boot-control record readback mismatch at 0x${address.toString(16)}: ` + + `wrote [${Array.from(written, (b) => b.toString(16).padStart(2, "0")).join(" ")}], ` + + `read ${readBack ? `[${Array.from(readBack, (b) => b.toString(16).padStart(2, "0")).join(" ")}]` : "nothing"}`, + ); + } + pushProgress(progress, onEvent, { + label: "Boot-control record written", + completedSteps: 2, + totalSteps: 2, + percent: 100, + }); + await loader.after("hard_reset"); + return { + chipName: chipName ? String(chipName) : null, + logs, + progress: compactProgress(progress), + }; + } finally { + try { + await transport.disconnect(); + } catch (error) { + console.warn("[esp32-bootctl] transport disconnect failed", error); + } + } + } catch (error) { + reportFailure("esp32-bootctl", error, onEvent); + throw error; + } +} + +/** + * Read the device's filesystem partition back to the host, verbatim. + * + * The partition is per board, and which board this is only becomes known + * when `loader.main()` completes its SYNC handshake — a device that cannot + * boot cannot be asked. So the region is resolved MID-FLOW, by calling back + * into `resolveRegion(chipName)` on the Rust side; the per-board table stays + * in one place instead of being mirrored here. + * + * **Default baud, deliberately.** These parts speak USB-Serial-JTAG, where + * the baud parameter is meaningless and negotiating a higher one is measurably + * SLOWER (3.2 s vs 4.2 s for 960 KB on the bench). Do not raise it. + */ +export async function readRawFilesystem(portId, esptoolModulePath, resolveRegion, onEvent) { + if (!isSupported()) { + throw new Error("Web Serial filesystem backup is not supported in this browser."); + } + + const logs = []; + const progress = []; + const terminal = terminalFor(logs, "esp32-fsread", onEvent); + try { + const port = getPort(portId); + await releasePort(portId); + + const { ESPLoader, Transport } = await loadEsptoolModule(esptoolModulePath); + const transport = new Transport(port, ESPTOOL_TRANSPORT_TRACING); + const loader = new ESPLoader({ + transport, + baudrate: 115200, + terminal, + debugLogging: false, + }); + + try { + const chipName = await loader.main(); + pushProgress(progress, onEvent, { + label: "Connected to ESP32 bootloader", + completedSteps: 0, + totalSteps: 1, + percent: 0, + }); + const region = resolveRegion(chipName ? String(chipName) : ""); + if (!region) { + throw new Error( + `No filesystem partition layout for chip ${chipName ?? "(unidentified)"}.`, + ); + } + const image = await loader.readFlash( + region.offset, + region.length, + (_packet, bytesRead, totalBytes) => { + const percent = totalBytes > 0 ? Math.round((bytesRead / totalBytes) * 100) : 0; + pushProgress(progress, onEvent, { + label: "Reading filesystem", + completedSteps: bytesRead, + totalSteps: totalBytes, + percent, + }); + }, + ); + if (!image || image.length !== region.length) { + // A short image would mount as a damaged filesystem and look like + // data loss on the device rather than a truncated transfer. + throw new Error( + `Filesystem read returned ${image ? image.length : 0} bytes, expected ${region.length}.`, + ); + } + pushProgress(progress, onEvent, { + label: "Filesystem read", + completedSteps: region.length, + totalSteps: region.length, + percent: 100, + }); + return { + chipName: chipName ? String(chipName) : null, + offset: region.offset, + length: region.length, + image, + logs, + progress: compactProgress(progress), + }; + } finally { + try { + await transport.disconnect(); + } catch (error) { + console.warn("[esp32-fsread] transport disconnect failed", error); + } + } + } catch (error) { + reportFailure("esp32-fsread", error, onEvent); + throw error; + } +} + function assertNoFlashCommunicationWarning(logs, context) { const warning = logs.find((line) => line.includes("Failed to communicate with the flash chip") || @@ -363,7 +561,11 @@ function parseAddress(address) { function reportFailure(target, error, onEvent = null) { const message = `${errorMessage(error)}${error?.stack ? `\n${error.stack}` : ""}`; emitEvent(onEvent, { kind: "log", message }); - console.error(`[${target}] ${message}`, error); + // String-only, deliberately: the console forwarder ships arguments to the + // Rust log bridge, which expects strings — a raw Error object arrives as + // `{}` and the whole entry dies with "invalid type: map, expected a + // string" (bench, 2026-07-31). The message already carries the stack. + console.error(`[${target}] ${message}`); } function errorMessage(error) { diff --git a/lp-app/lpa-link/src/providers/browser_serial_esp32/browser_esp32_flash.rs b/lp-app/lpa-link/src/providers/browser_serial_esp32/browser_esp32_flash.rs index c2479fdfd..10bb75ea0 100644 --- a/lp-app/lpa-link/src/providers/browser_serial_esp32/browser_esp32_flash.rs +++ b/lp-app/lpa-link/src/providers/browser_serial_esp32/browser_esp32_flash.rs @@ -2,7 +2,10 @@ use js_sys::{Array, Function, Promise, Reflect}; use wasm_bindgen::{JsCast, closure::Closure, prelude::*}; use wasm_bindgen_futures::JsFuture; -use crate::{LinkError, LinkManagementEvent, LinkManagementEventSink, LinkManagementProgress}; +use crate::{ + LinkError, LinkFlashRegion, LinkManagementEvent, LinkManagementEventSink, + LinkManagementProgress, +}; #[derive(Clone, Debug, Eq, PartialEq)] pub struct BrowserEsp32FirmwareManifest { @@ -37,6 +40,17 @@ pub struct BrowserEsp32FlashProgress { pub percent: Option, } +/// A raw filesystem image read back over Web Serial, plus the region and +/// chip the read resolved. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BrowserEsp32FilesystemReadResult { + pub image: Vec, + pub region: LinkFlashRegion, + pub chip_name: Option, + pub logs: Vec, + pub progress: Vec, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct BrowserEsp32ProbeResult { pub chip_name: Option, @@ -68,6 +82,23 @@ extern "C" { esptool_module_path: &str, on_event: &Function, ) -> Promise; + + #[wasm_bindgen(js_name = readRawFilesystem)] + fn js_read_raw_filesystem( + port_id: u32, + esptool_module_path: &str, + resolve_region: &Function, + on_event: &Function, + ) -> Promise; + + #[wasm_bindgen(js_name = writeBootControl)] + fn js_write_boot_control( + port_id: u32, + esptool_module_path: &str, + address: u32, + record: &[u8], + on_event: &Function, + ) -> Promise; } pub fn is_supported() -> bool { @@ -125,6 +156,90 @@ pub async fn erase_device_flash_with_events( }) } +/// Write the boot-control record, instructing the device's next boot. +/// +/// The record is encoded here, in Rust, and handed to JS as bytes — the +/// firmware that reads it cannot renegotiate the format at runtime, so +/// `lp-bootctl` stays the single implementation of it. +pub async fn write_boot_control_with_events( + port_id: u32, + esptool_module_path: &str, + flags: lp_bootctl::BootFlags, + events: LinkManagementEventSink, +) -> Result { + let on_event = management_event_callback(events); + let record = lp_bootctl::encode_record(flags); + let value = JsFuture::from(js_write_boot_control( + port_id, + esptool_module_path, + lp_bootctl::BOOTCTL_PARTITION_OFFSET, + &record, + on_event.as_ref().unchecked_ref(), + )) + .await + .map_err(js_error)?; + Ok(BrowserEsp32EraseResult { + chip_name: reflect_optional_string(&value, "chipName")?, + logs: reflect_string_array(&value, "logs")?, + progress: reflect_progress_array(&value, "progress")?, + }) +} + +/// Read the device's `lpfs` partition back into wasm memory. +/// +/// The per-board region table stays HERE: the JS side asks for it by chip +/// name once its SYNC handshake has one (see the `resolveRegion` callback in +/// `browser_esp32_flash.js`). Mirroring the offsets into JS would put the +/// same two numbers in two languages, and the wrong one produces a +/// plausible-looking archive of the wrong partition. +pub async fn read_raw_filesystem_with_events( + port_id: u32, + esptool_module_path: &str, + events: LinkManagementEventSink, +) -> Result { + let on_event = management_event_callback(events); + let resolve_region = Closure::wrap(Box::new(|chip: JsValue| -> JsValue { + let Some(region) = chip + .as_string() + .as_deref() + .and_then(LinkFlashRegion::lpfs_for_chip) + else { + return JsValue::NULL; + }; + let out = js_sys::Object::new(); + let _ = Reflect::set( + &out, + &"offset".into(), + &JsValue::from_f64(region.offset.into()), + ); + let _ = Reflect::set( + &out, + &"length".into(), + &JsValue::from_f64(region.length.into()), + ); + out.into() + }) as Box JsValue>); + + let value = JsFuture::from(js_read_raw_filesystem( + port_id, + esptool_module_path, + resolve_region.as_ref().unchecked_ref(), + on_event.as_ref().unchecked_ref(), + )) + .await + .map_err(js_error)?; + Ok(BrowserEsp32FilesystemReadResult { + image: js_sys::Uint8Array::new(&reflect_value(&value, "image")?).to_vec(), + region: LinkFlashRegion { + offset: reflect_u32(&value, "offset")?, + length: reflect_u32(&value, "length")?, + }, + chip_name: reflect_optional_string(&value, "chipName")?, + logs: reflect_string_array(&value, "logs")?, + progress: reflect_progress_array(&value, "progress")?, + }) +} + pub async fn probe_target( port_id: u32, esptool_module_path: &str, diff --git a/lp-app/lpa-link/src/providers/browser_serial_esp32/mod.rs b/lp-app/lpa-link/src/providers/browser_serial_esp32/mod.rs index 5af33c776..f39f9aacd 100644 --- a/lp-app/lpa-link/src/providers/browser_serial_esp32/mod.rs +++ b/lp-app/lpa-link/src/providers/browser_serial_esp32/mod.rs @@ -4,8 +4,8 @@ mod browser_serial_esp32_options; mod provider; pub use browser_esp32_flash::{ - BrowserEsp32EraseResult, BrowserEsp32FirmwareManifest, BrowserEsp32FlashProgress, - BrowserEsp32FlashResult, BrowserEsp32ProbeResult, + BrowserEsp32EraseResult, BrowserEsp32FilesystemReadResult, BrowserEsp32FirmwareManifest, + BrowserEsp32FlashProgress, BrowserEsp32FlashResult, BrowserEsp32ProbeResult, }; pub use browser_serial::{BrowserSerialPortHandle, granted_ports, install_serial_events}; pub use browser_serial_esp32_options::{ diff --git a/lp-app/lpa-link/src/providers/browser_serial_esp32/provider.rs b/lp-app/lpa-link/src/providers/browser_serial_esp32/provider.rs index c69b2608b..87d9bb3fa 100644 --- a/lp-app/lpa-link/src/providers/browser_serial_esp32/provider.rs +++ b/lp-app/lpa-link/src/providers/browser_serial_esp32/provider.rs @@ -5,6 +5,7 @@ use crate::provider::endpoint::{LinkEndpointId, LinkEndpointStatus}; use crate::provider::management_request::LinkManagementRequest; use crate::provider::management_result::{ LinkEraseDeviceResult, LinkFirmwareFlashResult, LinkFirmwareManifest, LinkManagementResult, + LinkRawFilesystemReadResult, }; use crate::provider::session::LinkSessionId; use crate::providers::browser_serial_esp32::BrowserSerialEsp32Options; @@ -14,9 +15,9 @@ use crate::providers::browser_serial_esp32::{ }; use crate::providers::{LinkProviderDescriptor, LinkProviderKind}; use crate::{ - LinkCapabilities, LinkConnection, LinkConnectionKind, LinkDiagnostic, LinkDiagnosticSeverity, - LinkEndpoint, LinkError, LinkLogEntry, LinkLogLevel, LinkManagementEventSink, - LinkManagementProgress, LinkProvider, LinkSession, LinkSessionStatus, + LinkBootControlResult, LinkCapabilities, LinkConnection, LinkConnectionKind, LinkDiagnostic, + LinkDiagnosticSeverity, LinkEndpoint, LinkError, LinkLogEntry, LinkLogLevel, + LinkManagementEventSink, LinkManagementProgress, LinkProvider, LinkSession, LinkSessionStatus, }; const RESET_BAUD_RATE: u32 = 115_200; @@ -71,7 +72,13 @@ impl BrowserSerialEsp32Provider { let mut capabilities = LinkCapabilities::esp32_serial_base(); if self.is_flash_supported() { - capabilities = capabilities.with_flash().with_device_erase(); + capabilities = capabilities + .with_flash() + .with_device_erase() + .with_boot_control() + // READ only (M6): restore is M7's, and advertising the write + // half early would surface a button that answers `unsupported`. + .with_raw_filesystem_read(); } let endpoint = LinkEndpoint::new(endpoint_id.clone(), self.kind(), label) .with_capabilities(capabilities); @@ -311,6 +318,67 @@ impl BrowserSerialEsp32Provider { self.extend_session_logs(session_id, logs)?; Ok(LinkManagementResult::ResetRuntime) } + LinkManagementRequest::SetBootControl { flags } => { + let port_id = self.endpoint_port_id(&endpoint_id)?; + let result = browser_esp32_flash::write_boot_control_with_events( + port_id, + self.options.esptool_module_path(), + lp_bootctl::BootFlags::from_bits(flags), + events.clone(), + ) + .await?; + let logs = result + .logs + .iter() + .map(|message| { + LinkLogEntry::new( + endpoint_id.clone(), + Some(session_id.clone()), + LinkLogLevel::Info, + message.clone(), + ) + }) + .collect::>(); + self.extend_session_logs(session_id, logs)?; + Ok(LinkManagementResult::SetBootControl( + LinkBootControlResult { + flags, + chip_name: result.chip_name, + logs: result.logs, + progress: map_progress(result.progress), + }, + )) + } + LinkManagementRequest::ReadRawFilesystem => { + let result = browser_esp32_flash::read_raw_filesystem_with_events( + port_id, + self.options.esptool_module_path(), + events.clone(), + ) + .await?; + let logs = result + .logs + .iter() + .map(|message| { + LinkLogEntry::new( + endpoint_id.clone(), + Some(session_id.clone()), + LinkLogLevel::Info, + message.clone(), + ) + }) + .collect::>(); + self.extend_session_logs(session_id, logs)?; + Ok(LinkManagementResult::ReadRawFilesystem( + LinkRawFilesystemReadResult { + image: result.image, + region: result.region, + chip_name: result.chip_name, + logs: result.logs, + progress: map_progress(result.progress), + }, + )) + } LinkManagementRequest::EraseRawFilesystem => { Err(LinkError::unsupported(format!("{:?}", request.operation()))) } @@ -370,6 +438,18 @@ impl BrowserSerialEsp32Provider { .map(|state| state.endpoint.id.clone()) } + /// Session-scoped [`Self::probe_target`], for the connector's + /// mode-detection escalation. Releases the app-protocol port first: the + /// SYNC handshake needs the wire to itself and reboots the device. + pub async fn probe_target_for_session( + &self, + session_id: &LinkSessionId, + ) -> Result { + self.release_protocol_if_open(session_id).await?; + let port_id = self.session_port_id(session_id)?; + browser_esp32_flash::probe_target(port_id, self.options.esptool_module_path()).await + } + fn session_port_id(&self, session_id: &LinkSessionId) -> Result { let sessions = self.sessions.borrow(); Ok(session_state(&sessions, session_id)?.port_id) diff --git a/lp-app/lpa-link/src/providers/fake/provider.rs b/lp-app/lpa-link/src/providers/fake/provider.rs index 8b7ab35dc..db74f85a5 100644 --- a/lp-app/lpa-link/src/providers/fake/provider.rs +++ b/lp-app/lpa-link/src/providers/fake/provider.rs @@ -121,7 +121,9 @@ impl FakeProvider { .with_capabilities( LinkCapabilities::esp32_serial_base() .with_flash() - .with_device_erase(), + .with_device_erase() + .with_boot_control() + .with_raw_filesystem_read(), ), ); self.devices.insert( @@ -162,6 +164,35 @@ impl FakeProvider { Ok(core::mem::take(&mut *lines)) } + /// Scripted bootloader probe. + /// + /// Mirrors the real providers' contract: `Ok(Some(chip))` when a + /// bootloader answers, `Err` when nothing does — and `Err` is NOT proof + /// the device is absent, only that the handshake went unanswered. The + /// fake reports a bootloader whenever its scripted device is in a + /// no-firmware state, which is what a real board in download mode does. + pub async fn probe_target( + &self, + session_id: &LinkSessionId, + ) -> Result, LinkError> { + #[cfg(feature = "fake-device")] + { + let endpoint_id = { + let sessions = self.sessions.borrow(); + sessions + .get(session_id) + .ok_or_else(|| LinkError::session_not_found(session_id.as_str()))? + .endpoint_id + .clone() + }; + if self.devices.contains_key(&endpoint_id) { + return Ok(Some("ESP32-C6 (fake)".to_string())); + } + } + let _ = session_id; + Err(LinkError::unsupported("probe_target")) + } + fn endpoint(&self, endpoint_id: &LinkEndpointId) -> Result<&LinkEndpoint, LinkError> { self.endpoints .iter() @@ -411,8 +442,9 @@ fn manage_fake_device( ) -> Result { use crate::providers::fake_device::FAKE_IMAGE_IDENTITY; use crate::{ - LinkEraseDeviceResult, LinkFirmwareFlashResult, LinkFirmwareManifest, - LinkManagementProgress, LinkManagementRequest, LinkManagementResult, + LinkBootControlResult, LinkEraseDeviceResult, LinkFirmwareFlashResult, + LinkFirmwareManifest, LinkFlashRegion, LinkManagementProgress, LinkManagementRequest, + LinkManagementResult, LinkRawFilesystemReadResult, }; match request { @@ -451,12 +483,83 @@ fn manage_fake_device( }, )) } + LinkManagementRequest::SetBootControl { flags } => { + // The fake device has no flash; echoing the flags is enough to + // exercise dispatch, capability gating, and the UX arm without + // hardware. The record's *encoding* is covered by lp-bootctl's + // golden vector, not here. + Ok(LinkManagementResult::SetBootControl( + LinkBootControlResult { + flags, + chip_name: Some("ESP32-C6 (fake)".to_string()), + logs: vec![format!( + "fake boot-control: recorded flags {flags:#010x} for the next boot" + )], + progress: vec![ + LinkManagementProgress::new("Writing boot-control record") + .with_percent(100), + ], + }, + )) + } + LinkManagementRequest::ReadRawFilesystem => { + let files = fake_device_files(device); + let region = LinkFlashRegion::lpfs_for_chip(FAKE_CHIP_NAME) + .expect("the fake device presents as a C6"); + let image = + crate::providers::fake_device::fake_filesystem_image::build_image(region, &files); + Ok(LinkManagementResult::ReadRawFilesystem( + LinkRawFilesystemReadResult { + logs: vec![format!( + "fake filesystem read: {} bytes at {:#x}", + image.len(), + region.offset + )], + progress: vec![ + LinkManagementProgress::new("Reading filesystem") + .with_steps(region.length, region.length) + .with_percent(100), + ], + image, + region, + chip_name: Some(FAKE_CHIP_NAME.to_string()), + }, + )) + } LinkManagementRequest::EraseRawFilesystem => { Err(LinkError::unsupported(format!("{:?}", request.operation()))) } } } +/// What the scripted device answers to a chip probe. Kept as one constant so +/// the fake's flash-region lookup and its result payloads cannot disagree. +#[cfg(feature = "fake-device")] +const FAKE_CHIP_NAME: &str = "ESP32-C6 (fake)"; + +/// The device's storage as absolute paths: its project files under the +/// scripted project dir, plus the root-level identity stamp — the same two +/// things `finish_light_player_boot` seeds into the fake server's memory fs. +#[cfg(feature = "fake-device")] +fn fake_device_files( + device: &crate::providers::fake_device::FakeEsp32Device, +) -> Vec<(String, Vec)> { + let Some(state) = device.light_player_state() else { + return Vec::new(); + }; + let mut files: Vec<(String, Vec)> = state + .project_files + .iter() + .map(|(relative, bytes)| (format!("{}/{relative}", state.project_dir), bytes.clone())) + .collect(); + if let Some(identity) = &state.identity + && let Ok(json) = lpc_wire::json::to_string(identity) + { + files.push((fw_host::DEVICE_IDENTITY_PATH.to_string(), json.into_bytes())); + } + files +} + /// Resources built when a device-backed endpoint connects. #[cfg(feature = "fake-device")] struct FakeDeviceSessionResources { diff --git a/lp-app/lpa-link/src/providers/fake_device/fake_device_core.rs b/lp-app/lpa-link/src/providers/fake_device/fake_device_core.rs index 06c0f3d66..866b77cca 100644 --- a/lp-app/lpa-link/src/providers/fake_device/fake_device_core.rs +++ b/lp-app/lpa-link/src/providers/fake_device/fake_device_core.rs @@ -100,6 +100,17 @@ impl FakeEsp32Device { self.lock().reset_current(); } + /// The scripted LightPlayer state, when the device is in that boot + /// state. Backs the fake raw-filesystem read: the image it returns holds + /// the same files the fake server serves, so a backup taken through the + /// fake contains what the device actually "has". + pub(crate) fn light_player_state(&self) -> Option { + match &self.lock().script.boot { + FakeBootState::LightPlayer(state) => Some(state.clone()), + _ => None, + } + } + /// Consume the scripted one-shot manage failure, if any. pub(crate) fn take_manage_failure(&self) -> Option { self.lock().script.manage_failure.take() diff --git a/lp-app/lpa-link/src/providers/fake_device/fake_filesystem_image.rs b/lp-app/lpa-link/src/providers/fake_device/fake_filesystem_image.rs new file mode 100644 index 000000000..0f3ff1859 --- /dev/null +++ b/lp-app/lpa-link/src/providers/fake_device/fake_filesystem_image.rs @@ -0,0 +1,63 @@ +//! A real littlefs image of the scripted device's storage. +//! +//! The fake provider's raw-filesystem read hands back an image the *actual* +//! parser can mount, rather than a placeholder blob. That is what makes the +//! studio-side backup flow testable without hardware: a fake that returned +//! arbitrary bytes would exercise dispatch and nothing else, and the failure +//! it would hide — an image that does not mount — is the whole operation. +//! +//! The geometry mirrors `fw-esp32c6/src/flash_storage.rs` (4 KB blocks, 512 B +//! cache, 64 B lookahead) at the C6's `lpfs` size, because the scripted device +//! presents itself as a C6. + +use littlefs_rust::{Config, Filesystem, RamStorage}; + +use crate::LinkFlashRegion; + +/// littlefs geometry, matching the firmware's `lpfs_config()`. +const BLOCK_SIZE: u32 = 4096; +const CACHE_SIZE: u32 = 512; +const LOOKAHEAD_SIZE: u32 = 64; + +/// Format a fresh image at `region`'s geometry and write `files` into it. +/// +/// Paths are absolute device paths (`/projects/studio/project.json`); parent +/// directories are created as needed. Returns the raw partition bytes. +pub(crate) fn build_image(region: LinkFlashRegion, files: &[(String, Vec)]) -> Vec { + let block_count = region.block_count(BLOCK_SIZE); + let mut storage = RamStorage::new(BLOCK_SIZE, block_count); + let config = image_config(block_count); + Filesystem::format(&mut storage, &config).expect("format the fake lpfs image"); + let fs = Filesystem::mount(storage, config) + .map_err(|(error, _)| error) + .expect("mount the fake lpfs image"); + for (path, bytes) in files { + create_parents(&fs, path); + fs.write_file(path, bytes) + .unwrap_or_else(|error| panic!("seed {path} into the fake lpfs image: {error:?}")); + } + let storage = fs.unmount().expect("unmount the fake lpfs image"); + storage.data().to_vec() +} + +fn image_config(block_count: u32) -> Config { + let mut config = Config::new(BLOCK_SIZE, block_count); + config.cache_size = CACHE_SIZE; + config.lookahead_size = LOOKAHEAD_SIZE; + config +} + +/// `mkdir -p` for the file's parents. littlefs has no recursive create, and +/// an existing directory is not an error worth distinguishing here. +fn create_parents(fs: &Filesystem, path: &str) { + let mut prefix = String::new(); + let mut segments = path.trim_start_matches('/').split('/').peekable(); + while let Some(segment) = segments.next() { + if segments.peek().is_none() { + break; + } + prefix.push('/'); + prefix.push_str(segment); + let _ = fs.mkdir(&prefix); + } +} diff --git a/lp-app/lpa-link/src/providers/fake_device/mod.rs b/lp-app/lpa-link/src/providers/fake_device/mod.rs index 9c6bf1ee0..db2b67e8c 100644 --- a/lp-app/lpa-link/src/providers/fake_device/mod.rs +++ b/lp-app/lpa-link/src/providers/fake_device/mod.rs @@ -25,6 +25,7 @@ pub mod failure_injection; pub mod fake_device_core; pub mod fake_device_script; pub mod fake_device_stream; +pub(crate) mod fake_filesystem_image; pub use failure_injection::FakeFailurePlan; pub use fake_device_core::FakeEsp32Device; diff --git a/lp-app/lpa-link/src/providers/host_serial_esp32/host_esp32_flash.rs b/lp-app/lpa-link/src/providers/host_serial_esp32/host_esp32_flash.rs index ab50b461e..a3d9cc528 100644 --- a/lp-app/lpa-link/src/providers/host_serial_esp32/host_esp32_flash.rs +++ b/lp-app/lpa-link/src/providers/host_serial_esp32/host_esp32_flash.rs @@ -16,15 +16,20 @@ use std::path::{Path, PathBuf}; use std::time::Duration; +use espflash::command::{Command, CommandType}; use espflash::connection::reset::{ResetAfterOperation, ResetBeforeOperation}; use espflash::flasher::{Flasher, ProgressCallbacks}; use espflash::targets::Chip; +use md5::Digest; use serde::Deserialize; use serialport::{SerialPort, SerialPortType, UsbPortInfo}; +use lp_bootctl::{BOOTCTL_PARTITION_OFFSET, BOOTCTL_PARTITION_SIZE, BootFlags, encode_record}; + use crate::{ - LinkEraseDeviceResult, LinkError, LinkFirmwareFlashResult, LinkFirmwareManifest, - LinkManagementEvent, LinkManagementEventSink, LinkManagementProgress, + LinkBootControlResult, LinkEraseDeviceResult, LinkError, LinkFirmwareFlashResult, + LinkFirmwareManifest, LinkFlashRegion, LinkManagementEvent, LinkManagementEventSink, + LinkManagementProgress, LinkRawFilesystemReadResult, }; /// Chip this provider flashes. The firmware package targets the ESP32-C6 @@ -36,6 +41,12 @@ const TARGET_CHIP: Chip = Chip::Esp32c6; /// matches the browser provider; espflash negotiates faster stub baud itself. const CONNECT_BAUD: u32 = 115_200; +/// `ESP_READ_FLASH` packet size and in-flight window. Both are the values the +/// M1 spike measured on hardware; the stub is already running by the time we +/// read (`Flasher::connect` uploads it), so this is the fast path. +const READ_BLOCK_SIZE: u32 = 4096; +const READ_MAX_IN_FLIGHT: u32 = 1024; + /// Flash firmware from the merged-image manifest at `manifest_path` over the /// serial port `port_name`. Emits live progress into `events` and returns the /// accumulated result (same shape as the browser provider's). @@ -115,6 +126,222 @@ pub(super) fn erase_device_flash( }) } +/// Write the boot-control sector, instructing the device's next boot. +/// +/// **One write, not several.** `write_bin_to_flash` issues `FLASH_BEGIN`, +/// which erases the sectors it is about to write — so splitting the 16-byte +/// record across two writes would have the second erase the first, leaving a +/// record that always fails its CRC and a feature that silently never works. +/// The explicit `erase_region` below is belt-and-braces for the rest of the +/// sector; integrity of the record itself comes from its magic and CRC. +pub(super) fn write_boot_control( + port_name: &str, + flags: BootFlags, + events: &LinkManagementEventSink, +) -> Result { + let mut recorder = EventRecorder::new(events); + recorder.log(format!( + "Writing boot-control record (flags {:#010x})", + flags.bits() + )); + + let mut flasher = connect(port_name, &mut recorder)?; + let chip_name = chip_name(&mut flasher); + + recorder.progress(LinkManagementProgress::new("Erasing boot-control sector")); + flasher + .erase_region(BOOTCTL_PARTITION_OFFSET, BOOTCTL_PARTITION_SIZE) + .map_err(|error| LinkError::other(format!("boot-control erase failed: {error}")))?; + + recorder.progress(LinkManagementProgress::new("Writing boot-control record")); + flasher + .write_bin_to_flash(BOOTCTL_PARTITION_OFFSET, &encode_record(flags), None) + .map_err(|error| LinkError::other(format!("boot-control write failed: {error}")))?; + recorder.progress(LinkManagementProgress::new("Writing boot-control record").with_percent(100)); + + reset_into_app(&mut flasher, &mut recorder); + recorder.log("Boot-control record written; it applies on the next restart"); + + Ok(LinkBootControlResult { + flags: flags.bits(), + chip_name, + logs: recorder.logs.clone(), + progress: recorder.progress.clone(), + }) +} + +/// Read the device's `lpfs` partition back to the host, verbatim. +/// +/// The region is resolved from the chip the SYNC handshake names, never +/// hardcoded: the C6 and S3 put `lpfs` in different places, and a backup of +/// the wrong 960 KB looks exactly like a backup of the right one. +/// +/// **Default baud, deliberately.** These parts speak USB-Serial-JTAG, where +/// the baud parameter is meaningless and negotiating a higher one costs real +/// time — measured on the bench (M1): 3.2 s at the default versus 4.2 s at +/// 921600 for the same 960 KB. Do not "optimize" this by raising it. +/// +/// The read is acked per packet, so progress is genuinely per-block rather +/// than a spinner: 240 packets for a C6's partition. +pub(super) fn read_raw_filesystem( + port_name: &str, + events: &LinkManagementEventSink, +) -> Result { + let mut recorder = EventRecorder::new(events); + let mut flasher = connect(port_name, &mut recorder)?; + let chip_name = chip_name(&mut flasher); + let region = chip_name + .as_deref() + .and_then(LinkFlashRegion::lpfs_for_chip) + .ok_or_else(|| { + LinkError::other(format!( + "no lpfs partition layout for chip {}", + chip_name.as_deref().unwrap_or("(unidentified)") + )) + })?; + recorder.log(format!( + "Reading {} bytes of filesystem at {:#x}", + region.length, region.offset + )); + + let image = read_flash_region(&mut flasher, region, &mut recorder)?; + reset_into_app(&mut flasher, &mut recorder); + recorder.log("Filesystem read complete"); + + Ok(LinkRawFilesystemReadResult { + image, + region, + chip_name, + logs: recorder.logs.clone(), + progress: recorder.progress.clone(), + }) +} + +/// Drive `ESP_READ_FLASH` over an established connection, acking each packet +/// and reporting progress, and verify the trailing MD5 the device sends. +/// +/// espflash's own `Flasher::read_flash` writes straight to a file and reports +/// nothing, neither of which suits a browser-shaped operation whose whole UX +/// problem is looking hung; this keeps the bytes in memory and narrates. +fn read_flash_region( + flasher: &mut Flasher, + region: LinkFlashRegion, + recorder: &mut EventRecorder, +) -> Result, LinkError> { + let label = "Reading filesystem"; + recorder.progress( + LinkManagementProgress::new(label) + .with_steps(0, region.length) + .with_percent(0), + ); + + let connection = flasher.connection(); + connection + .with_timeout(CommandType::ReadFlash.timeout(), |connection| { + connection.command(Command::ReadFlash { + offset: region.offset, + size: region.length, + block_size: READ_BLOCK_SIZE, + max_in_flight: READ_MAX_IN_FLIGHT, + }) + }) + .map_err(|error| LinkError::other(format!("filesystem read failed to start: {error}")))?; + + let total = region.length as usize; + let mut image: Vec = Vec::with_capacity(total); + while image.len() < total { + let chunk = read_vector_response(connection, "filesystem data")?; + // A short packet before the end means the device stopped mid-stream; + // a silently truncated backup is the worst possible outcome here. + if image.len() + chunk.len() < total && chunk.len() < READ_BLOCK_SIZE as usize { + return Err(LinkError::other(format!( + "filesystem read truncated at {} of {total} bytes", + image.len() + chunk.len() + ))); + } + image.extend_from_slice(&chunk); + // The device waits for the running total before sending more. + connection + .write_raw(image.len() as u32) + .map_err(|error| LinkError::other(format!("filesystem read ack failed: {error}")))?; + recorder.progress( + LinkManagementProgress::new(label) + .with_steps(image.len().min(total) as u32, region.length) + .with_percent(((image.len().min(total) as u64 * 100) / total as u64) as u32), + ); + } + if image.len() > total { + return Err(LinkError::other(format!( + "filesystem read returned {} bytes, expected {total}", + image.len() + ))); + } + + let digest = read_vector_response(connection, "filesystem digest")?; + let mut hasher = md5::Md5::new(); + hasher.update(&image); + if digest != hasher.finalize().as_slice() { + return Err(LinkError::other( + "filesystem read failed its checksum — the image is not trustworthy", + )); + } + recorder.progress( + LinkManagementProgress::new(label) + .with_steps(region.length, region.length) + .with_percent(100), + ); + Ok(image) +} + +/// One `Vector` response from the flash-read stream. +fn read_vector_response( + connection: &mut espflash::connection::Connection, + what: &str, +) -> Result, LinkError> { + let response = connection + .read_response() + .map_err(|error| LinkError::other(format!("{what} read failed: {error}")))? + .ok_or_else(|| LinkError::other(format!("{what}: the device stopped responding")))?; + match response.value { + espflash::connection::CommandResponseValue::Vector(bytes) => Ok(bytes), + other => Err(LinkError::other(format!( + "{what}: unexpected response {other:?}" + ))), + } +} + +/// Ask the device whether a ROM/stub bootloader is listening, and which chip +/// it is. +/// +/// This is the **authoritative** bootloader-mode test: `connect` performs the +/// esptool SYNC handshake, which only a bootloader answers. Enumeration data +/// cannot substitute — USB-Serial-JTAG parts present the same VID/PID in app +/// mode and download mode. +/// +/// **It reboots the device.** `connect` drives DTR/RTS to enter download +/// mode, and on USB-Serial-JTAG that reset drops USB enumeration. Callers +/// must own the wire exclusively and rebuild the link afterwards; never run +/// this speculatively against a healthy board. +/// +/// `Ok(None)` means "answered, but would not name itself" — still a +/// bootloader. `Err` means nothing answered, which is *not* proof the device +/// is absent; it may be running the app. +pub(super) fn probe_target( + port_name: &str, + events: &LinkManagementEventSink, +) -> Result, LinkError> { + let mut recorder = EventRecorder::new(events); + recorder.log(format!("Probing {port_name} for a bootloader")); + let mut flasher = connect(port_name, &mut recorder)?; + let chip_name = chip_name(&mut flasher); + reset_into_app(&mut flasher, &mut recorder); + recorder.log(match &chip_name { + Some(name) => format!("Bootloader answered: {name}"), + None => "Bootloader answered (chip did not identify itself)".to_string(), + }); + Ok(chip_name) +} + /// Reboot the device into its application firmware via a hard-reset signal /// pulse — no bootloader entry. Returns the emitted log lines. pub(super) fn reset_runtime( diff --git a/lp-app/lpa-link/src/providers/host_serial_esp32/provider.rs b/lp-app/lpa-link/src/providers/host_serial_esp32/provider.rs index 48af6cd83..cfd94be7c 100644 --- a/lp-app/lpa-link/src/providers/host_serial_esp32/provider.rs +++ b/lp-app/lpa-link/src/providers/host_serial_esp32/provider.rs @@ -158,6 +158,33 @@ impl HostSerialEsp32Provider { Ok(core::mem::take(&mut *lines)) } + /// Ask whether a bootloader is listening on this session's port, and + /// which chip it is. + /// + /// Not capability-gated: probing is a *question*, not an operation on + /// the device, and the answer is what tells callers which operations are + /// available at all. It still needs exclusive ownership of the wire, + /// because the SYNC handshake reboots the device. + pub async fn probe_target( + &self, + session_id: &LinkSessionId, + events: LinkManagementEventSink, + ) -> Result, LinkError> { + let port_name = self.session_port(session_id)?; + self.release_transport_if_open(session_id).await?; + host_esp32_flash::probe_target(&port_name, &events) + } + + /// Resolve a session's port without gating on a capability. + fn session_port(&self, session_id: &LinkSessionId) -> Result { + let state = self.state(); + let session = state + .sessions + .get(session_id) + .ok_or_else(|| LinkError::session_not_found(session_id.as_str()))?; + Ok(session.port_name.clone()) + } + /// Capability-gate a management request and resolve the session's port. fn session_manage_port( &self, @@ -256,6 +283,20 @@ impl HostSerialEsp32Provider { self.extend_session_logs(session_id, &logs)?; Ok(LinkManagementResult::ResetRuntime) } + LinkManagementRequest::SetBootControl { flags } => { + let result = host_esp32_flash::write_boot_control( + &port_name, + lp_bootctl::BootFlags::from_bits(flags), + &events, + )?; + self.extend_session_logs(session_id, &result.logs)?; + Ok(LinkManagementResult::SetBootControl(result)) + } + LinkManagementRequest::ReadRawFilesystem => { + let result = host_esp32_flash::read_raw_filesystem(&port_name, &events)?; + self.extend_session_logs(session_id, &result.logs)?; + Ok(LinkManagementResult::ReadRawFilesystem(result)) + } LinkManagementRequest::EraseRawFilesystem => { Err(LinkError::unsupported(format!("{:?}", request.operation()))) } @@ -473,7 +514,11 @@ fn upsert_port_endpoint( let endpoint = LinkEndpoint::new(endpoint_id.clone(), kind, label).with_capabilities( LinkCapabilities::esp32_serial_base() .with_flash() - .with_device_erase(), + .with_device_erase() + .with_boot_control() + // READ only (M6). The write half is M7's; advertising it now + // would let the UI offer an operation that answers `unsupported`. + .with_raw_filesystem_read(), ); if let Some(existing) = state diff --git a/lp-app/lpa-link/src/registry/connector.rs b/lp-app/lpa-link/src/registry/connector.rs index 05627c9a1..5c6ab64c5 100644 --- a/lp-app/lpa-link/src/registry/connector.rs +++ b/lp-app/lpa-link/src/registry/connector.rs @@ -62,6 +62,45 @@ impl LinkConnector { _ => None, } } + + /// Ask whether a ROM/stub bootloader is listening, and which chip it is. + /// + /// **Reboots the device** — the esptool SYNC handshake drives DTR/RTS to + /// enter download mode, and on USB-Serial-JTAG that reset drops USB + /// enumeration. Callers must hold the wire exclusively and rebuild + /// afterwards; `DeviceSession::probe_link_mode` does both. + /// + /// `Ok(None)` = a bootloader answered but did not name its chip. + /// `Err` = nothing answered, which does NOT prove the device is absent — + /// it may simply be running the app. + /// + /// Providers with no bootloader concept (sim runtimes, host processes) + /// report this as unsupported rather than pretending to answer. + pub(crate) async fn probe_target( + &self, + session_id: &LinkSessionId, + events: LinkManagementEventSink, + ) -> Result, LinkError> { + // Only the host provider surfaces probe progress as management + // events; the browser provider collects its logs into the probe + // result, and the rest have no bootloader to probe. Which arms exist + // is feature-dependent, so bind it unconditionally. + let _ = &events; + match self { + Self::Fake(provider) => provider.probe_target(session_id).await, + #[cfg(feature = "host-process")] + Self::HostProcess(_) => Err(LinkError::unsupported("probe_target")), + #[cfg(feature = "host-serial-esp32")] + Self::HostSerialEsp32(provider) => provider.probe_target(session_id, events).await, + #[cfg(all(feature = "browser-worker", target_arch = "wasm32"))] + Self::BrowserWorker(_) => Err(LinkError::unsupported("probe_target")), + #[cfg(all(feature = "browser-serial-esp32", target_arch = "wasm32"))] + Self::BrowserSerialEsp32(provider) => provider + .probe_target_for_session(session_id) + .await + .map(|result| result.chip_name), + } + } } impl LinkProvider for LinkConnector { diff --git a/lp-app/lpa-server/src/recovery_report.rs b/lp-app/lpa-server/src/recovery_report.rs index b8ca0cd6f..02436acd4 100644 --- a/lp-app/lpa-server/src/recovery_report.rs +++ b/lp-app/lpa-server/src/recovery_report.rs @@ -45,6 +45,9 @@ pub fn recovery_status_from_snapshot(snapshot: &RecoverySnapshot) -> RecoverySta reset_reason: snapshot.reset_cause.as_str().to_string(), boot_count: snapshot.boot_count, safe_mode: snapshot.safe_mode, + // The clamp is server state, not recovery-region state; the + // heartbeat assembly fills it in (see fw server_loop). + output_clamp: None, last_crash, paths, } diff --git a/lp-app/lpa-server/src/server.rs b/lp-app/lpa-server/src/server.rs index 20953bf04..b5127831e 100644 --- a/lp-app/lpa-server/src/server.rs +++ b/lp-app/lpa-server/src/server.rs @@ -37,6 +37,12 @@ pub struct LpServer { base_fs: Box, /// Last frame processing time in microseconds (for theoretical FPS calculation) last_frame_time_us: RefCell>, + /// Device-level safe-mode output ceiling (0..=255, `None` = no clamp). + /// + /// DEVICE state set by the embedder (firmware, from a consumed + /// boot-control record) — deliberately not project data, and applied to + /// every engine this server creates, present and future. + safe_output_clamp: Option, /// Optional memory stats callback for logging (ESP32 passes impl, others pass None) memory_stats: Option, /// Optional time provider for perf timing (e.g. shader comp). ESP32/emu pass, others None. @@ -160,6 +166,7 @@ impl LpServer { project_manager, base_fs, last_frame_time_us: RefCell::new(None), + safe_output_clamp: None, memory_stats, time_provider, button_service, @@ -518,7 +525,7 @@ impl LpServer { &mut self, path: &lpfs::lp_path::LpPath, ) -> Result { - self.project_manager.load_project( + let handle = self.project_manager.load_project( path, &mut *self.base_fs, self.output_provider.clone(), @@ -527,7 +534,38 @@ impl LpServer { self.button_service.clone(), self.radio_service.clone(), self.graphics.clone(), - ) + )?; + // The clamp is device state: every engine wears it, including this + // freshly created one. + if let Some(project) = self.project_manager.get_project_mut(handle) { + project + .engine_mut() + .set_safe_output_clamp(self.safe_output_clamp); + } + Ok(handle) + } + + /// Set (or clear) the device-level safe-mode output ceiling and apply it + /// to every loaded project's engine. Future loads inherit it too. + pub fn set_safe_output_clamp(&mut self, level: Option) { + self.safe_output_clamp = level; + let handles: alloc::vec::Vec<_> = self + .project_manager + .list_loaded_projects() + .into_iter() + .map(|loaded| loaded.handle) + .collect(); + for handle in handles { + if let Some(project) = self.project_manager.get_project_mut(handle) { + project.engine_mut().set_safe_output_clamp(level); + } + } + } + + /// The active device-level safe-mode output ceiling, for heartbeat + /// reporting (clients surface the safe-mode state and its exit). + pub fn safe_output_clamp(&self) -> Option { + self.safe_output_clamp } /// Set the last frame processing time (called by server loop) diff --git a/lp-app/lpa-studio-core/Cargo.toml b/lp-app/lpa-studio-core/Cargo.toml index 925d16e37..e63926a33 100644 --- a/lp-app/lpa-studio-core/Cargo.toml +++ b/lp-app/lpa-studio-core/Cargo.toml @@ -19,6 +19,11 @@ lpc-history = { path = "../../lp-core/lpc-history", default-features = false } lpc-model = { path = "../../lp-core/lpc-model" } lpc-view = { path = "../../lp-core/lpc-view" } lpc-wire = { path = "../../lp-core/lpc-wire" } +# Mounts raw `lpfs` images in wasm for the device filesystem backup (see +# `app/device/filesystem_backup`). Pure Rust, so the browser can do it. +littlefs-rust = { version = "0.1.0", default-features = false, features = [ + "alloc", +] } lpfs = { path = "../../lp-base/lpfs", default-features = false } lps-probe = { path = "../../lp-shader/lps-probe" } log = { workspace = true } diff --git a/lp-app/lpa-studio-core/src/app/device/bootloader_entry_flow.rs b/lp-app/lpa-studio-core/src/app/device/bootloader_entry_flow.rs new file mode 100644 index 000000000..972beee8a --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/device/bootloader_entry_flow.rs @@ -0,0 +1,209 @@ +//! Walking a user into bootloader mode, and telling them when they got there. +//! +//! The BOOT-button ritual is fiddly and easy to get subtly wrong — hold the +//! button *before* plugging in, not after; the strap is sampled at reset. +//! Without feedback, a failed attempt and a genuinely dead device look +//! identical, so people repeat the wrong motion and conclude the board is +//! bricked. **The confirmation is what makes the ritual learnable**, and it +//! is the reason this flow exists rather than a static list of steps. +//! +//! # Why this waits for an arrival instead of polling +//! +//! The authoritative test for bootloader mode is the esptool SYNC handshake, +//! and that handshake **reboots the device** (see +//! `docs/adr/2026-07-30-bootloader-mode-detection.md`). Polling it would +//! reboot a healthy board over and over, which is both destructive and +//! self-defeating: it could knock a working device *out* of the state the +//! user is trying to reach. +//! +//! So the flow is edge-triggered, not level-triggered. It waits for the +//! device to **re-enumerate** — the physical unplug/replug is part of the +//! ritual, so an arrival is exactly the moment worth probing — and probes +//! once, on that arrival. + +use super::recovery_instructions::RecoveryInstructions; + +/// Where the user is in the bootloader-entry ritual. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum BootloaderEntryFlow { + /// Showing the steps. Nothing is being probed. + Instructing { instructions: RecoveryInstructions }, + /// The user says they have done the steps; waiting for the device to + /// re-enumerate so there is an arrival worth probing. + /// + /// No probe runs in this state. A probe here would reboot whatever is + /// currently attached, which may be the very device the user just + /// carefully put into download mode. + Waiting { instructions: RecoveryInstructions }, + /// A device arrived and the SYNC probe answered. This is the payoff. + Confirmed { chip_name: Option }, + /// A device arrived, was probed, and did not answer. + /// + /// Explicitly NOT "your device is broken": an app-mode device ignores + /// SYNC too, so the honest reading is "that attempt did not land", and + /// the flow returns to the steps. + NotYet { instructions: RecoveryInstructions }, +} + +impl BootloaderEntryFlow { + /// Start the flow for whatever chip Studio knows about (see + /// [`RecoveryInstructions::for_chip`]). + pub fn start(chip_name: Option<&str>) -> Self { + Self::Instructing { + instructions: RecoveryInstructions::for_chip(chip_name), + } + } + + /// The user pressed "I've done that" — begin waiting for an arrival. + pub fn begin_waiting(self) -> Self { + match self { + Self::Instructing { instructions } + | Self::Waiting { instructions } + | Self::NotYet { instructions } => Self::Waiting { instructions }, + // Already confirmed: re-entering the ritual means starting over. + Self::Confirmed { chip_name } => Self::start(chip_name.as_deref()), + } + } + + /// A device re-enumerated and the probe answered. + pub fn on_probe_answered(self, chip_name: Option) -> Self { + Self::Confirmed { chip_name } + } + + /// A device re-enumerated and the probe went unanswered. + pub fn on_probe_unanswered(self) -> Self { + match self { + Self::Instructing { instructions } + | Self::Waiting { instructions } + | Self::NotYet { instructions } => Self::NotYet { instructions }, + Self::Confirmed { chip_name } => Self::start(chip_name.as_deref()), + } + } + + /// Whether an arrival should be probed right now. + /// + /// True **only** while waiting. This is the guard that keeps the flow + /// from rebooting healthy devices: outside `Waiting` an arrival is just + /// a device being plugged in, and probing it would be gratuitous. + pub fn should_probe_on_arrival(&self) -> bool { + matches!(self, Self::Waiting { .. }) + } + + pub fn is_confirmed(&self) -> bool { + matches!(self, Self::Confirmed { .. }) + } + + /// The steps to show, when there are any. `None` once confirmed — the + /// user is done and does not need the ritual again. + pub fn instructions(&self) -> Option<&RecoveryInstructions> { + match self { + Self::Instructing { instructions } + | Self::Waiting { instructions } + | Self::NotYet { instructions } => Some(instructions), + Self::Confirmed { .. } => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn starts_by_instructing_and_probes_nothing() { + let flow = BootloaderEntryFlow::start(Some("ESP32-C6")); + assert!(matches!(flow, BootloaderEntryFlow::Instructing { .. })); + assert!( + !flow.should_probe_on_arrival(), + "showing steps must not probe — the user has not done anything yet" + ); + assert!(flow.instructions().is_some()); + } + + #[test] + fn only_the_waiting_state_probes_arrivals() { + // The guard that stops this flow rebooting healthy boards. + let instructing = BootloaderEntryFlow::start(None); + assert!(!instructing.should_probe_on_arrival()); + + let waiting = BootloaderEntryFlow::start(None).begin_waiting(); + assert!(waiting.should_probe_on_arrival()); + + let confirmed = waiting.clone().on_probe_answered(None); + assert!( + !confirmed.should_probe_on_arrival(), + "a confirmed device must not be re-probed — that would reboot it" + ); + + let not_yet = waiting.on_probe_unanswered(); + assert!( + !not_yet.should_probe_on_arrival(), + "after a failed attempt the user must re-arm; silently re-probing \ + every replug would reboot the board repeatedly" + ); + } + + #[test] + fn a_successful_probe_confirms_and_carries_the_chip() { + let flow = BootloaderEntryFlow::start(Some("ESP32-C6")) + .begin_waiting() + .on_probe_answered(Some("ESP32-C6".to_string())); + assert!(flow.is_confirmed()); + assert_eq!( + flow, + BootloaderEntryFlow::Confirmed { + chip_name: Some("ESP32-C6".to_string()) + } + ); + assert!( + flow.instructions().is_none(), + "a confirmed user does not need the steps again" + ); + } + + #[test] + fn an_unanswered_probe_returns_to_the_steps_rather_than_declaring_failure() { + // An app-mode device ignores SYNC too, so "did not answer" is not + // "is broken". + let flow = BootloaderEntryFlow::start(Some("ESP32-S3")) + .begin_waiting() + .on_probe_unanswered(); + assert!(matches!(flow, BootloaderEntryFlow::NotYet { .. })); + assert!( + flow.instructions().is_some(), + "the user needs the steps back to try again" + ); + } + + #[test] + fn instructions_survive_a_failed_attempt() { + // Re-deriving them would be fine for a known chip, but for an unknown + // one it must not silently change what the user is reading mid-ritual. + let flow = BootloaderEntryFlow::start(None); + let original = flow.instructions().cloned().unwrap(); + let after = flow.begin_waiting().on_probe_unanswered(); + assert_eq!(after.instructions(), Some(&original)); + } + + #[test] + fn re_arming_from_waiting_is_idempotent() { + let once = BootloaderEntryFlow::start(None).begin_waiting(); + let twice = once.clone().begin_waiting(); + assert_eq!(once, twice); + } + + #[test] + fn re_entering_after_confirmation_starts_the_ritual_over() { + let confirmed = BootloaderEntryFlow::start(Some("ESP32-C6")) + .begin_waiting() + .on_probe_answered(Some("ESP32-C6".to_string())); + let restarted = confirmed.begin_waiting(); + assert!( + matches!(restarted, BootloaderEntryFlow::Instructing { .. }), + "starting over shows the steps again rather than waiting blindly" + ); + // ...and it remembers the chip, so the steps stay specific. + assert_eq!(restarted.instructions().unwrap().subject, "ESP32-C6"); + assert!(!restarted.instructions().unwrap().is_generic); + } +} diff --git a/lp-app/lpa-studio-core/src/app/device/device_op.rs b/lp-app/lpa-studio-core/src/app/device/device_op.rs index 19e5e1871..b2e6a8402 100644 --- a/lp-app/lpa-studio-core/src/app/device/device_op.rs +++ b/lp-app/lpa-studio-core/src/app/device/device_op.rs @@ -1,3 +1,4 @@ +use super::bootloader_entry_flow::BootloaderEntryFlow; use core::any::Any; use core::time::Duration; @@ -51,6 +52,39 @@ pub enum DeviceOp { /// stays; the board lands on Connected-empty. WipeProject, ResetToBlank, + /// Write the boot-control record so the device's NEXT restart comes up + /// without loading a project (`lp-bootctl`). + /// + /// The escape for a project that stops its own device from running — + /// too bright, too power-hungry, or hanging the watchdog. Nothing is + /// erased and the instruction is one-shot: the device consumes it as it + /// boots, so the restart after that is normal again. That is why this is + /// NOT destructive and does not sever a lens, unlike `ResetToBlank`. + BootSafeOnce, + /// Read the device's filesystem partition over the bootloader and hand + /// the user a ZIP of it. + /// + /// The rescue that has to happen BEFORE anything destructive: it works + /// on a board that cannot boot, because the bytes come off through the + /// ROM/stub bootloader rather than through the running server. Nothing + /// is written, so this is not destructive — but it does own the wire and + /// reboot the device, like every other management operation. + BackUpFilesystem, + /// Ask the device whether a bootloader is listening, and fold the answer + /// into the card's open bootloader-entry sheet. + /// + /// This is what makes the ritual's confirmation real. The passive + /// classifier CANNOT answer it: a board already in download mode printed + /// its ROM banner before Studio ever attached, so silence is the normal + /// case rather than evidence. Only the SYNC handshake can say. + /// + /// User-triggered, never automatic — the handshake reboots the device. + /// The user pressing "I've done that" IS the edge signal that a replug + /// happened, which is exactly when probing is worth its cost. + ProbeBootloaderMode { + card_key: String, + flow: BootloaderEntryFlow, + }, DisconnectDevice, /// Destroy THE simulator session (runtime-pool P3, Q5): quiesce the /// editor when the lens is on the sim, close the provider session @@ -120,6 +154,24 @@ impl ControllerOp for DeviceOp { "This will write LightPlayer firmware to the selected ESP32. Continue?", "Flash firmware", )), + Self::BootSafeOnce => ActionMeta::new( + "Start in safe mode", + "Have this device start once in safe mode — dim, or with \ + nothing loaded on older firmware — so a project that stops \ + it from running can be fixed.", + ActionPriority::Secondary, + ), + Self::BackUpFilesystem => ActionMeta::new( + "Download a backup", + "Copy everything on this device to a ZIP on your computer — \ + works even if the board will not start.", + ActionPriority::Secondary, + ), + Self::ProbeBootloaderMode { .. } => ActionMeta::new( + "Check the device", + "Ask the device whether it is listening in recovery mode.", + ActionPriority::Primary, + ), Self::WipeProject => ActionMeta::new( "Wipe project", "Delete the device's project storage; firmware stays.", @@ -128,8 +180,13 @@ impl ControllerOp for DeviceOp { .destructive() .with_confirmation(ActionConfirmation::new( "Wipe the project", - "Studio can't read this content, so it can't be backed up — \ - wiping deletes it for good and leaves the board empty.", + // This used to say the content "can't be backed up". Since + // M6 that is false: a raw filesystem backup does not need + // Studio to understand the content, only to read the bytes. + // Pointing at the way out is the honest gate. + "Studio can't read this content, and wiping deletes it for \ + good. Download a backup first if you might want it — that \ + works even on content Studio can't open.", "Wipe", )), Self::ResetToBlank => ActionMeta::new( @@ -186,6 +243,9 @@ impl ControllerOp for DeviceOp { | Self::ProvisionFirmware { .. } | Self::WipeProject | Self::ResetToBlank + | Self::BootSafeOnce + | Self::BackUpFilesystem + | Self::ProbeBootloaderMode { .. } | Self::DisconnectDevice | Self::StopSimulator | Self::RefreshConnections => ActionClass::Recovery, @@ -239,6 +299,7 @@ mod tests { DeviceOp::ResetDevice, DeviceOp::ProvisionFirmware { setup_name: None }, DeviceOp::ResetToBlank, + DeviceOp::BackUpFilesystem, DeviceOp::DisconnectDevice, DeviceOp::StopSimulator, DeviceOp::RefreshConnections, diff --git a/lp-app/lpa-studio-core/src/app/device/filesystem_backup/README.md b/lp-app/lpa-studio-core/src/app/device/filesystem_backup/README.md new file mode 100644 index 000000000..3c023ec9f --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/device/filesystem_backup/README.md @@ -0,0 +1,93 @@ +# Filesystem backup archive + +What Studio hands the user when they back up a device's storage: a ZIP built +from the raw `lpfs` partition read off the board over the bootloader +(`LinkManagementRequest::ReadRawFilesystem`). + +This works on a device that **cannot boot** — which is the whole point. The +originating failure was a project bright enough to brown its own board out at +power-up, looping forever. Nothing on such a device can be asked to list its +own files, so the partition comes off as bytes and is mounted here, in wasm, +by the same littlefs implementation the firmware uses. + +## Posture + +**Support-facing, but shaped as if it were public.** We do not promise this +format to users and we do not document it outside the repo. We *do* design it +as though we might, because that is what keeps the upgrade path free: M7 +(restore) and any future selective restore ("put just this one project back") +read these archives, and a layout invented for convenience today is a layout +somebody has to reverse tomorrow. + +Alpha versioning rule, same as share envelopes +(`docs/adr/2026-07-28-share-envelopes.md`): **version and refuse, never +migrate.** A reader that meets an unknown `formatVersion` says so. + +## Layout + +``` +manifest.json ← archive root, written first +files/.lp/device.json ← device paths, mirrored verbatim +files/lightplayer.json +files/projects/porch/project.json +files/projects/porch/shader.glsl +``` + +- **Device paths are mirrored verbatim** under the single `files/` root. + Nothing is flattened, renamed, or reordered into a different hierarchy. + Recovering a device path is `entry.strip_prefix("files/")` and prepending + `/` — not the reversal of a scheme. +- The `files/` prefix exists for exactly one reason: so `manifest.json` cannot + collide with a file the device happened to keep at its filesystem root. +- Entries are **sorted by device path**, and the manifest is written first, so + the same device state produces the same archive twice and a streaming reader + learns what it is holding before a megabyte of content. +- Compression is **deflate**. The content is small and mostly text. + +## Manifest fields + +`manifest.json`, camelCase: + +| Field | Meaning | +|---|---| +| `formatVersion` | `1`. Bumped when a reader must notice a change. | +| `capturedAtEpochSeconds` | When the backup was taken, from the app's injected clock. | +| `deviceUid` | The uid found at `/.lp/device.json` **in the captured image**, or absent for a board that was never named. | +| `chip` | What the bootloader named itself as during the read (`esp32c6`, `ESP32-C6 (QFN32) …`). | +| `partitionOffset` / `partitionLength` | Where the captured partition lives on that chip. | +| `blockSize` | littlefs block size the image was read at (4096). | +| `fileCount` | Number of files captured. | +| `totalBytes` | Sum of the captured files' sizes — **not** the partition size. | + +### Why `deviceUid` is load-bearing + +`/.lp/device.json` lives inside `lpfs`, so a device's identity is captured in +every backup and would be written back by a naive restore. Restoring one +board's backup onto another would give two boards the same uid, and Studio's +whole device registry keys on it. + +Recording the captured uid in the manifest is what lets a restore **detect** +that case rather than silently perform it. M7 owns the decision about what to +do then (preserve the target's own stamp is the plan); this milestone's job is +to make sure the fact is not lost. + +## What is NOT here + +- **Restore.** M7. `lpa-link` advertises the raw-filesystem READ only, so no + UI can offer a write that every provider answers with `unsupported`. +- **Whole-flash images.** M8. This is the filesystem partition, not the chip. +- **Selective restore.** A natural follow-on, and the reason the layout mirrors + device paths instead of inventing its own. + +## Where the code lives + +| File | Job | +|---|---| +| `backup_image.rs` | Mount the raw image read-only, walk it into `BackupFile`s. Fails loudly on a damaged filesystem. | +| `backup_manifest.rs` | The manifest type and its format version. | +| `backup_archive.rs` | Manifest + entries → ZIP bytes, and the download file name. Holds the fixture-image tests. | + +The geometry (4 KB blocks, 512 B cache, 64 B lookahead) must match +`lp-fw/fw-esp32c6/src/flash_storage.rs`'s `lpfs_config()` or the mount fails +or misreads. Block *count* is derived from the image length rather than +pinned, because it differs per board: 240 blocks on the C6, 384 on the S3. diff --git a/lp-app/lpa-studio-core/src/app/device/filesystem_backup/backup_archive.rs b/lp-app/lpa-studio-core/src/app/device/filesystem_backup/backup_archive.rs new file mode 100644 index 000000000..b0c723761 --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/device/filesystem_backup/backup_archive.rs @@ -0,0 +1,385 @@ +//! Build the ZIP: mount the image, mirror its paths, write the manifest. +//! +//! The layout is documented in this module's `README.md` and is a contract — +//! M7's restore and any future selective restore read it. Do not reshape it +//! casually. + +use std::io::{Cursor, Write}; + +use zip::write::SimpleFileOptions; + +use super::backup_image::{BackupFile, read_image_files}; +use super::backup_manifest::{BACKUP_FORMAT_VERSION, BackupManifest}; +// The device's identity stamp is INSIDE lpfs, so it is inside every backup. +// One definition of where it lives, shared with the pull path. +use crate::app::places::DEVICE_IDENTITY_PATH; + +/// Where the device's own files live inside the archive. +/// +/// Device paths are mirrored VERBATIM under this one root, so recovering a +/// path is a prefix strip rather than a reversal of some renaming scheme. +/// The prefix exists only so `manifest.json` cannot collide with a file the +/// device happened to keep at its filesystem root. +pub const ARCHIVE_FILES_ROOT: &str = "files"; + +/// The manifest's name at the archive root. +pub const ARCHIVE_MANIFEST_NAME: &str = "manifest.json"; + +/// A finished backup: the bytes, the name to offer them under, and the +/// manifest that went inside (kept so the UI can narrate without re-reading +/// the archive). +#[derive(Clone, Debug, PartialEq)] +pub struct BackupArchive { + pub file_name: String, + pub bytes: Vec, + pub manifest: BackupManifest, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum BackupError { + /// The image did not mount, or a file in it could not be read. On a + /// board being rescued this is a real possibility, and it must be said + /// out loud rather than turned into an empty archive. + Image(String), + Zip(String), + Manifest(String), +} + +impl core::fmt::Display for BackupError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::Image(m) => write!(f, "filesystem image: {m}"), + Self::Zip(m) => write!(f, "zip: {m}"), + Self::Manifest(m) => write!(f, "manifest: {m}"), + } + } +} + +/// What the read told us about where the image came from. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BackupSource { + /// The chip the bootloader named itself as. + pub chip: Option, + pub partition_offset: u32, + pub partition_length: u32, + /// Human label for the file name — the device's name if Studio knows one. + pub device_label: Option, +} + +/// Mount `image`, walk it, and emit the archive. +/// +/// `now_secs` is the app's injected clock (core reads no clocks — see the +/// sans-IO ADR); it stamps the manifest and dates the file name. +pub fn build_backup_archive( + image: &[u8], + source: &BackupSource, + now_secs: f64, +) -> Result { + let files = + read_image_files(image).map_err(|error| BackupError::Image(format!("{error:?}")))?; + + let manifest = BackupManifest { + format_version: BACKUP_FORMAT_VERSION, + captured_at_epoch_seconds: now_secs, + device_uid: device_uid_from(&files), + chip: source.chip.clone(), + partition_offset: source.partition_offset, + partition_length: source.partition_length, + block_size: 4096, + file_count: files.len() as u32, + total_bytes: files.iter().map(|file| file.bytes.len() as u64).sum(), + }; + let manifest_json = manifest + .to_json() + .map_err(|error| BackupError::Manifest(error.to_string()))?; + + let mut cursor = Cursor::new(Vec::new()); + { + let mut writer = zip::ZipWriter::new(&mut cursor); + let options = + SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + // Manifest first: a reader streaming the archive learns what it is + // holding before it reaches a megabyte of device content. + writer + .start_file(ARCHIVE_MANIFEST_NAME, options) + .map_err(|error| BackupError::Zip(error.to_string()))?; + writer + .write_all(manifest_json.as_bytes()) + .map_err(|error| BackupError::Zip(error.to_string()))?; + for file in &files { + writer + .start_file(archive_entry_name(&file.path), options) + .map_err(|error| BackupError::Zip(error.to_string()))?; + writer + .write_all(&file.bytes) + .map_err(|error| BackupError::Zip(error.to_string()))?; + } + writer + .finish() + .map_err(|error| BackupError::Zip(error.to_string()))?; + } + + Ok(BackupArchive { + file_name: backup_file_name(source.device_label.as_deref(), now_secs), + bytes: cursor.into_inner(), + manifest, + }) +} + +/// `/projects/demo/project.json` → `files/projects/demo/project.json`. +fn archive_entry_name(device_path: &str) -> String { + format!( + "{ARCHIVE_FILES_ROOT}/{}", + device_path.trim_start_matches('/') + ) +} + +/// The captured device's uid, read out of the identity stamp the image +/// carries. Absent when the board was never named, or when the stamp does +/// not parse — neither is worth failing a backup over. +fn device_uid_from(files: &[BackupFile]) -> Option { + let bytes = &files + .iter() + .find(|file| file.path == DEVICE_IDENTITY_PATH)? + .bytes; + serde_json::from_slice::(bytes) + .ok()? + .get("uid")? + .as_str() + .map(str::to_string) +} + +/// `lightplayer-backup-porch-sign-2026-07-31.zip`. +/// +/// Dated because a user rescuing a board takes more than one, and a browser +/// silently appending `(1)` is not a name anybody can read later. +fn backup_file_name(device_label: Option<&str>, now_secs: f64) -> String { + let label = device_label + .map(slugify_label) + .filter(|label| !label.is_empty()) + .unwrap_or_else(|| "device".to_string()); + format!("lightplayer-backup-{label}-{}.zip", date_stamp(now_secs)) +} + +fn slugify_label(label: &str) -> String { + let mut out = String::new(); + let mut pending_dash = false; + for ch in label.chars() { + if ch.is_ascii_alphanumeric() { + if pending_dash && !out.is_empty() { + out.push('-'); + } + pending_dash = false; + out.push(ch.to_ascii_lowercase()); + } else { + pending_dash = true; + } + } + out +} + +/// `YYYY-MM-DD` in UTC from epoch seconds, via Howard Hinnant's civil-date +/// algorithm. (`device_card.rs` carries a twin that also formats a time of +/// day; if a third appears, extract one.) +fn date_stamp(now_secs: f64) -> String { + let days = (now_secs as i64).div_euclid(86_400); + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = doy - (153 * mp + 2) / 5 + 1; + let month = if mp < 10 { mp + 3 } else { mp - 9 }; + let year = yoe + era * 400 + i64::from(month <= 2); + format!("{year:04}-{month:02}-{day:02}") +} + +#[cfg(test)] +mod tests { + use std::io::Read; + + use super::*; + + /// The fixture-image gate: build a real littlefs image in memory, back it + /// up, and read the archive back. No hardware, no device, no provider. + #[test] + fn a_fixture_image_round_trips_into_an_archive_with_verbatim_paths() { + let image = fixture_image(&[ + ( + "/.lp/device.json", + br#"{"uid":"dev_7pQr5St89uVwXy2C","name":"porch sign"}"#.to_vec(), + ), + ( + "/projects/porch/project.json", + br#"{"kind":"Project","name":"porch"}"#.to_vec(), + ), + ("/projects/porch/shader.glsl", b"void main() {}".to_vec()), + ( + "/lightplayer.json", + br#"{"startupProject":"porch"}"#.to_vec(), + ), + ]); + + let archive = build_backup_archive(&image, &source(), NOW).expect("archive builds"); + + let entries = archive_entries(&archive.bytes); + let names: Vec<&str> = entries.iter().map(|(name, _)| name.as_str()).collect(); + assert_eq!( + names, + vec![ + "manifest.json", + "files/.lp/device.json", + "files/lightplayer.json", + "files/projects/porch/project.json", + "files/projects/porch/shader.glsl", + ], + "device paths mirror verbatim under files/, sorted, manifest first" + ); + let shader = entries + .iter() + .find(|(name, _)| name == "files/projects/porch/shader.glsl") + .expect("the shader is in the archive"); + assert_eq!(shader.1, b"void main() {}"); + } + + /// The identity hazard M7 has to detect: the captured uid is recorded, so + /// a restore can tell it is about to clone a device. + #[test] + fn the_manifest_records_the_captured_identity_and_partition() { + let image = fixture_image(&[( + "/.lp/device.json", + br#"{"uid":"dev_7pQr5St89uVwXy2C","name":"porch sign"}"#.to_vec(), + )]); + + let archive = build_backup_archive(&image, &source(), NOW).expect("archive builds"); + + assert_eq!(archive.manifest.format_version, BACKUP_FORMAT_VERSION); + assert_eq!( + archive.manifest.device_uid.as_deref(), + Some("dev_7pQr5St89uVwXy2C") + ); + assert_eq!(archive.manifest.chip.as_deref(), Some("esp32c6")); + assert_eq!(archive.manifest.partition_offset, 0x0031_0000); + assert_eq!(archive.manifest.partition_length, 0x000F_0000); + assert_eq!(archive.manifest.file_count, 1); + + // …and it is IN the archive, not just on the struct. + let entries = archive_entries(&archive.bytes); + let (_, manifest_bytes) = entries + .iter() + .find(|(name, _)| name == "manifest.json") + .expect("manifest.json is at the archive root"); + let decoded: BackupManifest = + serde_json::from_slice(manifest_bytes).expect("manifest parses"); + assert_eq!(decoded, archive.manifest); + } + + /// A board that was never named has no identity stamp, and that must be + /// an absent field rather than a failed backup. + #[test] + fn an_unstamped_device_backs_up_with_no_uid() { + let image = fixture_image(&[("/projects/porch/project.json", b"{}".to_vec())]); + + let archive = build_backup_archive(&image, &source(), NOW).expect("archive builds"); + + assert_eq!(archive.manifest.device_uid, None); + assert_eq!(archive.manifest.file_count, 1); + } + + /// Garbage in the partition means the device's filesystem is not + /// readable. Saying so beats handing the user an empty archive and + /// calling it a backup. + #[test] + fn an_unmountable_image_fails_rather_than_producing_an_empty_archive() { + let image = vec![0xFFu8; 4096 * 240]; + + let error = build_backup_archive(&image, &source(), NOW).unwrap_err(); + + assert!( + matches!(error, BackupError::Image(_)), + "expected an image error, got {error}" + ); + } + + #[test] + fn the_file_name_carries_the_device_and_the_date() { + let image = fixture_image(&[("/projects/porch/project.json", b"{}".to_vec())]); + let mut source = source(); + source.device_label = Some("Porch Sign".to_string()); + + let archive = build_backup_archive(&image, &source, NOW).expect("archive builds"); + + assert_eq!( + archive.file_name, + "lightplayer-backup-porch-sign-2027-01-15.zip" + ); + } + + #[test] + fn an_unnamed_device_still_gets_a_readable_file_name() { + assert_eq!( + backup_file_name(None, NOW), + "lightplayer-backup-device-2027-01-15.zip" + ); + assert_eq!( + backup_file_name(Some(" "), NOW), + "lightplayer-backup-device-2027-01-15.zip" + ); + } + + /// 2027-01-15T04:26:40Z — a date with a two-digit month and day so the + /// zero-padding is exercised in the other direction by construction. + const NOW: f64 = 1_800_000_000.0; + + fn source() -> BackupSource { + BackupSource { + chip: Some("esp32c6".to_string()), + partition_offset: 0x0031_0000, + partition_length: 0x000F_0000, + device_label: None, + } + } + + /// Build a real C6-geometry littlefs image holding `files`. + fn fixture_image(files: &[(&str, Vec)]) -> Vec { + use littlefs_rust::{Config, Filesystem, RamStorage}; + + let block_count = 240; + let mut config = Config::new(4096, block_count); + config.cache_size = 512; + config.lookahead_size = 64; + let mut storage = RamStorage::new(4096, block_count); + Filesystem::format(&mut storage, &config).expect("format"); + let fs = Filesystem::mount(storage, config) + .map_err(|(error, _)| error) + .expect("mount"); + for (path, bytes) in files { + let mut prefix = String::new(); + let mut segments = path.trim_start_matches('/').split('/').peekable(); + while let Some(segment) = segments.next() { + if segments.peek().is_none() { + break; + } + prefix.push('/'); + prefix.push_str(segment); + let _ = fs.mkdir(&prefix); + } + fs.write_file(path, bytes).expect("seed the fixture image"); + } + fs.unmount().expect("unmount").data().to_vec() + } + + fn archive_entries(bytes: &[u8]) -> Vec<(String, Vec)> { + let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).expect("a zip archive"); + (0..archive.len()) + .map(|index| { + let mut file = archive.by_index(index).expect("entry"); + let name = file.name().to_string(); + let mut content = Vec::new(); + file.read_to_end(&mut content).expect("entry bytes"); + (name, content) + }) + .collect() + } +} diff --git a/lp-app/lpa-studio-core/src/app/device/filesystem_backup/backup_image.rs b/lp-app/lpa-studio-core/src/app/device/filesystem_backup/backup_image.rs new file mode 100644 index 000000000..ffa4a32f1 --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/device/filesystem_backup/backup_image.rs @@ -0,0 +1,124 @@ +//! Mount a raw `lpfs` image in memory and walk it into files. +//! +//! The image arrives as the partition's bytes verbatim from +//! `LinkManagementRequest::ReadRawFilesystem`. Mounting it here — in Rust, +//! compiled to wasm — is what makes the backup possible at all: the device +//! this came off may not boot, so nothing on the device can be asked to list +//! its own files. +//! +//! The geometry must match the firmware's (`fw-esp32c6/src/flash_storage.rs`: +//! 4 KB blocks, 512 B cache, 64 B lookahead) or the mount fails or misreads. +//! Block COUNT is derived from the image length rather than pinned, because +//! it differs per board — 240 blocks on the C6, 384 on the S3. + +use littlefs_rust::{Config, Error as LfsError, FileType, Filesystem, Storage}; + +/// littlefs geometry, matching `lpfs_config()` in the firmware. +const BLOCK_SIZE: u32 = 4096; +const CACHE_SIZE: u32 = 512; +const LOOKAHEAD_SIZE: u32 = 64; + +/// How deep the walk will follow directories before giving up. +/// +/// A bound, not a limit anyone should reach: real device storage is +/// `/projects//…` and `/.lp/…`. It exists because the input is a raw +/// image off a possibly-damaged board, and a corrupted directory entry that +/// points at itself must not hang the browser tab. +const MAX_DEPTH: usize = 16; + +/// One file recovered from the image: absolute device path, and its bytes. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BackupFile { + pub path: String, + pub bytes: Vec, +} + +/// Mount `image` and read every file in it, sorted by path. +/// +/// Sorted because the archive should be byte-reproducible for the same +/// device state: littlefs directory order is an implementation detail, and a +/// backup that reshuffles itself between captures is hard to diff. +pub fn read_image_files(image: &[u8]) -> Result, LfsError> { + let block_count = (image.len() / BLOCK_SIZE as usize) as u32; + let mut config = Config::new(BLOCK_SIZE, block_count); + config.cache_size = CACHE_SIZE; + config.lookahead_size = LOOKAHEAD_SIZE; + + let storage = ImageStorage { + data: image.to_vec(), + block_size: BLOCK_SIZE, + }; + let fs = Filesystem::mount(storage, config).map_err(|(error, _)| error)?; + + let mut files = Vec::new(); + walk_dir(&fs, "/", 0, &mut files)?; + files.sort_by(|a, b| a.path.cmp(&b.path)); + Ok(files) +} + +/// A read-only [`Storage`] over an image already in memory. +/// +/// Writes and erases are rejected rather than silently accepted: this is a +/// BACKUP path, and a mount that quietly repaired the image would be +/// modifying the only copy of a user's damaged filesystem. +struct ImageStorage { + data: Vec, + block_size: u32, +} + +impl Storage for ImageStorage { + fn read(&mut self, block: u32, offset: u32, buf: &mut [u8]) -> Result<(), LfsError> { + let start = (block as usize) + .checked_mul(self.block_size as usize) + .and_then(|base| base.checked_add(offset as usize)) + .ok_or(LfsError::Io)?; + let end = start.checked_add(buf.len()).ok_or(LfsError::Io)?; + if end > self.data.len() { + return Err(LfsError::Io); + } + buf.copy_from_slice(&self.data[start..end]); + Ok(()) + } + + fn write(&mut self, _block: u32, _offset: u32, _data: &[u8]) -> Result<(), LfsError> { + Err(LfsError::Io) + } + + fn erase(&mut self, _block: u32) -> Result<(), LfsError> { + Err(LfsError::Io) + } +} + +/// Depth-first walk collecting regular files. Unreadable entries abort the +/// walk: a backup that silently omits a file is worse than one that refuses. +fn walk_dir( + fs: &Filesystem, + dir: &str, + depth: usize, + out: &mut Vec, +) -> Result<(), LfsError> { + if depth >= MAX_DEPTH { + return Ok(()); + } + for entry in fs.list_dir(dir)? { + if entry.name == "." || entry.name == ".." { + continue; + } + let path = join_path(dir, &entry.name); + if entry.file_type == FileType::Dir { + walk_dir(fs, &path, depth + 1, out)?; + } else { + let bytes = fs.read_to_vec(&path)?; + out.push(BackupFile { path, bytes }); + } + } + Ok(()) +} + +fn join_path(dir: &str, name: &str) -> String { + if dir.ends_with('/') { + format!("{dir}{name}") + } else { + format!("{dir}/{name}") + } +} diff --git a/lp-app/lpa-studio-core/src/app/device/filesystem_backup/backup_manifest.rs b/lp-app/lpa-studio-core/src/app/device/filesystem_backup/backup_manifest.rs new file mode 100644 index 000000000..c685150e3 --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/device/filesystem_backup/backup_manifest.rs @@ -0,0 +1,48 @@ +//! The manifest written into every filesystem backup. +//! +//! Support-facing, but shaped as if it were public — see the module README. +//! The fields exist to answer the questions a restore has to ask before it +//! writes anything: *which board is this, which partition, and whose device +//! was it?* +//! +//! `device_uid` is the sharp one. `/.lp/device.json` lives INSIDE `lpfs`, so +//! it rides along in the archive; restoring a backup onto a different board +//! would clone an identity. Recording the captured uid here is what lets M7 +//! detect a cross-device restore instead of silently performing one. + +use serde::{Deserialize, Serialize}; + +/// Bumped when the archive layout changes in a way a reader must notice. +/// +/// Alpha posture (`docs/adr/2026-07-28-share-envelopes.md`'s house rule): +/// version and refuse, never migrate. +pub const BACKUP_FORMAT_VERSION: u32 = 1; + +/// `manifest.json` at the root of a filesystem backup archive. +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BackupManifest { + pub format_version: u32, + /// Epoch seconds at capture, from the app's injected clock. + pub captured_at_epoch_seconds: f64, + /// The device uid found at `/.lp/device.json` in the captured image, if + /// the board had ever been stamped. Absent is honest: a board that was + /// never named has none. + pub device_uid: Option, + /// The chip the bootloader named itself as during the read. + pub chip: Option, + /// Where the captured partition lives on that chip. + pub partition_offset: u32, + pub partition_length: u32, + /// littlefs block size the image was read at. + pub block_size: u32, + pub file_count: u32, + /// Sum of the captured files' sizes — not the partition size. + pub total_bytes: u64, +} + +impl BackupManifest { + pub fn to_json(&self) -> Result { + serde_json::to_string_pretty(self) + } +} diff --git a/lp-app/lpa-studio-core/src/app/device/filesystem_backup/mod.rs b/lp-app/lpa-studio-core/src/app/device/filesystem_backup/mod.rs new file mode 100644 index 000000000..dc0d68e48 --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/device/filesystem_backup/mod.rs @@ -0,0 +1,31 @@ +//! Turn a raw `lpfs` image read off a device into a ZIP the user can keep. +//! +//! See `README.md` next to this file for the archive layout and the manifest +//! field list — that is the normative description, because the layout is a +//! contract M7 (restore) and any future selective restore have to honor. +//! +//! The split here follows the two halves of the job: +//! +//! - [`backup_image`] mounts the littlefs image and walks it. This is the +//! half that can fail on a damaged device, and it fails LOUDLY rather than +//! producing a half-archive. +//! - [`backup_archive`] turns the walk into ZIP bytes and writes the +//! [`BackupManifest`] beside them. +//! +//! All of it runs in wasm: Studio is Rust, `littlefs-rust` is a pure-Rust +//! port, and the `zip` crate this crate already uses for package export +//! deflates fine there. **No filesystem parsing happens in JS** — JS only ever +//! sees bytes. + +mod backup_archive; +mod backup_image; +mod backup_manifest; +mod ui_device_backup; + +pub use backup_archive::{ + ARCHIVE_FILES_ROOT, ARCHIVE_MANIFEST_NAME, BackupArchive, BackupError, BackupSource, + build_backup_archive, +}; +pub use backup_image::{BackupFile, read_image_files}; +pub use backup_manifest::{BACKUP_FORMAT_VERSION, BackupManifest}; +pub use ui_device_backup::UiDeviceBackup; diff --git a/lp-app/lpa-studio-core/src/app/device/filesystem_backup/ui_device_backup.rs b/lp-app/lpa-studio-core/src/app/device/filesystem_backup/ui_device_backup.rs new file mode 100644 index 000000000..8868883cc --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/device/filesystem_backup/ui_device_backup.rs @@ -0,0 +1,22 @@ +//! The finished backup, as the view carries it to the shell. + +use std::rc::Rc; + +/// One completed filesystem backup, waiting to be saved. +/// +/// `seq` is session-monotonic and the shell downloads exactly when it +/// observes it advance — the same paint-key discipline the agent's debug +/// dump uses. Without it, every re-render of a stale DTO would re-download a +/// megabyte, which on a slow board is the difference between one file in the +/// user's Downloads folder and twenty. +/// +/// The bytes ride an `Rc<[u8]>` because the view is cloned on every render +/// and this is the largest thing in it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct UiDeviceBackup { + pub seq: u64, + pub file_name: String, + pub bytes: Rc<[u8]>, + /// Files captured, for the notice that announces the download. + pub file_count: u32, +} diff --git a/lp-app/lpa-studio-core/src/app/device/link_ux.rs b/lp-app/lpa-studio-core/src/app/device/link_ux.rs index b0f114cf5..1da6fc111 100644 --- a/lp-app/lpa-studio-core/src/app/device/link_ux.rs +++ b/lp-app/lpa-studio-core/src/app/device/link_ux.rs @@ -59,55 +59,30 @@ pub(crate) fn link_session_logs( } /// A management result's log/progress replay as console drafts. +/// +/// Variant-agnostic on purpose: it reads the result's uniform `logs()` / +/// `progress()` accessors instead of matching each operation. This used to +/// be four near-identical arms, which meant every new link operation broke +/// the build here and got a fifth copy that could drift from the others. +/// `ResetRuntime` is the one special case — it carries no logs of its own, +/// so it supplies its own line. pub(crate) fn management_result_logs(result: &LinkManagementResult) -> Vec { - match result { - LinkManagementResult::FlashFirmware(result) => { - let mut logs = result - .logs - .iter() - .map(|message| { - UiLogDraft::new(UiLogLevel::Info, UiLogOrigin::Link, message.clone()) - }) - .collect::>(); - logs.extend(result.progress.iter().map(|progress| { - UiLogDraft::new(UiLogLevel::Info, UiLogOrigin::Link, progress.label.clone()) - })); - logs - } - LinkManagementResult::EraseDeviceFlash(result) => { - let mut logs = result - .logs - .iter() - .map(|message| { - UiLogDraft::new(UiLogLevel::Info, UiLogOrigin::Link, message.clone()) - }) - .collect::>(); - logs.extend(result.progress.iter().map(|progress| { - UiLogDraft::new(UiLogLevel::Info, UiLogOrigin::Link, progress.label.clone()) - })); - logs - } - LinkManagementResult::EraseRawFilesystem(result) => { - let mut logs = result - .logs - .iter() - .map(|message| { - UiLogDraft::new(UiLogLevel::Info, UiLogOrigin::Link, message.clone()) - }) - .collect::>(); - logs.extend(result.progress.iter().map(|progress| { - UiLogDraft::new(UiLogLevel::Info, UiLogOrigin::Link, progress.label.clone()) - })); - logs - } - LinkManagementResult::ResetRuntime => { - vec![UiLogDraft::new( - UiLogLevel::Info, - UiLogOrigin::Link, - "runtime reset completed", - )] - } + if matches!(result, LinkManagementResult::ResetRuntime) { + return vec![UiLogDraft::new( + UiLogLevel::Info, + UiLogOrigin::Link, + "runtime reset completed", + )]; } + + result + .logs() + .iter() + .map(|message| UiLogDraft::new(UiLogLevel::Info, UiLogOrigin::Link, message.clone())) + .chain(result.progress().iter().map(|progress| { + UiLogDraft::new(UiLogLevel::Info, UiLogOrigin::Link, progress.label.clone()) + })) + .collect() } /// Map a provider log entry to a console draft: origin `Link`, the endpoint diff --git a/lp-app/lpa-studio-core/src/app/device/mod.rs b/lp-app/lpa-studio-core/src/app/device/mod.rs index 780e0c742..afdd01abe 100644 --- a/lp-app/lpa-studio-core/src/app/device/mod.rs +++ b/lp-app/lpa-studio-core/src/app/device/mod.rs @@ -1,3 +1,4 @@ +pub mod bootloader_entry_flow; pub mod connect_choices; pub mod connect_flow; pub mod connected_device_summary; @@ -5,11 +6,18 @@ pub mod deploy_op; pub mod device_controller; pub(crate) mod device_event_adapter; pub mod device_op; +pub mod filesystem_backup; pub(crate) mod link_ux; +pub mod recovery_instructions; +pub use bootloader_entry_flow::BootloaderEntryFlow; pub use connect_choices::{EndpointChoice, ProviderChoice}; pub use connect_flow::ConnectFlowState; pub use connected_device_summary::ConnectedDeviceSummary; pub use deploy_op::{DEPLOY_NODE_ID, DeployOp, DeployTarget}; pub use device_controller::{DeviceController, DeviceOpenOutcome}; pub use device_op::DeviceOp; +pub use filesystem_backup::{ + BackupArchive, BackupError, BackupManifest, BackupSource, UiDeviceBackup, +}; +pub use recovery_instructions::{RecoveryInstructions, RecoveryStep}; diff --git a/lp-app/lpa-studio-core/src/app/device/recovery_instructions.rs b/lp-app/lpa-studio-core/src/app/device/recovery_instructions.rs new file mode 100644 index 000000000..a30cde644 --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/device/recovery_instructions.rs @@ -0,0 +1,257 @@ +//! How to get a particular board into bootloader mode — and how to admit +//! when we do not know. +//! +//! # Why this is keyed on the chip, not the board +//! +//! The obvious source for board-specific instructions is the board manifest. +//! It does not work here. `HwManifest` is **device-side**: it is read from +//! the device's own `/hardware.json`, or compiled into its firmware. A device +//! that will not boot cannot tell Studio what board it is — and that is +//! exactly the situation these instructions exist for. +//! +//! So instructions key on the **chip family**, which Studio can honestly know +//! two ways: the `ServerHello` from a device it reached earlier, or the SYNC +//! probe's `chip_name` (see `lpa_link::DeviceLinkMode`). +//! +//! When it knows neither — the common case, since the user has not got into +//! bootloader mode yet — it renders **generic** guidance and says so. +//! Asserting "hold BOOT" on a board that may have no BOOT button is worse +//! than admitting the uncertainty: a user who follows a confident wrong +//! instruction concludes their device is dead. + +/// One step of a bootloader-entry sequence. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RecoveryStep { + pub text: String, +} + +impl RecoveryStep { + fn new(text: impl Into) -> Self { + Self { text: text.into() } + } +} + +/// A bootloader-entry sequence, and how much Studio actually knows. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RecoveryInstructions { + /// What to call the target in the heading — the chip when known, + /// otherwise a generic noun. + pub subject: String, + pub steps: Vec, + /// True when these are generic ESP32 steps rather than steps for a chip + /// Studio has actually identified. The UI must say so: an unhedged + /// instruction that does not match the user's board reads as "your + /// device is broken". + pub is_generic: bool, +} + +impl RecoveryInstructions { + /// Instructions for whatever Studio knows. + /// + /// `chip_name` is the chip family as reported by a hello or a SYNC probe + /// — `None` when Studio has never reached this device, which is the + /// normal state when someone is trying to *get into* bootloader mode. + pub fn for_chip(chip_name: Option<&str>) -> Self { + match ChipFamily::parse(chip_name) { + Some(family) => Self { + subject: family.display_name().to_string(), + steps: family.steps(), + is_generic: false, + }, + None => Self { + subject: "this device".to_string(), + steps: ChipFamily::generic_steps(), + is_generic: true, + }, + } + } +} + +/// The chip families Studio has real instructions for. +/// +/// Deliberately coarse. The entry sequence is a property of the chip's boot +/// straps, not of the board, so a family is the right granularity — and it is +/// the only granularity a probe can actually report. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ChipFamily { + Esp32C6, + Esp32S3, +} + +impl ChipFamily { + /// Parse a chip name from a hello or an esptool probe. + /// + /// Probe output is free-form ("ESP32-C6 (QFN32) (revision v0.2)"), so + /// this matches on a substring rather than expecting an exact token. + fn parse(chip_name: Option<&str>) -> Option { + let name = chip_name?.to_ascii_lowercase(); + // Check S3 before C6: neither is a prefix of the other, but keeping + // the checks explicit avoids a future family accidentally matching + // two arms. + if name.contains("esp32-s3") || name.contains("esp32s3") { + Some(Self::Esp32S3) + } else if name.contains("esp32-c6") || name.contains("esp32c6") { + Some(Self::Esp32C6) + } else { + None + } + } + + fn display_name(self) -> &'static str { + match self { + Self::Esp32C6 => "ESP32-C6", + Self::Esp32S3 => "ESP32-S3", + } + } + + fn steps(self) -> Vec { + // Both families use the same USB-Serial-JTAG strap sequence; they are + // separate variants so the heading can name the chip and so a family + // that diverges later has somewhere to go. + // Knowing the CHIP does not tell us the silkscreen: the button label + // is a board property. Boards routinely shorten it to "B" for space, + // so even the chip-specific path hedges the label — it just does not + // have to hedge the sequence. + vec![ + RecoveryStep::new("Unplug the USB cable."), + RecoveryStep::new("Hold the BOOT button — often labelled just \"B\". Keep holding it."), + RecoveryStep::new("Plug the cable back in while still holding it."), + RecoveryStep::new("Let go after a second or two."), + ] + } + + fn generic_steps() -> Vec { + vec![ + RecoveryStep::new("Unplug the USB cable."), + RecoveryStep::new( + "Hold the BOOT button — many boards shorten the label to just \ + \"B\", and some use IO0 or FLASH. Keep holding it.", + ), + RecoveryStep::new("Plug the cable back in while still holding that button."), + RecoveryStep::new("Let go after a second or two."), + ] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_known_chip_gets_named_non_generic_instructions() { + let instructions = RecoveryInstructions::for_chip(Some("ESP32-C6")); + assert_eq!(instructions.subject, "ESP32-C6"); + assert!(!instructions.is_generic); + assert!(!instructions.steps.is_empty()); + } + + #[test] + fn probe_output_is_matched_as_a_substring() { + // esptool reports "ESP32-C6FH4 (QFN32) (revision v0.2)"; an exact + // match would fall through to generic for every real device. + let instructions = + RecoveryInstructions::for_chip(Some("ESP32-C6FH4 (QFN32) (revision v0.2)")); + assert_eq!(instructions.subject, "ESP32-C6"); + assert!(!instructions.is_generic); + } + + #[test] + fn a_firmware_package_name_identifies_the_chip() { + // `UiDeviceCard.fw.package` is "fw-esp32c6" — the package names the + // chip it was built for, so a device Studio reached earlier can get + // specific instructions even though it is unreachable NOW. This is + // the only chip source available before the user gets into bootloader + // mode. + let instructions = RecoveryInstructions::for_chip(Some("fw-esp32c6")); + assert_eq!(instructions.subject, "ESP32-C6"); + assert!(!instructions.is_generic); + + let instructions = RecoveryInstructions::for_chip(Some("fw-esp32s3")); + assert_eq!(instructions.subject, "ESP32-S3"); + } + + #[test] + fn chip_matching_is_case_insensitive() { + assert_eq!( + RecoveryInstructions::for_chip(Some("esp32s3")).subject, + "ESP32-S3" + ); + assert_eq!( + RecoveryInstructions::for_chip(Some("ESP32-S3")).subject, + "ESP32-S3" + ); + } + + #[test] + fn an_unknown_device_gets_generic_instructions_and_admits_it() { + // The normal state before the user reaches bootloader mode: Studio + // has never talked to this device. + let instructions = RecoveryInstructions::for_chip(None); + assert!(instructions.is_generic); + assert_eq!(instructions.subject, "this device"); + assert!(!instructions.steps.is_empty()); + } + + #[test] + fn an_unrecognized_chip_falls_back_to_generic_rather_than_guessing() { + let instructions = RecoveryInstructions::for_chip(Some("ESP32-H2")); + assert!( + instructions.is_generic, + "an unknown chip must not inherit another family's button sequence" + ); + } + + #[test] + fn generic_steps_hedge_the_button_name() { + // A board whose button is labelled IO0 is not "broken" — the copy has + // to allow for it, or the user concludes it is. + let instructions = RecoveryInstructions::for_chip(None); + let joined = instructions + .steps + .iter() + .map(|step| step.text.as_str()) + .collect::>() + .join(" "); + // "B" is the one that actually bites: boards routinely shorten the + // silkscreen for space, and a user hunting for a button labelled + // "BOOT" on a board that says "B" concludes they have the wrong + // board. Reported from a real bench, 2026-07-31. + assert!( + joined.contains("\"B\"") && joined.contains("IO0") && joined.contains("FLASH"), + "generic copy must cover the abbreviated silkscreens: {joined}" + ); + } + + #[test] + fn no_sequence_asserts_an_unhedged_button_label() { + // Knowing the chip does not tell us the silkscreen — that is a board + // property, and boards shorten it to "B". Every sequence, specific or + // generic, must allow for that. + for chip in [Some("ESP32-C6"), Some("ESP32-S3"), None] { + let joined = RecoveryInstructions::for_chip(chip) + .steps + .iter() + .map(|step| step.text.as_str()) + .collect::>() + .join(" "); + assert!( + joined.contains("\"B\""), + "{chip:?} must allow for the abbreviated label: {joined}" + ); + } + } + + #[test] + fn every_sequence_starts_by_unplugging() { + // The strap is sampled at reset, so holding BOOT on an already-running + // board does nothing. Getting this order wrong is the single most + // common reason the ritual fails. + for chip in [Some("ESP32-C6"), Some("ESP32-S3"), None] { + let instructions = RecoveryInstructions::for_chip(chip); + assert!( + instructions.steps[0].text.to_lowercase().contains("unplug"), + "{chip:?} sequence must start by unplugging" + ); + } + } +} diff --git a/lp-app/lpa-studio-core/src/app/home/card_ui_state.rs b/lp-app/lpa-studio-core/src/app/home/card_ui_state.rs index 4e844b750..14e79f8d5 100644 --- a/lp-app/lpa-studio-core/src/app/home/card_ui_state.rs +++ b/lp-app/lpa-studio-core/src/app/home/card_ui_state.rs @@ -17,6 +17,7 @@ //! [`DeviceCardTab`]: crate::app::roster::DeviceCardTab //! [identity]: super::ui_device_card::UiDeviceCard::identity_key +use crate::app::device::BootloaderEntryFlow; use crate::app::roster::DeviceCardTab; /// One card's UI view-state. `Default` is a fresh card: the Status tab, @@ -46,6 +47,18 @@ pub enum CardSheet { Name, /// The Not-responding card's troubleshooting sheet (M6). Troubleshoot, + /// The bootloader-entry ritual: steps → waiting → confirmation. + /// + /// Carries the whole [`BootloaderEntryFlow`] as a value, so advancing + /// the ritual is just re-opening the sheet with the next state — no + /// separate op vocabulary, and the flow stays inspectable by e2e. + /// + /// Core ownership matters more here than for any other sheet: the + /// ritual REQUIRES physically unplugging the device, which tears down + /// the session and re-renders the card. View-state that died with the + /// component would lose the user's place at exactly the moment the + /// confirmation is supposed to arrive. + BootloaderEntry(BootloaderEntryFlow), } /// The destructive verb a [`CardSheet::Confirm`] gates. The web maps diff --git a/lp-app/lpa-studio-core/src/app/home/home_view_builder.rs b/lp-app/lpa-studio-core/src/app/home/home_view_builder.rs index 7485c8ae8..1d2a9189e 100644 --- a/lp-app/lpa-studio-core/src/app/home/home_view_builder.rs +++ b/lp-app/lpa-studio-core/src/app/home/home_view_builder.rs @@ -101,6 +101,18 @@ pub struct HomeDeviceEvidence { pub pending_uid: Option, /// The session's console tail (D42), oldest first. pub console_tail: Vec, + /// A card-owned op flow is mid-flight on this device (model §2, I1). + /// + /// Load-bearing for the card's EXISTENCE, not just its overlay: an + /// identity-less recovery board whose session lands `Gone` mid-op would + /// otherwise derive Offline and be dropped — which erased the card at + /// the exact moment its op was showing "unplug the board and plug it + /// back in" (bench, 2026-07-31). An op in flight pins the card. + pub op_in_flight: bool, + /// The session's latest heartbeat-reported recovery status. Carries + /// the safe-mode output clamp the card must surface (with its exit: + /// a power cycle). + pub recovery: Option, } /// Hydrate [`HomeInputs`] from a library snapshot fs. `open_elsewhere` @@ -185,6 +197,7 @@ pub fn build_home_view( library_available: false, opening, issue, + backup: None, }; }; @@ -211,6 +224,9 @@ pub fn build_home_view( library_available: true, opening, issue: issue.or_else(|| inputs.issue.clone()), + // The controller overlays a finished backup after the build (it is + // controller state, not library/roster evidence). + backup: None, } } @@ -330,6 +346,7 @@ pub(crate) fn sim_card(sim: &HomeSimEvidence) -> UiDeviceCard { transport: String::new(), state, project: sim.project.clone(), + safe_clamp: None, fw: None, sim: true, console_tail: sim.console_tail.clone(), @@ -347,7 +364,11 @@ pub(crate) fn live_device_card(live: &HomeDeviceEvidence) -> Option UiDev state, project, fw, + // Only a LIVE link's report counts: a stale clamp on a card whose + // session is gone would tell the user a replug is still needed + // after they already did it. + safe_clamp: match &live.link { + Some(DeviceState::Ready { .. }) => live + .recovery + .as_ref() + .and_then(|recovery| recovery.output_clamp), + _ => None, + }, sim: false, console_tail: live.console_tail.clone(), ui: CardUiState::default(), @@ -521,6 +552,7 @@ fn device_card(device: &RegisteredDevice, projects: &[UiPackageCard]) -> UiDevic UiDeviceCard { uid: Some(device.uid.clone()), name: device.name.clone(), + safe_clamp: None, // recorded at last sight from the live session's connector class transport: device.transport.clone(), state, @@ -579,6 +611,56 @@ mod tests { } } + #[test] + fn the_pinned_anonymous_card_takes_the_uid_less_op() { + // The two halves of the recovery-write ending, together: the pin + // keeps the anonymous card alive, AND the awaiting op rides it — + // half a fix showed the user a bare "Not seen yet" with no + // instruction (bench, 2026-07-31). + let evidence = HomeDeviceEvidence { + link: Some(DeviceState::Gone), + op_in_flight: true, + ..HomeDeviceEvidence::default() + }; + let card = live_device_card(&evidence).expect("pinned"); + assert!( + card.takes_card_op(None), + "the instruction must ride the pinned card" + ); + + // A REGISTERED offline card (uid'd) must not adopt a stray + // anonymous op — that guard is what the old rule was for. + let mut registered = card.clone(); + registered.uid = Some("dev_other".to_string()); + assert!(!registered.takes_card_op(None)); + } + + #[test] + fn an_in_flight_op_pins_the_card_through_gone() { + // The bench bug (2026-07-31): "Start in safe mode" resets the board, + // the anonymous recovery session lands Gone, the state derives + // Offline, and the card VANISHED — taking the "unplug the board and + // plug it back in" instruction with it. An op in flight must pin the + // card; without one, a Gone anonymous session stays invisible. + let evidence = HomeDeviceEvidence { + link: Some(DeviceState::Gone), + op_in_flight: true, + ..HomeDeviceEvidence::default() + }; + let card = live_device_card(&evidence).expect("op in flight pins the card"); + assert!(matches!(card.state, RosterCardState::Offline { .. })); + + let without_op = HomeDeviceEvidence { + link: Some(DeviceState::Gone), + op_in_flight: false, + ..HomeDeviceEvidence::default() + }; + assert!( + live_device_card(&without_op).is_none(), + "a Gone anonymous session with no op stays invisible" + ); + } + /// Evidence for a live, Ready device carrying `sync`. fn live(sync: DeviceSyncState) -> HomeDeviceEvidence { HomeDeviceEvidence { @@ -589,6 +671,40 @@ mod tests { } } + #[test] + fn a_live_heartbeat_clamp_reaches_the_card_and_a_dead_link_drops_it() { + let clamped_recovery = lpc_wire::server::RecoveryStatus { + level: lpc_wire::server::RecoveryLevelWire::Green, + reset_reason: "power-on".to_string(), + boot_count: 1, + safe_mode: false, + output_clamp: Some(26), + last_crash: None, + paths: Vec::new(), + }; + let mut evidence = live(DeviceSyncState { + identity: Some(DeviceIdentity { + uid: "dev_aaaaaaaaaaaaaaaa".to_string(), + name: "TestBoard1".to_string(), + }), + content: DeviceContent::Empty, + }); + evidence.recovery = Some(clamped_recovery.clone()); + + let card = device_card_from_live_evidence(&evidence); + assert_eq!( + card.safe_clamp, + Some(26), + "a Ready link's reported clamp is the card's safe-mode evidence" + ); + + // A Gone link keeps the last heartbeat in memory but the card must + // not claim the (rebooted, unclamped) board is still in safe mode. + evidence.link = Some(DeviceState::Gone); + let card = device_card_from_live_evidence(&evidence); + assert_eq!(card.safe_clamp, None); + } + /// A pool carrying one device session's evidence and no sim. fn device_pool(device: HomeDeviceEvidence) -> HomePoolEvidence { HomePoolEvidence { @@ -737,6 +853,7 @@ mod tests { state: offline.clone(), project: None, fw: None, + safe_clamp: None, sim: false, console_tail: Vec::new(), ui: CardUiState::default(), @@ -748,6 +865,7 @@ mod tests { state: offline, project: None, fw: None, + safe_clamp: None, sim: false, console_tail: Vec::new(), ui: CardUiState::default(), diff --git a/lp-app/lpa-studio-core/src/app/home/ui_device_card.rs b/lp-app/lpa-studio-core/src/app/home/ui_device_card.rs index e7cd00793..4adb88015 100644 --- a/lp-app/lpa-studio-core/src/app/home/ui_device_card.rs +++ b/lp-app/lpa-studio-core/src/app/home/ui_device_card.rs @@ -29,6 +29,11 @@ pub struct UiDeviceCard { /// evidence for the card's rich-object detail; `None` for remembered /// (offline) cards and pre-hello links. pub fw: Option, + /// Device-level safe-mode output ceiling (0–255) reported by the live + /// session's heartbeat. A power cycle is the only exit, so the card + /// must both flag the state AND say how to leave it. `None` when the + /// device reports no clamp (and always on offline/sim cards). + pub safe_clamp: Option, /// D36: this card is the live SIMULATOR session, wearing the same card /// grammar with the sim presentation (sim glyph, no connect ceremony, /// no rename, its own rich-object sections). The sim is not a device @@ -88,7 +93,17 @@ impl UiDeviceCard { } match target_uid { Some(uid) => self.uid.as_deref() == Some(uid), - None => !matches!(self.state, RosterCardState::Offline { .. }), + // A uid-less op belongs to THE anonymous hardware session (there + // is at most one), so it rides any uid-less card — including the + // Offline card that `op_in_flight` pins when the session dies + // mid-op. The offline exclusion only guards uid'd REGISTRY cards + // of other devices from adopting a stray anonymous op. Before + // pinned anonymous cards existed, "not offline" was equivalent; + // once a recovery write killed its own session, the pinned card + // was offline, the op refused to ride it, and the user got a + // bare "Not seen yet" instead of the replug instruction (bench, + // 2026-07-31 — bootloader-mode arm). + None => self.uid.is_none() || !matches!(self.state, RosterCardState::Offline { .. }), } } } diff --git a/lp-app/lpa-studio-core/src/app/home/ui_home_view.rs b/lp-app/lpa-studio-core/src/app/home/ui_home_view.rs index dc454da71..ba01798aa 100644 --- a/lp-app/lpa-studio-core/src/app/home/ui_home_view.rs +++ b/lp-app/lpa-studio-core/src/app/home/ui_home_view.rs @@ -1,6 +1,7 @@ //! The home gallery view model. use crate::UiIssue; +use crate::app::device::UiDeviceBackup; use super::ui_device_card::UiDeviceCard; use super::ui_example_card::UiExampleCard; @@ -26,6 +27,10 @@ pub struct UiHomeView { pub opening: Option, /// A provider-selection or library problem to surface on the home page. pub issue: Option, + /// A finished device filesystem backup waiting to be saved. The shell + /// downloads it when `seq` advances (see [`UiDeviceBackup`]); a + /// re-rendered stale DTO must never re-download a megabyte. + pub backup: Option, } impl UiHomeView { diff --git a/lp-app/lpa-studio-core/src/app/roster/card_tabs.rs b/lp-app/lpa-studio-core/src/app/roster/card_tabs.rs index 91bb5d43c..b0a68465a 100644 --- a/lp-app/lpa-studio-core/src/app/roster/card_tabs.rs +++ b/lp-app/lpa-studio-core/src/app/roster/card_tabs.rs @@ -177,6 +177,8 @@ mod tests { assert_eq!( tabs[4].sections[0].affordances, vec![ + DeviceDetailAffordance::Roster(RosterAffordance::Troubleshoot), + DeviceDetailAffordance::BackUpFilesystem, DeviceDetailAffordance::Roster(RosterAffordance::WipeProject), DeviceDetailAffordance::FlashFirmware, DeviceDetailAffordance::EraseDevice, @@ -252,10 +254,16 @@ mod tests { tab(&tabs, DeviceCardTab::Status).sections[0].affordances, vec![DeviceDetailAffordance::Roster(RosterAffordance::Reconnect)] ); - // registered + offline → the danger zone is Forget, nothing else + // registered + offline → Troubleshoot (always offered, 2026-07-31) + // then Forget. Troubleshoot earns its place even here: an offline + // card is exactly a device that stopped answering, and the sheet's + // Reconnect and recovery steps are what you want. assert_eq!( tab(&tabs, DeviceCardTab::Danger).sections[0].affordances, - vec![DeviceDetailAffordance::ForgetDevice] + vec![ + DeviceDetailAffordance::Roster(RosterAffordance::Troubleshoot), + DeviceDetailAffordance::ForgetDevice + ] ); // a Neutral remembered card announces nothing assert!(tabs.iter().all(|tab| tab.badge.is_none())); diff --git a/lp-app/lpa-studio-core/src/app/roster/device_rich_object.rs b/lp-app/lpa-studio-core/src/app/roster/device_rich_object.rs index 4641728af..e70871f79 100644 --- a/lp-app/lpa-studio-core/src/app/roster/device_rich_object.rs +++ b/lp-app/lpa-studio-core/src/app/roster/device_rich_object.rs @@ -37,6 +37,10 @@ use super::roster_card_state::RosterCardState; pub enum DeviceDetailAffordance { /// A card-grammar affordance (per the direction state table). Roster(RosterAffordance), + /// Danger zone, live device: download a ZIP of the device's storage, + /// read raw over the bootloader. The non-destructive row that belongs + /// ABOVE the destructive ones — it is what makes them survivable. + BackUpFilesystem, /// Danger zone, live device: install/repair firmware. FlashFirmware, /// Danger zone, live device: wipe the flash (confirmed). @@ -265,6 +269,14 @@ fn danger_section(input: &DeviceRichInput<'_>) -> Option) -> Option = core::iter::once(troubleshoot()) + .chain(backup) + .chain(affordances) + .collect(); Some(RichSection { title: "Danger zone".to_string(), // Neutral by construction: Danger weight never colors the rollup; @@ -344,6 +383,13 @@ mod tests { assert_eq!( danger.affordances, vec![ + // Troubleshoot leads the danger zone in EVERY state + // (2026-07-31) — recovery must not be gated on the ladder + // having ended on Not-responding. + DeviceDetailAffordance::Roster(RosterAffordance::Troubleshoot), + // Backup is READ before the destructive verbs, because it is + // what makes them survivable (M6). + DeviceDetailAffordance::BackUpFilesystem, DeviceDetailAffordance::Roster(RosterAffordance::WipeProject), DeviceDetailAffordance::FlashFirmware, DeviceDetailAffordance::EraseDevice, @@ -372,7 +418,10 @@ mod tests { ); assert_eq!( view.sections.last().unwrap().affordances, - vec![DeviceDetailAffordance::ForgetDevice] + vec![ + DeviceDetailAffordance::Roster(RosterAffordance::Troubleshoot), + DeviceDetailAffordance::ForgetDevice + ] ); } @@ -397,6 +446,50 @@ mod tests { ); } + /// The state this milestone exists for: a board sitting in ROM download + /// mode, whose own project is what stopped it. Backup must be reachable + /// from here, and it must be READ before the verbs that destroy the + /// thing it saves. + #[test] + fn a_board_in_recovery_mode_can_back_up_before_it_is_flashed_or_erased() { + let mut input = input(&RosterCardState::RecoveryMode); + input.project_name = None; + input.fw = None; + + let view = device_rich_object(&input); + + assert_eq!( + view.sections.last().unwrap().affordances, + vec![ + DeviceDetailAffordance::Roster(RosterAffordance::Troubleshoot), + DeviceDetailAffordance::BackUpFilesystem, + DeviceDetailAffordance::FlashFirmware, + DeviceDetailAffordance::EraseDevice, + ] + ); + } + + /// A blank or foreign board has no `lpfs` to read — the row could only + /// ever fail, so it is not offered. + #[test] + fn a_blank_or_foreign_board_is_offered_no_backup() { + for state in [ + RosterCardState::ReadyToSetUp, + RosterCardState::OtherFirmware, + ] { + let view = device_rich_object(&input(&state)); + assert!( + !view + .sections + .last() + .unwrap() + .affordances + .contains(&DeviceDetailAffordance::BackUpFilesystem), + "{state:?} has nothing to back up" + ); + } + } + #[test] fn firmware_chip_is_advisory_and_never_colors_the_rollup() { let bundled = BundledFirmware { diff --git a/lp-app/lpa-studio-core/src/app/roster/roster_card_state.rs b/lp-app/lpa-studio-core/src/app/roster/roster_card_state.rs index 1e99ec041..ed56921bd 100644 --- a/lp-app/lpa-studio-core/src/app/roster/roster_card_state.rs +++ b/lp-app/lpa-studio-core/src/app/roster/roster_card_state.rs @@ -65,9 +65,27 @@ pub enum RosterCardState { /// Why the content didn't parse (manifest error detail). detail: String, }, - /// Blank/erased flash (or ROM download mode): provisioning turns it - /// into a Device. Amber solid. + /// Blank/erased flash: provisioning turns it into a Device. Amber + /// solid. ReadyToSetUp, + /// The chip is sitting in ROM download mode. Amber solid. + /// + /// Split out of [`Self::ReadyToSetUp`] 2026-07-31 (bench report): the + /// two were collapsed, so Studio detected download mode and then threw + /// the fact away. They are not the same situation and they do not want + /// the same verbs. + /// + /// Users arrive here three ways — a new board that will not talk + /// normally, a board whose existing firmware interferes with flashing, + /// or a device they are trying to rescue — but the verbs are the same + /// for all three, so this presents ONE flow rather than branching. + /// + /// The load-bearing difference from `ReadyToSetUp`: **a device flashed + /// from here does not boot the new firmware on its own.** It has to be + /// physically unplugged and replugged. So the recovery flash ends on an + /// instruction, not on an auto-reconnect that would fail and report a + /// successful flash as a failure. + RecoveryMode, /// Recognized non-LightPlayer firmware, safe to replace. Amber solid. OtherFirmware, /// Speaks the wire framing but not this build's protocol: reflash is @@ -120,6 +138,7 @@ impl RosterCardState { | Self::EditedOnDevice { .. } | Self::Degraded { .. } | Self::ReadyToSetUp + | Self::RecoveryMode | Self::OtherFirmware | Self::NeedsFirmwareUpdate | Self::NeedsAName @@ -169,6 +188,9 @@ impl RosterCardState { Self::ConnectedEmpty => "Connected — nothing loaded".to_string(), Self::HoldsUnreadableData { .. } => "Holds unreadable data".to_string(), Self::ReadyToSetUp => "Ready to set up".to_string(), + // "Bootloader" is our word, not the user's; the technical term + // stays available on the rich object. + Self::RecoveryMode => "Recovery mode".to_string(), Self::OtherFirmware => "Other firmware detected".to_string(), Self::NeedsFirmwareUpdate => "Needs a firmware update".to_string(), Self::NeedsAName => "Needs a name".to_string(), @@ -187,6 +209,17 @@ impl RosterCardState { /// (§3c-3); states without time copy ignore it. pub fn sub_line(&self, now_secs: f64) -> Option { match self { + // §3a again: the label alone leaves the user guessing why a + // board they just plugged in is not simply working. Say what + // this state IS, and say the part that surprises people — a + // device flashed from here does not come back on its own. + Self::RecoveryMode => Some( + "This board is waiting to be re-flashed instead of running \ + its firmware — usually a new board that won't talk normally, \ + or one being rescued. Anything you install from here needs \ + the board unplugged and plugged back in before it will run." + .to_string(), + ), // §3a: explain the situation, not just the label — with the // honest wall-clock facts when we have them (§3c-3). Self::EditedOnDevice { @@ -231,6 +264,11 @@ impl RosterCardState { // wipe the unreadable content, land on "nothing loaded". Self::HoldsUnreadableData { .. } => Some(RosterAffordance::WipeProject), Self::ReadyToSetUp | Self::OtherFirmware => Some(RosterAffordance::SetUp), + // Not SetUp: this is not a normal provisioning. The recovery + // flash has a different ending (replug), and a device here may + // hold data the user came to rescue — so the headline verb must + // not be the one that erases it. + Self::RecoveryMode => Some(RosterAffordance::Troubleshoot), Self::NeedsFirmwareUpdate => Some(RosterAffordance::UpdateFirmware), Self::NeedsAName => Some(RosterAffordance::NameDevice), Self::Offline { .. } => Some(RosterAffordance::Reconnect), @@ -344,6 +382,20 @@ mod tests { ); } + #[test] + fn recovery_mode_explains_itself_and_warns_about_the_replug() { + let note = RosterCardState::RecoveryMode.sub_line(0.0).expect( + "recovery mode must explain itself — the label alone \ + leaves the user guessing", + ); + // The replug is the part that surprises people: without it a + // successful flash looks like a dead board. + assert!( + note.contains("unplugged") && note.contains("plugged back in"), + "the replug requirement must be stated: {note}" + ); + } + #[test] fn only_the_diverged_row_carries_the_banked_sub_line() { let now = 1_000_000.0; diff --git a/lp-app/lpa-studio-core/src/app/roster/roster_evidence.rs b/lp-app/lpa-studio-core/src/app/roster/roster_evidence.rs index 638201033..26368f2f7 100644 --- a/lp-app/lpa-studio-core/src/app/roster/roster_evidence.rs +++ b/lp-app/lpa-studio-core/src/app/roster/roster_evidence.rs @@ -17,7 +17,8 @@ //! | [`ConnectEvidence::PortHeldElsewhere`] | In use elsewhere | //! | [`ConnectEvidence::Failed`] (ladder exhausted) | Not responding | //! | [`DeviceState::Booting`] | Connecting/retrying | -//! | [`DeviceState::BlankFlash`] / [`DeviceState::Bootloader`] | Ready to set up | +//! | [`DeviceState::BlankFlash`] | Ready to set up | +//! | [`DeviceState::Bootloader`] | Recovery mode | //! | [`DeviceState::ForeignFirmware`] | Other firmware | //! | [`DeviceState::Incompatible`] | Needs firmware update | //! | [`DeviceState::Unresponsive`] | Not responding | @@ -117,7 +118,12 @@ pub fn derive_roster_card_state(evidence: &RosterEvidence<'_>) -> RosterCardStat Some(DeviceState::Booting) => RosterCardState::ConnectingRetrying { phase: ConnectPhase::Connecting, }, - Some(DeviceState::BlankFlash | DeviceState::Bootloader) => RosterCardState::ReadyToSetUp, + Some(DeviceState::BlankFlash) => RosterCardState::ReadyToSetUp, + // Split from ReadyToSetUp 2026-07-31: these were collapsed, so the + // session detected ROM download mode and the card discarded it. A + // board in download mode is not a blank board — it will not boot a + // freshly flashed image without a physical replug. + Some(DeviceState::Bootloader) => RosterCardState::RecoveryMode, Some(DeviceState::ForeignFirmware) => RosterCardState::OtherFirmware, Some(DeviceState::Incompatible { .. }) => RosterCardState::NeedsFirmwareUpdate, Some(DeviceState::Unresponsive { .. }) => RosterCardState::NotResponding, @@ -177,12 +183,22 @@ mod tests { } #[test] - fn bootloader_maps_to_ready_to_set_up() { - // ROM download mode is the no-firmware family: waiting to be - // flashed IS "ready to set up" + fn bootloader_maps_to_recovery_mode_not_ready_to_set_up() { + // These were collapsed until 2026-07-31, on the reasoning that + // "waiting to be flashed IS ready to set up". A bench report + // falsified it: the session detected ROM download mode and the card + // discarded the fact, so a user in download mode was shown the + // blank-board flow. They are different situations — a device + // flashed from download mode does not boot the new image without a + // physical replug — and they take different verbs. assert_eq!( derive(&evidence().with_link(&DeviceState::Bootloader)), - RosterCardState::ReadyToSetUp + RosterCardState::RecoveryMode + ); + assert_ne!( + derive(&evidence().with_link(&DeviceState::Bootloader)), + derive(&evidence().with_link(&DeviceState::BlankFlash)), + "download mode and blank flash must stay distinguishable" ); } diff --git a/lp-app/lpa-studio-core/src/app/runtime_pool/runtime_session.rs b/lp-app/lpa-studio-core/src/app/runtime_pool/runtime_session.rs index 7313a7b4e..dbaf0652c 100644 --- a/lp-app/lpa-studio-core/src/app/runtime_pool/runtime_session.rs +++ b/lp-app/lpa-studio-core/src/app/runtime_pool/runtime_session.rs @@ -408,6 +408,14 @@ impl RuntimeSession { .unwrap_or_default() } + /// The latest heartbeat-reported recovery status from this session's + /// attached client (safe-mode clamp evidence for the device card). + pub fn recovery_status(&self) -> Option<&lpc_wire::server::RecoveryStatus> { + self.client + .as_ref() + .and_then(StudioServerClient::recovery_status) + } + /// Drain device-console lines buffered by this session's event sink /// (device sessions; the sim buffers nothing here). pub fn take_device_console_logs(&mut self) -> Vec { diff --git a/lp-app/lpa-studio-core/src/app/server/studio_server_client.rs b/lp-app/lpa-studio-core/src/app/server/studio_server_client.rs index 53ecd4d0a..c0f5cb1db 100644 --- a/lp-app/lpa-studio-core/src/app/server/studio_server_client.rs +++ b/lp-app/lpa-studio-core/src/app/server/studio_server_client.rs @@ -31,6 +31,11 @@ pub struct StudioServerClient { client: LpClient>, protocol: String, pending_logs: Rc>>, + /// The latest heartbeat-reported recovery status. Heartbeats ride + /// request outcomes (unsolicited id-0 frames drained during reads), so + /// this refreshes on every wire operation — the lens pull cadence while + /// editing, the attach sequence on connect. + last_recovery: Option, } impl StudioServerClient { @@ -40,6 +45,7 @@ impl StudioServerClient { client: LpClient::new(io), protocol: protocol.into(), pending_logs: Rc::new(RefCell::new(Vec::new())), + last_recovery: None, } } @@ -57,6 +63,7 @@ impl StudioServerClient { client: LpClient::new(io), protocol, pending_logs, + last_recovery: None, }) } @@ -68,6 +75,7 @@ impl StudioServerClient { client: LpClient::new(session.client_io()), protocol: connection_protocol(&session.connection().kind), pending_logs: Rc::new(RefCell::new(Vec::new())), + last_recovery: None, } } @@ -100,9 +108,9 @@ impl StudioServerClient { .iter() .find(|project| project.handle == handle) .map(|project| project.path.clone()); - let mut logs = map_client_events(deploy.events); - logs.extend(map_client_events(inventory.events)); - logs.extend(map_client_events(loaded.events)); + let mut logs = self.absorb_events(deploy.events); + logs.extend(self.absorb_events(inventory.events)); + logs.extend(self.absorb_events(loaded.events)); logs.extend(self.take_pending_logs()); Ok(LoadedDemoProject { @@ -119,6 +127,22 @@ impl StudioServerClient { core::mem::take(&mut *self.pending_logs.borrow_mut()) } + /// Map side-channel events to console drafts, remembering the latest + /// heartbeat-reported recovery status on the way through (the card's + /// safe-mode evidence). + fn absorb_events(&mut self, events: Vec) -> Vec { + if let Some(recovery) = latest_recovery(&events) { + self.last_recovery = Some(recovery.clone()); + } + map_client_events(events) + } + + /// The latest heartbeat-reported recovery status, if any heartbeat has + /// arrived on this client yet. + pub fn recovery_status(&self) -> Option<&lpc_wire::server::RecoveryStatus> { + self.last_recovery.as_ref() + } + /// Open a library project on the runtime: whole-project replace → /// hash verification → load → version probe (load-as-push, D19). /// @@ -140,14 +164,14 @@ impl StudioServerClient { .await .map_err(map_client_error)?; let handle = deploy.value; - let mut logs = map_client_events(deploy.events); + let mut logs = self.absorb_events(deploy.events); let hash = self .client .hash_package(storage_id) .await .map_err(map_client_error)?; - logs.extend(map_client_events(hash.events)); + logs.extend(self.absorb_events(hash.events)); if hash.value != expected_hash { return Err(map_client_error( lpa_client::client_error::ClientError::Protocol(format!( @@ -164,7 +188,7 @@ impl StudioServerClient { .pull_changed_files(storage_id, lpc_model::FsVersion::new(i64::MAX - 1)) .await .map_err(map_client_error)?; - logs.extend(map_client_events(version_probe.events)); + logs.extend(self.absorb_events(version_probe.events)); let (_, synced_version) = version_probe.value; let inventory = self @@ -172,7 +196,7 @@ impl StudioServerClient { .project_inventory_read(handle) .await .map_err(map_client_error)?; - logs.extend(map_client_events(inventory.events)); + logs.extend(self.absorb_events(inventory.events)); // Resolve the server filesystem root by handle (same as the demo // path): the library slug is a display identity, not a server path, @@ -188,7 +212,7 @@ impl StudioServerClient { .iter() .find(|project| project.handle == handle) .map(|project| project.path.clone()); - logs.extend(map_client_events(loaded.events)); + logs.extend(self.absorb_events(loaded.events)); logs.extend(self.take_pending_logs()); Ok(LoadedLibraryProject { @@ -214,7 +238,7 @@ impl StudioServerClient { .await .map_err(map_client_error)?; let (updates, version) = outcome.value; - let mut logs = map_client_events(outcome.events); + let mut logs = self.absorb_events(outcome.events); logs.extend(self.take_pending_logs()); Ok(PulledFiles { updates, @@ -234,7 +258,7 @@ impl StudioServerClient { .replace_project_files(project_id, files) .await .map_err(map_client_error)?; - let mut logs = map_client_events(outcome.events); + let mut logs = self.absorb_events(outcome.events); logs.extend(self.take_pending_logs()); Ok(logs) } @@ -252,7 +276,7 @@ impl StudioServerClient { .fs_write(path, bytes.to_vec()) .await .map_err(map_client_error)?; - let mut logs = map_client_events(outcome.events); + let mut logs = self.absorb_events(outcome.events); logs.extend(self.take_pending_logs()); Ok(logs) } @@ -267,7 +291,7 @@ impl StudioServerClient { .hash_package(project_id) .await .map_err(map_client_error)?; - let mut logs = map_client_events(outcome.events); + let mut logs = self.absorb_events(outcome.events); logs.extend(self.take_pending_logs()); Ok((outcome.value, logs)) } @@ -397,7 +421,7 @@ impl StudioServerClient { .set_log_level(wire_log_level(level)) .await .map_err(map_client_error)?; - let mut logs = map_client_events(outcome.events); + let mut logs = self.absorb_events(outcome.events); logs.extend(self.take_pending_logs()); Ok(logs) } @@ -408,7 +432,7 @@ impl StudioServerClient { .project_list_loaded() .await .map_err(map_client_error)?; - let mut logs = map_client_events(loaded.events); + let mut logs = self.absorb_events(loaded.events); logs.extend(self.take_pending_logs()); Ok(LoadedProjectCatalog { projects: loaded @@ -429,9 +453,8 @@ impl StudioServerClient { .project_inventory_read(WireProjectHandle::new(choice.handle_id)) .await .map_err(map_client_error)?; - self.pending_logs - .borrow_mut() - .extend(map_client_events(inventory.events)); + let drafts = self.absorb_events(inventory.events); + self.pending_logs.borrow_mut().extend(drafts); Ok(LoadedRunningProject { fs_root: lpc_model::LpPathBuf::from(choice.project_id.as_str()), project_id: choice.project_id, @@ -451,7 +474,7 @@ impl StudioServerClient { .project_read(WireProjectHandle::new(handle_id), request) .await .map_err(map_client_error)?; - let mut logs = map_client_events(read.events); + let mut logs = self.absorb_events(read.events); logs.extend(self.take_pending_logs()); Ok(StudioProjectRead { events: read.value, @@ -470,7 +493,7 @@ impl StudioServerClient { .project_overlay_read(WireProjectHandle::new(handle_id)) .await .map_err(map_client_error)?; - let mut logs = map_client_events(read.events); + let mut logs = self.absorb_events(read.events); logs.extend(self.take_pending_logs()); Ok(StudioOverlayRead { overlay: read.value.overlay, @@ -495,7 +518,7 @@ impl StudioServerClient { ) .await .map_err(map_client_error)?; - let mut logs = map_client_events(response.events); + let mut logs = self.absorb_events(response.events); logs.extend(self.take_pending_logs()); Ok(StudioOverlayMutation { result: response.value.result, @@ -518,7 +541,7 @@ impl StudioServerClient { .project_node_command(WireProjectHandle::new(handle_id), node, command) .await .map_err(map_client_error)?; - let mut logs = map_client_events(response.events); + let mut logs = self.absorb_events(response.events); logs.extend(self.take_pending_logs()); Ok(StudioNodeCommand { response: response.value, @@ -531,7 +554,7 @@ impl StudioServerClient { /// (`ProjectController::asset_content`). pub async fn fs_read(&mut self, path: &lpc_model::LpPath) -> Result { let read = self.client.fs_read(path).await.map_err(map_client_error)?; - let mut logs = map_client_events(read.events); + let mut logs = self.absorb_events(read.events); logs.extend(self.take_pending_logs()); Ok(StudioFsRead { data: read.value, @@ -551,7 +574,7 @@ impl StudioServerClient { .project_create_node(WireProjectHandle::new(handle_id), request) .await .map_err(map_client_error)?; - let mut logs = map_client_events(outcome.events); + let mut logs = self.absorb_events(outcome.events); logs.extend(self.take_pending_logs()); Ok(StudioCreateNode { response: outcome.value, @@ -571,7 +594,7 @@ impl StudioServerClient { .project_remove_node(WireProjectHandle::new(handle_id), request) .await .map_err(map_client_error)?; - let mut logs = map_client_events(outcome.events); + let mut logs = self.absorb_events(outcome.events); logs.extend(self.take_pending_logs()); Ok(StudioRemoveNode { response: outcome.value, @@ -591,7 +614,7 @@ impl StudioServerClient { .project_inventory_read(WireProjectHandle::new(handle_id)) .await .map_err(map_client_error)?; - let mut logs = map_client_events(inventory.events); + let mut logs = self.absorb_events(inventory.events); logs.extend(self.take_pending_logs()); Ok((node_def_artifacts(&inventory.value), logs)) } @@ -607,7 +630,7 @@ impl StudioServerClient { .project_overlay_commit(WireProjectHandle::new(handle_id)) .await .map_err(map_client_error)?; - let mut logs = map_client_events(response.events); + let mut logs = self.absorb_events(response.events); logs.extend(self.take_pending_logs()); Ok(StudioOverlayCommit { result: response.value.result, @@ -641,7 +664,7 @@ impl StudioServerClient { .await { PullOutcome::Completed { events, observed } => { - let mut logs = map_client_events(observed); + let mut logs = self.absorb_events(observed); logs.extend(self.take_pending_logs()); Ok(StudioProjectReadOutcome::Completed(StudioProjectRead { events, @@ -721,6 +744,18 @@ fn connection_protocol(kind: &LinkConnectionKind) -> String { } } +/// The newest heartbeat-reported recovery status in an event batch (the +/// safe-mode clamp evidence [`StudioServerClient::recovery_status`] keeps). +fn latest_recovery(events: &[ClientEvent]) -> Option<&lpc_wire::server::RecoveryStatus> { + events.iter().rev().find_map(|event| match event { + ClientEvent::Heartbeat { + recovery: Some(recovery), + .. + } => Some(recovery), + _ => None, + }) +} + /// Map side-channel client events to console log drafts. /// /// Healthy heartbeats are telemetry, not log content: they arrive every @@ -864,6 +899,19 @@ mod tests { assert!(map_client_events(events).is_empty()); } + #[test] + fn the_newest_heartbeat_recovery_report_wins_the_batch() { + let mut clamped = recovery_status(RecoveryLevelWire::Green, false, None); + clamped.output_clamp = Some(26); + let events = vec![ + heartbeat_event(Some(recovery_status(RecoveryLevelWire::Green, false, None))), + heartbeat_event(Some(clamped.clone())), + ]; + + assert_eq!(latest_recovery(&events), Some(&clamped)); + assert_eq!(latest_recovery(&[]), None); + } + #[test] fn red_recovery_heartbeat_still_logs_an_error() { let crash = CrashSummaryWire { @@ -988,6 +1036,7 @@ mod tests { reset_reason: "power-on".to_string(), boot_count: 1, safe_mode, + output_clamp: None, last_crash, paths: Vec::new(), } diff --git a/lp-app/lpa-studio-core/src/app/studio/studio_controller.rs b/lp-app/lpa-studio-core/src/app/studio/studio_controller.rs index 65c491351..ab6326399 100644 --- a/lp-app/lpa-studio-core/src/app/studio/studio_controller.rs +++ b/lp-app/lpa-studio-core/src/app/studio/studio_controller.rs @@ -139,6 +139,14 @@ pub struct StudioController { /// card). One slot because the pool holds one hardware session; this /// grows into a per-identity map with the pool. device_card_op: Option<(Option, Rc>)>, + /// The most recent finished device filesystem backup, carried on the + /// home view until the shell downloads it. Kept (rather than emitted and + /// forgotten) because the view is a full snapshot; `device_backup_seq` is + /// what stops a re-render from re-downloading it. + device_backup: Option, + /// Session-monotonic backup counter. Never reset — the shell compares it + /// against the last one it acted on. + device_backup_seq: u64, /// When the last sim crash auto-reboot ran (`None` = never). Epoch /// seconds on the injected clock; the flap guard: a second crash /// within [`SIM_CRASH_REBOOT_GUARD_SECS`] stays Failed for manual @@ -216,6 +224,8 @@ impl StudioController { port_held_retry_at: None, card_ui: std::collections::HashMap::new(), device_card_op: None, + device_backup: None, + device_backup_seq: 0, sim_crash_reboot_at: None, random: Rc::new(clock_fallback_random), settings: crate::app::settings::SettingsStore::default(), @@ -854,6 +864,7 @@ impl StudioController { .into_iter() .map(|card| self.overlay_card_ui(card)) .collect(); + view.backup = self.device_backup.clone(); Some(view) } @@ -904,6 +915,11 @@ impl StudioController { .device_session() .map(|session| session.console_tail().iter().cloned().collect()) .unwrap_or_default(), + op_in_flight: self.device_card_op.is_some(), + recovery: self + .pool + .device_session() + .and_then(|session| session.recovery_status().cloned()), }; let sim = self .pool @@ -2067,6 +2083,11 @@ impl StudioController { } DeviceOp::WipeProject => self.wipe_project().await, DeviceOp::ResetToBlank => self.reset_to_blank(updates).await, + DeviceOp::BootSafeOnce => self.boot_safe_once(updates).await, + DeviceOp::BackUpFilesystem => self.back_up_filesystem(updates).await, + DeviceOp::ProbeBootloaderMode { card_key, flow } => { + self.probe_bootloader_mode(card_key, flow).await + } DeviceOp::RefreshConnections => { // Drop the session (no provider close) + catalog refresh. self.device.refresh_provider_catalog(); @@ -3967,7 +3988,11 @@ impl StudioController { degrade_subject: "device reset", server_reconnect_failed_notice: "Device reset; reconnect after it finishes booting", // a runtime reset reboots the device; the project survives. + // Reset/erase/boot-control all leave the device able to come + // back by itself. + awaits_manual_replug: false, severs_lens: false, + result_sink: None, }, updates, ) @@ -3979,21 +4004,38 @@ impl StudioController { updates: UxUpdateSink, setup_name: Option, ) -> UiResult { + // A flash performed while the chip sits in ROM download mode is a + // different flow, and the difference is the ENDING: the board does + // not boot the image it was just given until it is physically + // replugged. The shape follows the device's actual state rather + // than which button was pressed, because that is what determines + // whether waiting for a reattach is sensible. + let from_recovery = self.device_is_in_recovery_mode(); let mut outcome = self .run_device_management( ManagementFlowSpec { request: LinkManagementRequest::FlashFirmware, progress_label: "Flashing firmware", - reconnect_detail: "Waiting for firmware boot", + reconnect_detail: if from_recovery { + "Unplug the board and plug it back in to start it" + } else { + "Waiting for firmware boot" + }, failed_exit_label: "Back to set up", record_captured_logs_on_success: false, done_notice: provision_notice, degrade_subject: "firmware flashed", - server_reconnect_failed_notice: - "Firmware flashed; reconnect the server after the device finishes booting", + server_reconnect_failed_notice: if from_recovery { + "Firmware flashed. Unplug the board and plug it back in — \ + it will not start on its own from recovery mode." + } else { + "Firmware flashed; reconnect the server after the device finishes booting" + }, + awaits_manual_replug: from_recovery, // a flash reboots into new firmware; the stored project // survives and reloads on reattach. severs_lens: false, + result_sink: None, }, updates, ) @@ -4060,6 +4102,213 @@ impl StudioController { ))) } + /// Ask the device whether a bootloader is listening, and fold the answer + /// into the card's open bootloader-entry sheet. + /// + /// This is the step that makes the ritual's confirmation real. Without + /// it the sheet can show steps and then never resolve, which is worse + /// than showing nothing: the user has no way to tell a failed attempt + /// from a dead board, which is the exact confusion the flow exists to + /// remove. + /// + /// The probe REBOOTS the device, so it runs only here — on the user's + /// explicit "I've done that", which is itself the signal that a replug + /// just happened. + async fn probe_bootloader_mode( + &mut self, + card_key: String, + flow: crate::BootloaderEntryFlow, + ) -> UiResult { + use crate::CardUiOp; + + // Show the waiting state first: the probe takes seconds (reset, + // sync, re-enumerate) and a frozen sheet reads as a hang. + let waiting = flow.begin_waiting(); + self.apply_card_ui_op(CardUiOp::OpenSheet { + card: card_key.clone(), + sheet: crate::CardSheet::BootloaderEntry(waiting.clone()), + }); + + let Some(session) = self.hardware_session() else { + return Err(UiError::MissingSession( + "no hardware device session to probe".to_string(), + )); + }; + let probed = session + .probe_link_mode(lpa_link::DeviceEventSink::noop()) + .await; + + let settled = match probed { + Ok(mode) if mode.is_bootloader() => { + let chip_name = match &mode { + lpa_link::DeviceLinkMode::Bootloader { chip_name, .. } => chip_name.clone(), + _ => None, + }; + waiting.on_probe_answered(chip_name) + } + // Reached the device but it is not in bootloader mode, OR the + // probe itself failed. Both mean "that attempt did not land" — + // NOT "the device is broken", since an app-mode device ignores + // the handshake too. + Ok(_) | Err(_) => waiting.on_probe_unanswered(), + }; + self.apply_card_ui_op(CardUiOp::OpenSheet { + card: card_key, + sheet: crate::CardSheet::BootloaderEntry(settled.clone()), + }); + + Ok(if settled.is_confirmed() { + UiNotices::new().with_notice(UiNotice::info("Device is in recovery mode")) + } else { + UiNotices::new() + }) + } + + /// Whether the managed device is currently sitting in ROM download + /// mode — the state whose flash needs a manual replug to take effect. + fn device_is_in_recovery_mode(&self) -> bool { + self.hardware_session().is_some_and(|session| { + matches!(session.snapshot().state, lpa_link::DeviceState::Bootloader) + }) + } + + /// Write the boot-control record so the next restart skips project + /// auto-load. + /// + /// Deliberately gentler than [`Self::reset_to_blank`]: nothing is erased, + /// so the lens is not severed and the user keeps their project. The + /// device consumes the record as it boots, so this affects exactly one + /// restart. + async fn boot_safe_once(&mut self, updates: UxUpdateSink) -> UiResult { + // From app mode the record is written and the device reboots into + // its firmware by itself. From RECOVERY mode it cannot: the + // manually-entered download mode latches until power-on reset + // (bench-confirmed 2026-07-31), so the ending is the replug + // instruction — the same physics as the recovery flash. + let from_recovery = self.device_is_in_recovery_mode(); + self.run_device_management( + ManagementFlowSpec { + request: LinkManagementRequest::start_safe_mode(), + progress_label: "Arming safe mode", + reconnect_detail: if from_recovery { + "Unplug the board and plug it back in to start it" + } else { + "Restarting in safe mode — if it doesn't reconnect in a \ + few seconds, unplug the board and plug it back in" + }, + failed_exit_label: "Back to device", + record_captured_logs_on_success: false, + done_notice: boot_safe_once_notice, + degrade_subject: "safe mode armed", + server_reconnect_failed_notice: if from_recovery { + "Safe mode armed. Unplug the board and plug it back in — \ + it will start dim, or with nothing loaded on older \ + firmware." + } else { + "Safe mode armed. If the board doesn't reconnect on its \ + own, unplug it and plug it back in." + }, + // ALWAYS the awaiting ending, both modes (bench 2026-07-31): + // from app mode the board normally returns by itself and a + // successful reattach clears the op — but when the reattach + // misses (USB re-enumeration races the rebuild), a bare + // "Not seen yet" offline card with no guidance is the worst + // of the endings. An instruction that self-clears on success + // costs nothing when the happy path lands. + awaits_manual_replug: true, + // Nothing is erased — the project is still on the device and + // the editor's lens stays valid. + severs_lens: false, + result_sink: None, + }, + updates, + ) + .await + } + + /// Read the device's filesystem over the bootloader and publish a ZIP of + /// it for the shell to download. + /// + /// This is the operation that makes the originating failure survivable — + /// the user's work comes off the board BEFORE anything destructive + /// happens to it — so it deliberately reads like the gentle ops: nothing + /// is erased, the lens is not severed, and a device in recovery mode is + /// told it needs a replug rather than being reported as a failure. + async fn back_up_filesystem(&mut self, updates: UxUpdateSink) -> UiResult { + let from_recovery = self.device_is_in_recovery_mode(); + let device_label = self + .device_sync() + .and_then(|sync| sync.identity.as_ref()) + .map(|identity| identity.name.clone()); + let sink: Rc>> = Rc::new(RefCell::new(None)); + let mut outcome = self + .run_device_management( + ManagementFlowSpec { + request: LinkManagementRequest::ReadRawFilesystem, + progress_label: "Backing up", + reconnect_detail: if from_recovery { + "Unplug the board and plug it back in to start it" + } else { + "Waiting for device boot" + }, + failed_exit_label: "Back to device", + record_captured_logs_on_success: false, + done_notice: |_| UiNotice::info("Backup read from the device"), + degrade_subject: "device backed up", + server_reconnect_failed_notice: if from_recovery { + "Backup taken. Unplug the board and plug it back in — \ + it will not start on its own from recovery mode." + } else { + "Backup taken; reconnect after the device finishes booting" + }, + awaits_manual_replug: from_recovery, + // A read writes nothing: the project is still on the + // device and a lens on it stays valid. + severs_lens: false, + result_sink: Some(Rc::clone(&sink)), + }, + updates, + ) + .await?; + + let read = match sink.borrow_mut().take() { + Some(LinkManagementResult::ReadRawFilesystem(read)) => read, + // The provider answered something else entirely — a dispatch + // bug, not a device problem. Say so rather than pretending. + _ => { + return Err(UiError::Link( + "the filesystem read returned no image".to_string(), + )); + } + }; + let archive = crate::app::device::filesystem_backup::build_backup_archive( + &read.image, + &crate::app::device::BackupSource { + chip: read.chip_name.clone(), + partition_offset: read.region.offset, + partition_length: read.region.length, + device_label, + }, + (self.now_secs)(), + ) + .map_err(|error| UiError::Link(error.to_string()))?; + + self.device_backup_seq += 1; + let file_count = archive.manifest.file_count; + outcome = outcome.with_notice(UiNotice::info(format!( + "Backed up {file_count} file(s) to {}", + archive.file_name + ))); + self.device_backup = Some(crate::UiDeviceBackup { + seq: self.device_backup_seq, + file_name: archive.file_name, + bytes: Rc::from(archive.bytes), + file_count, + }); + self.mark_dirty(); + Ok(outcome) + } + async fn reset_to_blank(&mut self, updates: UxUpdateSink) -> UiResult { self.run_device_management( ManagementFlowSpec { @@ -4074,7 +4323,11 @@ impl StudioController { "Device wiped; reconnect after the device finishes booting", // a wipe erases the flash — the project is gone; a lens on // this device is severed and the app returns to the gallery. + // Reset/erase/boot-control all leave the device able to come + // back by itself. + awaits_manual_replug: false, severs_lens: true, + result_sink: None, }, updates, ) @@ -4162,7 +4415,9 @@ impl StudioController { )); } }; - session.manage(spec.request, event_sink).await + // Cloned so the spec stays whole: the reattach half below still + // needs its copy for `reattach_failure_op`. + session.manage(spec.request.clone(), event_sink).await }; // The manage half settled (either way): session replaces unblock // and the card's operation narration clears. @@ -4190,6 +4445,11 @@ impl StudioController { self.record_logs(management_result_logs(&management.result)); let mut outcome = UiNotices::new().with_notice((spec.done_notice)(&management.result)); + // Hand the raw result on AFTER the log replay and the notice, so the + // move costs nothing (a filesystem image is ~1 MB). + if let Some(sink) = &spec.result_sink { + *sink.borrow_mut() = Some(management.result); + } // The reattach half is the op's AwaitingDevice phase (I2) — the // overlay stays up, narrating the expected gap. It is a LONG // phase (the board reboots and re-enumerates), so the delta goes @@ -4207,9 +4467,48 @@ impl StudioController { match self.attach_runtime(device_id, updates).await { Ok(mut attach_outcome) => { outcome.notices.append(&mut attach_outcome.notices); - // Landed (I3): the flow ends; the card re-derives and its - // Status tab announces what's next. - self.device_card_op = None; + if spec.awaits_manual_replug && self.device_is_in_recovery_mode() { + // Attached — but the board landed back in the BOOTLOADER. + // A C6 over USB-Serial-JTAG re-enters download mode on + // the post-write RTS reset (bench, 2026-07-31), so the + // reattach "succeeds" into a recovery session and the + // old clear-on-Ok dropped the user on the recovery card, + // which advises installing the firmware they may have + // JUST installed. For an op that only finishes when the + // board really boots, this ending is the replug + // instruction, same as the reattach-miss arm below. + self.push_log(UiLogDraft::new( + UiLogLevel::Info, + UiLogOrigin::Studio, + format!( + "{} — the board stays in the bootloader until replugged", + spec.degrade_subject + ), + )); + *card_op.borrow_mut() = reattach_failure_op(&spec, ""); + outcome = + outcome.with_notice(UiNotice::info(spec.server_reconnect_failed_notice)); + } else { + // Landed (I3): the flow ends; the card re-derives and its + // Status tab announces what's next. + self.device_card_op = None; + } + } + // The device was never going to come back by itself. Stay in + // the AwaitingDevice phase with the instruction that ends it, + // rather than calling a successful flash a failure and marking + // the session failed for doing exactly what it must do. + Err(_) if spec.awaits_manual_replug => { + self.push_log(UiLogDraft::new( + UiLogLevel::Info, + UiLogOrigin::Studio, + format!( + "{} — waiting for the board to be replugged", + spec.degrade_subject + ), + )); + *card_op.borrow_mut() = reattach_failure_op(&spec, ""); + outcome = outcome.with_notice(UiNotice::info(spec.server_reconnect_failed_notice)); } Err(error) => { self.push_log(UiLogDraft::new( @@ -4225,11 +4524,7 @@ impl StudioController { } // Failed reattach renders on the card too (I4) — with the // same single exit, not a silent fall-through. - *card_op.borrow_mut() = crate::CardOp::failed( - format!("{} — reconnect failed", spec.degrade_subject), - error.to_string(), - spec.failed_exit_label, - ); + *card_op.borrow_mut() = reattach_failure_op(&spec, &error.to_string()); outcome = outcome.with_notice(UiNotice::info(spec.server_reconnect_failed_notice)); } } @@ -4548,6 +4843,27 @@ fn project_tree_item_actions( /// wipe): the link request plus the notice/log wording that differs between /// them. Everything else — quiesce, capture, manage, reopen, reattach, /// degrade — is shared in `StudioController::run_device_management`. +/// What the card shows when the post-operation reattach does not land. +/// +/// Two different situations wear the same error: a device that FAILED to come +/// back, and a device that was never going to. A board flashed from ROM +/// download mode does not boot the new image until it is physically +/// replugged, so treating its non-return as a failure reports a successful +/// flash as a failure — on the one path a user in recovery actually takes. +fn reattach_failure_op(spec: &ManagementFlowSpec, error: &str) -> crate::CardOp { + if spec.awaits_manual_replug { + // Not failed: awaiting the one action that finishes the job, with + // the instruction itself as the narration. + crate::CardOp::awaiting(spec.reconnect_detail) + } else { + crate::CardOp::failed( + format!("{} — reconnect failed", spec.degrade_subject), + error.to_string(), + spec.failed_exit_label, + ) + } +} + struct ManagementFlowSpec { request: LinkManagementRequest, /// Activity label while the management operation runs. @@ -4567,6 +4883,16 @@ struct ManagementFlowSpec { /// reset" → "device reset but serial reopen failed: …". degrade_subject: &'static str, server_reconnect_failed_notice: &'static str, + /// The device will NOT come back on its own — the user has to unplug + /// and replug it — so a failed reattach is the EXPECTED ending, not a + /// failure. + /// + /// True for a flash performed while the chip sits in ROM download mode: + /// it does not boot the freshly written image without a power cycle. + /// Without this the flow flashes successfully, waits for a device that + /// cannot return, and then reports the success as a failure — which is + /// exactly the path a user in recovery takes. + awaits_manual_replug: bool, /// The op takes the project WITH it (a destructive wipe): when the /// editor lens sits on the managed device, fully quiesce it — detach /// the lens so the app returns to the gallery — and say so, rather than @@ -4574,6 +4900,10 @@ struct ManagementFlowSpec { /// project on the device, so its lens only resets its live edit-state /// and stays put to reload after the reattach (device-lifecycle P3). severs_lens: bool, + /// Where the raw management result lands for flows whose outcome is more + /// than a notice — the filesystem backup needs the image bytes. `None` + /// everywhere else, and the result is MOVED in (it can be a megabyte). + result_sink: Option>>>, } fn provision_notice(result: &LinkManagementResult) -> UiNotice { @@ -4585,6 +4915,19 @@ fn provision_notice(result: &LinkManagementResult) -> UiNotice { } } +fn boot_safe_once_notice(result: &LinkManagementResult) -> UiNotice { + let label = match result { + LinkManagementResult::SetBootControl(result) => { + result.chip_name.as_deref().unwrap_or("This device") + } + _ => "This device", + }; + UiNotice::info(format!( + "{label} will start once in safe mode — dim, or with nothing loaded \ + on older firmware. The restart after that is normal." + )) +} + fn reset_notice(result: &LinkManagementResult) -> UiNotice { match result { LinkManagementResult::EraseDeviceFlash(result) => { @@ -6059,3 +6402,54 @@ mod tests { fn wake(self: Arc) {} } } + +#[cfg(test)] +mod reattach_failure_tests { + use super::*; + use crate::CardOp; + + fn spec(awaits_manual_replug: bool) -> ManagementFlowSpec { + ManagementFlowSpec { + request: LinkManagementRequest::FlashFirmware, + progress_label: "Flashing firmware", + reconnect_detail: "Unplug the board and plug it back in to start it", + failed_exit_label: "Back to set up", + record_captured_logs_on_success: false, + done_notice: provision_notice, + degrade_subject: "firmware flashed", + server_reconnect_failed_notice: "Firmware flashed.", + awaits_manual_replug, + severs_lens: false, + result_sink: None, + } + } + + #[test] + fn a_device_that_cannot_return_is_awaited_not_failed() { + // The board was flashed from ROM download mode: it does not boot the + // new image until a human power-cycles it. Reporting that as a + // failure calls a successful flash a failure, on the one path a user + // in recovery actually takes. + let op = reattach_failure_op(&spec(true), "device did not come back"); + assert_eq!( + op, + CardOp::awaiting("Unplug the board and plug it back in to start it"), + "the ending must be the instruction that finishes the job" + ); + } + + #[test] + fn a_device_that_should_have_returned_still_fails_loudly() { + // The ordinary case must keep its Failed render and its single exit + // (model §2 I4) — tolerating THIS would hide real breakage. + let op = reattach_failure_op(&spec(false), "serial reopen failed"); + assert_eq!( + op, + CardOp::failed( + "firmware flashed — reconnect failed".to_string(), + "serial reopen failed".to_string(), + "Back to set up", + ) + ); + } +} diff --git a/lp-app/lpa-studio-core/src/app/studio/studio_link_e2e_tests.rs b/lp-app/lpa-studio-core/src/app/studio/studio_link_e2e_tests.rs index 75fc20b7c..f968036e2 100644 --- a/lp-app/lpa-studio-core/src/app/studio/studio_link_e2e_tests.rs +++ b/lp-app/lpa-studio-core/src/app/studio/studio_link_e2e_tests.rs @@ -2538,6 +2538,89 @@ fn coexisting_fixture( /// A studio whose link registry holds one fake provider with one scripted /// device endpoint. Returns the device handle for injection/assertions. +/// M6, the whole point: a device's storage comes off as a ZIP through the +/// REAL provider path — read raw, mounted in-process, archived — and the +/// archive holds the files the board actually has. +/// +/// The fake answers the raw read with a genuine littlefs image, so this +/// exercises every step the hardware path takes except the serial bytes: +/// dispatch, capability gate, mount, walk, manifest, zip, and the seq-gated +/// hand-off to the shell. +#[test] +fn backing_up_a_device_publishes_a_zip_of_its_files() { + use std::io::Read; + + let script = FakeDeviceScript::new(FakeBootState::LightPlayer( + FakeLightPlayerState::new() + .with_project_files(vec![ + ( + "project.json".to_string(), + br#"{"kind":"Project","name":"sign","nodes":{}}"#.to_vec(), + ), + ("shader.glsl".to_string(), b"void main() {}".to_vec()), + ]) + .with_identity(FakeDeviceIdentity::new( + "dev_bbbbbbbbbbbbbbbb", + "Bench board", + )), + )); + let (_store, host) = library(); + let (mut studio, _device, endpoint_id) = studio_with_fake_device(script); + studio.attach_library(host); + connect_through_link(&mut studio, &endpoint_id).expect("connect succeeds"); + drive(studio.dispatch(device_action(DeviceOp::BackUpFilesystem))).expect("the backup succeeds"); + + let backup = studio + .view() + .home + .expect("the gallery is showing") + .backup + .expect("a finished backup rides the view"); + assert_eq!(backup.seq, 1, "the first backup of the session"); + assert!( + backup + .file_name + .starts_with("lightplayer-backup-bench-board-"), + "the file names itself after the device: {}", + backup.file_name + ); + + let mut archive = + zip::ZipArchive::new(std::io::Cursor::new(backup.bytes.as_ref())).expect("a zip archive"); + let names: Vec = (0..archive.len()) + .map(|index| archive.by_index(index).unwrap().name().to_string()) + .collect(); + assert!( + names.contains(&"manifest.json".to_string()), + "the manifest is at the archive root: {names:?}" + ); + assert!( + names.contains(&"files/projects/studio/shader.glsl".to_string()), + "device paths mirror verbatim under files/: {names:?}" + ); + let mut shader = String::new(); + archive + .by_name("files/projects/studio/shader.glsl") + .expect("the shader entry") + .read_to_string(&mut shader) + .expect("shader bytes"); + assert_eq!(shader, "void main() {}"); + + // The identity hazard M7 has to detect: the captured uid is recorded, so + // a restore can tell it is about to clone a device. + let mut manifest_json = String::new(); + archive + .by_name("manifest.json") + .expect("the manifest entry") + .read_to_string(&mut manifest_json) + .expect("manifest bytes"); + let manifest: serde_json::Value = + serde_json::from_str(&manifest_json).expect("the manifest parses"); + assert_eq!(manifest["deviceUid"], "dev_bbbbbbbbbbbbbbbb"); + assert_eq!(manifest["formatVersion"], 1); + assert_eq!(manifest["partitionOffset"], 0x0031_0000); +} + fn studio_with_fake_device( script: FakeDeviceScript, ) -> (StudioController, FakeEsp32Device, LinkEndpointId) { diff --git a/lp-app/lpa-studio-core/src/app/studio/ui_studio_view.rs b/lp-app/lpa-studio-core/src/app/studio/ui_studio_view.rs index 8f9e6cd89..ceef6f0c0 100644 --- a/lp-app/lpa-studio-core/src/app/studio/ui_studio_view.rs +++ b/lp-app/lpa-studio-core/src/app/studio/ui_studio_view.rs @@ -259,6 +259,7 @@ mod tests { state, project: None, fw: None, + safe_clamp: None, sim: false, console_tail: Vec::new(), ui: CardUiState::default(), @@ -273,6 +274,7 @@ mod tests { library_available: true, opening: None, issue: None, + backup: None, })) } diff --git a/lp-app/lpa-studio-core/src/lib.rs b/lp-app/lpa-studio-core/src/lib.rs index be8c62193..6923a2fc0 100644 --- a/lp-app/lpa-studio-core/src/lib.rs +++ b/lp-app/lpa-studio-core/src/lib.rs @@ -34,8 +34,9 @@ pub use app::agent::{ }; pub use app::bus::{UiBusChannelView, UiBusSiteView, UiBusView}; pub use app::device::{ - ConnectFlowState, ConnectedDeviceSummary, DEPLOY_NODE_ID, DeployOp, DeployTarget, - DeviceController, DeviceOp, DeviceOpenOutcome, EndpointChoice, ProviderChoice, + BootloaderEntryFlow, ConnectFlowState, ConnectedDeviceSummary, DEPLOY_NODE_ID, DeployOp, + DeployTarget, DeviceController, DeviceOp, DeviceOpenOutcome, EndpointChoice, ProviderChoice, + RecoveryInstructions, RecoveryStep, UiDeviceBackup, }; pub use app::home::{ CardOp, CardOpPhase, CardSheet, CardUiOp, CardUiState, CardVerb, HOME_NODE_ID, diff --git a/lp-app/lpa-studio-web/assets/tailwind.css b/lp-app/lpa-studio-web/assets/tailwind.css index a6beb14e7..8254bf79c 100644 --- a/lp-app/lpa-studio-web/assets/tailwind.css +++ b/lp-app/lpa-studio-web/assets/tailwind.css @@ -405,21 +405,9 @@ .tw\:min-h-\[46px\] { min-height: 46px; } - .tw\:min-h-\[210px\] { - min-height: 210px; - } - .tw\:min-h-\[240px\] { - min-height: 240px; - } - .tw\:min-h-\[260px\] { - min-height: 260px; - } .tw\:min-h-\[320px\] { min-height: 320px; } - .tw\:min-h-\[370px\] { - min-height: 370px; - } .tw\:min-h-\[380px\] { min-height: 380px; } @@ -1617,6 +1605,9 @@ --tw-tracking: var(--tw-tracking-wide); letter-spacing: var(--tw-tracking-wide); } + .tw\:\[overflow-wrap\:anywhere\] { + overflow-wrap: anywhere; + } .tw\:break-words { overflow-wrap: break-word; } @@ -1835,6 +1826,10 @@ --tw-outline-style: none; outline-style: none; } + .tw\:select-all { + -webkit-user-select: all; + user-select: all; + } .tw\:select-none { -webkit-user-select: none; user-select: none; @@ -2230,6 +2225,11 @@ cursor: not-allowed; } } + .tw\:disabled\:text-dim-foreground { + &:disabled { + color: var(--tw-color-dim-foreground); + } + } .tw\:disabled\:opacity-40 { &:disabled { opacity: 40%; diff --git a/lp-app/lpa-studio-web/scripts/story-apply-refresh.mjs b/lp-app/lpa-studio-web/scripts/story-apply-refresh.mjs index c510fe9aa..df478764e 100644 --- a/lp-app/lpa-studio-web/scripts/story-apply-refresh.mjs +++ b/lp-app/lpa-studio-web/scripts/story-apply-refresh.mjs @@ -21,15 +21,6 @@ const REFRESH_MANIFEST_FILE = ".refresh-manifest.json"; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(scriptDir, "../../.."); -const freshDir = path.resolve(process.argv[2] ?? ""); -const baselineDir = path.resolve( - process.argv[3] ?? path.join(repoRoot, "lp-app/lpa-studio-web/story-images"), -); -if (!process.argv[2]) { - console.error("Usage: story-apply-refresh.mjs [baseline-dir]"); - process.exit(2); -} - export async function applyRefresh(freshDir, baselineDir) { let manifest; try { @@ -71,7 +62,19 @@ export async function applyRefresh(freshDir, baselineDir) { return { replaced: replace.length, removed: remove.length }; } +// CLI entry only. Argument parsing MUST stay inside this guard: `story-pull.mjs` +// imports `applyRefresh`, and a module-scope `process.exit(2)` for missing argv +// made that import — and therefore `just studio-story-pull`, the documented +// manual fallback for drift — exit 2 before it did anything. if (import.meta.url === `file://${process.argv[1]}`) { + const freshDir = path.resolve(process.argv[2] ?? ""); + const baselineDir = path.resolve( + process.argv[3] ?? path.join(repoRoot, "lp-app/lpa-studio-web/story-images"), + ); + if (!process.argv[2]) { + console.error("Usage: story-apply-refresh.mjs [baseline-dir]"); + process.exit(2); + } try { const { replaced, removed } = await applyRefresh(freshDir, baselineDir); console.log( diff --git a/lp-app/lpa-studio-web/src/app/home/device_card.rs b/lp-app/lpa-studio-web/src/app/home/device_card.rs index cdaa32395..1ec4c6934 100644 --- a/lp-app/lpa-studio-web/src/app/home/device_card.rs +++ b/lp-app/lpa-studio-web/src/app/home/device_card.rs @@ -24,12 +24,12 @@ use dioxus::prelude::*; use lpa_studio_core::{ - BundledFirmware, CardSheet as CardSheetState, CardTabView, CardUiOp, CardVerb, ControllerId, - DEPLOY_NODE_ID, DeployOp, DeviceCardTab, DeviceController, DeviceDetailAffordance, DeviceOp, - DeviceRichInput, HomeOp, LinkProviderKind, ProjectController, ProjectOp, RichObjectView, - RichSection, RosterAffordance, RosterCardState, RosterTreatment, SimDetailAffordance, - SimRichInput, UiAction, UiDeviceCard, UiDeviceProjectChip, UiStatusKind, device_card_tabs, - device_rich_object, sim_rich_object, + BootloaderEntryFlow, BundledFirmware, CardSheet as CardSheetState, CardTabView, CardUiOp, + CardVerb, ControllerId, DEPLOY_NODE_ID, DeployOp, DeviceCardTab, DeviceController, + DeviceDetailAffordance, DeviceOp, DeviceRichInput, HomeOp, LinkProviderKind, ProjectController, + ProjectOp, RecoveryInstructions, RichObjectView, RichSection, RosterAffordance, + RosterCardState, RosterTreatment, SimDetailAffordance, SimRichInput, UiAction, UiDeviceCard, + UiDeviceProjectChip, UiStatusKind, device_card_tabs, device_rich_object, sim_rich_object, }; use lpa_studio_core::{UiLogEntry, UiLogLevel}; @@ -59,6 +59,8 @@ pub(crate) enum DeviceCardSheet { /// Reconnect and recovery-flash escapes. Card-resident per D41 /// (supersedes the contract-era "merged-outline popup" language). Troubleshoot, + /// The bootloader-entry ritual (M5): steps, waiting, confirmation. + BootloaderEntry(BootloaderEntryFlow), } /// What a rendered affordance row does. Sheet and tab rows carry a @@ -124,6 +126,7 @@ fn sheet_to_web(sheet: &CardSheetState, card: &UiDeviceCard) -> DeviceCardSheet CardSheetState::Confirm(verb) => DeviceCardSheet::Confirm(verb_to_action(verb, card)), CardSheetState::Name => DeviceCardSheet::Name, CardSheetState::Troubleshoot => DeviceCardSheet::Troubleshoot, + CardSheetState::BootloaderEntry(flow) => DeviceCardSheet::BootloaderEntry(flow.clone()), } } @@ -155,6 +158,12 @@ fn close_sheet_action(card_key: &str) -> UiAction { /// `YYYY-MM-DD HH:MM LightPlayer`. A fixed story clock derives a /// deterministic UTC name (baselines never drift); live rendering uses /// the platform's local clock. +/// The clamp's user-facing brightness, 0–100. 255 is "no reduction", so +/// the wire's 26 renders as the ~10% the boot log promises. +fn clamp_percent(clamp: u8) -> u32 { + (u32::from(clamp) * 100 + 127) / 255 +} + fn default_setup_name(now_secs: Option) -> String { #[cfg(target_arch = "wasm32")] if now_secs.is_none() { @@ -192,6 +201,99 @@ fn civil_from_days(z: i64) -> (i64, u32, u32) { (year, month, day) } +/// The Recovery-mode face: the state's two exit verbs, each with a +/// one-line summary that lets the user self-select their case. Studio +/// cannot tell the cases apart — a board in download mode sends no hello, +/// so it cannot be linked to a registered device — which is why the copy +/// disambiguates instead of a wizard. +/// +/// Order: Install first (the common new-board arrival), the rescue verb +/// second. Neither endangers the other case: a flash does not touch the +/// project partition, and a boot-control record on a non-LightPlayer +/// board is inert. +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn RecoveryFace(card_key: String, on_action: EventHandler) -> Element { + rsx! { + div { class: "tw:mt-1 tw:grid tw:gap-3", + div { class: "tw:grid tw:gap-1", + div { + CardSheetButton { + label: "⚡ Install firmware", + tone: SheetButtonTone::Primary, + onclick: move |_| { + on_action.call(UiAction::from_op( + ControllerId::new(DeviceController::NODE_ID), + DeviceOp::ProvisionFirmware { setup_name: None }, + )); + }, + } + } + p { class: "tw:m-0 tw:text-xs tw:leading-snug tw:text-subtle-foreground", + "Put LightPlayer on this board. New boards and boards running " + "other firmware start here. Projects on the board survive." + } + } + div { class: "tw:grid tw:gap-1", + div { + CardSheetButton { + label: "Start in safe mode", + tone: SheetButtonTone::Quiet, + onclick: { + let action = boot_safe_once_action(); + move |_| on_action.call(action.clone()) + }, + } + } + p { class: "tw:m-0 tw:text-xs tw:leading-snug tw:text-subtle-foreground", + "Already runs LightPlayer, but its project stops it from " + "starting? Start once with the LEDs dimmed so you can " + "connect and fix the project." + } + } + div { class: "tw:grid tw:gap-1", + div { + CardSheetButton { + label: "Download a backup", + tone: SheetButtonTone::Quiet, + onclick: { + let action = back_up_filesystem_action(); + move |_| on_action.call(action.clone()) + }, + } + } + p { class: "tw:m-0 tw:text-xs tw:leading-snug tw:text-subtle-foreground", + "About to try something drastic? Save everything on the " + "board to a ZIP on your computer first. This works even " + "though it will not start." + } + } + // Wayfinding, not another verb: everything else recovery-shaped + // (wipe, erase, and — once it lands — restore) lives in the + // danger zone, and a user on this face should not have to know + // that. + button { + // No underline: at this size a dotted underline through two + // wrapped lines reads as STRIKETHROUGH — struck-out "wipe, + // erase" is exactly the wrong signal. The arrow + hover + // carry the clickability. + class: "tw:cursor-pointer tw:border-0 tw:bg-transparent tw:p-0 tw:text-left tw:text-xs tw:text-muted-foreground tw:hover:text-strong-foreground tw:hover:underline", + r#type: "button", + onclick: { + let card_key = card_key.clone(); + move |_| { + on_action.call(home_action(HomeOp::CardUi(CardUiOp::SelectTab { + card: card_key.clone(), + tab: DeviceCardTab::Danger, + }))); + } + }, + "More options — wipe, erase, or troubleshoot →" + } + } + } +} + /// The blank board's SETUP FORM (state-flow model §1-A): the Status tab /// IS the form — a prefilled date-default name plus ONE Install button, /// no confirm, no separate naming dialog. The name rides the provision @@ -553,70 +655,81 @@ pub(crate) fn DeviceCard( } // Everything below the title bar shares one wrapper: a D41 // sheet dims exactly this region, so the name above it stays - // readable (spike round 3: sheets spare the title bar). An - // open sheet floors the region's height — a short tab body - // must never clip the panel (the card is overflow-hidden); - // the drift sheet's three stacked verbs need the tall floor. - div { class: match (pane, active_sheet.as_ref(), card.ui.op.is_some()) { - (true, _, _) => "tw:relative tw:flex tw:min-h-0 tw:flex-1 tw:flex-col", - // an op overlay covers this region — floor it so the - // progress bar + technical terminal have room to read - (false, _, true) => "tw:relative tw:min-h-[240px]", - // title + three instruction bullets + three stacked - // buttons — the tallest sheet - (false, Some(DeviceCardSheet::Troubleshoot), _) => "tw:relative tw:min-h-[370px]", - // title + message + input + button row (the 210px - // floor clipped it — walkthrough §4.10) - (false, Some(DeviceCardSheet::Name), _) => "tw:relative tw:min-h-[260px]", - (false, Some(_), _) => "tw:relative tw:min-h-[210px]", - (false, None, _) => "tw:relative", - }, - // the icon-tab row (below the title bar — spike anatomy; - // pane mode drops the Console tab, round 3.5) - div { - class: "tw:flex tw:flex-none tw:gap-0.5 tw:border-b tw:border-border tw:bg-terminal tw:px-1.5 tw:py-1", - role: "tablist", - for tab_view in tabs.iter().filter(|tab| !(pane && tab.tab == DeviceCardTab::Console)) { - {tab_button(tab_view, active_tab, &card_key, on_action)} - } - } - div { class: if pane { "tw:grid tw:min-h-0 tw:flex-1 tw:content-start tw:gap-1.5 tw:overflow-y-auto tw:p-3" } else { "tw:grid tw:content-start tw:gap-1.5 tw:p-3" }, - match active_tab { - DeviceCardTab::Status => rsx! { - {status_tab_body(&card, &tabs, chip_muted, on_action, &card_key, now_secs)} - }, - DeviceCardTab::Console => rsx! { - {console_tab_body(&card.console_tail)} - }, - DeviceCardTab::Project if picker_mode => rsx! { - {project_picker_body(&project_choices, on_action)} - }, - _ => rsx! { - {sections_tab_body(&tabs, active_tab, on_action, &card_key)} - }, + // readable (spike round 3: sheets spare the title bar). + // + // GRID STACK, not an absolute overlay (2026-07-31). The tab + // content, the sheet, and the op overlay all occupy the same + // grid cell, so the region is as tall as the TALLEST of them — + // a sheet can grow the card. The previous scheme positioned + // overlays absolutely and compensated with a hand-maintained + // per-sheet min-height table, which produced a recurring class + // of clipping bugs (Name at 210px, the bootloader sheet, then + // the troubleshoot sheet leaving the user stuck with no + // scroll). Content-driven height deletes the class. + div { class: if pane { "ux-card-stack tw:min-h-0 tw:flex-1" } else { "ux-card-stack" }, + div { class: if pane { "tw:relative tw:flex tw:min-h-0 tw:flex-col" } else { "tw:relative tw:flex tw:flex-col" }, + // the icon-tab row (below the title bar — spike anatomy; + // pane mode drops the Console tab, round 3.5) + div { + class: "tw:flex tw:flex-none tw:gap-0.5 tw:border-b tw:border-border tw:bg-terminal tw:px-1.5 tw:py-1", + role: "tablist", + for tab_view in tabs.iter().filter(|tab| !(pane && tab.tab == DeviceCardTab::Console)) { + {tab_button(tab_view, active_tab, &card_key, on_action)} + } } - } - if pane { - // D42 pane mode (round 3.5): the console is a - // permanent expanded bottom region — a normal console; - // no tab, no strip. - div { class: "ux-console-region", - if card.console_tail.is_empty() { - p { class: "tw:m-0 tw:font-mono tw:text-xs tw:text-dim-foreground", - "No console output yet." + // Safe-mode callout: above the tab body so it is visible + // on EVERY tab — a clamped board looks broken (dim), and + // the only exit is a power cycle the user must be told + // about. Evidence: live heartbeat only (core clears it + // the moment the link drops). + if let Some(clamp) = card.safe_clamp { + div { class: "ux-safe-mode-callout", + p { class: "tw:m-0 tw:text-xs tw:font-semibold", + "Safe mode — output limited to {clamp_percent(clamp)}%" } - } else { - for entry in card.console_tail.iter() { - div { class: console_line_class(entry.level), "{entry.message}" } + p { class: "tw:m-0 tw:text-xs tw:leading-snug", + "This boot is intentionally dimmed for recovery. Fix the project, then unplug the board and plug it back in to restore full brightness." } } } - } - // D42's ambient strip: the console's latest line at the - // card's bottom edge; clicking jumps to the Console tab, - // and the strip HIDES while that tab is active. - if !pane && active_tab != DeviceCardTab::Console && !card.console_tail.is_empty() { - {console_strip(&card.console_tail, &card_key, on_action)} + div { class: if pane { "tw:grid tw:min-h-0 tw:flex-1 tw:content-start tw:gap-1.5 tw:overflow-y-auto tw:p-3" } else { "tw:grid tw:content-start tw:gap-1.5 tw:p-3" }, + match active_tab { + DeviceCardTab::Status => rsx! { + {status_tab_body(&card, &tabs, chip_muted, on_action, &card_key, now_secs)} + }, + DeviceCardTab::Console => rsx! { + {console_tab_body(&card.console_tail)} + }, + DeviceCardTab::Project if picker_mode => rsx! { + {project_picker_body(&project_choices, on_action)} + }, + _ => rsx! { + {sections_tab_body(&tabs, active_tab, on_action, &card_key)} + }, + } + } + if pane { + // D42 pane mode (round 3.5): the console is a + // permanent expanded bottom region — a normal console; + // no tab, no strip. + div { class: "ux-console-region", + if card.console_tail.is_empty() { + p { class: "tw:m-0 tw:font-mono tw:text-xs tw:text-dim-foreground", + "No console output yet." + } + } else { + for entry in card.console_tail.iter() { + div { class: console_line_class(entry.level), "{entry.message}" } + } + } + } + } + // D42's ambient strip: the console's latest line at the + // card's bottom edge; clicking jumps to the Console tab, + // and the strip HIDES while that tab is active. + if !pane && active_tab != DeviceCardTab::Console && !card.console_tail.is_empty() { + {console_strip(&card.console_tail, &card_key, on_action)} + } } if let Some(active_sheet) = active_sheet.as_ref() { {device_card_sheet_view(active_sheet, &card, &card_key, on_action)} @@ -822,6 +935,11 @@ fn status_tab_body( card.state, RosterCardState::ReadyToSetUp | RosterCardState::OtherFirmware ); + // Recovery mode's Status tab carries the exit verbs DIRECTLY (bench + // feedback 2026-07-31): a user here has already done the hard part, + // and routing them through Troubleshoot — a sheet that mostly explains + // how to get INTO this state — was backwards. + let recovery_face = !card.sim && card.state == RosterCardState::RecoveryMode; rsx! { for section in health { for line in section.lines.iter() { @@ -865,6 +983,8 @@ fn status_tab_body( replaces: matches!(card.state, RosterCardState::OtherFirmware), on_action, } + } else if recovery_face { + RecoveryFace { card_key: card_key.to_string(), on_action } } else { for section in health { for row in section.affordances.iter() { @@ -982,7 +1102,15 @@ fn device_card_sheet_view( NameDeviceSheet { card_key, on_action } }, DeviceCardSheet::Troubleshoot => rsx! { - TroubleshootSheet { uid: card.uid.clone(), card_key, on_action } + TroubleshootSheet { + uid: card.uid.clone(), + card_key, + firmware_package: card.fw.as_ref().map(|fw| fw.package.clone()), + on_action, + } + }, + DeviceCardSheet::BootloaderEntry(flow) => rsx! { + BootloaderEntrySheet { flow: flow.clone(), card_key, on_action } }, } } @@ -1007,10 +1135,17 @@ fn strip_confirmation(action: UiAction) -> UiAction { fn TroubleshootSheet( uid: Option, card_key: String, + firmware_package: Option, on_action: EventHandler, ) -> Element { let reconnect = reconnect_device_action(uid); let recovery_flash = flash_device_action(false); + let boot_safe_once = boot_safe_once_action(); + // The firmware package names the chip it was built for ("fw-esp32c6"), + // which is the only chip source available before the user reaches + // bootloader mode — a device that will not boot cannot tell us its board. + // `None` yields generic steps that say they are generic. + let instructions = RecoveryInstructions::for_chip(firmware_package.as_deref()); rsx! { CardSheet { on_dismiss: { @@ -1021,7 +1156,25 @@ fn TroubleshootSheet( ul { class: "tw:m-0 tw:mb-3 tw:grid tw:list-disc tw:gap-1 tw:pl-4 tw:text-xs tw:leading-normal tw:text-muted-foreground", li { "Check the USB cable — charge-only cables never carry data." } li { "Unplug the device, plug it back in, then Reconnect." } - li { "Still stuck? Hold BOOT while plugging in, then flash the firmware." } + li { + "If it was fine until you changed the project, the project may be " + "stopping it from starting — start it once in safe mode." + } + } + div { class: "tw:mb-3 tw:rounded tw:border tw:border-line tw:p-2", + div { class: "tw:mb-1 tw:text-xs tw:font-medium", + "Still stuck? Put {instructions.subject} into recovery mode:" + } + ol { class: "tw:m-0 tw:grid tw:list-decimal tw:gap-1 tw:pl-4 tw:text-xs tw:leading-normal tw:text-muted-foreground", + for step in instructions.steps.iter() { + li { "{step.text}" } + } + } + if instructions.is_generic { + div { class: "tw:mt-1 tw:text-xs tw:text-muted-foreground", + "These are the usual ESP32 steps — this board may differ." + } + } } div { class: "tw:grid tw:justify-end tw:gap-2", CardSheetButton { @@ -1035,6 +1188,17 @@ fn TroubleshootSheet( } }, } + CardSheetButton { + label: "Start in safe mode", + tone: SheetButtonTone::Quiet, + onclick: { + let card_key = card_key.clone(); + move |_| { + on_action.call(close_sheet_action(&card_key)); + on_action.call(boot_safe_once.clone()); + } + }, + } CardSheetButton { label: "Flash firmware…", tone: SheetButtonTone::Quiet, @@ -1056,6 +1220,106 @@ fn TroubleshootSheet( } } +/// The bootloader-entry sheet (M5): the ritual, and — the whole point — +/// the confirmation that it worked. +/// +/// Without feedback a failed attempt and a dead board look identical, so +/// people repeat the wrong motion and conclude the device is bricked. The +/// `Confirmed` arm is what makes the ritual learnable. +/// +/// Advancing is re-opening the sheet with the next flow value, so the +/// renderer holds no state of its own. +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn BootloaderEntrySheet( + flow: BootloaderEntryFlow, + card_key: String, + on_action: EventHandler, +) -> Element { + // "I've done that" must PROBE, not merely change state. Advancing the + // sheet without asking the device would show a waiting spinner that + // never resolves — worse than nothing, because the user still cannot + // tell a failed attempt from a dead board. The probe reboots the + // device, and the button press is exactly the signal that a replug just + // happened, so this is the one honest place to run it. + let probe = |card_key: &str, flow: BootloaderEntryFlow| { + UiAction::from_op( + ControllerId::new(DeviceController::NODE_ID), + DeviceOp::ProbeBootloaderMode { + card_key: card_key.to_string(), + flow, + }, + ) + .with_label("Check the device") + }; + rsx! { + CardSheet { + on_dismiss: { + let card_key = card_key.clone(); + move |_| on_action.call(close_sheet_action(&card_key)) + }, + match &flow { + BootloaderEntryFlow::Confirmed { chip_name } => { + let subject = chip_name.clone().unwrap_or_else(|| "The device".to_string()); + rsx! { + CardSheetTitle { text: "Recovery mode — ready" } + p { class: "tw:m-0 tw:mb-3 tw:text-xs tw:leading-normal tw:text-muted-foreground", + "{subject} is listening. You can flash firmware, back it up, or " + "have it start once without its project." + } + } + } + _ => { + let instructions = flow.instructions().expect("non-confirmed states carry steps"); + let waiting = flow.should_probe_on_arrival(); + rsx! { + CardSheetTitle { text: "Put {instructions.subject} into recovery mode" } + if matches!(flow, BootloaderEntryFlow::NotYet { .. }) { + p { class: "tw:m-0 tw:mb-2 tw:text-xs tw:leading-normal tw:text-status-attention-foreground", + "That attempt didn't land — the device answered as if it were " + "running normally. Worth another go; the timing is fiddly." + } + } + ol { class: "tw:m-0 tw:mb-3 tw:grid tw:list-decimal tw:gap-1 tw:pl-4 tw:text-xs tw:leading-normal tw:text-muted-foreground", + for step in instructions.steps.iter() { + li { "{step.text}" } + } + } + if instructions.is_generic { + p { class: "tw:m-0 tw:mb-3 tw:text-xs tw:text-muted-foreground", + "These are the usual ESP32 steps — this board may differ." + } + } + if waiting { + p { class: "tw:m-0 tw:mb-3 tw:text-xs tw:leading-normal", + "Waiting for the device to reappear…" + } + } + } + } + } + div { class: "tw:grid tw:justify-end tw:gap-2", + if !flow.is_confirmed() && !flow.should_probe_on_arrival() { + CardSheetButton { + label: "I've done that", + tone: SheetButtonTone::Primary, + onclick: { + let card_key = card_key.clone(); + let flow = flow.clone(); + move |_| on_action.call(probe(&card_key, flow.clone())) + }, + } + } + CardSheetButton { + label: "Close", + tone: SheetButtonTone::Quiet, + onclick: move |_| on_action.call(close_sheet_action(&card_key)), + } + } + } + } +} + /// The destructive-confirm sheet: copy from the action's own /// [`lpa_studio_core::ActionConfirmation`] (title · message · verb); /// Confirm dispatches the action with the gate stripped (the sheet WAS @@ -1260,6 +1524,34 @@ pub(crate) fn connect_device_action() -> UiAction { /// confirmation renders as the D41 sheet); with nothing connected it /// opens the recovery chooser (link-only open, no app attach), after /// which the card's own state carries the flow. +/// Write the boot-control record so the device's next restart skips its +/// project. Non-destructive and one-shot — see `DeviceOp::BootSafeOnce`. +pub(crate) fn boot_safe_once_action() -> UiAction { + UiAction::from_op( + ControllerId::new(DeviceController::NODE_ID), + DeviceOp::BootSafeOnce, + ) + .with_label("Start in safe mode") +} + +/// Read the device's storage over the bootloader and download it as a ZIP. +/// +/// No confirmation: nothing is written. It is deliberately the row people +/// meet BEFORE the destructive verbs, because it is what makes them +/// survivable — see `DeviceOp::BackUpFilesystem`. +pub(crate) fn back_up_filesystem_action() -> UiAction { + UiAction::from_op( + ControllerId::new(DeviceController::NODE_ID), + DeviceOp::BackUpFilesystem, + ) + .with_label("Download a backup") + .with_summary( + "Save everything on this device to a ZIP on your computer — this \ + works even if the board will not start.", + ) + .with_icon("download") +} + pub(crate) fn flash_device_action(device_connected: bool) -> UiAction { let action = if device_connected { UiAction::from_op( @@ -1497,16 +1789,28 @@ fn wire_card_affordance( .with_icon("edit"); Some(CardRowAction::Sheet(CardSheetState::Name, display)) } - // M6: the Not-responding card's affordance opens the - // troubleshooting sheet (display action is meta-only). + // Opens the troubleshooting sheet. The action here is meta-only — + // it supplies the row's label/summary/icon and is never dispatched; + // the sheet is the effect. + // + // `strip_confirmation` is load-bearing, not tidiness. This row + // borrowed ProvisionFirmware purely as a carrier, and inherited its + // DESTRUCTIVE confirmation with it — so opening a read-only help + // sheet asked "This will write LightPlayer firmware to the selected + // ESP32. Continue?" (reported from the bench 2026-07-31; latent + // since M6, and exposed once Troubleshoot became reachable from + // every state). A meta-only carrier must never inherit a gate for + // an effect it does not have. DeviceDetailAffordance::Roster(RosterAffordance::Troubleshoot) => { - let display = UiAction::from_op( - ControllerId::new(DeviceController::NODE_ID), - DeviceOp::ProvisionFirmware { setup_name: None }, - ) - .with_label(RosterAffordance::Troubleshoot.label()) - .with_summary("Steps to try when the device is not responding.") - .with_icon("zap"); + let display = strip_confirmation( + UiAction::from_op( + ControllerId::new(DeviceController::NODE_ID), + DeviceOp::ProvisionFirmware { setup_name: None }, + ) + .with_label(RosterAffordance::Troubleshoot.label()) + .with_summary("Steps to try when the device is not responding.") + .with_icon("zap"), + ); Some(CardRowAction::Sheet(CardSheetState::Troubleshoot, display)) } // The unreadable card's wipe: destructive → the D41 confirm sheet @@ -1534,6 +1838,11 @@ fn wire_card_affordance( CardSheetState::Confirm(CardVerb::Flash), strip_confirmation(flash_device_action_destructive()), )), + // Non-destructive, so it dispatches straight from the row — a + // confirm gate on "save a copy of your work" would be theatre. + DeviceDetailAffordance::BackUpFilesystem => { + Some(CardRowAction::from_action(back_up_filesystem_action())) + } DeviceDetailAffordance::EraseDevice => Some(CardRowAction::Sheet( CardSheetState::Confirm(CardVerb::Erase), strip_confirmation(erase_device_action(card.name.clone())), diff --git a/lp-app/lpa-studio-web/src/app/home/home_gallery.rs b/lp-app/lpa-studio-web/src/app/home/home_gallery.rs index 96fb946da..3e64437b9 100644 --- a/lp-app/lpa-studio-web/src/app/home/home_gallery.rs +++ b/lp-app/lpa-studio-web/src/app/home/home_gallery.rs @@ -44,6 +44,21 @@ pub fn HomeGallery( // pastes aimed at a text field — so ordinary typing is untouched // (see `gallery_paste`). let _paste_listener = use_hook(move || install_paste_listener(on_action)); + // A finished device backup downloads exactly when its `seq` advances. + // The view is a full snapshot, so without this paint key every + // re-render would drop another copy of a megabyte-sized zip into the + // user's Downloads folder (same discipline as the agent debug dump). + let downloaded_backup_seq = use_hook(|| std::rc::Rc::new(std::cell::Cell::new(0_u64))); + if let Some(backup) = &home.backup + && downloaded_backup_seq.get() < backup.seq + { + downloaded_backup_seq.set(backup.seq); + if let Err(error) = + crate::app::home::package_export::trigger_zip_download(&backup.file_name, &backup.bytes) + { + log::warn!("device backup download failed: {error:?}"); + } + } // only touch the browser's serial API when the caller didn't already // answer the grant question (stories always do — headless Chrome's // getPorts is crash-prone, and the probe is pointless there anyway) diff --git a/lp-app/lpa-studio-web/src/app/home/home_gallery_stories.rs b/lp-app/lpa-studio-web/src/app/home/home_gallery_stories.rs index 59ff99665..3d338740f 100644 --- a/lp-app/lpa-studio-web/src/app/home/home_gallery_stories.rs +++ b/lp-app/lpa-studio-web/src/app/home/home_gallery_stories.rs @@ -74,6 +74,7 @@ fn devices() -> Vec { name: "2026-07-02-0930-porch-sign".to_string(), }), fw: None, + safe_clamp: None, sim: false, console_tail: Vec::new(), ui: Default::default(), @@ -90,6 +91,7 @@ fn devices() -> Vec { name: "2026-07-02-0930-porch-sign".to_string(), }), fw: None, + safe_clamp: None, sim: false, console_tail: Vec::new(), ui: Default::default(), @@ -110,6 +112,7 @@ fn first_run() -> Element { library_available: true, opening: None, issue: None, + backup: None, }; rsx! { section { class: "tw:p-4", @@ -137,6 +140,7 @@ fn gallery_chooser_buttons() -> Element { library_available: true, opening: None, issue: None, + backup: None, }; rsx! { section { class: "tw:p-4", @@ -159,6 +163,7 @@ fn populated() -> Element { library_available: true, opening: None, issue: None, + backup: None, }; rsx! { section { class: "tw:p-4", @@ -196,6 +201,7 @@ fn connected_device_and_project_chip() -> Element { state: RosterCardState::ReadyToSetUp, project: None, fw: None, + safe_clamp: None, sim: false, console_tail: Vec::new(), ui: Default::default(), @@ -207,6 +213,7 @@ fn connected_device_and_project_chip() -> Element { library_available: true, opening: None, issue: None, + backup: None, }; rsx! { section { class: "tw:p-4", @@ -233,6 +240,7 @@ fn project_open_in_another_tab() -> Element { library_available: true, opening: None, issue: None, + backup: None, }; rsx! { section { class: "tw:p-4", @@ -255,6 +263,7 @@ fn opening_a_project() -> Element { library_available: true, opening: None, issue: None, + backup: None, }; home.opening = Some(home.projects[0].uid.clone()); rsx! { @@ -334,6 +343,7 @@ fn sim_device_card(with_project: bool) -> UiDeviceCard { name: "2026-07-02-0930-porch-sign".to_string(), }), fw: None, + safe_clamp: None, sim: true, console_tail: Vec::new(), ui: Default::default(), @@ -363,6 +373,7 @@ fn sim_and_live_device_home() -> UiHomeView { library_available: true, opening: None, issue: None, + backup: None, } } @@ -394,6 +405,7 @@ fn sim_running_only() -> Element { library_available: true, opening: None, issue: None, + backup: None, }, None, ) @@ -406,6 +418,26 @@ fn sim_and_live_device() -> Element { gallery(sim_and_live_device_home(), None) } +#[story( + description = "Safe mode: the device booted with the recovery output clamp (dim on purpose). The card wears the warning callout on every tab — what happened, and that a replug is the exit — because a clamped board otherwise just looks broken." +)] +fn device_in_safe_mode() -> Element { + let mut device = devices().remove(0); + device.safe_clamp = Some(26); + gallery( + UiHomeView { + devices: vec![device], + projects: packages(), + examples: examples(), + library_available: true, + opening: None, + issue: None, + backup: None, + }, + None, + ) +} + #[story( description = "The D28 aggregate (M5): ONE project live on both the sim and a device presents 'Live in 2 places' on its card — one line, not two; amber because the device runs behind (the tooltip spells the places out). Chips stay inert pointers — the runtime cards are one glance up." )] @@ -431,6 +463,7 @@ fn project_live_in_two_places() -> Element { library_available: true, opening: None, issue: None, + backup: None, }, None, ) @@ -451,6 +484,7 @@ fn sim_and_offline_device() -> Element { library_available: true, opening: None, issue: None, + backup: None, }, None, ) @@ -486,6 +520,7 @@ fn store_unavailable_with_issue() -> Element { library_available: false, opening: None, issue: Some(UiIssue::new("Failed to open serial port.")), + backup: None, }; rsx! { section { class: "tw:p-4", diff --git a/lp-app/lpa-studio-web/src/app/home/package_export.rs b/lp-app/lpa-studio-web/src/app/home/package_export.rs index e67bb3c48..f11fca9a9 100644 --- a/lp-app/lpa-studio-web/src/app/home/package_export.rs +++ b/lp-app/lpa-studio-web/src/app/home/package_export.rs @@ -87,7 +87,7 @@ pub(crate) fn export_package_as(target: ExportTarget, form: ExportForm) { } }; // the slug already carries its date stamp — no extra prefix - if let Err(error) = trigger_download(&format!("{}.zip", target.slug), &bytes) { + if let Err(error) = trigger_zip_download(&format!("{}.zip", target.slug), &bytes) { log::warn!("export download of {} failed: {error:?}", target.slug); } } @@ -111,13 +111,26 @@ pub(crate) fn export_package_as(target: ExportTarget, form: ExportForm) { #[cfg(not(target_arch = "wasm32"))] pub(crate) fn export_package_as(_target: ExportTarget, _form: ExportForm) {} +/// Host builds (story capture, view tests) have no browser to download to. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn trigger_zip_download(_file_name: &str, _bytes: &[u8]) -> Result<(), ()> { + Ok(()) +} + /// Download a package as a zip (the gallery card's affordance). pub(crate) fn export_package_to_download(card: &UiPackageCard) { export_package_as(ExportTarget::from(card), ExportForm::Zip); } +/// Hand `bytes` to the browser as a named `.zip` download. +/// +/// Shared with the device filesystem backup, which produces its zip in core +/// and only needs the browser half. #[cfg(target_arch = "wasm32")] -fn trigger_download(file_name: &str, bytes: &[u8]) -> Result<(), wasm_bindgen::JsValue> { +pub(crate) fn trigger_zip_download( + file_name: &str, + bytes: &[u8], +) -> Result<(), wasm_bindgen::JsValue> { use wasm_bindgen::JsCast; let parts = js_sys::Array::new(); diff --git a/lp-app/lpa-studio-web/src/app/readme_stories.rs b/lp-app/lpa-studio-web/src/app/readme_stories.rs index 9bedad77c..04b92ce18 100644 --- a/lp-app/lpa-studio-web/src/app/readme_stories.rs +++ b/lp-app/lpa-studio-web/src/app/readme_stories.rs @@ -207,6 +207,7 @@ fn readme_lens_card() -> UiDeviceCard { name: "evening-glow".to_string(), }), fw: None, + safe_clamp: None, sim: true, console_tail: vec![ line(0.0, "engine: project loaded · 241 points"), @@ -270,6 +271,7 @@ fn readme_home_view() -> UiHomeView { name: "2026-07-04-1102-evening-glow".to_string(), }), fw: None, + safe_clamp: None, sim: true, console_tail: Vec::new(), ui: Default::default(), @@ -284,6 +286,7 @@ fn readme_home_view() -> UiHomeView { name: "2026-07-02-0930-porch-sign".to_string(), }), fw: None, + safe_clamp: None, sim: false, console_tail: Vec::new(), ui: Default::default(), @@ -300,5 +303,6 @@ fn readme_home_view() -> UiHomeView { library_available: true, opening: None, issue: None, + backup: None, } } diff --git a/lp-app/lpa-studio-web/src/app/roster/roster_card_stories.rs b/lp-app/lpa-studio-web/src/app/roster/roster_card_stories.rs index 96d568ef0..dd50f6c2f 100644 --- a/lp-app/lpa-studio-web/src/app/roster/roster_card_stories.rs +++ b/lp-app/lpa-studio-web/src/app/roster/roster_card_stories.rs @@ -18,9 +18,9 @@ use dioxus::prelude::*; use lpa_studio_web_story_macros::story; use lpa_studio_core::{ - BundledFirmware, CardOp, CardSheet, CardUiState, CardVerb, ConnectPhase, DegradedReason, - DeviceCardTab, RosterCardState, UiDeviceCard, UiDeviceProjectChip, UiLogEntry, UiLogLevel, - UiLogOrigin, UiLogSource, + BootloaderEntryFlow, BundledFirmware, CardOp, CardSheet, CardUiState, CardVerb, ConnectPhase, + DegradedReason, DeviceCardTab, RosterCardState, UiDeviceCard, UiDeviceProjectChip, UiLogEntry, + UiLogLevel, UiLogOrigin, UiLogSource, }; use lpc_wire::FwProvenance; @@ -221,6 +221,130 @@ fn troubleshoot_sheet_open() -> Element { }]) } +#[story( + description = "Amber filled edge: the chip is sitting in ROM download mode. Split out of Ready-to-set-up 2026-07-31 after a bench report — the two were collapsed, so Studio detected download mode and then discarded the fact, showing the blank-board flow instead. The load-bearing difference: a device flashed from here does NOT boot the new firmware on its own; it has to be physically replugged." +)] +fn recovery_mode() -> Element { + sheet(vec![card(RosterCardState::RecoveryMode, false)]) +} + +#[story( + description = "Bootloader-entry, step 1 (M5): the ritual for a chip Studio KNOWS — the card's firmware provenance named it, so the steps are specific. Every sequence starts by unplugging: the boot strap is sampled at reset, so holding BOOT on a running board does nothing." +)] +fn bootloader_entry_instructing() -> Element { + sheet(vec![rsx! { + div { class: "tw:w-64", + DeviceCard { + card: UiDeviceCard { + ui: opened( + DeviceCardTab::Status, + Some(CardSheet::BootloaderEntry(BootloaderEntryFlow::start(Some( + "fw-esp32c6", + )))), + ), + ..device_card(RosterCardState::NotResponding, false) + }, + now_secs: Some(STORY_NOW), + on_action: |_| {}, + } + } + }]) +} + +#[story( + description = "Bootloader-entry for an UNKNOWN device (M5): Studio never reached this board, so it cannot name the chip. Generic steps, hedged button name, and an explicit admission that they may not match — an unhedged wrong instruction reads as a dead device." +)] +fn bootloader_entry_generic() -> Element { + sheet(vec![rsx! { + div { class: "tw:w-64", + DeviceCard { + card: UiDeviceCard { + ui: opened( + DeviceCardTab::Status, + Some(CardSheet::BootloaderEntry(BootloaderEntryFlow::start(None))), + ), + ..device_card(RosterCardState::NotResponding, false) + }, + now_secs: Some(STORY_NOW), + on_action: |_| {}, + } + } + }]) +} + +#[story( + description = "Bootloader-entry, waiting (M5): the user has done the steps. Nothing is probed in this state — the probe reboots the device, so it fires only on a re-enumeration, which the ritual's replug already provides." +)] +fn bootloader_entry_waiting() -> Element { + sheet(vec![rsx! { + div { class: "tw:w-64", + DeviceCard { + card: UiDeviceCard { + ui: opened( + DeviceCardTab::Status, + Some(CardSheet::BootloaderEntry( + BootloaderEntryFlow::start(Some("fw-esp32c6")).begin_waiting(), + )), + ), + ..device_card(RosterCardState::NotResponding, false) + }, + now_secs: Some(STORY_NOW), + on_action: |_| {}, + } + } + }]) +} + +#[story( + description = "Bootloader-entry, CONFIRMED (M5): the payoff, and the reason this flow exists. Without it a failed attempt and a dead board look identical, so people repeat the wrong motion and conclude the device is bricked." +)] +fn bootloader_entry_confirmed() -> Element { + sheet(vec![rsx! { + div { class: "tw:w-64", + DeviceCard { + card: UiDeviceCard { + ui: opened( + DeviceCardTab::Status, + Some(CardSheet::BootloaderEntry( + BootloaderEntryFlow::start(Some("fw-esp32c6")) + .begin_waiting() + .on_probe_answered(Some("ESP32-C6".to_string())), + )), + ), + ..device_card(RosterCardState::NotResponding, false) + }, + now_secs: Some(STORY_NOW), + on_action: |_| {}, + } + } + }]) +} + +#[story( + description = "Bootloader-entry, not-yet (M5): the probe went unanswered. Deliberately NOT 'your device is broken' — an app-mode device ignores the handshake too, so the honest reading is that the attempt did not land." +)] +fn bootloader_entry_not_yet() -> Element { + sheet(vec![rsx! { + div { class: "tw:w-64", + DeviceCard { + card: UiDeviceCard { + ui: opened( + DeviceCardTab::Status, + Some(CardSheet::BootloaderEntry( + BootloaderEntryFlow::start(Some("fw-esp32c6")) + .begin_waiting() + .on_probe_unanswered(), + )), + ), + ..device_card(RosterCardState::NotResponding, false) + }, + now_secs: Some(STORY_NOW), + on_action: |_| {}, + } + } + }]) +} + #[story(description = "Gray filled edge: the port is held by another tab; quiet auto-retry.")] fn in_use_elsewhere() -> Element { sheet(vec![card(RosterCardState::InUseElsewhere, false)]) @@ -636,6 +760,7 @@ fn device_card(state: RosterCardState, with_project: bool) -> UiDeviceCard { name: "porch-sign".to_string(), }), fw: None, + safe_clamp: None, sim: false, console_tail: Vec::new(), ui: Default::default(), @@ -660,6 +785,7 @@ fn sim_card(with_project: bool) -> UiDeviceCard { name: "porch-sign".to_string(), }), fw: None, + safe_clamp: None, sim: true, console_tail: Vec::new(), ui: Default::default(), diff --git a/lp-app/lpa-studio-web/src/app/story_fixtures.rs b/lp-app/lpa-studio-web/src/app/story_fixtures.rs index a282ee232..7f6ea6c9c 100644 --- a/lp-app/lpa-studio-web/src/app/story_fixtures.rs +++ b/lp-app/lpa-studio-web/src/app/story_fixtures.rs @@ -48,6 +48,7 @@ pub(crate) fn simulator_lens_card() -> UiDeviceCard { name: "demo-project".to_string(), }), fw: None, + safe_clamp: None, sim: true, console_tail: vec![UiLogEntry::new( STORY_LOG_TIMESTAMP, diff --git a/lp-app/lpa-studio-web/src/style.css b/lp-app/lpa-studio-web/src/style.css index 341947cfa..b579f460c 100644 --- a/lp-app/lpa-studio-web/src/style.css +++ b/lp-app/lpa-studio-web/src/style.css @@ -278,9 +278,25 @@ label:has(> input:not(:disabled)) { stays visible — you always know whose sheet this is) and spares the tint edge. One at a time; clicking the backdrop dismisses. */ +/* Grid stack (2026-07-31): the below-the-header wrapper stacks the tab + content, the sheet, and the op overlay in ONE grid cell, so the region + is as tall as the tallest of them and a sheet can grow the card. The + old absolute overlays needed a hand-maintained per-sheet min-height + table — a recurring clipping-bug class, now deleted. */ + +.ux-card-stack { + position: relative; + display: grid; +} + +.ux-card-stack > * { + grid-area: 1 / 1; + min-width: 0; +} + .ux-card-sheet { - position: absolute; - inset: 0; + /* In-flow grid item: contributes height instead of clipping. The + z-index still layers it over the content cell. */ z-index: 6; display: grid; place-items: center; @@ -401,8 +417,7 @@ label:has(> input:not(:disabled)) { and the same console tail, shown as a technical terminal. */ .ux-card-op { - position: absolute; - inset: 0; + /* In-flow grid-stack item, same as .ux-card-sheet above. */ z-index: 5; display: flex; flex-direction: column; @@ -465,9 +480,28 @@ label:has(> input:not(:disabled)) { 100% { left: 100%; } } +/* Safe-mode callout: the device booted with the recovery output clamp. + Warning family (device condition), NOT the node language's unsaved + yellow; sits above the card's tab body so every tab shows it. */ +.ux-safe-mode-callout { + display: grid; + gap: 2px; + margin: 6px 8px 0; + padding: 6px 10px; + background: var(--studio-status-warning-bg); + border: 1px solid var(--studio-status-warning-border); + border-radius: 4px; + color: var(--studio-status-warning-text); +} + .ux-card-op-term { flex: 1; min-height: 0; + /* The grid-stack made the op overlay content-sized (2026-07-31): a + flex:1 + overflow scroller only scrolls when its parent is bounded, + and the parent no longer is. Unbounded streams own their cap now — + without this, a flash's terminal grows the card by its whole log. */ + max-height: 220px; margin: 0; overflow-y: auto; padding: 8px 10px; @@ -492,6 +526,17 @@ label:has(> input:not(:disabled)) { white-space: nowrap; } +/* The error line is the one thing the user must read IN FULL — the tail + lines above stay one-line-ellipsized like a console, but ellipsizing the + failure reason hid the actionable half of an esptool warning on the bench + ("Boot-control write failed: Flash…", 2026-07-31). */ +.ux-card-op-term > .ux-console-line-error { + overflow: visible; + text-overflow: clip; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + .ux-card-op-term > .ux-console-line-warn { color: var(--studio-status-warning-text); } diff --git a/lp-app/lpa-studio-web/story-images/studio__home__home-gallery__device-in-safe-mode__lg.png b/lp-app/lpa-studio-web/story-images/studio__home__home-gallery__device-in-safe-mode__lg.png new file mode 100644 index 000000000..6e4600098 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__home__home-gallery__device-in-safe-mode__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__home__home-gallery__device-in-safe-mode__md.png b/lp-app/lpa-studio-web/story-images/studio__home__home-gallery__device-in-safe-mode__md.png new file mode 100644 index 000000000..073321648 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__home__home-gallery__device-in-safe-mode__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__home__home-gallery__device-in-safe-mode__sm.png b/lp-app/lpa-studio-web/story-images/studio__home__home-gallery__device-in-safe-mode__sm.png new file mode 100644 index 000000000..70fb6cea9 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__home__home-gallery__device-in-safe-mode__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__bootloader-entry-confirmed__lg.png b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__bootloader-entry-confirmed__lg.png new file mode 100644 index 000000000..4476a23be Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__bootloader-entry-confirmed__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__bootloader-entry-confirmed__md.png b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__bootloader-entry-confirmed__md.png new file mode 100644 index 000000000..3eb403040 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__bootloader-entry-confirmed__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__bootloader-entry-confirmed__sm.png b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__bootloader-entry-confirmed__sm.png new file mode 100644 index 000000000..e73c2f20e Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__bootloader-entry-confirmed__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__bootloader-entry-generic__lg.png b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__bootloader-entry-generic__lg.png new file mode 100644 index 000000000..c7a5d3470 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__bootloader-entry-generic__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__bootloader-entry-generic__md.png b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__bootloader-entry-generic__md.png new file mode 100644 index 000000000..4dbe14035 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__bootloader-entry-generic__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__bootloader-entry-generic__sm.png b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__bootloader-entry-generic__sm.png new file mode 100644 index 000000000..2877c373b Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__bootloader-entry-generic__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__bootloader-entry-instructing__lg.png b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__bootloader-entry-instructing__lg.png new file mode 100644 index 000000000..f8109f5f5 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__bootloader-entry-instructing__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__bootloader-entry-instructing__md.png b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__bootloader-entry-instructing__md.png new file mode 100644 index 000000000..51619a562 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__bootloader-entry-instructing__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__bootloader-entry-instructing__sm.png b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__bootloader-entry-instructing__sm.png new file mode 100644 index 000000000..4dac11f6c Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__bootloader-entry-instructing__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__bootloader-entry-not-yet__lg.png b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__bootloader-entry-not-yet__lg.png new file mode 100644 index 000000000..591e84230 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__bootloader-entry-not-yet__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__bootloader-entry-not-yet__md.png b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__bootloader-entry-not-yet__md.png new file mode 100644 index 000000000..431493eec Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__bootloader-entry-not-yet__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__bootloader-entry-not-yet__sm.png b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__bootloader-entry-not-yet__sm.png new file mode 100644 index 000000000..7ef0db0d2 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__bootloader-entry-not-yet__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__bootloader-entry-waiting__lg.png b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__bootloader-entry-waiting__lg.png new file mode 100644 index 000000000..a6cdc42ce Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__bootloader-entry-waiting__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__bootloader-entry-waiting__md.png b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__bootloader-entry-waiting__md.png new file mode 100644 index 000000000..26c29fb30 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__bootloader-entry-waiting__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__bootloader-entry-waiting__sm.png b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__bootloader-entry-waiting__sm.png new file mode 100644 index 000000000..0dbf4b9d0 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__bootloader-entry-waiting__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__danger-tab-offline__lg.png b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__danger-tab-offline__lg.png index 51219f1f8..ce8df37fc 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__danger-tab-offline__lg.png and b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__danger-tab-offline__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__danger-tab-offline__md.png b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__danger-tab-offline__md.png index dd7eb3607..a2aa57802 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__danger-tab-offline__md.png and b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__danger-tab-offline__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__danger-tab-offline__sm.png b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__danger-tab-offline__sm.png index 19b63b3d2..59f8223e3 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__danger-tab-offline__sm.png and b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__danger-tab-offline__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__danger-tab-running-behind__lg.png b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__danger-tab-running-behind__lg.png index 731b523f0..d3d67dd10 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__danger-tab-running-behind__lg.png and b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__danger-tab-running-behind__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__danger-tab-running-behind__md.png b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__danger-tab-running-behind__md.png index 43976cb50..a6d8d0b4a 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__danger-tab-running-behind__md.png and b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__danger-tab-running-behind__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__danger-tab-running-behind__sm.png b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__danger-tab-running-behind__sm.png index 9fe4472b8..c835f8b2f 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__danger-tab-running-behind__sm.png and b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__danger-tab-running-behind__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__erase-sheet-open__lg.png b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__erase-sheet-open__lg.png index 415aeda3a..119bcdf1f 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__erase-sheet-open__lg.png and b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__erase-sheet-open__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__erase-sheet-open__md.png b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__erase-sheet-open__md.png index 48a9f1fd7..1837b991c 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__erase-sheet-open__md.png and b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__erase-sheet-open__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__erase-sheet-open__sm.png b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__erase-sheet-open__sm.png index a46de5108..2d6e83612 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__erase-sheet-open__sm.png and b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__erase-sheet-open__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__name-sheet-open__lg.png b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__name-sheet-open__lg.png index 331b0b5eb..3929dc443 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__name-sheet-open__lg.png and b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__name-sheet-open__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__name-sheet-open__md.png b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__name-sheet-open__md.png index 76c1ab7e5..91218b187 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__name-sheet-open__md.png and b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__name-sheet-open__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__name-sheet-open__sm.png b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__name-sheet-open__sm.png index 2396e1a93..5df8261ff 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__name-sheet-open__sm.png and b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__name-sheet-open__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__op-overlay-awaiting-device__lg.png b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__op-overlay-awaiting-device__lg.png index 7a7ffed2c..7d2e3e688 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__op-overlay-awaiting-device__lg.png and b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__op-overlay-awaiting-device__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__op-overlay-awaiting-device__md.png b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__op-overlay-awaiting-device__md.png index b48b003c4..212a7bdcd 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__op-overlay-awaiting-device__md.png and b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__op-overlay-awaiting-device__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__op-overlay-awaiting-device__sm.png b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__op-overlay-awaiting-device__sm.png index d2e20ef42..205df502a 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__op-overlay-awaiting-device__sm.png and b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__op-overlay-awaiting-device__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__op-overlay-determinate__lg.png b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__op-overlay-determinate__lg.png index 98f542d51..b0954fcad 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__op-overlay-determinate__lg.png and b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__op-overlay-determinate__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__op-overlay-determinate__md.png b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__op-overlay-determinate__md.png index ce25e5cd4..8fcb706df 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__op-overlay-determinate__md.png and b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__op-overlay-determinate__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__op-overlay-determinate__sm.png b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__op-overlay-determinate__sm.png index a1d3887c5..0318506bd 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__op-overlay-determinate__sm.png and b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__op-overlay-determinate__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__op-overlay-failed__lg.png b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__op-overlay-failed__lg.png index 5f4f93451..1187b252f 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__op-overlay-failed__lg.png and b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__op-overlay-failed__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__op-overlay-failed__md.png b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__op-overlay-failed__md.png index 78830f480..bc2b11e0d 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__op-overlay-failed__md.png and b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__op-overlay-failed__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__op-overlay-failed__sm.png b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__op-overlay-failed__sm.png index 59cde73b9..54375514f 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__op-overlay-failed__sm.png and b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__op-overlay-failed__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__op-overlay-indeterminate__lg.png b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__op-overlay-indeterminate__lg.png index acadd959c..d91fdd978 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__op-overlay-indeterminate__lg.png and b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__op-overlay-indeterminate__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__op-overlay-indeterminate__md.png b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__op-overlay-indeterminate__md.png index 16db07050..68eeb0d61 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__op-overlay-indeterminate__md.png and b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__op-overlay-indeterminate__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__op-overlay-indeterminate__sm.png b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__op-overlay-indeterminate__sm.png index 6416c5a64..d33cf9cd6 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__op-overlay-indeterminate__sm.png and b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__op-overlay-indeterminate__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__recovery-mode__lg.png b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__recovery-mode__lg.png new file mode 100644 index 000000000..697774fdf Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__recovery-mode__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__recovery-mode__md.png b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__recovery-mode__md.png new file mode 100644 index 000000000..3d31aa510 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__recovery-mode__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__recovery-mode__sm.png b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__recovery-mode__sm.png new file mode 100644 index 000000000..9f456947d Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__recovery-mode__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__stop-sim-sheet-open__lg.png b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__stop-sim-sheet-open__lg.png index 08a084d9d..57b1d5e42 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__stop-sim-sheet-open__lg.png and b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__stop-sim-sheet-open__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__stop-sim-sheet-open__md.png b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__stop-sim-sheet-open__md.png index fc1ed7611..4fa76e056 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__stop-sim-sheet-open__md.png and b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__stop-sim-sheet-open__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__stop-sim-sheet-open__sm.png b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__stop-sim-sheet-open__sm.png index 1f7ee1d72..49bcafbf7 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__stop-sim-sheet-open__sm.png and b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__stop-sim-sheet-open__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__troubleshoot-sheet-open__lg.png b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__troubleshoot-sheet-open__lg.png index 788fe92b0..b15c7df4b 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__troubleshoot-sheet-open__lg.png and b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__troubleshoot-sheet-open__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__troubleshoot-sheet-open__md.png b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__troubleshoot-sheet-open__md.png index c92f20727..cc72e2848 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__troubleshoot-sheet-open__md.png and b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__troubleshoot-sheet-open__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__troubleshoot-sheet-open__sm.png b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__troubleshoot-sheet-open__sm.png index 3233f002a..c4cdbc80f 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__troubleshoot-sheet-open__sm.png and b/lp-app/lpa-studio-web/story-images/studio__roster__roster-card__troubleshoot-sheet-open__sm.png differ diff --git a/lp-base/lp-bootctl/Cargo.toml b/lp-base/lp-bootctl/Cargo.toml new file mode 100644 index 000000000..2675b1e27 --- /dev/null +++ b/lp-base/lp-bootctl/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "lp-bootctl" +version = "1.0.0" +authors.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +description = "Boot-control sector: a flash-persisted instruction to the firmware's next boot" + +[dependencies] + +[lints] +workspace = true diff --git a/lp-base/lp-bootctl/README.md b/lp-base/lp-bootctl/README.md new file mode 100644 index 000000000..13a7b9412 --- /dev/null +++ b/lp-base/lp-bootctl/README.md @@ -0,0 +1,112 @@ +# lp-bootctl + +The **boot-control sector**: a flash-persisted instruction to the firmware's +next boot. + +One 4 KB flash partition (`bootctl`, at `0xe000` on every supported board) +holding a 16-byte record that the firmware reads *before* it auto-loads a +project. It exists so a device can be recovered when its own project is what +stops it from running — a project bright enough to brown the board out, a +shader that hangs the watchdog, anything that dies before the link is usable. + +This crate is `no_std`, zero-alloc, and pure: encode, decode, and the byte +layout. It performs no IO. Flash access lives in the edges — +`lp-fw/fw-esp32c6/src/bootctl.rs` on the device, and the `lpa-link` providers +on the host. + +Design rationale, alternatives, and the partition-layout change: +[`docs/adr/2026-07-30-boot-control-sector.md`](../../docs/adr/2026-07-30-boot-control-sector.md). + +## Why not the recovery region + +`lp-recovery`'s breadcrumb region lives in RTC fast RAM. It survives software +and watchdog resets but **not a power cycle** — and unplugging the board is +exactly what a person does to a device that is misbehaving. A latch a user +erases by doing the obvious thing is not a latch. This sector is flash-resident. + +## Two writers, one format + +| Writer | When | Why it needs this channel | +|---|---|---| +| **Host** (esptool / espflash) | Device is in ROM download mode | No firmware is running to receive a message; flash is the only channel | +| **Firmware** | *Not yet implemented* — follow-up plan | Latch its own degraded state across a power cycle | + +Both share this crate, so they cannot disagree about the format. + +## The rules that matter + +**Blank is safe.** An erased sector, a bad magic, a bad CRC, a future format +version, a short read, and a torn write **all** decode to "boot normally". +There is exactly one way to get a non-default boot: a fully valid record that +asks for one. A corrupt sector can never *cause* a degraded boot — only fail +to prevent one. + +**One write.** Write the whole 16-byte record to an erased sector in a single +operation. Do **not** split it: every flash-write API that can reach this +sector issues the ESP ROM/stub `FLASH_BEGIN`, which erases the sectors it is +about to write, so a second write meant to publish a first erases it instead. +Integrity comes from the magic and CRC — every prefix of a partial write fails +one of the two checks and decodes as "no record". + +**Consume on read.** The firmware erases a valid record the moment it reads +it, before acting on it. The instruction is one-shot; a crash during the +recovery boot cannot make it sticky. + +**Unknown flag bits are ignored, not rejected.** A newer host asking for +something this firmware cannot apply still gets the instructions it does +understand. + +**Safe mode is skip + clamp, with clamp winning.** Bits `8..16` carry a +safe-mode output clamp level (`0` = none, else a brightness ceiling out of +255). Hosts set both the skip bit and a dim clamp; a firmware that +implements the clamp loads the project dimmed and ignores the skip, while +older firmware ignores the clamp bits and honors the skip. Same record, +best available behavior on each. + +## Layout + +| Offset | Size | Field | +|---|---|---| +| `0` | 4 | Magic `LPBC` — **written last** | +| `4` | 2 | Format version (LE) | +| `6` | 2 | Padding | +| `8` | 4 | Flags (LE) | +| `12` | 4 | CRC-32 over bytes `0..12` (LE) | + +The rest of the sector is left erased. + +## Usage + +```rust +use lp_bootctl::{BOOTCTL_PARTITION_OFFSET, BootFlags, decode, encode_record}; + +// Host side: ask the device to start once in safe mode. Skip + clamp +// together, so the record does the best thing each firmware knows how to. +let flags = BootFlags::SKIP_PROJECT_AUTOLOAD.with_safe_clamp(BootFlags::DEFAULT_SAFE_CLAMP); +let record = encode_record(flags); +// erase the sector, then write `record` at BOOTCTL_PARTITION_OFFSET in one go + +// Device side, at boot: +let outcome = decode(§or_bytes); +if outcome.skip_project_autoload() { + // come up reachable with nothing loaded +} +``` + +The sector must be **erased** before the write; NOR flash cannot turn a `0` +back into a `1`. + +## Tests + +`tests/partition_layout.rs` guards the hand-maintained agreement between +[`BOOTCTL_PARTITION_OFFSET`] and both boards' `partitions.csv`: that the +offsets match, that no pre-existing partition moved, that nothing overlaps, +that the layout still fits 4 MB, and that the two boards stay identical. + +Those guards live here rather than in `fw-esp32c6` because that crate is +RV32-only and excluded from host builds — a `#[cfg(test)]` module there would +never run. + +```bash +cargo test -p lp-bootctl +``` diff --git a/lp-base/lp-bootctl/src/boot_control.rs b/lp-base/lp-bootctl/src/boot_control.rs new file mode 100644 index 000000000..ee8525993 --- /dev/null +++ b/lp-base/lp-bootctl/src/boot_control.rs @@ -0,0 +1,224 @@ +//! The decoded record, and every way decoding can end. + +use crate::boot_flags::BootFlags; +use crate::sector::{RECORD_LEN, decode_record, encode_record}; + +/// A valid boot-control record: what the next boot was asked to do. +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub struct BootControl { + flags: BootFlags, +} + +impl BootControl { + /// A record carrying no instructions. + pub const NONE: Self = Self { + flags: BootFlags::NONE, + }; + + pub const fn new(flags: BootFlags) -> Self { + Self { flags } + } + + pub const fn flags(self) -> BootFlags { + self.flags + } + + /// Whether this boot should skip the project auto-load. + pub const fn skip_project_autoload(self) -> bool { + self.flags.contains(BootFlags::SKIP_PROJECT_AUTOLOAD) + } + + /// The single boot decision, with the safe-mode precedence applied. + /// + /// This is THE place the skip-vs-clamp precedence lives — consumers ask + /// for the action rather than re-deriving it from raw flags, so the rule + /// ("a clamp-capable firmware loads the project dimmed and ignores the + /// skip") cannot drift between implementations. + pub const fn boot_action(self) -> BootAction { + if let Some(level) = self.flags.safe_clamp() { + return BootAction::LoadClamped { level }; + } + if self.skip_project_autoload() { + return BootAction::SkipAutoload; + } + BootAction::Normal + } + + /// Encode to the on-flash record. Write it in ONE operation — see + /// [`encode_record`](crate::encode_record) for why splitting the write + /// destroys it. + pub fn encode(self) -> [u8; RECORD_LEN] { + encode_record(self.flags) + } +} + +/// What this boot should actually do, precedence already applied. +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub enum BootAction { + /// Boot normally. + Normal, + /// Come up reachable with nothing loaded. The pre-clamp degradation, + /// and the fallback for firmware that predates the clamp. + SkipAutoload, + /// Load the project, but ceiling every fixture's output at + /// `level`/255. The preferred safe mode: the user sees their work + /// running dim, connects, and fixes it. + LoadClamped { level: u8 }, +} + +/// Every way a read of the boot-control sector can resolve. +/// +/// Only [`Valid`](Self::Valid) can change how the device boots. Every other +/// variant — including all four failure modes — means "boot normally"; they +/// are distinguished so the firmware log can say *why*, not so callers can +/// treat them differently. +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub enum DecodeOutcome { + /// A record this build understands. + Valid(BootControl), + /// Erased flash: no record was ever written here. + Blank, + /// Not erased, but not ours either — foreign data or a torn write that + /// lost the magic. + Invalid, + /// Our magic, but the payload does not match its checksum. + CrcMismatch, + /// Our magic and a good checksum, from a format this build predates. + UnsupportedVersion { found: u16 }, +} + +impl DecodeOutcome { + /// The record, if there is a usable one. + pub fn control(self) -> Option { + match self { + Self::Valid(control) => Some(control), + _ => None, + } + } + + /// Whether this boot should skip the project auto-load. + /// + /// Prefer [`Self::boot_action`], which applies the safe-mode precedence. + /// Every failure mode answers `false`, so a corrupt sector can never + /// *cause* a degraded boot — it can only fail to prevent one. + pub fn skip_project_autoload(self) -> bool { + self.control() + .is_some_and(BootControl::skip_project_autoload) + } + + /// The boot decision, with the safe-mode precedence applied. Every + /// failure mode answers [`BootAction::Normal`]. + pub fn boot_action(self) -> BootAction { + match self.control() { + Some(control) => control.boot_action(), + None => BootAction::Normal, + } + } + + /// A short, stable reason for the firmware boot log. + pub fn as_str(self) -> &'static str { + match self { + Self::Valid(_) => "valid", + Self::Blank => "blank", + Self::Invalid => "invalid", + Self::CrcMismatch => "crc-mismatch", + Self::UnsupportedVersion { .. } => "unsupported-version", + } + } +} + +/// Decode a boot-control sector read from flash. +/// +/// `bytes` may be the whole 4 KB sector or just its head; only the first +/// [`RECORD_LEN`] bytes are examined. A short slice decodes to +/// [`DecodeOutcome::Invalid`]. +pub fn decode(bytes: &[u8]) -> DecodeOutcome { + decode_record(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn none_instructs_nothing() { + assert!(!BootControl::NONE.skip_project_autoload()); + assert!(BootControl::NONE.flags().is_empty()); + } + + #[test] + fn encode_decode_round_trips() { + let control = BootControl::new(BootFlags::SKIP_PROJECT_AUTOLOAD); + let decoded = decode(&control.encode()); + assert_eq!(decoded.control(), Some(control)); + assert!(decoded.skip_project_autoload()); + } + + #[test] + fn every_failure_mode_declines_to_skip_autoload() { + for outcome in [ + DecodeOutcome::Blank, + DecodeOutcome::Invalid, + DecodeOutcome::CrcMismatch, + DecodeOutcome::UnsupportedVersion { found: 99 }, + ] { + assert!( + !outcome.skip_project_autoload(), + "{outcome:?} must fall back to a normal boot" + ); + assert_eq!(outcome.control(), None); + } + } + + #[test] + fn a_valid_but_empty_record_declines_to_skip_autoload() { + let outcome = DecodeOutcome::Valid(BootControl::NONE); + assert!(!outcome.skip_project_autoload()); + assert!(outcome.control().is_some()); + } + + #[test] + fn the_precedence_rule_lives_here_and_clamp_wins() { + use crate::BootFlags; + // Studio writes skip + clamp together; a clamp-capable firmware + // must load-dimmed, not skip. This is the format-level rule the ADR + // promises, so it is asserted at the format level. + let both = BootControl::new(BootFlags::SKIP_PROJECT_AUTOLOAD.with_safe_clamp(26)); + assert_eq!(both.boot_action(), BootAction::LoadClamped { level: 26 }); + + let skip_only = BootControl::new(BootFlags::SKIP_PROJECT_AUTOLOAD); + assert_eq!(skip_only.boot_action(), BootAction::SkipAutoload); + + let clamp_only = BootControl::new(BootFlags::NONE.with_safe_clamp(128)); + assert_eq!( + clamp_only.boot_action(), + BootAction::LoadClamped { level: 128 } + ); + + assert_eq!(BootControl::NONE.boot_action(), BootAction::Normal); + } + + #[test] + fn every_failure_mode_boots_normally_via_boot_action() { + for outcome in [ + DecodeOutcome::Blank, + DecodeOutcome::Invalid, + DecodeOutcome::CrcMismatch, + DecodeOutcome::UnsupportedVersion { found: 9 }, + ] { + assert_eq!(outcome.boot_action(), BootAction::Normal); + } + } + + #[test] + fn reasons_are_distinguishable_in_logs() { + assert_eq!(DecodeOutcome::Blank.as_str(), "blank"); + assert_eq!(DecodeOutcome::Invalid.as_str(), "invalid"); + assert_eq!(DecodeOutcome::CrcMismatch.as_str(), "crc-mismatch"); + assert_eq!( + DecodeOutcome::UnsupportedVersion { found: 2 }.as_str(), + "unsupported-version" + ); + assert_eq!(DecodeOutcome::Valid(BootControl::NONE).as_str(), "valid"); + } +} diff --git a/lp-base/lp-bootctl/src/boot_flags.rs b/lp-base/lp-bootctl/src/boot_flags.rs new file mode 100644 index 000000000..1b077c983 --- /dev/null +++ b/lp-base/lp-bootctl/src/boot_flags.rs @@ -0,0 +1,154 @@ +//! What a boot-control record can ask the next boot to do. + +/// Bitfield of boot-time instructions. +/// +/// # Layout of the underlying `u32` +/// +/// | Bits | Meaning | +/// |---|---| +/// | `0..8` | Boolean instructions. Bit 0 is [`Self::SKIP_PROJECT_AUTOLOAD`]. | +/// | `8..16` | Safe-mode output clamp level: `0` = none, else a brightness ceiling out of 255. | +/// | `16..32` | **Reserved.** | +/// +/// # Safe-mode precedence +/// +/// A record may carry BOTH the skip bit and a clamp level — that is what +/// Studio's "Start in safe mode" writes. **A firmware that implements the +/// clamp loads the project dimmed and IGNORES the skip bit**: seeing the +/// user's work at low current is a strictly better degradation than a dark +/// board. A firmware that predates the clamp ignores the unknown bits and +/// honors the skip. Same record, best available behavior on each — that is +/// why the host sets both rather than choosing per firmware version. +/// +/// Unknown bits in a record whose version this build understands are +/// **ignored, not rejected** — a newer host asking for a clamp this +/// firmware cannot apply still gets the skip it also asked for, rather +/// than falling back to a normal boot. +#[derive(Copy, Clone, PartialEq, Eq, Debug, Default)] +pub struct BootFlags(u32); + +impl BootFlags { + /// No instructions: equivalent to having no record at all. + pub const NONE: Self = Self(0); + + /// Skip the project auto-load for this boot. + /// + /// The device comes up reachable with nothing loaded, so a project that + /// kills the board on load can be replaced or deleted over the link. + pub const SKIP_PROJECT_AUTOLOAD: Self = Self(1 << 0); + + /// Default safe-mode clamp: ~10% brightness. Bright enough to see the + /// project running, far below brownout territory. + pub const DEFAULT_SAFE_CLAMP: u8 = 26; + + const CLAMP_SHIFT: u32 = 8; + const CLAMP_MASK: u32 = 0xFF << Self::CLAMP_SHIFT; + + /// Bits this build assigns meaning to. Everything else is reserved. + const KNOWN: u32 = Self::SKIP_PROJECT_AUTOLOAD.0 | Self::CLAMP_MASK; + + pub const fn from_bits(bits: u32) -> Self { + Self(bits) + } + + pub const fn bits(self) -> u32 { + self.0 + } + + pub const fn is_empty(self) -> bool { + self.0 == 0 + } + + /// Whether every bit in `other` is set here. + pub const fn contains(self, other: Self) -> bool { + self.0 & other.0 == other.0 + } + + pub const fn union(self, other: Self) -> Self { + Self(self.0 | other.0) + } + + /// Attach a safe-mode output clamp level (`0` clears it). + pub const fn with_safe_clamp(self, level: u8) -> Self { + Self((self.0 & !Self::CLAMP_MASK) | ((level as u32) << Self::CLAMP_SHIFT)) + } + + /// The safe-mode clamp level, when one is set: a brightness ceiling out + /// of 255. See the type docs for the precedence rule against + /// [`Self::SKIP_PROJECT_AUTOLOAD`]. + pub const fn safe_clamp(self) -> Option { + let level = ((self.0 & Self::CLAMP_MASK) >> Self::CLAMP_SHIFT) as u8; + if level == 0 { None } else { Some(level) } + } + + /// Whether the record carries instructions this build does not + /// understand. Diagnostic only — unknown bits are never a decode + /// failure. + pub const fn has_unknown_bits(self) -> bool { + self.0 & !Self::KNOWN != 0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn none_is_empty_and_contains_nothing() { + assert!(BootFlags::NONE.is_empty()); + assert!(!BootFlags::NONE.contains(BootFlags::SKIP_PROJECT_AUTOLOAD)); + } + + #[test] + fn skip_autoload_round_trips_through_bits() { + let flags = BootFlags::SKIP_PROJECT_AUTOLOAD; + assert_eq!(BootFlags::from_bits(flags.bits()), flags); + assert!(flags.contains(BootFlags::SKIP_PROJECT_AUTOLOAD)); + assert!(!flags.is_empty()); + } + + #[test] + fn union_accumulates() { + let flags = BootFlags::NONE.union(BootFlags::SKIP_PROJECT_AUTOLOAD); + assert!(flags.contains(BootFlags::SKIP_PROJECT_AUTOLOAD)); + } + + #[test] + fn the_safe_clamp_rides_bits_8_to_15() { + let flags = BootFlags::SKIP_PROJECT_AUTOLOAD.with_safe_clamp(26); + assert_eq!(flags.safe_clamp(), Some(26)); + assert!(flags.contains(BootFlags::SKIP_PROJECT_AUTOLOAD)); + // Round-trips through raw bits (the wire carries a plain u32). + assert_eq!(BootFlags::from_bits(flags.bits()).safe_clamp(), Some(26)); + // Clamp bits are KNOWN now — a clamp-carrying record is not flagged + // as from-the-future. + assert!(!flags.has_unknown_bits()); + } + + #[test] + fn a_zero_clamp_means_none_and_clears() { + assert_eq!(BootFlags::NONE.safe_clamp(), None); + let cleared = BootFlags::SKIP_PROJECT_AUTOLOAD + .with_safe_clamp(26) + .with_safe_clamp(0); + assert_eq!(cleared.safe_clamp(), None); + assert!(cleared.contains(BootFlags::SKIP_PROJECT_AUTOLOAD)); + } + + #[test] + fn reserved_bits_are_flagged_but_do_not_hide_known_ones() { + // A newer host setting a bit in the still-reserved 16..32 range. + let flags = BootFlags::from_bits(BootFlags::SKIP_PROJECT_AUTOLOAD.bits() | (0x1 << 20)); + assert!(flags.has_unknown_bits()); + assert!( + flags.contains(BootFlags::SKIP_PROJECT_AUTOLOAD), + "unknown bits must not suppress instructions this build understands" + ); + } + + #[test] + fn known_bits_alone_are_not_flagged_as_unknown() { + assert!(!BootFlags::SKIP_PROJECT_AUTOLOAD.has_unknown_bits()); + assert!(!BootFlags::NONE.has_unknown_bits()); + } +} diff --git a/lp-base/lp-bootctl/src/crc32.rs b/lp-base/lp-bootctl/src/crc32.rs new file mode 100644 index 000000000..3dec96c35 --- /dev/null +++ b/lp-base/lp-bootctl/src/crc32.rs @@ -0,0 +1,63 @@ +//! Small bitwise CRC-32 (IEEE / ISO-HDLC, reflected). +//! +//! No lookup table: this runs once per boot over 12 bytes. Eight iterations +//! per byte is nothing, and a 1 KB table would cost more flash than it saves +//! on a device where flash headroom is the binding constraint. +//! +//! Deliberately a local copy rather than a dependency: `lp-recovery` keeps +//! its equivalent private, and one shared 20-line helper is not worth a +//! crate edge between two `no_std` primitives that must not depend on each +//! other. + +pub(crate) struct Crc32(u32); + +impl Crc32 { + pub(crate) fn new() -> Self { + Self(0xFFFF_FFFF) + } + + pub(crate) fn update(&mut self, bytes: &[u8]) { + for &byte in bytes { + self.0 ^= u32::from(byte); + for _ in 0..8 { + self.0 = if self.0 & 1 != 0 { + (self.0 >> 1) ^ 0xEDB8_8320 + } else { + self.0 >> 1 + }; + } + } + } + + pub(crate) fn finish(self) -> u32 { + self.0 ^ 0xFFFF_FFFF + } +} + +/// CRC-32 of a single byte slice. +pub(crate) fn crc32(bytes: &[u8]) -> u32 { + let mut crc = Crc32::new(); + crc.update(bytes); + crc.finish() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn matches_the_known_check_vector() { + // CRC-32/ISO-HDLC of "123456789" is 0xCBF43926. + assert_eq!(crc32(b"123456789"), 0xCBF4_3926); + } + + #[test] + fn empty_input_is_zero() { + assert_eq!(crc32(&[]), 0); + } + + #[test] + fn single_bit_changes_the_result() { + assert_ne!(crc32(&[0x00]), crc32(&[0x01])); + } +} diff --git a/lp-base/lp-bootctl/src/lib.rs b/lp-base/lp-bootctl/src/lib.rs new file mode 100644 index 000000000..088ea6995 --- /dev/null +++ b/lp-base/lp-bootctl/src/lib.rs @@ -0,0 +1,66 @@ +//! The boot-control sector: a flash-persisted instruction to the next boot. +//! +//! One 4 KB flash sector carrying a small record that the firmware reads +//! **before** it auto-loads a project. Its purpose is to make a device +//! recoverable when its own project is what prevents it from running — a +//! too-bright project that browns the board out, a shader that hangs the +//! watchdog, anything that dies before the link is usable. +//! +//! # Why flash, and not the recovery region +//! +//! [`lp_recovery`](../lp_recovery/index.html)'s breadcrumb region lives in +//! RTC fast RAM. That survives software and watchdog resets but **not a +//! power cycle** — and unplugging the board is exactly what a person does to +//! a device that is misbehaving. A latch that a user can erase by doing the +//! obvious thing is not a latch. This sector is flash-resident so it +//! survives. +//! +//! # Two writers +//! +//! The sector is written from two directions, and the format is shared so +//! they cannot disagree: +//! +//! - **The host**, over esptool/espflash, while the device sits in ROM +//! download mode. This is the path that works on a board that cannot boot +//! far enough to talk to anything. +//! - **The firmware itself**, so a device that keeps failing can latch its +//! own degraded state across a power cycle. (Not yet implemented — the +//! firmware side currently only reads and clears. See the follow-up plan.) +//! +//! # Blank is safe +//! +//! A device that has never seen this feature has `0xFF` bytes here, and that +//! **must** decode to "boot normally". So must a bad magic, a bad CRC, a +//! future version, and a torn write. There is exactly one way to get a +//! non-default boot: a fully valid record that says so. Every other state, +//! including every corruption state, falls back to normal operation. +//! +//! # Torn writes +//! +//! The record is written to an erased sector in **one** operation, and its +//! integrity rests on the magic and the CRC rather than on write ordering. +//! +//! This is not the discipline [`lp_recovery`] uses — it publishes RTC-RAM +//! structures by flipping a single visibility word last. That trick is +//! unavailable here: every flash-write API that can reach this sector (the +//! ESP ROM/stub `FLASH_BEGIN`, hence both `espflash` and `esptool-js`) +//! **erases the sectors it is about to write**, so a second write meant to +//! publish a first one erases it instead. +//! +//! The CRC covers the magic, so any interrupted write fails either the magic +//! check or the checksum, and decodes as "no record". `encode_record` is the +//! whole API; see [`sector`] for the byte layout. + +#![no_std] + +mod boot_control; +mod boot_flags; +mod crc32; +mod sector; + +pub use boot_control::{BootAction, BootControl, DecodeOutcome, decode}; +pub use boot_flags::BootFlags; +pub use sector::{ + BOOTCTL_PARTITION_OFFSET, BOOTCTL_PARTITION_SIZE, RECORD_LEN, SECTOR_MAGIC, SECTOR_VERSION, + encode_record, +}; diff --git a/lp-base/lp-bootctl/src/sector.rs b/lp-base/lp-bootctl/src/sector.rs new file mode 100644 index 000000000..387cd49dd --- /dev/null +++ b/lp-base/lp-bootctl/src/sector.rs @@ -0,0 +1,287 @@ +//! The on-flash byte layout, and the order its bytes must be written in. + +use crate::boot_control::{BootControl, DecodeOutcome}; +use crate::boot_flags::BootFlags; +use crate::crc32::crc32; + +/// Flash offset of the `bootctl` partition. Identical on every supported +/// board, so host writers and firmware readers agree without consulting a +/// partition table. +/// +/// Kept in sync by hand with `lp-fw/fw-esp32c6/partitions.csv` and +/// `lp-fw/fw-esp32s3/partitions.csv`; `tests/partition_layout.rs` is the +/// guard. +pub const BOOTCTL_PARTITION_OFFSET: u32 = 0x0000_E000; + +/// Size of the `bootctl` partition: one 4 KB flash erase sector. +pub const BOOTCTL_PARTITION_SIZE: u32 = 0x0000_1000; + +/// Identifies an initialized record. Compared as bytes, so there is no +/// endianness to get wrong across the host writer and the device reader. +pub const SECTOR_MAGIC: [u8; 4] = *b"LPBC"; + +/// Bump on any layout change. Old records are **discarded, never migrated** — +/// a record this build does not understand decodes to a normal boot. +pub const SECTOR_VERSION: u16 = 1; + +/// Bytes of the record. The rest of the sector is left erased. +pub const RECORD_LEN: usize = 16; + +const MAGIC_RANGE: core::ops::Range = 0..4; +const VERSION_RANGE: core::ops::Range = 4..6; +const PAD_RANGE: core::ops::Range = 6..8; +const FLAGS_RANGE: core::ops::Range = 8..12; +const CRC_RANGE: core::ops::Range = 12..16; + +/// Bytes the CRC covers: magic, version, pad, flags — everything but the +/// CRC itself. +const CRC_COVERED: core::ops::Range = 0..12; + +/// Erased NOR flash reads as all-ones. +const ERASED_BYTE: u8 = 0xFF; + +/// Encode a record for writing to an **erased** sector. +/// +/// Write it in a single operation. Do not split it into multiple writes: +/// every flash-write API that can reach this sector (the ESP ROM/stub +/// `FLASH_BEGIN`, and therefore both `espflash` and `esptool-js`) **erases +/// the sectors it is about to write**, so a second write to the same sector +/// destroys the first. Integrity comes from the magic and CRC, not from +/// write ordering: any partial or interrupted write fails one of those two +/// checks and decodes as "no record", which boots normally. +pub fn encode_record(flags: BootFlags) -> [u8; RECORD_LEN] { + let mut record = [0u8; RECORD_LEN]; + record[MAGIC_RANGE].copy_from_slice(&SECTOR_MAGIC); + record[VERSION_RANGE].copy_from_slice(&SECTOR_VERSION.to_le_bytes()); + record[PAD_RANGE].copy_from_slice(&0u16.to_le_bytes()); + record[FLAGS_RANGE].copy_from_slice(&flags.bits().to_le_bytes()); + let crc = crc32(&record[CRC_COVERED]); + record[CRC_RANGE].copy_from_slice(&crc.to_le_bytes()); + record +} + +/// Decode a record read from flash. +/// +/// Every failure mode — blank, foreign, torn, corrupt, or from a future +/// format — resolves to a variant that callers treat as "boot normally". +/// There is exactly one variant that changes behavior. +pub(crate) fn decode_record(bytes: &[u8]) -> DecodeOutcome { + if bytes.len() < RECORD_LEN { + return DecodeOutcome::Invalid; + } + let record = &bytes[..RECORD_LEN]; + + if record[MAGIC_RANGE] != SECTOR_MAGIC { + // Distinguish "never written" from "written with something else" so + // the firmware log can say which. Both boot normally. + return if record.iter().all(|&b| b == ERASED_BYTE) { + DecodeOutcome::Blank + } else { + DecodeOutcome::Invalid + }; + } + + let stored_crc = u32::from_le_bytes( + record[CRC_RANGE] + .try_into() + .expect("CRC_RANGE is exactly 4 bytes"), + ); + if crc32(&record[CRC_COVERED]) != stored_crc { + return DecodeOutcome::CrcMismatch; + } + + let version = u16::from_le_bytes( + record[VERSION_RANGE] + .try_into() + .expect("VERSION_RANGE is exactly 2 bytes"), + ); + if version != SECTOR_VERSION { + return DecodeOutcome::UnsupportedVersion { found: version }; + } + + let flags = BootFlags::from_bits(u32::from_le_bytes( + record[FLAGS_RANGE] + .try_into() + .expect("FLAGS_RANGE is exactly 4 bytes"), + )); + DecodeOutcome::Valid(BootControl::new(flags)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn valid_record() -> [u8; RECORD_LEN] { + encode_record(BootFlags::SKIP_PROJECT_AUTOLOAD) + } + + #[test] + fn round_trips_a_valid_record() { + let record = valid_record(); + match decode_record(&record) { + DecodeOutcome::Valid(control) => { + assert!(control.flags().contains(BootFlags::SKIP_PROJECT_AUTOLOAD)); + assert!(control.skip_project_autoload()); + } + other => panic!("expected Valid, got {other:?}"), + } + } + + #[test] + fn erased_sector_is_blank() { + let erased = [ERASED_BYTE; RECORD_LEN]; + assert_eq!(decode_record(&erased), DecodeOutcome::Blank); + assert!(!decode_record(&erased).skip_project_autoload()); + } + + #[test] + fn an_erased_full_sector_is_blank() { + // The firmware hands us a whole 4 KB read, not just the record. + let sector = [ERASED_BYTE; BOOTCTL_PARTITION_SIZE as usize]; + assert_eq!(decode_record(§or), DecodeOutcome::Blank); + } + + #[test] + fn foreign_bytes_are_invalid_not_blank() { + let foreign = [0x00u8; RECORD_LEN]; + assert_eq!(decode_record(&foreign), DecodeOutcome::Invalid); + } + + #[test] + fn bad_magic_is_rejected() { + let mut record = valid_record(); + record[0] = b'X'; + assert_eq!(decode_record(&record), DecodeOutcome::Invalid); + } + + #[test] + fn corrupt_flags_fail_the_crc() { + let mut record = valid_record(); + record[FLAGS_RANGE.start] ^= 0xFF; + assert_eq!(decode_record(&record), DecodeOutcome::CrcMismatch); + } + + #[test] + fn corrupt_crc_is_rejected() { + let mut record = valid_record(); + record[CRC_RANGE.start] ^= 0xFF; + assert_eq!(decode_record(&record), DecodeOutcome::CrcMismatch); + } + + #[test] + fn a_future_version_is_not_honored() { + let mut record = valid_record(); + record[VERSION_RANGE].copy_from_slice(&(SECTOR_VERSION + 1).to_le_bytes()); + // Re-CRC so the version, not the checksum, is what rejects it. + let crc = crc32(&record[CRC_COVERED]); + record[CRC_RANGE].copy_from_slice(&crc.to_le_bytes()); + assert_eq!( + decode_record(&record), + DecodeOutcome::UnsupportedVersion { + found: SECTOR_VERSION + 1 + } + ); + assert!(!decode_record(&record).skip_project_autoload()); + } + + #[test] + fn a_short_read_is_invalid() { + let record = valid_record(); + assert_eq!( + decode_record(&record[..RECORD_LEN - 1]), + DecodeOutcome::Invalid + ); + assert_eq!(decode_record(&[]), DecodeOutcome::Invalid); + } + + #[test] + fn a_torn_write_that_lost_the_magic_is_blank() { + // Payload landed, magic did not: exactly what the write order + // guarantees an interrupted write looks like. + let mut record = valid_record(); + record[MAGIC_RANGE].copy_from_slice(&[ERASED_BYTE; 4]); + assert!(!decode_record(&record).skip_project_autoload()); + } + + #[test] + fn a_torn_write_that_lost_the_payload_fails_the_crc() { + // The impossible-by-ordering case, checked anyway: magic present + // over an erased payload. + let mut record = valid_record(); + for byte in &mut record[MAGIC_RANGE.end..] { + *byte = ERASED_BYTE; + } + assert_eq!(decode_record(&record), DecodeOutcome::CrcMismatch); + assert!(!decode_record(&record).skip_project_autoload()); + } + + /// Every prefix of a record — what a write interrupted partway through + /// leaves behind — must decode as "no record". This is the property that + /// replaces write-ordering: the sector cannot be published in two steps, + /// because every flash API that writes it also erases it first. + #[test] + fn no_partial_write_is_ever_honored() { + let record = valid_record(); + for written in 0..RECORD_LEN { + let mut partial = [ERASED_BYTE; RECORD_LEN]; + partial[..written].copy_from_slice(&record[..written]); + assert!( + !decode_record(&partial).skip_project_autoload(), + "a write interrupted after {written} bytes must not be honored" + ); + } + // ...and the complete record still is. + assert!(decode_record(&record).skip_project_autoload()); + } + + #[test] + fn unknown_flag_bits_still_decode_and_keep_known_instructions() { + let flags = BootFlags::from_bits(BootFlags::SKIP_PROJECT_AUTOLOAD.bits() | (0x5 << 24)); + let record = encode_record(flags); + match decode_record(&record) { + DecodeOutcome::Valid(control) => { + assert!(control.skip_project_autoload()); + assert!(control.flags().has_unknown_bits()); + } + other => panic!("expected Valid, got {other:?}"), + } + } + + #[test] + fn empty_flags_decode_as_valid_but_instruct_nothing() { + let record = encode_record(BootFlags::NONE); + assert!(matches!(decode_record(&record), DecodeOutcome::Valid(_))); + assert!(!decode_record(&record).skip_project_autoload()); + } + + #[test] + fn the_record_fits_the_partition() { + assert!(RECORD_LEN as u32 <= BOOTCTL_PARTITION_SIZE); + } + + /// Golden vector: the exact 16 bytes of a `SKIP_PROJECT_AUTOLOAD` record. + /// + /// This crate is not the only thing that will ever produce these bytes — + /// host writers over esptool encode the same record, and this is the + /// fixture they can be checked against. Changing it means changing the + /// on-flash format, which needs a `SECTOR_VERSION` bump, not a new + /// expected value here. + /// + /// Verified against a device: written to `bootctl` with esptool and + /// honored by `fw-esp32c6` on real silicon (2026-07-30). + #[test] + fn skip_autoload_matches_its_golden_bytes() { + let record = encode_record(BootFlags::SKIP_PROJECT_AUTOLOAD); + assert_eq!( + record, + [ + 0x4c, 0x50, 0x42, 0x43, // magic "LPBC" + 0x01, 0x00, // version 1 + 0x00, 0x00, // pad + 0x01, 0x00, 0x00, 0x00, // flags = SKIP_PROJECT_AUTOLOAD + 0x9e, 0x6e, 0x44, 0x3f, // CRC-32 of the preceding 12 bytes + ], + "on-flash format changed; bump SECTOR_VERSION rather than this vector" + ); + } +} diff --git a/lp-base/lp-bootctl/tests/partition_layout.rs b/lp-base/lp-bootctl/tests/partition_layout.rs new file mode 100644 index 000000000..6ac993538 --- /dev/null +++ b/lp-base/lp-bootctl/tests/partition_layout.rs @@ -0,0 +1,174 @@ +//! Guards the hand-maintained agreement between this crate's constants and +//! the firmware partition tables. +//! +//! `lp-bootctl` hardcodes the sector's flash offset so a host writer (over +//! esptool, on a device that cannot boot) and the firmware reader agree +//! without either one parsing a partition table. That only stays true if +//! `partitions.csv` and [`lp_bootctl::BOOTCTL_PARTITION_OFFSET`] move +//! together — hence this test. +//! +//! It lives here rather than in `fw-esp32c6` because that crate is RV32-only +//! and excluded from host builds: a `#[cfg(test)]` module there would never +//! run. See `docs/adr/2026-07-30-boot-control-sector.md`. + +use lp_bootctl::{BOOTCTL_PARTITION_OFFSET, BOOTCTL_PARTITION_SIZE, RECORD_LEN}; + +const C6_PARTITIONS: &str = include_str!("../../../lp-fw/fw-esp32c6/partitions.csv"); +const S3_PARTITIONS: &str = include_str!("../../../lp-fw/fw-esp32s3/partitions.csv"); + +/// Per-board expectations. The boards' tables legitimately DIVERGE above +/// `0x10000` — the S3 has an 8 MB partition floor +/// (`2026-07-30-esp32s3-partition-floor.md`) — so the invariant lp-bootctl +/// actually needs is narrower than whole-table identity: the LOW region +/// (nvs, bootctl, phy_init, factory start) is identical everywhere, which is +/// what lets `BOOTCTL_PARTITION_OFFSET` be a constant instead of a +/// partition-table lookup. +const COMMON_LOW_REGION: &[(&str, u32)] = + &[("nvs", 0x9000), ("phy_init", 0xf000), ("factory", 0x10000)]; + +/// `lpfs` must not move on either board — an existing device's filesystem +/// image stays valid only if its partition stays put. The expected offset is +/// per-board because the S3's 8 MB floor placed it differently. +const LPFS_OFFSETS: &[(&str, u32, u32)] = &[ + ("esp32c6", 0x310000, 0x40_0000), + ("esp32s3", 0x610000, 0x80_0000), +]; + +#[test] +fn bootctl_offset_and_size_match_every_board() { + for (board, csv) in [("esp32c6", C6_PARTITIONS), ("esp32s3", S3_PARTITIONS)] { + let bootctl = partition(csv, "bootctl") + .unwrap_or_else(|| panic!("{board} partitions.csv declares a bootctl partition")); + assert_eq!( + bootctl.offset, BOOTCTL_PARTITION_OFFSET, + "{board}: bootctl offset must match lp_bootctl::BOOTCTL_PARTITION_OFFSET" + ); + assert_eq!( + bootctl.size, BOOTCTL_PARTITION_SIZE, + "{board}: bootctl size must match lp_bootctl::BOOTCTL_PARTITION_SIZE" + ); + } +} + +#[test] +fn the_record_fits_the_sector() { + assert!( + RECORD_LEN as u32 <= BOOTCTL_PARTITION_SIZE, + "the record must fit the partition the firmware reads it from" + ); +} + +#[test] +fn the_low_region_is_identical_on_every_board() { + for (board, csv) in [("esp32c6", C6_PARTITIONS), ("esp32s3", S3_PARTITIONS)] { + for &(name, expected) in COMMON_LOW_REGION { + let found = partition(csv, name) + .unwrap_or_else(|| panic!("{board} partitions.csv still declares {name}")); + assert_eq!( + found.offset, expected, + "{board}: {name} moved — the shared low region is what lets \ + lp-bootctl hardcode its offset" + ); + } + } +} + +#[test] +fn lpfs_stays_put_on_each_board() { + for &(board, expected_offset, _) in LPFS_OFFSETS { + let csv = csv_for(board); + let lpfs = partition(csv, "lpfs") + .unwrap_or_else(|| panic!("{board} partitions.csv still declares lpfs")); + assert_eq!( + lpfs.offset, expected_offset, + "{board}: lpfs moved — existing devices' filesystem images would \ + be invalidated" + ); + } +} + +#[test] +fn bootctl_came_out_of_nvs() { + // The 4 KB was taken from nvs (unused: no LightPlayer code touches NVS, + // and esp-radio's "NVS" is a RAM array). If someone re-grows nvs, the + // two partitions overlap and this catches it. + for (board, csv) in [("esp32c6", C6_PARTITIONS), ("esp32s3", S3_PARTITIONS)] { + let nvs = partition(csv, "nvs").expect("nvs exists"); + let bootctl = partition(csv, "bootctl").expect("bootctl exists"); + assert_eq!( + nvs.offset + nvs.size, + bootctl.offset, + "{board}: bootctl must start where nvs ends" + ); + } +} + +#[test] +fn no_partitions_overlap_and_each_board_fits_its_flash() { + for &(board, _, flash_size) in LPFS_OFFSETS { + let csv = csv_for(board); + let mut parts = partitions(csv); + parts.sort_by_key(|p| p.offset); + for pair in parts.windows(2) { + let (first, second) = (&pair[0], &pair[1]); + assert!( + first.offset + first.size <= second.offset, + "{board}: {} overlaps {}", + first.name, + second.name + ); + } + let last = parts.last().expect("at least one partition"); + assert!( + last.offset + last.size <= flash_size, + "{board}: layout exceeds its {flash_size:#x} flash floor" + ); + } +} + +fn csv_for(board: &str) -> &'static str { + match board { + "esp32c6" => C6_PARTITIONS, + "esp32s3" => S3_PARTITIONS, + other => panic!("unknown board {other}"), + } +} + +#[derive(PartialEq, Eq, Debug)] +struct Partition { + name: String, + offset: u32, + size: u32, +} + +fn partitions(csv: &str) -> Vec { + csv.lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .map(|line| { + let fields: Vec<&str> = line.split(',').map(str::trim).collect(); + assert!( + fields.len() >= 5, + "partition row {line:?} should have at least 5 fields" + ); + Partition { + name: fields[0].to_string(), + offset: parse_hex(fields[3]), + size: parse_hex(fields[4]), + } + }) + .collect() +} + +fn partition(csv: &str, name: &str) -> Option { + partitions(csv).into_iter().find(|p| p.name == name) +} + +fn parse_hex(text: &str) -> u32 { + let digits = text + .strip_prefix("0x") + .or_else(|| text.strip_prefix("0X")) + .unwrap_or(text); + u32::from_str_radix(digits, 16) + .unwrap_or_else(|_| panic!("partition field {text:?} is not hex")) +} diff --git a/lp-core/lpc-engine/src/engine/engine.rs b/lp-core/lpc-engine/src/engine/engine.rs index 1cf06d68a..ac7a7e438 100644 --- a/lp-core/lpc-engine/src/engine/engine.rs +++ b/lp-core/lpc-engine/src/engine/engine.rs @@ -61,6 +61,14 @@ pub struct Engine { services: EngineServices, demand_roots: Vec, graphics: Option>, + /// Device-level safe-mode output ceiling, Q16 (`None` = no clamp). + /// + /// DEVICE state, not project data: set by the embedder (firmware, from a + /// consumed boot-control record), never by anything a project can touch. + /// That separation is the point — the mechanism that saves a device must + /// not be editable by the thing that broke it. Composed into every + /// fixture's power scale via `min` in the fixture render. + safe_output_clamp_q16: Option, /// The tree shape and resolver epoch as of the last tick, so that a /// structural change that forgot to invalidate resolution is caught here /// rather than by someone noticing a stale value on a device. @@ -88,6 +96,7 @@ impl Engine { services, demand_roots: Vec::new(), graphics: None, + safe_output_clamp_q16: None, #[cfg(debug_assertions)] last_structural_check: None, } @@ -271,6 +280,16 @@ impl Engine { self.graphics = graphics; } + /// Set (or clear) the device-level safe-mode output ceiling. + /// + /// `level` is a brightness ceiling out of 255, from the boot-control + /// record's clamp bits. Applies to EVERY fixture — including ones with + /// no power model — because the clamped project may predate the power + /// feature entirely. + pub fn set_safe_output_clamp(&mut self, level: Option) { + self.safe_output_clamp_q16 = level.map(|level| (u32::from(level) << 16) / 255); + } + pub fn graphics(&self) -> Option<&Arc> { self.graphics.as_ref() } @@ -467,6 +486,7 @@ impl Engine { button_service, radio_service, frame_time_seconds: time_s, + safe_output_clamp_q16: self.safe_output_clamp_q16, }; { @@ -534,6 +554,7 @@ impl Engine { button_service, radio_service, frame_time_seconds: time_s, + safe_output_clamp_q16: self.safe_output_clamp_q16, }; host.render_node_texture(product, request) } @@ -579,6 +600,7 @@ impl Engine { button_service, radio_service, frame_time_seconds: time_s, + safe_output_clamp_q16: self.safe_output_clamp_q16, }; let result = session.resolve(&mut host, &key); self.resolver = resolver_tmp; @@ -631,6 +653,7 @@ impl Engine { button_service, radio_service, frame_time_seconds: time_s, + safe_output_clamp_q16: self.safe_output_clamp_q16, }; host.render_node_control(product, request, target) } @@ -667,6 +690,7 @@ impl Engine { button_service, radio_service, frame_time_seconds: time_s, + safe_output_clamp_q16: self.safe_output_clamp_q16, }; let result = session.resolve(&mut host, &QueryKey::Bus(channel.clone())); self.resolver = resolver; @@ -697,6 +721,7 @@ impl Engine { button_service, radio_service, frame_time_seconds: time_s, + safe_output_clamp_q16: self.safe_output_clamp_q16, }; host.render_node_control_probe(product, request, target, display_layout) } @@ -714,6 +739,7 @@ struct EngineResolveHost<'a> { button_service: Option>, radio_service: Option>, frame_time_seconds: f32, + safe_output_clamp_q16: Option, } impl EngineResolveHost<'_> { @@ -1410,6 +1436,7 @@ impl EngineResolveHost<'_> { revision, self.graphics.clone(), self.frame_time_seconds, + self.safe_output_clamp_q16, self, ); catch_node_panic_framed(lp_recovery::FrameKind::NodeRender, &recovery_name, || { @@ -1505,6 +1532,7 @@ impl EngineResolveHost<'_> { revision, self.graphics.clone(), self.frame_time_seconds, + self.safe_output_clamp_q16, self, ); catch_node_panic_framed(lp_recovery::FrameKind::NodeRender, &recovery_name, || { @@ -1919,6 +1947,7 @@ pub(crate) fn resolve_with_engine_host( button_service, radio_service, frame_time_seconds: time_s, + safe_output_clamp_q16: eng.safe_output_clamp_q16, }; let result = session .resolve(&mut host, &key) @@ -1957,6 +1986,7 @@ pub(super) fn resolve_twice_same_frame_with_engine_host( button_service, radio_service, frame_time_seconds: time_s, + safe_output_clamp_q16: eng.safe_output_clamp_q16, }; let result = session.resolve(&mut host, &key).and_then(|first| { session diff --git a/lp-core/lpc-engine/src/node/contexts.rs b/lp-core/lpc-engine/src/node/contexts.rs index b127ba648..9f0678018 100644 --- a/lp-core/lpc-engine/src/node/contexts.rs +++ b/lp-core/lpc-engine/src/node/contexts.rs @@ -381,6 +381,9 @@ pub struct ControlRenderContext<'a> { revision: Revision, graphics: Option>, frame_time_seconds: f32, + /// Device-level safe-mode output ceiling, Q16. See + /// `Engine::set_safe_output_clamp` — device state, never project data. + safe_output_clamp_q16: Option, services: &'a mut dyn ControlRenderServices, } @@ -390,6 +393,7 @@ impl<'a> ControlRenderContext<'a> { revision: Revision, graphics: Option>, frame_time_seconds: f32, + safe_output_clamp_q16: Option, services: &'a mut dyn ControlRenderServices, ) -> Self { Self { @@ -397,10 +401,16 @@ impl<'a> ControlRenderContext<'a> { revision, graphics, frame_time_seconds, + safe_output_clamp_q16, services, } } + /// The device-level safe-mode ceiling, when one is armed (Q16). + pub fn safe_output_clamp_q16(&self) -> Option { + self.safe_output_clamp_q16 + } + pub fn node_id(&self) -> NodeId { self.node_id } diff --git a/lp-core/lpc-engine/src/nodes/fixture/fixture_node.rs b/lp-core/lpc-engine/src/nodes/fixture/fixture_node.rs index a0b4828ff..aa9049055 100644 --- a/lp-core/lpc-engine/src/nodes/fixture/fixture_node.rs +++ b/lp-core/lpc-engine/src/nodes/fixture/fixture_node.rs @@ -579,8 +579,22 @@ impl ControlNode for FixtureNode { ); } - let mut power = if settings.power.is_limited() { - PowerPass::limited(self.power_scale_q16) + // The device-level safe clamp composes with the fixture's own budget + // scale by `min` — ceilings compose; neither can boost. It applies to + // EVERY fixture, budgeted or not: the project being clamped may + // predate the power feature entirely, and safe mode must dim it + // anyway. + let budget_scale = if settings.power.is_limited() { + self.power_scale_q16 + } else { + power_limit::UNITY_SCALE_Q16 + }; + let effective_scale = budget_scale.min( + ctx.safe_output_clamp_q16() + .unwrap_or(power_limit::UNITY_SCALE_Q16), + ); + let mut power = if effective_scale < power_limit::UNITY_SCALE_Q16 { + PowerPass::limited(effective_scale) } else { PowerPass::unlimited() }; @@ -1661,6 +1675,144 @@ mod tests { TextureRenderProduct::new(width, height, format, pixels).map_err(err_ctx("solid texture")) } + /// A ticked engine holding one directly-sampled two-lamp fixture fed by + /// [`FixtureExpectedSampleProducer`], with NO power budget — so anything + /// that scales its output came from somewhere else. + #[cfg(feature = "node-shader")] + fn direct_sampled_fixture_engine() -> (Engine, ProjectRegistry, lpc_model::NodeId) { + let mut engine = Engine::new(TreePath::parse("/show.t").unwrap()); + let registry = ProjectRegistry::new(); + engine.set_graphics(Some(Arc::new(lp_gfx_lpvm::TargetLpvmGraphics::new( + lp_shader::ShaderFrontend::LpsGlsl, + )))); + let frame = Revision::new(1); + let root = engine.tree().root(); + let spine = test_placeholder_spine(); + + let sh_id = engine + .tree_mut() + .add_child( + root, + lpc_model::NodeName::parse("sh").unwrap(), + lpc_model::NodeName::parse("shader").unwrap(), + WireChildKind::Input { + source: WireSlotIndex(0), + }, + spine.clone(), + frame, + ) + .unwrap(); + + let out_path = shader_output_path(); + engine + .attach_runtime_node( + sh_id, + Box::new(FixtureExpectedSampleProducer { + state: ShaderState::new(VisualProduct::new(sh_id, 0)), + expected_points: vec![2 * 65536, 2 * 65536, 4 * 65536, 2 * 65536], + colors: vec![[1000, 2000, 3000, u16::MAX], [4000, 5000, 6000, u16::MAX]], + expected_width: 4, + expected_height: 4, + }), + frame, + ) + .unwrap(); + + // Two lamps: center + right edge (the retired 2-ring construction's + // exact resolved positions). + let mapping = MappingConfig::path_points_vec( + vec![PathSpec::point_list(0, [[0.5, 0.5], [1.0, 0.5]])], + 2.0, + ); + + let fix_id = engine + .tree_mut() + .add_child( + root, + lpc_model::NodeName::parse("fx").unwrap(), + lpc_model::NodeName::parse("fixture").unwrap(), + WireChildKind::Input { + source: WireSlotIndex(0), + }, + spine, + frame, + ) + .unwrap(); + + engine + .attach_runtime_node( + fix_id, + Box::new(FixtureNode::new( + fix_id, + mapping, + FixtureSamplingConfig::Direct, + frame, + )), + frame, + ) + .unwrap(); + bind_fixture_def_defaults(&mut engine, fix_id, frame); + engine + .add_binding( + BindingDraft { + source: BindingSource::ProducedSlot { + node: sh_id, + slot: out_path, + }, + target: BindingTarget::ConsumedSlot { + node: fix_id, + slot: fixture_input_path(), + }, + priority: BindingPriority::new(0), + kind: Kind::Color, + owner: fix_id, + }, + frame, + ) + .unwrap(); + engine + .add_binding( + BindingDraft { + source: BindingSource::Literal(LpValue::F32(0.0)), + target: BindingTarget::ConsumedSlot { + node: fix_id, + slot: default_demand_input_path(), + }, + priority: BindingPriority::new(0), + kind: Kind::Color, + owner: fix_id, + }, + frame, + ) + .unwrap(); + + engine.add_demand_root(fix_id); + engine.tick(®istry, 10).unwrap(); + (engine, registry, fix_id) + } + + /// Render the two-lamp fixture's six unorm16 channels. + #[cfg(feature = "node-shader")] + fn render_fixture_samples( + engine: &mut Engine, + registry: &ProjectRegistry, + fix_id: lpc_model::NodeId, + ) -> Vec { + let extent = ControlExtent::new(1, 6); + let request = ControlRenderRequest::unorm16(extent); + let mut samples = vec![0u16; extent.sample_count() as usize]; + let target = ControlRenderTarget::new(extent, ControlSampleFormat::Unorm16, &mut samples); + engine + .render_control_for_test( + registry, + ControlProduct::new(fix_id, 0, extent), + &request, + target, + ) + .expect("control render"); + samples + } + fn bind_fixture_def_defaults(engine: &mut Engine, fix_id: lpc_model::NodeId, frame: Revision) { bind_fixture_def_slot( engine, @@ -2403,129 +2555,43 @@ mod tests { #[test] #[cfg(feature = "node-shader")] fn fixture_direct_sampling_sends_pixel_space_points_and_output_size() { - let mut engine = Engine::new(TreePath::parse("/show.t").unwrap()); - let registry = ProjectRegistry::new(); - engine.set_graphics(Some(Arc::new(lp_gfx_lpvm::TargetLpvmGraphics::new( - lp_shader::ShaderFrontend::LpsGlsl, - )))); - let frame = Revision::new(1); - let root = engine.tree().root(); - let spine = test_placeholder_spine(); - - let sh_id = engine - .tree_mut() - .add_child( - root, - lpc_model::NodeName::parse("sh").unwrap(), - lpc_model::NodeName::parse("shader").unwrap(), - WireChildKind::Input { - source: WireSlotIndex(0), - }, - spine.clone(), - frame, - ) - .unwrap(); - - let out_path = shader_output_path(); - engine - .attach_runtime_node( - sh_id, - Box::new(FixtureExpectedSampleProducer { - state: ShaderState::new(VisualProduct::new(sh_id, 0)), - expected_points: vec![2 * 65536, 2 * 65536, 4 * 65536, 2 * 65536], - colors: vec![[1000, 2000, 3000, u16::MAX], [4000, 5000, 6000, u16::MAX]], - expected_width: 4, - expected_height: 4, - }), - frame, - ) - .unwrap(); + let (mut engine, registry, fix_id) = direct_sampled_fixture_engine(); - // Two lamps: center + right edge (the retired 2-ring construction's - // exact resolved positions). - let mapping = MappingConfig::path_points_vec( - vec![PathSpec::point_list(0, [[0.5, 0.5], [1.0, 0.5]])], - 2.0, + assert_eq!( + render_fixture_samples(&mut engine, ®istry, fix_id), + vec![1000u16, 2000, 3000, 4000, 5000, 6000] ); + } - let fix_id = engine - .tree_mut() - .add_child( - root, - lpc_model::NodeName::parse("fx").unwrap(), - lpc_model::NodeName::parse("fixture").unwrap(), - WireChildKind::Input { - source: WireSlotIndex(0), - }, - spine, - frame, - ) - .unwrap(); - - engine - .attach_runtime_node( - fix_id, - Box::new(FixtureNode::new( - fix_id, - mapping, - FixtureSamplingConfig::Direct, - frame, - )), - frame, - ) - .unwrap(); - bind_fixture_def_defaults(&mut engine, fix_id, frame); - engine - .add_binding( - BindingDraft { - source: BindingSource::ProducedSlot { - node: sh_id, - slot: out_path, - }, - target: BindingTarget::ConsumedSlot { - node: fix_id, - slot: fixture_input_path(), - }, - priority: BindingPriority::new(0), - kind: Kind::Color, - owner: fix_id, - }, - frame, - ) - .unwrap(); - engine - .add_binding( - BindingDraft { - source: BindingSource::Literal(LpValue::F32(0.0)), - target: BindingTarget::ConsumedSlot { - node: fix_id, - slot: default_demand_input_path(), - }, - priority: BindingPriority::new(0), - kind: Kind::Color, - owner: fix_id, - }, - frame, - ) - .unwrap(); - - engine.add_demand_root(fix_id); - engine.tick(®istry, 10).unwrap(); + /// The device-level safe clamp reaches the wire: a fixture with no power + /// budget of its own still emits scaled samples while the clamp is set, + /// and unscaled ones once it is cleared. + /// + /// This is the render-level proof for `Engine::set_safe_output_clamp`. + /// The clamp is what makes a boot-control safe-mode restart *dim* rather + /// than merely project-less, so "the setter stores a number" is not the + /// property worth pinning — "the samples come out smaller" is. + #[test] + #[cfg(feature = "node-shader")] + fn safe_output_clamp_scales_emitted_samples_and_clearing_restores_them() { + let (mut engine, registry, fix_id) = direct_sampled_fixture_engine(); - let extent = ControlExtent::new(1, 6); - let request = ControlRenderRequest::unorm16(extent); - let mut samples = vec![0u16; extent.sample_count() as usize]; - let target = ControlRenderTarget::new(extent, ControlSampleFormat::Unorm16, &mut samples); - engine - .render_control_for_test( - ®istry, - ControlProduct::new(fix_id, 0, extent), - &request, - target, - ) - .expect("control render"); + // 128/255 of full, as the boot-control record's clamp bits express + // it: q16 = (128 << 16) / 255 = 32896, applied as (v * q16) >> 16. + engine.set_safe_output_clamp(Some(128)); + assert_eq!( + render_fixture_samples(&mut engine, ®istry, fix_id), + vec![501u16, 1003, 1505, 2007, 2509, 3011], + "a clamped fixture must emit scaled samples even with no power budget" + ); - assert_eq!(samples, vec![1000u16, 2000, 3000, 4000, 5000, 6000]); + // One-shot by design: the record is consumed at boot, so clearing the + // clamp has to restore full output without re-loading the project. + engine.set_safe_output_clamp(None); + assert_eq!( + render_fixture_samples(&mut engine, ®istry, fix_id), + vec![1000u16, 2000, 3000, 4000, 5000, 6000] + ); } #[test] diff --git a/lp-core/lpc-wire/src/json.rs b/lp-core/lpc-wire/src/json.rs index 0f85c8f46..b0f4dfdb4 100644 --- a/lp-core/lpc-wire/src/json.rs +++ b/lp-core/lpc-wire/src/json.rs @@ -218,6 +218,7 @@ mod ser_write_json_tests { reset_reason: "watchdog-reset".to_string(), boot_count: 4, safe_mode: false, + output_clamp: None, last_crash: Some(crate::server::CrashSummaryWire { cause: "watchdog".to_string(), path: "boot/node:nodes/fire".to_string(), diff --git a/lp-core/lpc-wire/src/server/recovery_status.rs b/lp-core/lpc-wire/src/server/recovery_status.rs index 872866f41..c2a908255 100644 --- a/lp-core/lpc-wire/src/server/recovery_status.rs +++ b/lp-core/lpc-wire/src/server/recovery_status.rs @@ -58,6 +58,12 @@ pub struct RecoveryStatus { /// Whether this boot skipped project auto-load after repeated /// incomplete boots. pub safe_mode: bool, + /// Device-level safe-mode output ceiling (0–255) applied to every + /// loaded project this boot, from a consumed boot-control record. + /// `None` = no clamp. RAM-only: a power cycle clears it, which is the + /// user's exit from safe mode — clients must say so. + #[serde(default)] + pub output_clamp: Option, #[serde(default)] pub last_crash: Option, /// Active blame-ledger entries (yellow and red). diff --git a/lp-fw/fw-esp32-common/src/server_loop.rs b/lp-fw/fw-esp32-common/src/server_loop.rs index 597821de8..5eed777b3 100644 --- a/lp-fw/fw-esp32-common/src/server_loop.rs +++ b/lp-fw/fw-esp32-common/src/server_loop.rs @@ -214,7 +214,14 @@ pub async fn run_server_loop( loaded_projects, uptime_ms: current_time.saturating_sub(startup_time), memory, - recovery: lpa_server::recovery_report::current_recovery_status(), + recovery: lpa_server::recovery_report::current_recovery_status().map( + |mut status| { + // The clamp is server state, not recovery-region + // state — stamp it here where both are in scope. + status.output_clamp = server.safe_output_clamp(); + status + }, + ), }, ); diff --git a/lp-fw/fw-esp32c6/Cargo.toml b/lp-fw/fw-esp32c6/Cargo.toml index 96c7693f2..c1da5bdad 100644 --- a/lp-fw/fw-esp32c6/Cargo.toml +++ b/lp-fw/fw-esp32c6/Cargo.toml @@ -115,6 +115,7 @@ lpc-model = { path = "../../lp-core/lpc-model", default-features = false, option lpc-wire = { path = "../../lp-core/lpc-wire", default-features = false, optional = true, features = ["ser-write-json"] } lp-perf = { path = "../../lp-base/lp-perf", default-features = false } lp-recovery = { path = "../../lp-base/lp-recovery", default-features = false } +lp-bootctl = { path = "../../lp-base/lp-bootctl", default-features = false } serde = { workspace = true, default-features = false, features = ["alloc", "derive"] } hashbrown = { workspace = true } diff --git a/lp-fw/fw-esp32c6/partitions.csv b/lp-fw/fw-esp32c6/partitions.csv index 78b11cc76..0569ac861 100644 --- a/lp-fw/fw-esp32c6/partitions.csv +++ b/lp-fw/fw-esp32c6/partitions.csv @@ -1,5 +1,11 @@ # Name, Type, SubType, Offset, Size, Flags -nvs, data, nvs, 0x9000, 0x6000, +# bootctl carries a flash-persisted instruction to the next boot (lp-bootctl). +# Its 4 KB came out of nvs, which nothing reads: no LightPlayer code touches +# NVS, and esp-radio's "NVS" is a RAM array, not this partition. Taking it +# from nvs keeps every other offset fixed, so existing lpfs images stay valid. +# See docs/adr/2026-07-30-boot-control-sector.md. +nvs, data, nvs, 0x9000, 0x5000, +bootctl, data, undefined, 0xe000, 0x1000, phy_init, data, phy, 0xf000, 0x1000, factory, app, factory, 0x10000, 0x300000, lpfs, data, spiffs, 0x310000, 0xF0000, diff --git a/lp-fw/fw-esp32c6/src/bootctl.rs b/lp-fw/fw-esp32c6/src/bootctl.rs new file mode 100644 index 000000000..6cbb0fedb --- /dev/null +++ b/lp-fw/fw-esp32c6/src/bootctl.rs @@ -0,0 +1,77 @@ +//! Reading — and consuming — the boot-control sector at boot. +//! +//! The format lives in `lp-bootctl`; this is the ESP32 flash adapter for it, +//! the mirror of [`crate::flash_storage`]'s adapter for `lpfs`. +//! +//! # Consume-on-read +//! +//! The record is erased the moment a valid one is read, **before** anything +//! acts on it. That is what makes the instruction one-shot: the user asked +//! for one recovery boot, not a permanent mode. +//! +//! Consuming before acting (rather than after) also means a crash *during* +//! the recovery boot cannot make the instruction sticky. The failure it +//! trades for — an erase that fails, leaving the flag set — strands the +//! device in a reachable, no-project state, which is the safe direction to +//! fail in and is itself recoverable by writing a fresh record. +//! +//! Only records we recognize are erased. Foreign or corrupt bytes are left +//! exactly as found: they already decode to a normal boot, and stomping a +//! region we do not understand is worse than ignoring it. + +use embedded_storage::nor_flash::{NorFlash, ReadNorFlash}; +use lp_bootctl::{BOOTCTL_PARTITION_OFFSET, BOOTCTL_PARTITION_SIZE, DecodeOutcome, RECORD_LEN}; + +/// Read the boot-control record and consume it if it is one of ours. +/// +/// Returns what the record said. Every failure path — unreadable flash, +/// blank sector, corrupt record — returns an outcome that boots normally, +/// so a flash problem can never *cause* a degraded boot. +pub fn read_and_consume(flash: &mut esp_storage::FlashStorage<'_>) -> DecodeOutcome { + let mut record = [0u8; RECORD_LEN]; + if let Err(error) = flash.read(BOOTCTL_PARTITION_OFFSET, &mut record) { + log::warn!("[BOOTCTL] read failed ({error:?}) — booting normally"); + return DecodeOutcome::Invalid; + } + + let outcome = lp_bootctl::decode(&record); + match outcome { + DecodeOutcome::Blank => log::debug!("[BOOTCTL] no record"), + DecodeOutcome::Valid(control) => { + log::info!( + "[BOOTCTL] record found: flags={:#010x}", + control.flags().bits() + ); + if control.flags().has_unknown_bits() { + log::warn!( + "[BOOTCTL] record carries instructions this build does not implement \ + (flags={:#010x}) — applying the ones it does", + control.flags().bits() + ); + } + consume(flash); + } + other => log::warn!( + "[BOOTCTL] unusable record ({}) — booting normally", + other.as_str() + ), + } + outcome +} + +/// Erase the sector so the instruction applies exactly once. +fn consume(flash: &mut esp_storage::FlashStorage<'_>) { + let from = BOOTCTL_PARTITION_OFFSET; + let to = from + BOOTCTL_PARTITION_SIZE; + match flash.erase(from, to) { + Ok(()) => log::info!("[BOOTCTL] record consumed"), + Err(error) => log::error!( + "[BOOTCTL] failed to consume record ({error:?}) — it will apply again next boot" + ), + } +} + +// The layout guards for this adapter (bootctl offset/size vs partitions.csv) +// live in `lp-base/lp-bootctl/tests/partition_layout.rs`: this crate is +// RV32-only and excluded from host builds, so a `#[cfg(test)]` module here +// would never run. diff --git a/lp-fw/fw-esp32c6/src/main.rs b/lp-fw/fw-esp32c6/src/main.rs index 473460fef..5af3b85d4 100644 --- a/lp-fw/fw-esp32c6/src/main.rs +++ b/lp-fw/fw-esp32c6/src/main.rs @@ -245,6 +245,8 @@ use fw_esp32_common::time; #[cfg(all(feature = "server", not(fw_harness),))] use fw_esp32_common::transport; +#[cfg(all(not(feature = "memory_fs"), not(fw_harness),))] +mod bootctl; #[cfg(all(not(feature = "memory_fs"), not(fw_harness),))] mod flash_storage; #[cfg(all(not(feature = "memory_fs"), not(fw_harness),))] @@ -410,11 +412,24 @@ fn boot_firmware(spawner: embassy_executor::Spawner) -> FirmwareApp { .expect("Failed to initialize RMT"); esp_println::println!("[INIT] RMT peripheral initialized"); + // Boot-control sector: a flash-persisted instruction from a previous run + // or from the host over esptool. Read (and consumed) before the + // filesystem mounts, because it must survive the power cycle that wipes + // the RTC recovery region — see docs/adr/2026-07-30-boot-control-sector.md. + #[cfg(not(feature = "memory_fs"))] + let (boot_control, flash) = { + let mut flash_storage = esp_storage::FlashStorage::new(flash); + let outcome = crate::bootctl::read_and_consume(&mut flash_storage); + (outcome, flash_storage) + }; + #[cfg(feature = "memory_fs")] + let boot_control = lp_bootctl::DecodeOutcome::Blank; + // Create filesystem before hardware providers so /hardware.json can override board policy. let base_fs: Box = { #[cfg(not(feature = "memory_fs"))] { - let flash_storage = esp_storage::FlashStorage::new(flash); + let flash_storage = flash; match lp_fs::LpFsFlash::init( crate::flash_storage::LpFlashStorage::new(flash_storage), crate::flash_storage::lpfs_config, @@ -526,17 +541,43 @@ fn boot_firmware(spawner: embassy_executor::Spawner) -> FirmwareApp { esp_println::println!("[INIT] LpServer created"); // Auto-load project at boot (from config or lexical-first) — unless - // repeated incomplete boots put us in safe mode: then the server comes - // up reachable but nothing crash-prone is loaded. - if boot_assessment.safe_mode { - let incomplete_boots = lp_recovery::snapshot() - .map(|s| s.consecutive_incomplete_boots) - .unwrap_or(0); - log::error!( - "[RECOVERY] SAFE MODE: {incomplete_boots} consecutive incomplete boots — skipping project auto-load" - ); - } else { - boot::auto_load_project(&mut server); + // something asks us not to. Two independent reasons can skip it, and the + // log always says which one applied: + // + // - the boot-control sector (a host wrote "start once without loading a + // project", or a previous run latched it), which survives a power cycle; + // - repeated incomplete boots, tracked in the RTC recovery region, which + // does not. + // + // The boot-control record was already consumed by the read, so this is a + // one-shot: the next boot loads normally unless something says otherwise + // again. + // The boot-control record's action comes pre-decided by lp-bootctl's + // precedence rule (clamp wins over skip — a dim, visible board beats a + // dark one). An explicit user instruction outranks the ladder: the user + // may be recovering exactly the loop the ladder saw. + match boot_control.boot_action() { + lp_bootctl::BootAction::LoadClamped { level } => { + log::error!( + "[BOOTCTL] SAFE MODE: output clamped to {level}/255 — loading the project dimmed" + ); + server.set_safe_output_clamp(Some(level)); + boot::auto_load_project(&mut server); + } + lp_bootctl::BootAction::SkipAutoload => { + log::error!("[BOOTCTL] SAFE BOOT: boot-control record — skipping project auto-load"); + } + lp_bootctl::BootAction::Normal if boot_assessment.safe_mode => { + let incomplete_boots = lp_recovery::snapshot() + .map(|s| s.consecutive_incomplete_boots) + .unwrap_or(0); + log::error!( + "[RECOVERY] SAFE MODE: {incomplete_boots} consecutive incomplete boots — skipping project auto-load" + ); + } + lp_bootctl::BootAction::Normal => { + boot::auto_load_project(&mut server); + } } // Create time provider diff --git a/lp-fw/fw-esp32s3/partitions.csv b/lp-fw/fw-esp32s3/partitions.csv index af47247a8..202d6668d 100644 --- a/lp-fw/fw-esp32s3/partitions.csv +++ b/lp-fw/fw-esp32s3/partitions.csv @@ -5,7 +5,14 @@ # factory 6 MB @ 0x010000 .. 0x610000 # lpfs 1.5 MB @ 0x610000 .. 0x790000 # end 0x790000 of 0x800000 — 448 KB slack -nvs, data, nvs, 0x9000, 0x6000, +# +# bootctl carries a flash-persisted instruction to the next boot (lp-bootctl), +# carved from nvs exactly as on the C6: the LOW region (nvs/bootctl/phy_init) +# is identical on every board, so lp-bootctl's hardcoded offset holds even +# though the tables diverge above 0x10000 per the S3's 8 MB floor. +# See docs/adr/2026-07-30-boot-control-sector.md. +nvs, data, nvs, 0x9000, 0x5000, +bootctl, data, undefined, 0xe000, 0x1000, phy_init, data, phy, 0xf000, 0x1000, factory, app, factory, 0x10000, 0x600000, lpfs, data, spiffs, 0x610000, 0x180000,