Right-size the flash layout and write the index once per import
Two capacity items that were blocking the raised per-variant caps.
The partition tables gave the app far more room than the largest image
needs while starving the vault: the 8 MB boards now run 2 x 0x2C0000 app
slots with a 0x260000 LittleFS, the 4 MB boards a 0x290000 app with
0x160000 of filesystem, and the 16 MB tables keep their geometry. The
loaded 8 MB build sits at 78 % of its app slot and the 4 MB ones near
70 %, so the headroom is real rather than asserted. Two rationales that
had gone stale — a ">1500 credentials" claim and an Arduino/ESP-TEE
headroom note — are gone.
Changing the layout means a device must be fully re-flashed and its vault
repopulated, which is the standing pre-release policy; the flashing and
variant docs now say so where someone upgrading would look.
The bulk import used to rewrite index.bin for every row, and each rewrite
materialized the whole capacity-scaled working set: at 500 credentials
that is a flash rewrite and ~89 KB of transient per credential. A batch
guard now defers the rows and rebuilds the index once at the end.
Deferral is only safe if a half-finished batch can never be mistaken for
a finished one, and review found three ways it could be:
- A transient key-derivation failure returned without closing the batch.
Every later index mutation then reported success while writing nothing,
favorites and ordering survived only in RAM, and the portal's
index-repair button reported success while doing nothing — until a
reboot. The batch now closes before the write, so a failed write cannot
leave mutators deferring into a buffer nobody will flush, and the
key-derivation path aborts the batch explicitly.
- If the pre-batch unlink failed — lfs_remove needs a metadata block, so
it fails on a full filesystem — the old index survived and the batch
deferred every row into oblivion, leaving a MAC-valid, complete-looking
index missing every imported credential with no path back. The batch
now refuses to open and per-row writes stand: slower, not wrong.
- The batch depth was atomic to survive the vault worker's inline-
execution fallback but the snapshot pointer was not, leaving a
use-after-free window in exactly that case.
Also: the capacity guard's ceiling search could two-cycle and return
whichever value the iteration budget's parity landed on, one credential
above the self-consistent ceiling — failing open in the direction it
exists to prevent. It now iterates to a real fixpoint and takes the lower
value on a cycle.
Verified: native 2860/2860, the variant-config guard over all 38
environments, and every one of the eight boards builds — including
m5core_ink, which had never been built directly before.
Claude-Session: https://claude.ai/code/session_01Q2J5gQSFMTDLVzPUYog51r
Right-size the flash layout and write the index once per import
Two capacity items that were blocking the raised per-variant caps.
The partition tables gave the app far more room than the largest image
needs while starving the vault: the 8 MB boards now run 2 x 0x2C0000 app
slots with a 0x260000 LittleFS, the 4 MB boards a 0x290000 app with
0x160000 of filesystem, and the 16 MB tables keep their geometry. The
loaded 8 MB build sits at 78 % of its app slot and the 4 MB ones near
70 %, so the headroom is real rather than asserted. Two rationales that
had gone stale — a ">1500 credentials" claim and an Arduino/ESP-TEE
headroom note — are gone.
Changing the layout means a device must be fully re-flashed and its vault
repopulated, which is the standing pre-release policy; the flashing and
variant docs now say so where someone upgrading would look.
The bulk import used to rewrite index.bin for every row, and each rewrite
materialized the whole capacity-scaled working set: at 500 credentials
that is a flash rewrite and ~89 KB of transient per credential. A batch
guard now defers the rows and rebuilds the index once at the end.
Deferral is only safe if a half-finished batch can never be mistaken for
a finished one, and review found three ways it could be:
- A transient key-derivation failure returned without closing the batch.
Every later index mutation then reported success while writing nothing,
favorites and ordering survived only in RAM, and the portal's
index-repair button reported success while doing nothing — until a
reboot. The batch now closes before the write, so a failed write cannot
leave mutators deferring into a buffer nobody will flush, and the
key-derivation path aborts the batch explicitly.
- If the pre-batch unlink failed — lfs_remove needs a metadata block, so
it fails on a full filesystem — the old index survived and the batch
deferred every row into oblivion, leaving a MAC-valid, complete-looking
index missing every imported credential with no path back. The batch
now refuses to open and per-row writes stand: slower, not wrong.
- The batch depth was atomic to survive the vault worker's inline-
execution fallback but the snapshot pointer was not, leaving a
use-after-free window in exactly that case.
Also: the capacity guard's ceiling search could two-cycle and return
whichever value the iteration budget's parity landed on, one credential
above the self-consistent ceiling — failing open in the direction it
exists to prevent. It now iterates to a real fixpoint and takes the lower
value on a cycle.
Verified: native 2860/2860, the variant-config guard over all 38
environments, and every one of the eight boards builds — including
m5core_ink, which had never been built directly before.
Claude-Session: https://claude.ai/code/session_01Q2J5gQSFMTDLVzPUYog51r
Convert transport and DUT-layer docstrings to RST
Add Sphinx-ready RST docstrings (native field lists: :param:, :returns:,
:raises:, :cvar:, :ivar:) and complete PEP 484 type hints on all public
signatures across the transport + DUT-control layer:
- transport/base.py: DutTransport / HostTransport ABC contracts, all
concrete helpers, TransportError / TransportTimeout / HostResult.
- transport/serial_dut.py: SerialDut.__init__, send, read_available,
expect_token, close, raw property; _open, _open_with_recovery,
_kill_port_holders.
- transport/mock.py: MockDut / MockHostTransport all methods, ivar/cvar
for .sent/.ran/.mac; make_boot_banner.
- dut/debug_console.py: DebugConsole and all 60+ public methods; VaultStatus
dataclass; parse_fields.
- dut/flash.py: FlashError, _find_pio, flash_debug, _confirm_boot_marker.
- dut/identify.py: IdentityError, MacMismatchError, discover_ports,
read_mac_esptool, resolve_verified_port, confirm_mac; private helpers.
- dut/screenshot.py: ScreenshotError, _Header, _rgb565_to_image,
_parse_header, _read_header, _decode_row, capture, to_png_bytes.
- dut/video.py: VideoError, _FrameHeader, _StreamReader (open/close/
next_frame), _parse_header, _rle_decode, _frame_to_image, record_stream,
motion_score, frames_to_gif_bytes.
No logic, control flow, or public signatures were changed.
Claude-Session: https://claude.ai/code/session_01Y7Cy1HetRp6TZAUAeekN8X
Convert transport and DUT-layer docstrings to RST
Add Sphinx-ready RST docstrings (native field lists: :param:, :returns:,
:raises:, :cvar:, :ivar:) and complete PEP 484 type hints on all public
signatures across the transport + DUT-control layer:
- transport/base.py: DutTransport / HostTransport ABC contracts, all
concrete helpers, TransportError / TransportTimeout / HostResult.
- transport/serial_dut.py: SerialDut.__init__, send, read_available,
expect_token, close, raw property; _open, _open_with_recovery,
_kill_port_holders.
- transport/mock.py: MockDut / MockHostTransport all methods, ivar/cvar
for .sent/.ran/.mac; make_boot_banner.
- dut/debug_console.py: DebugConsole and all 60+ public methods; VaultStatus
dataclass; parse_fields.
- dut/flash.py: FlashError, _find_pio, flash_debug, _confirm_boot_marker.
- dut/identify.py: IdentityError, MacMismatchError, discover_ports,
read_mac_esptool, resolve_verified_port, confirm_mac; private helpers.
- dut/screenshot.py: ScreenshotError, _Header, _rgb565_to_image,
_parse_header, _read_header, _decode_row, capture, to_png_bytes.
- dut/video.py: VideoError, _FrameHeader, _StreamReader (open/close/
next_frame), _parse_header, _rle_decode, _frame_to_image, record_stream,
motion_score, frames_to_gif_bytes.
No logic, control flow, or public signatures were changed.
Claude-Session: https://claude.ai/code/session_01Y7Cy1HetRp6TZAUAeekN8X
Add the StickS3 UI v2: home pager, detail carousel, TOTP, settings, onboarding
Implements the Claude Design v2 handoff on the StickS3 (135x240), built against
pixel-exact 1:1 mock references extracted from the handoff and verified by
overlay diff against captures from real silicon.
Screens
- Home pager: lock face, timing-arc PIN dial, icon tab strip, vault list.
- Credential detail: 6-stop action carousel (type, username, URL, related TOTP,
notes, delete) over the untouched decrypt/reveal/scrub paths.
- TOTP tab: per-row live codes with countdown rings.
- Settings: navigable tree with Wi-Fi, NTP, timezone and DST screens.
- Onboarding: welcome, button intros, language, five interactive component
tutorials (dial, tabs, lists, values, writing), then PIN creation.
Reusable components (screens compose; components own anatomy)
- CountdownRing, TotpCodeCard, Ipv4Field, TutorialHint, drawPinDialRimDigits and
drawListRowSelectionBand; the inline TOTP ring/code sites now delegate.
Timezone and DST
- Extends the in-house timex engine to 40 zones with POSIX TZ strings and a DST
toggle (default on). The internal epoch stays UTC: timezone and DST convert
display only, so the TOTP epoch (RFC 6238) and the lockout math never shift.
A native test pins that invariance across every zone.
Fixes found on silicon
- popup::confirm/alert blocked the TWDT-subscribed loopTask, so any modal left
open past the 5 s window rebooted the device - reproducible with the
credential delete confirm under no other load. The blocking loops now feed the
watchdog through the platform facade.
- AUDIO ON|OFF never persisted: saveSettings() had no callers repo-wide while
the help text and docs claimed it survived a reboot.
- The onboarding language A-hold confirm could never fire: wasClicked() consumed
the release edge before wasReleaseFor() ran.
- Cancelling the delete confirm exited the whole detail view, via a late release
edge leaking out of the modal.
- Settings entry landed on the second row: multi-tap consumes click edges but
leaves press edges latched, so the first tick read a stale nav.
- The video stream server ran above the vault worker on Core 0 and starved vault
I/O during capture.
QA
- 18 new hardware tests: timezone/DST, TOTP UTC invariance, NTP server
validation, Wi-Fi store, NTP sync, demo seed, audio persistence.
- The video capture test had silently skipped since WEB-05: /stream requires a
token the decoder never sent, so a 401 became a skip. It now sends the token
and fails hard on auth errors.
Native 2258/2258; sticks3, m5stack_gray, m5core_ink and tdeck build -Werror;
clang-tidy reports 0 findings across 269 TUs.
Claude-Session: https://claude.ai/code/session_01P6BNTpbgrvnZXSJnNuj8ZJ
Stream only the band that changed
During motion the live stream could sample only about one in three UI
states: reading the whole 64,800-byte frame takes ~16 ms under the
seqlock, and any composition during that window invalidates the read.
The pixels were tear-free; WHERE they changed was simply unknown, so the
reader had no choice but to fetch everything.
The panel driver now keeps a stream-damage accumulator: the union of
every shadow mutation since the reader last consumed it, fed by
commitFrame's dirty bounds and the few unbracketed direct-draw sites,
guarded by a critical mux whose sections are a handful of compares, and
enabled only while a stream is actually consuming — idle rendering pays
nothing. display_hal exposes enable/peek/clear; the e-paper backend
stubs them (partials are meaningless against its refresh model).
The wire grows fmt=4: an 8-byte [x][y][w][h] prefix plus the band
RLE-encoded, pasted by the receiver over its previous frame. Partials
are sent only when a full frame is already on the wire and the damage
covers less than ~60% of the panel; anything else takes the full path.
The race protocol is deliberately lossy toward pessimism: damage that
lands after a captured read bumps the frame sequence, so the next tick
sees "changed" with an empty accumulator and falls back to a full
frame. A stale clear can cost an optimization, never pixels.
Measured on the Stick during continuous holds: stale reads fall from
29% to 1% — a footer-sized band reads in a fraction of a full frame's
time, so the seqlock read now succeeds where it used to race the very
composition it was sampling. Average motion payload ~1.4 KB, 30 fps,
distinct captured frames 46 -> 54, and the stream-composited frame
diffs exactly zero against a fresh serial screenshot.
2553 native tests; all eight variants build, e-paper included.
Claude-Session: https://claude.ai/code/session_01ABhkBJsMKTZAxZh4Vh7jsF