feat: Device recovery via ROM bootloader mode - #207
Draft
Yona-Appletree wants to merge 50 commits into
Draft
Conversation
A project can make its own device unrecoverable: one bright enough to brown the board out comes up, kills the rail, resets, auto-loads itself, and loops. lp-recovery's boot-loop ladder does not catch it, and its breadcrumb region lives in RTC RAM that a power cycle wipes — so the first thing a user does (unplug it) erases the evidence. Add a 4 KB `bootctl` flash partition carrying an instruction the firmware reads before it auto-loads a project. Flash-resident so it survives the power cycle; shared format so the two writers cannot disagree — the host over esptool while the device sits in ROM download mode (the only channel that works on a board that cannot boot), and later the firmware itself. The 4 KB comes out of `nvs`, which nothing reads: no LightPlayer code touches NVS, and esp-radio's `NVS` is a RAM array, not the partition. That moves no other offset, so `lpfs` stays put and existing devices' filesystem images remain valid. Blank is safe: an erased sector, bad magic, bad CRC, future version, short read, and torn write all decode to "boot normally". A corrupt sector can never cause a degraded boot, only fail to prevent one. NOR flash cannot flip one word to publish a record, so the payload is written first and the magic last — `encode_write_order()` makes that ordering the API rather than writer discipline. The firmware consumes the record on read, before acting on it, so a crash during the recovery boot cannot make the instruction sticky. Layout guards live in lp-bootctl's tests, not fw-esp32c6: that crate is RV32-only and excluded from host builds, so tests there would never run. Validated: cargo test -p lp-bootctl (34), just check, just test (29 suites, 0 failures), just fw-esp32c6-size-check — 278 800 B headroom vs the 65 536 B margin. ADR: docs/adr/2026-07-30-boot-control-sector.md Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rwin-b6680f # Conflicts: # docs/adr/README.md
The firmware is not the only thing that will ever encode this record — host writers over esptool produce the same 16 bytes, and without a fixture the two implementations can drift into disagreeing about a format neither side can renegotiate at runtime. These exact bytes were written to a C6's bootctl partition with esptool and honored by fw-esp32c6 on silicon: [BOOTCTL] record found: flags=0x00000001 [BOOTCTL] record consumed [BOOTCTL] SAFE BOOT: boot-control record — skipping project auto-load The sector read back erased afterwards, confirming the one-shot consume. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds the link-layer vocabulary for writing the boot-control sector, plus the espflash implementation behind it. `LinkOperation::WriteBootControl` joins the capability set; `LinkManagementRequest::SetBootControl` carries `lp_bootctl::BootFlags` bits as a plain u32 so the wire type stays independent of the on-flash format crate, with `boot_safe_once()` as the constructor callers should reach for instead of assembling bits. The host writer erases the sector, then writes payload-first/magic-last via `encode_write_order` rather than reassembling the record — that ordering is the whole reason an interrupted write is safe, and duplicating it at a call site is how it stops being true. The fake provider implements the request too, so dispatch, capability gating, and the UX arm are all testable without hardware; the encoding itself is covered by lp-bootctl's golden vector rather than re-asserted there. Browser provider and the Studio action are still to come. Validated: cargo test -p lpa-link --all-features (74 passed), cargo check -p lpa-link --all-features. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adding SetBootControl broke the build in lpa-studio-core: four near-identical match arms over LinkManagementResult, one per operation, so every new operation is a build break that gets a fifth copy free to drift from the other four. Collapse it. `logs()`/`progress()` were already uniform accessors inside lpa-link, just private — make them public and read through them instead of matching. ResetRuntime stays special-cased because it genuinely carries no logs of its own. Also moves the fake provider's LinkBootControlResult import into the `fake-device`-gated function that uses it, alongside the other result types, so the import is not unused when the feature is off. Found by CI, not locally: I ran a targeted `cargo check -p lpa-link` rather than `just check`, which is the actual parity gate. Validated: just check, cargo test -p lpa-link --all-features (74), cargo test -p lpa-studio-core (776). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The payload-then-magic ordering could 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 — true of espflash's write_bin_to_flash and of esptool-js's writeFlash alike. So the second write, meant to publish the first, would erase it: a valid magic over an erased payload, failing its own CRC. Safe, but the feature would silently never work. The hardware walk missed this because it wrote the record as a single 16-byte file with esptool, which is the path that works. Integrity never needed the ordering — the CRC covers the magic, so every prefix of an interrupted write fails one of the two checks. `no_partial_write_is_ever_honored` now asserts exactly that, at all 16 truncation points, which is a stronger claim than the ordering test it replaces. Drops WriteOrder/encode_write_order for a public encode_record, and updates the ADR and README, which both documented the ordering as the discipline. Validated: cargo test -p lp-bootctl (35), cargo check -p lpa-link --all-features. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`story-apply-refresh.mjs` parsed 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 story drift has been dead since the scripts were split, and CI's own failure message sends you into it. Argument parsing moves inside the `import.meta.url === argv[1]` guard, with a comment saying why it has to stay there. CLI usage and the import path are both verified. Found while diagnosing a capture crash on this branch. Also appends the incident to the debt register, including a note that the guard step reports "drift" even when the crash sentinel has positively identified a crash — the step conclusions, not the step name, are the reliable signal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Completes SetBootControl across both real providers. The record is encoded in Rust and handed to JS as bytes — the firmware reading it cannot renegotiate the format at runtime, so lp-bootctl stays its single implementation. One writeFlash call, no explicit erase: FLASH_BEGIN already erases the sectors it writes, which is also why the record must not be split across two writes. This was also the CI break. The browser provider's dispatch is feature-gated to wasm, so the non-exhaustive match the new variant created was invisible to `just check` AND to `cargo check -p lpa-link --all-features` on the host — it only failed under `dx build [wasm32-unknown-unknown]`, surfacing as a red "Validate story baselines" that looks exactly like a capture wedge. The debt register entry is corrected accordingly, with the step conclusions that tell the three cases apart. Validated: cargo check --target wasm32-unknown-unknown for both -p lpa-link --features browser-serial-esp32 and -p lpa-studio-web; just check; cargo test -p lpa-link --all-features (74). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Surfaces SetBootControl as a DeviceOp on the not-responding card's troubleshoot sheet, next to the recovery flash. Deliberately not destructive and severs_lens: false — unlike ResetToBlank this erases nothing, so the user keeps their project and the editor's lens stays valid. The device consumes the record as it boots, so it affects exactly one restart, which the completion notice says out loud. Adds the missing rung to the sheet's advice: the existing bullets assume a cable or firmware problem, and had nothing for "it worked until I changed the project", which is the case this whole plan exists for. Minimal placement on purpose — M5 owns making the recovery flow presentable. Validated: just check, cargo test -p lpa-studio-core (776), cargo check -p lpa-studio-web --target wasm32-unknown-unknown. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drives a real board the way Studio does — connect → SetBootControl → device reboots reachable with nothing loaded → reboot again → the record is gone and the project loads normally. The second reboot is the point, and the step most easily skipped by hand: the record is one-shot, so "safe once" must not silently become "safe forever". Asserting it here means the earlier manual esptool walk is no longer the only thing standing behind that claim. Companion to manage_smoke, which covers erase/flash/reset and IS destructive; this one erases nothing and the device's project survives. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
App / Bootloader / Unknown, exposed on the device snapshot. The tempting implementation cannot work and is worth naming so it is not re-proposed: VID/PID tells you nothing here. C6 and S3 use the chip's native USB-Serial-JTAG peripheral and the ROM bootloader uses THAT SAME peripheral, so the device enumerates as 303A:1001 whether it is running our firmware or sitting in download mode. Bridge boards always report the bridge. The mode has to be established by talking to the device. This lands the passive half: a hello means App, ROM download-mode boot lines mean Bootloader, everything else is Unknown. Boot lines stay corroboration — strong when present, meaningless when absent, because a board already in download mode printed its banner before Studio attached. Readiness is untouched: a proto-matching ServerHello remains the only thing that grants it, and this module classifies rather than promotes. `probe_would_help()` encodes when escalating is worth it. It is false for App on purpose: the SYNC probe resets the device and drops USB enumeration, so probing a healthy board costs a reboot and buys nothing. Validated: cargo test -p lpa-link --all-features (83, 9 new); just check; cargo check -p lpa-studio-web --target wasm32-unknown-unknown. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Completes the three-way classifier with its authoritative tier: `DeviceSession::probe_link_mode` runs the esptool SYNC handshake, which only a ROM/stub bootloader answers. That handshake is the only way to separate "in download mode" from "not responding" — USB-Serial-JTAG parts present identical enumeration data in both states. It takes DeviceMode::Management and rebuilds the link afterwards, the same discipline as manage(), because the handshake REBOOTS the device: it drives DTR/RTS to enter download mode, and on USB-Serial-JTAG that reset drops USB enumeration. probe_would_help() is false for App so callers do not pay a reboot for an answer they already have. An unanswered probe is not reported as Unknown. A device running the app ignores SYNC too, so on probe failure the session reports the rebuilt link's PASSIVE classification — the honest answer rather than a guess. Providers without a bootloader concept report the probe unsupported instead of fabricating an answer. The fake provider implements it, so the escalation, its exclusivity, and the App-mode shortcut are all covered without hardware. ADR: docs/adr/2026-07-30-bootloader-mode-detection.md — including VID/PID matching recorded as REJECTED-because-impossible, since it is the thing most likely to be re-proposed by someone who has not hit the USB-Serial-JTAG detail. Validated: cargo test -p lpa-link --all-features (86, 3 new); just check; cargo check -p lpa-studio-web --target wasm32-unknown-unknown. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Captured in the pinned CI environment by run: https://github.com/light-player/lightplayer/actions/runs/30596999682
Contributor
CI refreshed the story baselines on this branchThe Review every PNG in the PR's Files changed view (swipe / onion-skin). 35 more file(s)
|
M5's model half. Corrects a planning assumption that did not survive contact: the plan said board-specific recovery instructions should come from board manifest data. They cannot. HwManifest is DEVICE-side — read from the device's own /hardware.json or compiled into its firmware — and a device that will not boot cannot tell Studio what board it is, which is exactly the situation these instructions exist for. There is no Studio-side board catalog. So instructions key on chip family, which Studio can honestly know two ways: a hello from a device it reached earlier, or the SYNC probe's chip_name from M4. When it knows neither — the normal state before the user reaches bootloader mode — it renders generic guidance and SAYS it is generic. An unhedged "hold BOOT" on a board whose button is labelled IO0 does not read as wrong instructions; it reads as a dead device. Chip matching is substring-based because esptool reports "ESP32-C6FH4 (QFN32) (revision v0.2)" — an exact match would fall through to generic for every real board. An unrecognized chip falls back to generic rather than inheriting another family's button sequence. Every sequence starts by unplugging, and a test enforces it: the strap is sampled at reset, so holding BOOT on a running board does nothing. Getting that order wrong is the most common reason the ritual fails. Validated: cargo test -p lpa-studio-core (783, 7 new); just check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The confirmation is the point of this milestone. Without it a failed attempt at the BOOT-button ritual and a genuinely dead board look identical, so people repeat the wrong motion and conclude the device is bricked. Edge-triggered, not level-triggered. The authoritative test for bootloader mode is the SYNC handshake, and that handshake REBOOTS the device — polling it would reboot a healthy board repeatedly and could knock a device out of the very state the user just achieved. So the flow waits for the device to re-enumerate (the physical replug is already part of the ritual, so an arrival is exactly the moment worth probing) and probes once, on that arrival. `should_probe_on_arrival()` is the guard, true only while Waiting, and a test pins it false in all three other states. An unanswered probe is `NotYet`, not failure: an app-mode device ignores SYNC too, so the honest reading is "that attempt did not land" and the flow returns the user to the steps — with the SAME instructions, so an unknown chip's guidance does not silently change mid-ritual. Also fixes an unused-parameter warning on the connector probe: `events` is consumed only by the host arm, and which arms exist is feature-dependent. Validated: cargo test -p lpa-studio-core (790, 7 new). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…sheet Replaces the single hardcoded "Hold BOOT while plugging in" bullet with the chip-keyed sequence from RecoveryInstructions, as numbered steps. The chip comes from the card's firmware provenance: `fw.package` is "fw-esp32c6", which names the chip it was built for. That is the only chip source available BEFORE the user reaches bootloader mode — a device that will not boot cannot tell us its board — so a device Studio reached earlier gets specific steps even while it is unreachable now. Without it, generic steps that say they are generic. The old bullet also had the order wrong by omission: it said "hold BOOT while plugging in" without saying to unplug first. The strap is sampled at reset, so on an already-running board there is nothing to hold BOOT for. Validated: cargo test -p lpa-studio-core (791); just check; cargo check -p lpa-studio-web --target wasm32-unknown-unknown. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Renders the ritual and — the whole point of M5 — tells the user the moment it worked. Without that, a failed attempt and a dead board look identical, so people repeat the wrong motion and conclude the device is bricked. The sheet carries the whole BootloaderEntryFlow as its view-state value, so advancing is re-opening with the next state and the renderer holds nothing of its own. 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 — component-local state would lose the user's place at exactly the moment the confirmation is due. The not-yet copy says the attempt did not land, not that the device is broken: an app-mode device ignores the SYNC handshake too, so "no answer" genuinely does not mean "no device". Five stories, one per state, including the generic-instructions case where Studio cannot name the chip and says so. Validated: just check; cargo test -p lpa-studio-core (791) and -p lpa-studio-web (214); cargo check -p lpa-studio-web --target wasm32-unknown-unknown. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Captured in the pinned CI environment by run: https://github.com/light-player/lightplayer/actions/runs/30597711030
Caught by looking at the captured baselines rather than trusting them: the waiting story was BYTE-IDENTICAL to the instructing one at all three sizes. Not a rendering-logic bug — the card's per-sheet min-height had no arm for this sheet, so it fell to the 210px catch-all and clipped right after step 4. Every state differs only BELOW that cut, so four of five states rendered as the same picture, and the confirmation this whole milestone exists for was invisible. Gives the sheet its own floor, state-dependent: 430px for the step states (a two-line title, four wrapping steps, plus a conditional warn or hedge line, plus buttons) and 210px for Confirmed, whose payoff is one line and a button and was left stranded above a void by the tall floor. Also fixes `tw:text-warn` on the not-yet line — not a real class, so it silently did nothing and the "that attempt didn't land" copy rendered as ordinary text. The token is `tw:text-status-attention-foreground`. Local captures are non-authoritative (macOS ≠ CI) and are NOT committed; they were used only to confirm the clipping is gone. CI recaptures the baselines. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Captured in the pinned CI environment by run: https://github.com/light-player/lightplayer/actions/runs/30599620882
Two bench reports, both correct.
**The probe was never wired.** M4 built `probe_link_mode`, M5 built the flow
that consumes probe results — and NOTHING connected them; `rg` for callers
of either found only doc comments. The button moved the sheet to Waiting and
then no probe ever ran, so the confirmation this milestone exists for could
not fire. The passive classifier cannot cover for it: a board already in
download mode printed its ROM banner before Studio attached, which is the
"silence proves nothing" case the ADR describes.
Adds `DeviceOp::ProbeBootloaderMode { card_key, flow }`: shows Waiting
immediately (the probe takes seconds; a frozen sheet reads as a hang), runs
the SYNC handshake, and folds the answer back as Confirmed or NotYet.
Reaching the device but finding it NOT in bootloader mode is treated like a
failed probe — both mean "that attempt did not land", since an app-mode
device ignores the handshake too. User-triggered only: the handshake reboots
the device, and the button press is itself the signal that a replug just
happened.
**Boards label the button "B".** Many shorten the silkscreen because four
letters do not fit. The deeper point is that the label is a BOARD property,
not a chip one — knowing the chip buys the sequence, not the legend — so
both the generic and chip-specific steps now hedge it.
`no_sequence_asserts_an_unhedged_button_label` keeps a future chip family
from quietly reintroducing the assumption.
I had each piece green in isolation and took my remaining-work list from the
plan instead of asking whether a user could finish the flow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Captured in the pinned CI environment by run: https://github.com/light-player/lightplayer/actions/runs/30600592762
Bench report: a board in ROM download mode showed "Ready to set up" with an Install-firmware button, and the recovery flow was unreachable. Both halves were real. `DeviceState::Bootloader` has existed all along and was collapsed with BlankFlash into one card state — so the session detected download mode and the card threw the fact away. And Troubleshoot hung off NotResponding alone, which is the wrong gate: the states where a user most needs recovery are the ones where the ladder ended somewhere ELSE. Splits `RosterCardState::RecoveryMode` out of ReadyToSetUp, labelled "Recovery mode" — bootloader is our word, not the user's. Users arrive three ways (a new board that will not talk, firmware that interferes with flashing, a device being rescued) but the verbs are the same for all three, so this is one flow, not a branch. Its affordance is Troubleshoot, NOT SetUp. This is not a normal provisioning: a device here may hold data the user came to rescue, so the headline verb must not be the one that erases it. Troubleshoot now leads the danger zone in every state that HAS one. Working states still have none — troubleshooting mid-operation would want the wire the operation is holding — and an empty list still means no section rather than a section with one row. Validated: just check; cargo test -p lpa-studio-core (792) and -p lpa-studio-web (214); wasm32 build. Four expectations updated where the danger list legitimately changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Captured in the pinned CI environment by run: https://github.com/light-player/lightplayer/actions/runs/30601914050
…mode explains itself Two bench reports. **The confirm dialog was a real bug.** The Troubleshoot row's action is meta-only — it supplies the row's label/summary/icon and is never dispatched, because the sheet is the effect. But it borrowed `DeviceOp::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?". Latent since M6; exposed the moment Troubleshoot became reachable from every state. Its sibling rows (ChooseProject, NameDevice) use inert HomeOp carriers — only this one reached for a destructive op. Now stripped, with the reason written down: a meta-only carrier must never inherit a gate for an effect it does not have. **Recovery mode now says what it is.** The label alone left the user guessing why a board they just plugged in was not simply working. The sub-line names the situation, the two common ways people arrive at it, and the part that actually surprises them: anything installed from here needs a physical replug before it will run. A test pins that last sentence, because it is the difference between a successful flash and what looks like a dead board. Validated: just check; cargo test -p lpa-studio-core (793); wasm32 build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The flow's shape was wrong for the one path a user in recovery takes. A
board flashed while sitting in ROM download mode does not boot the image it
was just given until it is physically replugged — so the post-flash reattach
CANNOT succeed, and the flow rendered the card's Failed op. A successful
flash reported as a failure.
The shape now follows the device's actual state rather than which button was
pressed, because the state is what determines whether waiting for a reattach
is sensible: `awaits_manual_replug` is set when the session is in
DeviceState::Bootloader, and it changes both the narration ("Unplug the
board and plug it back in to start it") and the ending.
`reattach_failure_op` makes the distinction explicit and testable: two
different situations wear the same error — a device that FAILED to come back
and a device that was never going to. The first keeps its Failed render and
single exit (tolerating it would hide real breakage); the second stays in
AwaitingDevice with the instruction as the narration, and does not mark the
session failed for doing exactly what it must do.
Tested as a pure decision rather than through the flow: the fake device's
connect-error injector lives on the provider, which the e2e helper moves
into the registry and does not return, and changing that helper's signature
would touch 31 call sites.
Validated: just check; cargo test -p lpa-studio-core (795, 2 new); wasm32.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three bench findings from the Recovery-mode walk, one commit each half. **The face now carries the exits.** A user in Recovery mode has already done the hard part; routing them through Troubleshoot — a sheet that mostly explains how to get INTO this state — was backwards. The face offers both exit verbs directly, each with a one-line summary that lets the user self-select their case, because Studio cannot tell the cases apart (a board in download mode sends no hello, so it cannot be linked to a registered device). Install first (the common new-board arrival), the rescue verb second; neither endangers the other's case. **Sheets are now grid-stacked, not absolute.** The card's below-the-header wrapper stacks tab content, sheet, and op overlay in ONE grid cell, so the region is as tall as the tallest of them and a sheet GROWS the card. The old absolute overlays could not contribute height, which forced a hand-maintained per-sheet min-height table — a recurring clipping-bug class (Name at 210px, the bootloader sheet, then the troubleshoot sheet leaving the user stuck with no way to scroll). The table is deleted; height is content-driven everywhere, including the op overlay. **Boot-safe from recovery ends on the replug.** The manually-entered download mode latches until power-on reset (bench-confirmed), so writing the boot-control record from Recovery mode cannot end on an auto-reconnect — same physics as the recovery flash, now the same ending. Validated: just check; cargo test -p lpa-studio-core (795) and -p lpa-studio-web (214); wasm32 build; local captures confirm the stuck sheet now renders in full and the face reads as designed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…edence
Bench feedback: "start without the project" meant nothing to users. What
they want is safe mode — the board starts, the LEDs are dim, they connect
and fix the project.
The record format already reserved bits 8..16 for exactly this, so the
contract is definable NOW even though the firmware clamp is follow-up work:
- Bits 8..16 are assigned: a safe-mode output clamp level, 0 = none, else a
brightness ceiling out of 255 (default 26 ≈ 10% — visible, far below
brownout territory).
- Precedence, defined in the format and testable: a firmware that
implements the clamp loads the project DIMMED and ignores the skip bit —
a dim visible board beats a dark one. Firmware that predates the clamp
ignores the unknown bits and honors the skip.
- Studio's verb writes BOTH. The same record therefore means "nothing
loaded" today and upgrades to "project running dim" the moment the clamp
lands in firmware, with no format bump and no Studio change.
LinkManagementRequest::boot_safe_once() becomes start_safe_mode(); every
user-facing string moves to safe-mode language, hedged honestly ("dim, or
with nothing loaded on older firmware") until the clamp ships.
The Recovery-mode face also gains a wayfinding row — "More options — wipe,
erase, or troubleshoot…" — jumping to the danger zone, where those verbs
live and where M6/M7's backup/restore rows will land. A user on this face
should not have to know that.
ADR amended (clamp bits assigned, firmware consumption stays with the
follow-up plan, which shares the output path with the fixture mA limiter).
Validated: cargo test -p lp-bootctl (31+6) -p lpa-link --all-features (86)
-p lpa-studio-core (795) -p lpa-studio-web (214); just check; wasm32 build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A dotted underline through two wrapped lines at 12px renders as struck-out text — and this row says "wipe, erase", so it read as deleted options. Caught in the story capture. Hover underline + a trailing arrow carry the clickability instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rwin-b6680f # Conflicts: # lp-fw/fw-esp32s3/partitions.csv
PR #208 (fixture power limiting) landed the application point this was waiting for, and its power_limit module explicitly anticipated "a later caller composing an additional outer ceiling (a device-level safe clamp, say)". This is that caller. The chain, each layer doing one thing: - lp-bootctl: `BootAction` — THE home of the skip-vs-clamp precedence (clamp wins; a dim visible board beats a dark one). Consumers ask for the action instead of re-deriving it from flags, so the rule cannot drift between implementations. Every decode failure is BootAction::Normal. - lpc-engine: `Engine::set_safe_output_clamp(Option<u8>)` — DEVICE state, deliberately not project data: the mechanism that saves a device must not be editable by the thing that broke it. Threaded through the resolve host into ControlRenderContext; the fixture render composes it into the power scale by `min`, and it applies to EVERY fixture, budgeted or not — the clamped project may predate the power feature entirely. - lpa-server: `LpServer::set_safe_output_clamp` applies to every loaded engine and every future load. - fw-esp32c6: matches on `boot_action()` — LoadClamped loads the project dimmed (the explicit user instruction also outranks the recovery ladder, since the user may be recovering exactly the loop the ladder saw); SkipAutoload keeps the old dark-but-reachable behavior for records written without a clamp. Merged main first; the S3 partition conflict was real — main gave the S3 an 8 MB partition floor, so the boards' tables legitimately diverge above 0x10000. bootctl re-carved into the 8 MB layout at the same 0xe000; the layout guards now assert the true invariant (identical LOW region, per-board lpfs/flash bounds) instead of whole-table identity. Not yet covered: a render-level test that a clamped fixture actually emits scaled samples — the fixture test harness has no power case to extend, and building one is delegated with M6 rather than done inline. The hardware walk covers it end-to-end meanwhile. Validated: cargo test -p lp-bootctl (33+6) -p lpc-engine (298+) -p lpa-server; just check; just fw-esp32c6-size-check — 284 144 B headroom. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Engine::set_safe_output_clamp` landed without a render-level test, so the property that matters — a clamped fixture EMITS smaller samples — was unpinned. The setter storing a q16 number was covered; the composition into the fixture render was not. Extracts the direct-sampling harness into `direct_sampled_fixture_engine` (a fixture with no power budget of its own, so any scaling can only have come from the clamp) and asserts both directions: set, and cleared again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Captured in the pinned CI environment by run: https://github.com/light-player/lightplayer/actions/runs/30659736954
…won't boot `LinkOperation::ReadRawFilesystem` has been declared and unimplemented since the link layer was designed. This fills it in: a management request that reads the device's `lpfs` partition back to the host verbatim, over the bootloader, so it works on a board whose own project prevents it running. The region is NOT a parameter and NOT a constant. It 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, which is exactly the case this exists for. So `LinkFlashRegion::lpfs_for_chip` resolves it from the detected chip, guarded against both partitions.csv files by test, and an unrecognized chip is refused rather than guessed at: a wrong region yields a plausible-looking archive of the wrong bytes. Capability advertisement is READ ONLY — `with_raw_filesystem_read()` split out of the paired builder — because restore is M7 and advertising the write half early would put a button in the UI that every provider answers `unsupported`. Host: an in-memory ESP_READ_FLASH loop rather than espflash's file-writing `read_flash`, so the bytes stay in RAM and every acked packet reports progress; the device's trailing MD5 is verified. Browser: esptool-js `readFlash`, with the region resolved mid-flow through a Rust callback so the per-board table is not mirrored into JS. Default baud on both — raising it on USB-Serial-JTAG is measurably SLOWER (M1 bench: 3.2 s vs 4.2 s for 960 KB). The fake provider answers with a REAL littlefs image of its scripted files. A fake returning arbitrary bytes would exercise dispatch and hide the only failure that matters — an image that does not mount. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`app/device/filesystem_backup`: mount the raw lpfs image in wasm with the same littlefs implementation the firmware uses, walk it, and emit a ZIP with the device's paths mirrored verbatim under `files/` plus a `manifest.json`. Support-facing, but shaped as if it were public — that is what keeps the upgrade path free, since M7's restore and any future selective restore read these archives. The layout and every manifest field are documented in the module's README, which is normative. The manifest records the captured device uid. `/.lp/device.json` is INSIDE lpfs, so identity rides along in every backup and a naive restore would clone it; recording the uid is what lets M7 detect that rather than silently do it. An unmountable image fails loudly. On a board being rescued that is a real possibility, and an empty archive presented as a backup is the worst available outcome. Tested against fixture images built in-process — no hardware, no device, no provider. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`DeviceOp::BackUpFilesystem` runs the raw read through the same management orchestration flash and erase use — release the wire, narrate, reattach — and then builds the archive and publishes it for the shell to download. Two details the flow turns on: - It reads like the GENTLE ops, because it is one. Nothing is written, so the lens is not severed, and a device in recovery mode gets the replug instruction rather than being reported as a failure for doing what ROM download mode does. - The download is seq-gated (`UiDeviceBackup`, the agent debug-dump pattern). The view is a full snapshot, so without the paint key every re-render would drop another megabyte into the user's Downloads folder. The danger zone offers it above the destructive verbs in every state that has a filesystem to read — including Recovery mode, the state this exists for — and not on blank or foreign boards, where the row could only fail. Also fixes copy that M6 makes untrue: the unreadable-content wipe gate said the content "can't be backed up". A raw backup does not need Studio to understand the content, only to read the bytes, so the gate now points at the way out instead of asserting there is none. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Recovery-mode face gains "Download a backup" below "Start in safe mode": a board in download mode is exactly where somebody is about to try something drastic, and the copy says the thing that is not obvious — this works even though the board will not start. The danger zone carries the same verb as a plain row (no confirm gate — a gate on "save a copy of your work" is theatre), and `HomeGallery` performs the download when the backup's seq advances. Plus an end-to-end test through the REAL provider path: dispatch → capability gate → raw read → mount → walk → manifest → zip, asserting the archive holds the device's own files at their own paths and records the captured uid. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…' into claude/xenodochial-darwin-b6680f
M6 shipped the backup as a README-level contract; M7 reading these archives back is the moment the layout stops being an implementation detail. Ratified by Yona at the bench walk. Records the posture (support-facing, shaped as if public), the alpha versioning rule (version and refuse, never migrate — same as share envelopes), the verbatim-paths layout selective restore depends on, and why the manifest carries the chip/partition/uid fields (they are what let M7/M8 refuse the dangerous restores). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dev server failed to build: 12 story fixtures construct UiHomeView as a literal, and M6's new `backup` field never reached them. Neither gate caught it — `just check` skips wasm32 entirely, and the M6 review's wasm build ran DEFAULT features while the story fixtures sit behind the stories feature dx enables. The wasm gap, one level deeper than the last time it bit. The review gate for studio-view changes is therefore `cargo build -p lpa-studio-web --target wasm32-unknown-unknown --all-features` — build, all features — and this commit is what running it looks like. Found live by Yona when the dev server refused to build for the hardware walk. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…get caps Two bench reports from the hardware walk. **The vanishing card.** "Start in safe mode" writes the record, the board drops USB during the espflash reset, the anonymous recovery session lands Gone, the state derives Offline — and `live_device_card` returns None for Offline, so the card vanished at the exact moment its op overlay was showing "unplug the board and plug it back in". The overlay machinery even documents that an identity-less board's op "rides the live card" — there was just no card left to ride. An op in flight now pins the card (`HomeDeviceEvidence::op_in_flight`); a Gone anonymous session with no op stays invisible as before. Regression test covers both directions. **The comically long console.** The grid-stack change (which lets sheets grow the card) also made the op overlay content-sized — and its terminal is a `flex:1` + `overflow-y:auto` scroller, which only scrolls when its parent is bounded. The parent no longer is, so a flash's log grew the card line by line and nothing ever scrolled. Unbounded streams own their caps now: `.ux-card-op-term` and `.ux-console-lines` get max-height + scroll. Validated: cargo test -p lpa-studio-core (808, 1 new); just check; cargo build -p lpa-studio-web --target wasm32-unknown-unknown --all-features (the gate the previous story-fixture break taught us). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The bench found it as "Arming safe mode failed / Yet to handle Non Compressed Writes": the browser boot-control writer passed compress: false, and esptool-js 0.6.0 simply does not implement uncompressed writes — it throws. The record was never written, which also reframes the walk's ending: the board coming up "Connected — nothing loaded" was not safe mode, it was a normal boot of a board that happened to be empty. compress: true, like flashFirmware always used. The ROM/stub accepts FLASH_DEFL_BEGIN in download mode, and deflate erases the target sectors exactly as FLASH_BEGIN does, so the one-write rule for the record holds. This is the path M1 flagged as untestable without a human port grant, and this is what that flag was about: the host writer (espflash) was hardware-verified, the browser writer (esptool-js) first ran against silicon on Yona's bench today. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bench: from app mode, "Start in safe mode" landed on a bare "Not seen yet" offline card — no overlay, no guidance. The spec set awaits_manual_replug only from recovery mode, on the theory that an app-mode board auto-returns; when the post-write reattach misses (USB re-enumeration racing the rebuild), the flow's ending was the worst of the options: silence. The awaiting ending is now unconditional for this op, with per-mode copy — recovery mode keeps the hard replug instruction; app mode says the board should return by itself and what to do if it doesn't. A successful reattach still clears the op, so the happy path pays nothing. The op rides the registered offline card by uid (takes_card_op), so the instruction stays visible even when the session dies. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bench, bootloader-mode arm: still a bare "Not seen yet" after the previous fix. The two halves contradicted each other — op_in_flight pins the anonymous card (which renders Offline once the session dies), while takes_card_op(None) refused to attach a uid-less op to ANY offline card. The card survived; the instruction didn't. The offline exclusion exists to keep uid'd REGISTRY cards of other devices from adopting a stray anonymous op, and it still does. A uid-less op now rides any uid-less card — there is at most one anonymous hardware session — including the pinned one. Test pins the pair: the pinned card takes the op, a registered offline card does not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…zing The technical-details box styles every line as a one-line console tail; for log lines that is right, but the error line is the one thing the user must read in full. On the bench the actionable half of an esptool warning died behind the ellipsis: "Boot-control write failed: Flash…". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
reportFailure passed the raw Error object as console.error's second
argument; the console forwarder ships arguments to the Rust log bridge,
which expects strings — the object arrived as {} and the WHOLE entry died
with "invalid type: map, expected a string", exactly on the line that
carried the boot-control failure. The message string already contains the
error text and stack; the object added nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…oise Bench, ESP32-C6 rev 2 over USB-Serial-JTAG: esptool-js's flash-ID probe reads 0 and prints "Failed to communicate with the flash chip" on a board where actual stub traffic demonstrably works — the 2.9 MB firmware write and the filesystem backup both succeeded on the same plug session, and a power cycle does not clear it. The ID probe drives per-chip SPI registers; FLASH_DEFL_*/READ_FLASH are a different path. Gating the boot-control write on that warning was a permanent false positive on this board. The gate is now the guarantee we actually want: read the record back from flash and compare byte-for-byte. Mismatch throws with both hex dumps; the warning is left as log noise. The erase path keeps its warning gate — there is nothing to read back after an erase. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ays so Bench ask: after arming safe mode (or flashing firmware) from download mode, the flow should END on "unplug the board and plug it back in" — the board does not boot the result until then. The spec already encoded that ending (awaits_manual_replug + copy), but only the reattach-MISS arm used it. On a C6 over USB-Serial-JTAG the post-write RTS reset re-enters download mode, so the reattach succeeds into a recovery session, the Ok arm cleared the op, and the user landed on the recovery card advising them to install the firmware they had just installed. The Ok arm now checks: op wants a real boot + device still in recovery = the replug instruction, same op as the miss arm. A wipe keeps its clear-on-Ok (the recovery card IS its correct next step), and app-mode ops that land running are untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
















Plan:
lp2025/2026-07-30-device-recovery-bootloaderRecovery for a device whose own project is what stops it from running — the
originating case being a project bright enough to brown the board out, which
loops forever because it never stays up long enough for Studio to connect.
Draft: opened at the first commit so CI signals early. M1–M2 of 9.
Landed
esptool-js0.6.0 exposesreadFlash(offset, size, onPacketReceived)and thatmain()already runsthe stub. Measured on real C6 silicon: 960 KB in 3.2 s, 4 MB in 13.6 s;
write → read-back → restore byte-identical. Two planning assumptions were
wrong and are corrected in the plan: raising the baud is slower (USB
CDC — the parameter is meaningless), and
ESP_READ_FLASHis not stub-only.lp-base/lp-bootctlcrate, abootctlpartition carved from the unused
nvs(no other offset moves, so existinglpfsimages stay valid), and a firmware read-and-consume path ahead ofproject auto-load.
Still to come
M3 "boot safe next time" end to end · M4 bootloader-mode detection ·
M5 recovery UX (visual gate) · M6/M7 filesystem backup+restore as ZIP ·
M8 whole-flash backup/restore · M9 flap detector + cleanup.
Validation so far
cargo test -p lp-bootctl— 34 passedjust check— greenjust test— 29 suites, 0 failuresjust fw-esp32c6-size-check— 278 800 B headroom vs the 65 536 B marginADR:
docs/adr/2026-07-30-boot-control-sector.md🤖 Generated with Claude Code