Commits
From Settings (vault unlocked) the device can join a saved WiFi network
as a station, sync the clock via one-shot SNTP against trusted public
NTP servers, and always disconnect. Servers are LITERAL IPv4 only —
never a hostname, never DNS (esp_sntp is fed parsed addresses; a strict
dotted-quad validator gates every entry). Defaults are Cloudflare
(162.159.200.1, 162.159.200.123), Google (216.239.35.0) and NIST
(132.163.97.1), tried in order; the user can persist an override list
of up to four IPs so dead defaults never require a firmware update.
Saved networks (SSID+PSK, max 8) and the NTP server list live in a new
wifi.bin vault module using the standard envelope (AES-256-CBC +
HMAC-SHA256, encrypt-then-MAC, verify-before-decrypt, type-bound), only
readable with the vault unlocked, staged through the crash-safe rekey,
and crypto-erased by wipeAll. PSKs are zeroized after use and never
logged or echoed.
The sync engine runs on a Core-0 worker: BLE off, join the strongest
saved network, query servers in order, sanity-clamp the epoch and never
step backward past an armed lockout, commit via the rtc facade with a
new display-only src=ntp tag — clock TRUST is never elevated (NTP is
unauthenticated; the forward-jump lockout fail-closed model is the
spoofing defense) — then a RAII station guard tears WiFi down and
restores BLE on every exit path. The station facade stays a generic
transport for future consumers (on-device OTA).
Debug console gains WIFISCAN / WIFIADD / WIFILIST / WIFIDEL, NTPSYNC
(GPSSYNC-style blocking flow with machine-checkable teardown acks) and
NTPSERVERS? / NTPSERVERSET / NTPSERVERCLEAR — all DEBUG_SERIAL_BUTTONS
gated. HIL-verified end to end on the StickS3: real sync against the
default servers, user-list precedence across reboot, TEST-NET failure
paths, trust unchanged, BLE restored, store left clean.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Doxygen: name the setKnownBackground parameter so @param resolves, and
unglue the colon from the HwTestStateHandler reference. clang-tidy:
initialize RowSink::readback_ in the member initializer list.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Add a debug-gated Component Gallery (GOTO GALLERY): a full-screen
showcase that renders each shared UI component on its own card and
advances on any input — buttons, touch zones, the CoreInk dial, and any
keyboard key or trackball event on the keyboard/trackball variants — so
the v2 look can be validated on every panel. Modeled on the HwTest
special mode and excluded from release/secure builds.
Add an AUDIO ON|OFF debug command that mutes/unmutes UI feedback and
persists the preference to NVS, so QA can silence a device without a
reflash.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
The status cluster drew the battery as "NN%" text; a full "100%" is
four characters and starved the title on the 135 px Stick header,
forcing an early ellipsis. Replace it with a fixed-width gauge glyph
(outline + terminal + proportional fill, tinted green while charging and
red at <=15%). The level reads at a glance from the fill and the title
gets the reclaimed width. Removes the now-unused percentage-formatting
helper.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
AA shapes did a read-modify-write against the CPU shadow framebuffer,
so the classic-ESP32 320x240 boards (m5stack_gray, core2_v13) that lack
one fell back to jagged primitives. Add a known-background back-end: when
the surface is not readable, blend each shape's edge coverage against a
canvas-tracked background color and write only the covered pixels via a
direct drawPixel. The readback path is preserved byte-for-byte, and the
canvas tracks the current background (set on fillScreen, overridable per
draw). Also raise the AA scanline scratch to the fleet's widest panel
(256 -> 320) — a rounded rect wider than 256 px silently vanished before.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
The CoreInk e-paper drew UI text with the crude 5x7 bitmap font because
the anti-aliased atlas path was gated off on monochrome panels. Enable
it: CoreInk now uses the same Inter / JetBrains Mono atlases as the color
fleet, mapping each glyph's coverage to ink/paper through an 8x8 Bayer
ordered dither (with a small weight bias so hairlines survive on e-ink)
instead of an RGB565 alpha blend. Sizes are raised to the e-ink
legibility floor (body 16, title 24) via a per-device mono floor in the
metrics resolver. The color alpha-blend path is unchanged and every
native-scale color glyph stays byte-identical; the bitmap font remains
compiled as a fallback.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Match the eight system icons to the approved handoff geometry
(docs/design/handoff/assets/icons): Settings becomes three sliders with
knobs (was a gear), and add Credential (ID card) and Totp (circular-
arrow timer, distinct from the plain clock); Lock, Key, Battery, Ble and
Warning are reshaped to the handoff strokes. Icons stay primitive-drawn
so they render backend-uniform on color TFT and 1-bit e-paper at zero
flash cost.
Switch the header brand mark to the ring-less keyhole glyph
(logo-glyph); the full ring mark stays for >=24 px branding (lock
screen, onboarding, about). Add native tests for the icon catalog and
the header mark.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
The v2 type ladder resolves the hero PIN digit and the TOTP data role
to sizes with no native atlas, so they were magnified from a smaller
rung (blurry). Add native sans48 / mono20 / mono48 atlases gated to the
Roomy 320x240 boards (gray, cores3_se, core2_v13) and link the existing
sans32 on the Compact Stick boards, so those scaled draws resample from
a near-native rung instead of upscaling. The role->atlas mapping is
unchanged, so every native-scale glyph stays byte-identical.
Regenerate the whole atlas set against the current i18n glyph subset
(the committed atlases had drifted to a stale subset) and add a
pre-commit font-atlas freshness guard so i18n or pipeline changes can no
longer silently diverge from the generated output. No partition
overflows; the tightest board (4 MB Plus1) sits at ~54%.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Bring the approved design handoff from the Claude Design project into
docs/design/handoff/: RGB565-safe design tokens with WCAG contrast
data, the logo set (full mark + solid glyph), the 8-icon system set,
and the seven 1:1 screen catalogs (brand identity, StickS3, CoreInk,
CoreS3/Core2 touch, Cardputer, T-Deck, Gray) that drive the UI v2
implementation.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Brings the SSD1681 fast-animation driver (5 fps bounded regions + 6-tone
grayscale), the pinned multicolor brand-logo pipeline with e-paper gray
families, photo-verified device fronts and measurements, and the Claude
Design context bundle.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Replace the stale bring-up-paused note: the SSD1681 (GDEW0154D67)
driver is hardware-verified with bounded fast region animation, 6-tone
static grayscale, and deep-clean support.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Define KLEIDOS_STREAM_TOKEN_TO_SERIAL in sticks3_debug so VIDEO START
logs the WEB-05 X-Stream-Token over the local USB console, making the
QA live-video stream drivable again over serial (debug builds only;
the token gate itself is unchanged).
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Self-contained design-brief package for the next-gen UI pass: README
brief with the phased workflow (identity, components, screens per
device, pixel-exact handoff), verified device inventory, the mined
interaction model (per-screen StickS3 button maps plus input-scaling
guidance), the implemented component catalog, design-system tokens and
constraint references, photo-verified true-scale device fronts, 29 live
StickS3 captures, three real-cadence UI videos (PIN timing arc, vault
navigation, letter carousel), brand identity, and the multicolor/gray
logo contact sheets.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Recompute the per-device legibility floors from the datasheet-verified
active areas and PPI (Stick 241 PPI, Core 199, T-Deck 141, Cardputer
241), and replace the blanket never-animate e-paper rule with the
validated CoreInk envelope: instant full-screen transitions plus a
bounded small-region ~5 fps exception for feedback content.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Overlay-check every front.svg against real device photos (owner photos
for sticks3/cores3-se/coreink/cardputer-adv/tdeck-plus, repo reference
photos for the rest) and correct the generator geometry: datasheet-true
active areas everywhere (Gray's LCD was drawn square), CoreS3 SE / Core2
Power and Reset moved to the owner-measured left-edge/bottom-edge spots,
Core2 touch dots at x 12/25/38.5 y 45, Gray's single Power/Reset at
13 mm on the left edge, all Stick screens at 4-5 mm below the case top,
CoreInk confirmed with no front dial (jog wheel on the right edge),
T-Deck chevrons pointing inward at the centered trackball, Plus2 BtnA
unified with Plus1's press-point geometry (identical chassis).
Reconcile measurements.json with the photo evidence (CoreInk origin_y,
Core2 origin_y, T-Deck converted to a case-top vertical datum) and drop
the obsolete non-Plus tdeck from the generator registry.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Replace the flat Simple Icons glyphs with true multicolor vector art,
resolved per brand through a pinned source chain (gilbarbara@commit,
svgl@commit, Wikimedia Commons with per-file licenses, upstream project
repos) by the new scripts/brands/curate_logos.py; 200/200 brands now
have an SVG source (4 flat simpleicons pins remain where no legitimate
vector exists). Fix 47 manifest entries whose 'domains' carried
Simple-Icons source-page domains instead of the brand's real domain
(live cosmetic bug: those brands fell back to the letter tile).
Add gray4/gray6 brand-atlas families for the e-paper (per-pixel Rec.601
luminance of the multicolor render, Bayer 8x8 ordered dithering) with
KLEIDOS_BRANDS_FAMILY 3/4 gating in CMake and check_variants, and
regenerate the full family x size matrix (color/mono/gray4/gray6 at
24/32/96/128/256). Exclude pinned brand SVGs from the mixed-line-ending
hook so vendored sources stay byte-exact with their sha256 pins.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Optimization round on the CoreInk fast-animation mode, all measured on
silicon via the EINKLUT live-tuning command (sweep waveform variants over
serial without reflashing):
- Fast partials run 0x22 = 0x8C: skipping the enable-analog stage (its
HV-ready detection costs ~91 ms even with the rails already up) cuts the
per-update fixed cost to ~94 ms. The stage is mandatory after full
refreshes, whose sequence powers the analog down, so the bank-settle pass
keeps 0xCC and doubles as the analog power-up (fastSettleSequence).
- Built-in waveform is the owner-picked quality-ladder point: 4 drive
frames at +/-17 V source voltage (VCOM/VGH stay on the panel's OTP trim)
plus a 1-frame reinforcement poke, the only ladder variant whose
animation trail self-cleans: 194 ms/update, 5.1 fps (from 503 ms / 1.5
fps at the start of the effort). BUSY poll tightened to 1 ms; the
post-update RAM rewrite proved redundant (read-back-verified) and the
gate-scan-window experiment proved frame period is FR-fixed, so neither
ships.
- ssd1681_lut.h: pure constexpr builders for the 153-byte waveform layout
(fast differential LUT with kick/burst/rest/poke knobs, graded grayscale
LUT, source-voltage register codes), the single source of truth shared
by the controller tables, EINKLUT and EINKGRAY, with a native host test
suite pinning the layout against the datasheet.
- 2-bit grayscale rendering (EpaperPanelDriver::renderGrayImage +
display::renderEpaperGray + EINKGRAY): both RAM planes select one of
four LUT slots per pixel, so graded drive lengths yield 4 tones in one
update or 6 tones (white + 4 grays + black) in two stacked updates —
owner-validated on the panel. The command holds the tones until serial
input (returning lets the FSM repaint and collapse them).
- SSD1681 register read-back (readPanelRegister over the shared SDA line):
the 0x2F Status Bit Read chip-ID bits fingerprint the controller in
silicon, settling that this CoreInk unit carries the D67/SSD1681 panel
revision, and SPIRD now works on e-paper. ANIMTEST gains TAG plus an
on-panel firmware-revision letter so tuning-ladder observers always know
which build/step they are watching.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Give the CoreInk (GDEW0154D67 / SSD1681) an LCD-like animation path: a
custom differential waveform LUT uploaded over 0x32 (1 inverse-kick frame
+ chopped 4/rest/3 drive bursts, ~20 ms/frame at FR 2) driven through a
new EpaperPanelDriver::setFastPartial() mode that keeps the analog rails
on between updates (0x22 = 0xCC) and consolidates with a quality refresh
on exit. Partial updates drop from ~503 ms (OTP waveform, full-panel
flash every 8th) to a steady ~370 ms with no flashes: 1.5 -> 2.7 fps.
Correctness against the controller's real RAM model, verified by reading
the panel RAM back over SPI (0x41/0x27, half-duplex three-wire on the
shared SDA line):
- Mode-2 display updates ping-pong two B/W RAM banks; window-limited RAM
writes leave stale frames in the alternate bank that the differential
waveform re-drives as solid ghosts. Every update now reloads the full
B/W plane and rewrites it after the update; settleAlternateRamBank()
primes the alternate bank on fast-mode entry and after full refreshes.
- The controller maintains its old-image (RED) plane itself; the driver
never writes it. Analog calibration (VCOM/VGH/VSH/VSL) stays at the
panel's per-unit OTP values instead of generic overrides.
Fix aa::available() returning true on e-paper: coverage blending through
the thresholding 1 bpp store left fringe rings on every erase; e-paper
now always uses the crisp primitives, as documented at the call sites.
Debug console additions: ANIMTEST [frames|LOOP] [FAST] [HOLD] [DUMP]
[FILM] (timed bouncing-ball animation with per-frame flush latency,
endless mode with periodic deghost cleans, frozen-state and per-frame
panel-RAM dumps), EINKCLEAN [cycles] (full-panel deghosting flashes) and
EINKDUMP (base64 dump of panel BW/old RAM + CPU frame planes), plus the
display::setFastPartial()/readEpaperRam() e-paper facade hooks.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
- debug_console.py: TrkSelfTest dataclass, parse_trk_selftest, and the
dut.trk_selftest() helper (device token, pin mapping, click level, ISR-armed
state, PASS/FAIL) mirroring kbd_selftest/imu_selftest.
- test_trk_selftest_parse.py: no-hardware parser unit tests over a MockDut
(quadrature PASS, click-held informational, absent, not-armed FAIL, boot
noise, missing line).
- test_trackball.py: trackball-capability-gated self-test (PASS, device match,
ISRs armed — the load-bearing input-path evidence for a device whose motion
cannot be synthesized) plus an absent-graceful check for the rest of the
fleet. Motion is intentionally not driven — it needs a physical finger.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Mirror the keyboard suites, adapted to a passive device whose concrete driver
cannot link natively (its edge-ISR critical sections need FreeRTOS), exactly
like the BMI270:
- test_trackball_factory: the constexpr classifier decisions, the pin-validity
check that stands in for a passive device's impossible address probe, and the
factory wiring — with no family compiled it resolves the never-null
NullTrackball with an inert transport surface (amalgam-includes the factory).
- test_trackball_selftest: the pure pass/fail helpers (drain coherence,
informational click-idle level, overall gate — happy/absent) and the
NullTrackball "no trackball" default.
Both registered in the native test_filter. The concrete QuadratureTrackball
comms-free self-test is validated on-silicon by TRKRW via HIL.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Mirror IMURW/KBDRW: TRKRW (alias TRKSELFTEST, DEBUG_SERIAL_BUTTONS-gated) runs
the driver's passive-device self-test and prints the detail line plus PASS/FAIL.
A trackball has no bus/registers/identity and its motion needs a finger, so the
command verifies only what is verifiable without touch — pins configured, edge
ISRs armed, click line readable, accumulators drain coherently — and states so
in the detail string, e.g.
TRKRW ok=1 detail=QuadTrackball pins=3/15/1/2/0 lvl=1 isr=armed (motion needs touch) PASS
On a no-trackball board it reports "no trackball"/ok=0 without a crash. The
existing TRACKBALL? snapshot is retained. Documented in serial-debug-commands.md.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
The HAL wrapper now keeps only the app-seam policy — turning the driver's raw
quadrature snapshot into a clamped TrackballEvent (delta arithmetic, velocity
interval, center-click edge detection) — and consumes the pin-level driver via
ITrackball*. All baremetal (GPIO config, edge ISRs, accumulators) moved to
drivers/trackball/QuadratureTrackball. Trackball navigation on the T-Deck is
behavior-preserving: scroll, click and velocity feel are unchanged.
- board_def.h: add the mutable trackballDriver (ITrackball*) slot, resolved at
HAL bootstrap, mirroring keyboardController; the app-facing TrackballInput
slot is unchanged.
- hal_bootstrap_{tdeck,local}.cpp: resolve trackballDriver via the flag-gated
createTrackball factory on every board (NullTrackball where absent, so the
slot is never null and TRKRW/TRACKBALL? report absent gracefully), then init
it before the wrapper binds.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Bring the trackball to the same device-driver structure as PMIC/RTC/IMU/
keyboard. A trackball is a passive GPIO device — no bus, registers, or chip
identity — so the layer is honest about that: it cannot be electrically
probed, and its motion needs a physical finger.
- i_trackball.h: device-level ITrackball contract (init/isAvailable/name,
drainSteps raw quadrature snapshot, isClickDown, inject, selfTest) + the
QuadratureSteps transport struct.
- quadrature_trackball.{h,cpp}: generic four-direction + click GPIO quadrature
driver parameterized on its pin mapping (the T-Deck is one mapping). Holds
the falling-edge IRAM ISRs, ISR-resident step accumulators and velocity
timestamps (a hardware singleton), moved verbatim from the HAL wrapper so
ISR discipline and velocity math are unchanged.
- trackball_factory.{h,cpp}: flag-gated createTrackball (TRACKBALL_TYPE_*,
like the LilyGo keyboard leg) with a natively-testable constexpr classifier
and pin-validity check standing in for the impossible address probe; returns
the shared NullTrackball otherwise.
- trackball_selftest.h: pure, hardware-free pass/fail helpers (drain coherence,
informational click-idle level, overall gate) — the passive-device analogue
of imu_selftest.h / keyboard_selftest.h.
- null_trackball.h: never-null no-op driver for boards without a trackball.
- TRACKBALL_TYPE_QUADRATURE flag default (platform_defaults.h) + T-Deck enable;
drv_trackball Doxygen group.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
SLEEPTEST deliberately bypasses PowerManager::enterDeepSleep(), so the
new T-Deck hold path (rail-off latch, LoRa/ES7210 parking) had no
serial-reachable wake for HIL — the production wake source is a physical
key. The optional FULL argument arms the timer wake first and then
enters sleep through the per-board enterDeepSleep(), exercising the real
teardown while remaining remotely recoverable (ESP wake sources
accumulate). GPIO-power boards only; a PMIC VDD cut would defeat the
timer, as documented.
T-Deck HIL evidence (SLEEPTEST 15000 FULL): SYS reports
reason=DEEPSLEEP wake=TIMER from_deep_sleep=1, and post-wake the ES7210
registers re-read the power-down state (0x01=0x7F 0x06=0x07 0x40=0xC0
0x4B/0x4C=0xFF) — the holds released on wake, the rail re-powered, and
the bootstrap re-asserted the parking.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
- Gray (IP5306): test_battery_voltage_plausible asserted a LiPo voltage
range on every DUT, but the IP5306 has no battery-voltage ADC — the
backend structurally reports 0 mV. Key the assertion on the PMIC model
and assert the structural 0 for IP5306 (HIL: model=IP5306 batt_pct=100
charging=1, batt_mv=0).
- AXP backend: the boot-time PMIC probe can transiently NACK right after
a serial-open DTR/RTS reset, leaving the whole boot with no PMIC
(model=none, 0 readings) — observed intermittently on core2_v13 and
plus1. Add a bounded lazy re-probe (one retry on first telemetry read)
so a single missed probe no longer costs the boot's telemetry. Verified
on core2_v13: 3 consecutive full power-QA runs, 5/5 each.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
- Emit a boot log line when the Cardputer IR emitter is parked OFF
(GPIO44=0), giving on-silicon evidence in the absence of a GPIO-read
console command. HIL-confirmed: "IR emitter parked OFF (GPIO44=0)".
- Drop the redundant battery voltage/percent assertions from
test_battery_telemetry_matches_pmic_capability (the dedicated tests
above already cover them) and keep only the PMIC-capability-keyed
current / VBUS-current / die-temperature checks. This also makes the
test robust to a transient PMIC-probe miss (model=none, 0 reading),
which is a reset artifact, not a telemetry-capability regression.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
On-silicon (T-Deck, ES7210 at 0x40) the leading RESET (0x00=0xFF) held
the codec's digital core in reset, so the follow-up clock-off / analog /
power-down register writes never latched — I2CRD read 0x01=0x20,
0x06=0x00, 0x40=0x80 (reset defaults). Follow the authoritative ESP-ADF
es7210_stop() sequence with no reset: the mic-power, analog, clock-gate,
and power-down registers now latch and read back 0x01=0x7F, 0x06=0x07,
0x40=0xC0, 0x4B/0x4C=0xFF — a verifiable power-down state.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Extend the HIL power suite so each board's advertised battery data is
asserted capability-appropriately (wide bounds, never exact values):
- test_power.py: a PMIC-model-keyed test — AXP192 must return plausible
battery-current / VBUS-current / die-temperature; AXP2101 a die-temp
but batt_ma=na (no battery-current ADC); IP5306 (Gray) skips the
voltage assertion (no voltage ADC, structurally 0); M5PM1 / plain-ADC
assert percent + voltage only.
- test_pmic_accessors.py: assert batt_ma is a plausible number on AXP192
and the "na" literal on AXP2101 / M5PM1 / IP5306 / ADC boards, covering
the refactored axp192BatteryCurrentMilliamps path end-to-end.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
- Override pmicName() on the StickS3 M5PM1 backend so RES?/PMIC?
report "M5PM1" instead of "none"; the chip has no current/VBUS/temp/
coulomb ADCs, so percent + millivolts + charging remain its ceiling.
- Extract the AXP192 battery-current, VBUS-current, and VBUS-voltage
conversions (and the 13-bit discharge-ADC assembly) out of axp192.cpp
into pure detail:: helpers in pmic_common.h, completing the existing
decode-helper pattern, and cover them with native tests. The
arithmetic is unchanged; the AXP192 accessor math was previously the
only telemetry decode with no off-hardware test.
Audit note: the four ADC-battery boards (Plus2, CoreInk, Cardputer,
T-Deck) expose no charge/VBUS-sense GPIO, so isCharging() stays
false (unavailable) by hardware; IP5306 (Gray) and AXP2101 already
surface everything their silicon offers.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Kleidos never records audio or transmits IR, but those peripherals power
up on their boards and draw idle current. Hold them off:
- T-Deck: force the unused ES7210 mic-array ADC into I2C power-down at
bootstrap (probes 0x40 first; touches only its own address so the
shared keyboard/touch bus is undisturbed) and park its clock lines and
the reserved external-I2S pins as pulled-down inputs. MCLK gating alone
is not enough — the ES7210 analog front end stays biased until the
power-down registers are written.
- T-Deck deep sleep: re-assert LoRa RST low / CS high and latch the
peripheral-rail-off and radio-control levels with gpio::holdEnable so
they survive sleep; hal::begin() releases the holds on the next boot
before reconfiguring the pads.
- Cardputer: drive the unused IR LED (G44, active-high) to its inactive
LOW level and hold it through deep sleep. The ES8311 codec is never
instantiated and sits in its power-on-default mic-path-down state, so
it needs no action.
New ES7210 power-down driver and PIN_ES7210_*/PIN_I2S_*/PIN_IR_TX
device-config plumbing (default -1 on boards without the hardware).
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Expose gpio::holdEnable / holdDisable (wrapping gpio_hold_en/dis) so a
parked pad's level can be latched, and arm gpio_deep_sleep_hold_en() in
sleep::deepSleepStart() so held pads retain their level through deep
sleep. Previously the facade could only release a hold (resetPin), so any
pin parked before sleep floated back to its reset default during sleep.
This is the primitive that keeps unused-peripheral control lines pinned
to a safe state under the sub-20 uA budget.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Extend the pytest QA the same way the IMU self-test was formalized:
- A dut.kbd_selftest() helper plus a KbdSelfTest dataclass and parse_kbd_selftest
parser in qa_lib/dut/debug_console.py (mirrors imu_selftest + parse_imu_selftest).
- A no-hardware parser unit test (test_kbd_selftest_parse.py) pinning the TCA8418
and LilyGo detail decoding over a MockDut.
- A keyboard-capability-gated test_kbd_selftest + test_kbd_snapshot_alive plus a
test_kbd_absent_graceful for the no-keyboard fleet (test_keyboard.py).
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
- test_keyboard_factory: classifyKeyboard pure decisions (all families),
createKeyboardController probe wiring over a fake internal-I2C bus (TCA8418
detected/absent, never-nullptr, stable singleton), and the concrete
Tca8418::selfTest over the fake bus (happy / write-mismatch / comms-fail).
- test_keyboard_selftest: the pure write-verified + FIFO-plausible helpers and
the NullKeyboardController "no keyboard" default.
- Register both suites in the native test_filter (learning from the IMU round,
which initially forgot this).
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Add the KBDRW (alias KBDSELFTEST) DEBUG_SERIAL_BUTTONS command mirroring IMURW:
it runs the keyboard controller's selfTest via board::get().keyboardController and
prints KBDRW ok=<0|1> detail=<line> state=<...>. On a no-keyboard board the detail
is "no keyboard" and ok=0. The existing KBD? snapshot is kept. Read-back only;
never prints key data. Documented in docs/testing/serial-debug-commands.md.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Wire the new chip abstraction into the runtime, mirroring BoardDef::imu:
- Tca8418 and LilygoKeyboard now implement IKeyboardController (init/isAvailable/
name) and add a comms + register read/write selfTest. The TCA8418 self-test
does a functional CFG read, a write-readback-with-restore on the unused COL8-9
debounce register, and a FIFO sanity read; the register-less LilyGo proves an
address ACK + benign read and notes it has no write path.
- BoardDef gains a mutable keyboardController slot, populated at HAL bootstrap by
createKeyboardController (local + T-Deck backends), exactly like imu.
- The CardputerKeyboard / TDeckKeyboard wrappers no longer own a concrete driver
instance: they consume board::get().keyboardController via IKeyboardController*.
The app-facing BoardDef::keyboard (KeyboardInput*) contract is unchanged, so the
Cardputer Fn layer + Backspace-as-BtnB and the T-Deck keymap behave identically.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Introduce a chip-level keyboard-controller abstraction that brings the keyboard
drivers up to the PMIC/RTC/IMU bar, mirroring the IMU factory work:
- IKeyboardController: a minimal common core (init/isAvailable/name/selfTest)
plus capability-style optional accessors, since the two chips do not share an
event model (TCA8418 raw matrix FIFO vs LilyGo decoded keycode + backlight).
- NullKeyboardController: the never-nullptr no-op returned on no-keyboard boards.
- keyboard_factory: createKeyboardController(address, intPin) probes the internal
I2C bus and returns the matching driver. Detection is honest: the TCA8418 has
no chip-id register (NXP datasheet), so identity is a KEYBOARD_TYPE_TCA8418-gated
address-ACK + functional CFG read; the register-less LilyGo C3 is a
KEYBOARD_TYPE_LILYGO-gated address ACK + benign read. classifyKeyboard is a
pure, natively testable decision seam; an un-probed family can never false-match.
- keyboard_selftest: pure write-verified + FIFO-plausible helpers.
- KEYBOARD_TYPE_TCA8418 / KEYBOARD_TYPE_LILYGO gating flags mirror IMU_TYPE_*.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Add the IMU self-test to the pytest hardware suite so every IMU board is
regression-gated on explicit comms + WHO_AM_I READ + register WRITE-readback +
gravity data-path, not ad-hoc serial checks. qa_lib gains an ImuSelfTest parser +
DebugConsole.imu_selftest() (drives the new IMURW command); test_imu.py gains
test_imu_selftest (imu-gated, asserts the write value was read back + chip/family
oracle + gravity window) and test_imu_absent_graceful (no-IMU boards report absent
with no false detection); a no-hardware parser unit test pins the parsing.
devices.yaml flips cardputer + core2_v13 to imu:true (both carry a BMI270), so the
imu-gated tests fan out to them under --dut all. HIL: all 6 IMU boards PASS
(cardputer@0x69, core2_v13, plus1/plus2/gray, sticks3 pending a PWR tap); the 3
no-IMU boards skip cleanly + pass the absent check.
Web research (docs.m5stack.com/en/core/Cardputer-Adv, the store/CNX spec tables)
+ an on-device I2C scan confirm the M5Cardputer ADV carries a BMI270 6-axis IMU;
the original Cardputer and v1.1 have none. The variant gated it off ('keep
disabled until the ADV BMI270 path is migrated and smoke-tested') — the createImu
probe factory is that migration. Enable HAS_IMU/IMU_TYPE_BMI270. The chip answers
at 0x69 (SDO strapped high), NOT the 0x68 default — IMU_TYPE_ADDR 0x69 — on the
shared SDA=G8/SCL=G9 bus alongside the TCA8418 keyboard (0x34) and ES8311 codec
(0x18), all coexisting without contention. HIL-validated: IMURW self-test PASSes
(BMI270 chipid=0x24, write-readback restored, gravity 1.02g), keyboard intact.
Add IImu::selfTest (default reports "no IMU"), implemented in the MPU6886 and
BMI270 drivers. It proves the full I2C path in three ordered phases: a WHO_AM_I
/ chip-id READ, a non-destructive save/write/verify/restore of a safe config
register (MPU6886 sample-rate divider 0x19; BMI270 the unused INT2_IO_CTRL pin
register 0x54, writable field bits only) to exercise the WRITE path without
lasting config drift, and an accel-magnitude data-path check for gravity.
Extract the pure pass/fail predicates into imu_selftest.h so both drivers share
them and the decision logic stays unit-testable off-hardware (the concrete
drivers cannot link natively — MPU6886 needs the RTOS facade, BMI270 the Bosch
API), mirroring the classifyImu seam. Add the DEBUG-gated IMURW/IMUSELFTEST
console command, native test coverage, and a MAC-verified QA HIL runner.
Web research (docs.m5stack.com/en/core/Core2_v1.3) + HIL confirm the Core2 v1.3
carries a BMI270 6-axis IMU at 0x68 — the v1.0 shipped an MPU6886, v1.3 upgraded
to the BMI270. The variant had HAS_IMU 0 with only a leftover IMU_I2C_ADDR, so the
on-board IMU went completely unused (shake-to-lock, motion features dead). Set
HAS_IMU 1 / IMU_TYPE_BMI270 1; the new createImu WHO_AM_I probe factory resolves it
with no board_*.cpp change. Interrupt left off (poll-only) as the BMI270 INT is not
wired to a free ESP32 GPIO on the AXP192-based Core2. HIL-validated on the real
board (MAC a4:f0:0f:df:bb:60): boot log 'BMI270 detected at 0x68', driver init OK,
live IMU? reads mag~0.985g + live gyro.
The imu-probe-factory branch added test/drivers/test_imu_factory but did not list
it in platformio.ini test_filter, so `pio test -e native` (and thus CI) skipped it.
Register it alongside the other drivers/ suites. Full native suite now 2038/2038
(was 2023 + the 15 imu_factory cases).
Bring the IMU up to the PMIC/RTC abstraction bar. Add
drivers/imu/imu_factory.{h,cpp}: createImu(address) probes the internal
I2C bus and returns the matching driver, mirroring createPmic/createRtc.
Detection reuses the drivers' identity constants: BMI270 chip-id 0x24 at
reg 0x00, MPU6886-family WHO_AM_I 0x19/0x68/0x71 at reg 0x75, and QMI8658
0x05 at reg 0x00 (recognized but unimplemented -> NullImu). A pure
classifyImu() carries the decision; concrete instantiation stays
compile-gated on the board's IMU_TYPE_* flags (the BMI270 Bosch backend
is only linkable where built). The factory never returns nullptr.
The IMU is now resolved once at HAL bootstrap and written into the
runtime-settable BoardDef::imu slot (mutable), so the ~12 board::get().imu
consumers are unchanged. Remove the per-board static IMU instantiation,
#include, and .imu wiring from all 9 board_*.cpp files.
Add test/drivers/test_imu_factory: exhaustive classifyImu family coverage
(all families incl. BMI270/MPU, off-hardware) plus createImu probe wiring
driven by a fake I2C bus (QMI8658/unknown/read-fail -> NullImu).
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Two CI-infra fixes (backlog item 4):
Representative build matrix. The device-build matrix compiled 6 near-overlapping
variants (12 jobs) yet MISSED the e-paper HAL and AXP2101 compile paths entirely.
Replace it with the 5 variants that cover every compile-time feature branch —
sticks3 (S3/M5PM1/ST7789-DMA/buttons/full debug console), tdeck (S3/keyboard/
touch/trackball/SD/GPS), cores3_se (S3/AXP2101/PSRAM), m5core_ink (classic-ESP32
xtensa + e-paper, 4 MB no-OTA), m5stack_gray (classic-ESP32 + ST7789 full-frame
polling). Repoint the design-token guard from m5stickc_plus2 to sticks3, and add
the missing install_idf602.sh step to the tag-only release job (same latent
UnknownPackageError the other jobs had).
Shared PlatformIO home cache. ~/.platformio (the espressif32 platform, the ~1.9 GB
ESP-IDF 6.0.2 framework, the toolchains) is identical for every variant, but each
matrix job cached its own ~2 GB copy under a per-env key — ~10 duplicates that
overflowed GitHub's 10 GB repo cache limit and caused intermittent cache-save
failures (e.g. the cardputer_debug Post-Cache flake). Cache it once under a shared
repo-scoped key used by every device-building job across ci/codeql/quality, with a
small per-variant .pio for build output. The host native job keeps its own key
(no device framework).
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
The fleet builds with CONFIG_COMPILER_CXX_EXCEPTIONS and _RTTI unset, so the
device firmware has no exception-unwind machinery. The native host-test build did
not disable them, so it emitted an unwind branch pair on every potentially-throwing
call — phantom conditions that do not exist on-device yet gcov counted as
uncovered, dragging branch coverage on otherwise fully-tested files (notably the
pure-declaration headers). No host test or host-compiled source uses throw/catch,
so aligning the flags is behaviour-neutral: the native suite still passes 2023/2023.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Make the host AppUpdateSession stub scriptable (fakeota:: knobs + spy,
default-disabled to the original begin-fails posture): beginOk,
writeFailAtCall, endOk plus call/byte counters. This lets applyFirmware()
run its full control flow on the host with mock image bytes.
Add files-suite tests for the OTA success loop (begin -> four chunked
writes -> set-boot, asserting the progress-callback stream reaches 100%)
and each failure branch (write fails at chunk K -> abort; set-boot fails
-> no abort). Also add the audit-log row short-write test (header ok, row
truncated -> exportAuditLog returns false) using the shared seam.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Add a default-disabled short-write fault-injection seam to the shared
in-memory file fake (g_shortWriteAfter, reset in fakefs::reset()): once
armed, File::write() commits one byte short and returns a short count so
callers guarded by `written != n` take their failure branch. Mirrors the
existing g_failOpenWriteAfter seam and leaves every other suite untouched.
Drive it from the sync suite to exercise copyLittleFsToSd() and
writeManifest()'s short-write branches (truncated SD copy + unreadable
manifest via exportVault, whose per-file failures are non-fatal) and
copySdToLittleFs()'s branch (stageImport aborts and purges its staging).
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Add two topical SdVault suites covering the large previously-untested surface:
- test_sd_vault_files: the text-file browser (isTextFile / listDir /
readTextFile), vault presence + manifest read/parse (valid, malformed,
oversized, missing) and getVaultIterations, ensureDirectories + formatCard,
wordlist access (has/path/lineCount/readLine incl. last-line-no-newline and
out-of-range), key-file 2FA (has/generate/read/delete with all guards), and
firmware detection + applyFirmware guard paths (no firmware / undersized /
OTA-begin failure). A test-controlled audit source drives exportAuditLog with
real rows.
- test_sd_vault_sync: exportVault (file copy + manifest + stale-dir clean + the
T4.3 keyfile co-location refusal), and the staged import pipeline
(stageImport / stagedImportPinLength / commitImport / abortImport and the
importVault one-shot), including wrong-PIN rejection with the live vault left
intact.
The OTA write success path and the copy short-write error paths are genuine
device hardware / not injectable through the in-memory fake and are left to
on-device QA.
sd_vault.cpp: line 47% -> 82%, branch 30% -> 54%.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Extend the two existing SdVault suites:
- CSV import: cover the KeePass / 1Password / Firefox / Generic format
detectors (incl. the title/login/user/email and website/uri column
aliases), the Bitwarden/1Password type-column gate (login vs non-login),
quoted fields with embedded commas and doubled-quote escapes, header
whitespace trimming, the Firefox URL-domain display name, missing-name and
full-vault skip paths, and hasCsvFiles presence/mount guards.
- Encrypted backup: cover hasEncryptedBackup, the null/zero-length passphrase
guards, the undersized/oversized envelope size-range rejections, and the
null-passphrase export guard.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Add test/i18n/test_i18n covering i18n.cpp (previously 0% coverage): a
mechanical tr() sweep over every StringId across every supported language
(catching a missing English entry or a regressed column), the tr() out-of-range
and empty-string fallback paths, active-language selection, setLang range
guard, and langCode/langName mapping including out-of-range fallbacks. The
i18n runtime and generated string pool are amalgam-included; the ESP_PLATFORM
NVS persistence is compiled out on the host so no NVS fake is needed.
i18n.cpp: line 0% -> 97%, branch 0% -> 90%.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Complete the round: add the 8-digit/60-second valid path plus the invalid-period
and empty-secret skip paths, exercising the second operand of each TOTP import
validation short-circuit the 6/30 round-trip never reached.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Add save/rebuild write-open failure branches (credential, TOTP, meta,
index) via the storage fault seam, null session-key rejections for
save/load/list TOTP, locked-session isFavorite, and the delete path where
the generation-bump meta write fails but the record is still unlinked.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Add export meta-only vs meta+bodies, unreadable-slot skip, and import
rejections (null meta, malformed JSON, unsupported version, meta wrong
size/bad format), out-of-range and invalid-secret skips, cross-device
round-trip restore, and full-replace slot purge.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Add STAGE/COMMIT/PROMOTE failure paths (commit-rename fail discards the
mutation, promote-rename fail rolls forward on next unlock), recover()
edges (bad-tag/short/wrong-version stage discard, torn marker), locked and
unlocked delete fallbacks, list skip of unloadable entries, and tampered
meta MAC rejection.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Add old-meta read failures (absent, wrong size, tampered MAC), per-slot
envelope rejection (bad version, tampered cipher, size/alignment), index
staging failure, empty-vault meta-only rekey, bulk multi-slot promotion,
roll-forward recovery edges, and first/later staging write-open failures.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Add argument guards, envelope/decoder rejection via crafted MAC-valid
indexes (bad version, count overflow, truncated row, trailing garbage),
stale-id eviction, rebuild-skip of undecryptable credentials, and the
rebuild/upsert write-open failure paths via the storage fault seam.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Add Writer/Reader overflow and truncated-decode branches for the binary
record codec; forge MAC-valid records to reach the strict credential and
TOTP decoders (bad plaintext version, field-length overflow, truncated
body, trailing garbage) plus pre-read size/alignment guards, listId cap,
and the write-open fail-closed path via the new storage fault seam.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Add out-of-phase no-op guards and null/oversize/mismatch branches for the
change-pin, credential-detail, and onboarding state machines, driving
their pure boolean decision points to near-full branch coverage.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Add zero-iteration fail-closed+zeroize checks across deriveKey /
deriveKeySoftware / deriveKeyMbedtls, a cross-backend byte-equality check
over a two-block (48-byte) PBKDF2 output exercising the final-block partial
take, and decryptWithContext freed-key and invalid-length rejection.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Add g_failOpenWriteAfter to the in-memory LittleFS harness, mirroring the
existing g_failRenameAfter power-loss seam: when >= 0 it fails the Nth
open() in a write mode, modeling a full or worn-out flash. This lets the
vault suites exercise the fail-closed error paths behind every
open-for-write failure (save, index write, rekey staging). Default -1
keeps every existing suite unaffected.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
The first de-flake of test_all_tracked_blocks_zeroed_on_deallocation still failed
on Linux CI (Expected 0x00 Was 0xA0): it freed all 2000 blocks and then re-acquired
them, but glibc consolidates a bulk of freed chunks and writes bin metadata (fd/bk
pointers — the 0xA0 low byte) beyond the first 16 bytes, into the payload tail the
test inspects. The wipe had run (no 0xCC survived), yet the allocator's own
bookkeeping tripped the assertion.
Switch to the single-block pattern already proven on this CI by
test_vault_backup_zeroize: free one block and re-acquire the same size immediately,
so the tcache returns that exact chunk with only its 16-byte link disturbed and the
tail still zeroed. Loop the cycle kLargeCount times — each cycle returns the heap to
the same state, exercising the wipe and the record()/forget() table churn without
the bulk-free metadata hazard.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
From Settings (vault unlocked) the device can join a saved WiFi network
as a station, sync the clock via one-shot SNTP against trusted public
NTP servers, and always disconnect. Servers are LITERAL IPv4 only —
never a hostname, never DNS (esp_sntp is fed parsed addresses; a strict
dotted-quad validator gates every entry). Defaults are Cloudflare
(162.159.200.1, 162.159.200.123), Google (216.239.35.0) and NIST
(132.163.97.1), tried in order; the user can persist an override list
of up to four IPs so dead defaults never require a firmware update.
Saved networks (SSID+PSK, max 8) and the NTP server list live in a new
wifi.bin vault module using the standard envelope (AES-256-CBC +
HMAC-SHA256, encrypt-then-MAC, verify-before-decrypt, type-bound), only
readable with the vault unlocked, staged through the crash-safe rekey,
and crypto-erased by wipeAll. PSKs are zeroized after use and never
logged or echoed.
The sync engine runs on a Core-0 worker: BLE off, join the strongest
saved network, query servers in order, sanity-clamp the epoch and never
step backward past an armed lockout, commit via the rtc facade with a
new display-only src=ntp tag — clock TRUST is never elevated (NTP is
unauthenticated; the forward-jump lockout fail-closed model is the
spoofing defense) — then a RAII station guard tears WiFi down and
restores BLE on every exit path. The station facade stays a generic
transport for future consumers (on-device OTA).
Debug console gains WIFISCAN / WIFIADD / WIFILIST / WIFIDEL, NTPSYNC
(GPSSYNC-style blocking flow with machine-checkable teardown acks) and
NTPSERVERS? / NTPSERVERSET / NTPSERVERCLEAR — all DEBUG_SERIAL_BUTTONS
gated. HIL-verified end to end on the StickS3: real sync against the
default servers, user-list precedence across reboot, TEST-NET failure
paths, trust unchanged, BLE restored, store left clean.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Add a debug-gated Component Gallery (GOTO GALLERY): a full-screen
showcase that renders each shared UI component on its own card and
advances on any input — buttons, touch zones, the CoreInk dial, and any
keyboard key or trackball event on the keyboard/trackball variants — so
the v2 look can be validated on every panel. Modeled on the HwTest
special mode and excluded from release/secure builds.
Add an AUDIO ON|OFF debug command that mutes/unmutes UI feedback and
persists the preference to NVS, so QA can silence a device without a
reflash.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
The status cluster drew the battery as "NN%" text; a full "100%" is
four characters and starved the title on the 135 px Stick header,
forcing an early ellipsis. Replace it with a fixed-width gauge glyph
(outline + terminal + proportional fill, tinted green while charging and
red at <=15%). The level reads at a glance from the fill and the title
gets the reclaimed width. Removes the now-unused percentage-formatting
helper.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
AA shapes did a read-modify-write against the CPU shadow framebuffer,
so the classic-ESP32 320x240 boards (m5stack_gray, core2_v13) that lack
one fell back to jagged primitives. Add a known-background back-end: when
the surface is not readable, blend each shape's edge coverage against a
canvas-tracked background color and write only the covered pixels via a
direct drawPixel. The readback path is preserved byte-for-byte, and the
canvas tracks the current background (set on fillScreen, overridable per
draw). Also raise the AA scanline scratch to the fleet's widest panel
(256 -> 320) — a rounded rect wider than 256 px silently vanished before.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
The CoreInk e-paper drew UI text with the crude 5x7 bitmap font because
the anti-aliased atlas path was gated off on monochrome panels. Enable
it: CoreInk now uses the same Inter / JetBrains Mono atlases as the color
fleet, mapping each glyph's coverage to ink/paper through an 8x8 Bayer
ordered dither (with a small weight bias so hairlines survive on e-ink)
instead of an RGB565 alpha blend. Sizes are raised to the e-ink
legibility floor (body 16, title 24) via a per-device mono floor in the
metrics resolver. The color alpha-blend path is unchanged and every
native-scale color glyph stays byte-identical; the bitmap font remains
compiled as a fallback.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Match the eight system icons to the approved handoff geometry
(docs/design/handoff/assets/icons): Settings becomes three sliders with
knobs (was a gear), and add Credential (ID card) and Totp (circular-
arrow timer, distinct from the plain clock); Lock, Key, Battery, Ble and
Warning are reshaped to the handoff strokes. Icons stay primitive-drawn
so they render backend-uniform on color TFT and 1-bit e-paper at zero
flash cost.
Switch the header brand mark to the ring-less keyhole glyph
(logo-glyph); the full ring mark stays for >=24 px branding (lock
screen, onboarding, about). Add native tests for the icon catalog and
the header mark.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
The v2 type ladder resolves the hero PIN digit and the TOTP data role
to sizes with no native atlas, so they were magnified from a smaller
rung (blurry). Add native sans48 / mono20 / mono48 atlases gated to the
Roomy 320x240 boards (gray, cores3_se, core2_v13) and link the existing
sans32 on the Compact Stick boards, so those scaled draws resample from
a near-native rung instead of upscaling. The role->atlas mapping is
unchanged, so every native-scale glyph stays byte-identical.
Regenerate the whole atlas set against the current i18n glyph subset
(the committed atlases had drifted to a stale subset) and add a
pre-commit font-atlas freshness guard so i18n or pipeline changes can no
longer silently diverge from the generated output. No partition
overflows; the tightest board (4 MB Plus1) sits at ~54%.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Bring the approved design handoff from the Claude Design project into
docs/design/handoff/: RGB565-safe design tokens with WCAG contrast
data, the logo set (full mark + solid glyph), the 8-icon system set,
and the seven 1:1 screen catalogs (brand identity, StickS3, CoreInk,
CoreS3/Core2 touch, Cardputer, T-Deck, Gray) that drive the UI v2
implementation.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Brings the SSD1681 fast-animation driver (5 fps bounded regions + 6-tone
grayscale), the pinned multicolor brand-logo pipeline with e-paper gray
families, photo-verified device fronts and measurements, and the Claude
Design context bundle.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Replace the stale bring-up-paused note: the SSD1681 (GDEW0154D67)
driver is hardware-verified with bounded fast region animation, 6-tone
static grayscale, and deep-clean support.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Define KLEIDOS_STREAM_TOKEN_TO_SERIAL in sticks3_debug so VIDEO START
logs the WEB-05 X-Stream-Token over the local USB console, making the
QA live-video stream drivable again over serial (debug builds only;
the token gate itself is unchanged).
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Self-contained design-brief package for the next-gen UI pass: README
brief with the phased workflow (identity, components, screens per
device, pixel-exact handoff), verified device inventory, the mined
interaction model (per-screen StickS3 button maps plus input-scaling
guidance), the implemented component catalog, design-system tokens and
constraint references, photo-verified true-scale device fronts, 29 live
StickS3 captures, three real-cadence UI videos (PIN timing arc, vault
navigation, letter carousel), brand identity, and the multicolor/gray
logo contact sheets.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Recompute the per-device legibility floors from the datasheet-verified
active areas and PPI (Stick 241 PPI, Core 199, T-Deck 141, Cardputer
241), and replace the blanket never-animate e-paper rule with the
validated CoreInk envelope: instant full-screen transitions plus a
bounded small-region ~5 fps exception for feedback content.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Overlay-check every front.svg against real device photos (owner photos
for sticks3/cores3-se/coreink/cardputer-adv/tdeck-plus, repo reference
photos for the rest) and correct the generator geometry: datasheet-true
active areas everywhere (Gray's LCD was drawn square), CoreS3 SE / Core2
Power and Reset moved to the owner-measured left-edge/bottom-edge spots,
Core2 touch dots at x 12/25/38.5 y 45, Gray's single Power/Reset at
13 mm on the left edge, all Stick screens at 4-5 mm below the case top,
CoreInk confirmed with no front dial (jog wheel on the right edge),
T-Deck chevrons pointing inward at the centered trackball, Plus2 BtnA
unified with Plus1's press-point geometry (identical chassis).
Reconcile measurements.json with the photo evidence (CoreInk origin_y,
Core2 origin_y, T-Deck converted to a case-top vertical datum) and drop
the obsolete non-Plus tdeck from the generator registry.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Replace the flat Simple Icons glyphs with true multicolor vector art,
resolved per brand through a pinned source chain (gilbarbara@commit,
svgl@commit, Wikimedia Commons with per-file licenses, upstream project
repos) by the new scripts/brands/curate_logos.py; 200/200 brands now
have an SVG source (4 flat simpleicons pins remain where no legitimate
vector exists). Fix 47 manifest entries whose 'domains' carried
Simple-Icons source-page domains instead of the brand's real domain
(live cosmetic bug: those brands fell back to the letter tile).
Add gray4/gray6 brand-atlas families for the e-paper (per-pixel Rec.601
luminance of the multicolor render, Bayer 8x8 ordered dithering) with
KLEIDOS_BRANDS_FAMILY 3/4 gating in CMake and check_variants, and
regenerate the full family x size matrix (color/mono/gray4/gray6 at
24/32/96/128/256). Exclude pinned brand SVGs from the mixed-line-ending
hook so vendored sources stay byte-exact with their sha256 pins.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Optimization round on the CoreInk fast-animation mode, all measured on
silicon via the EINKLUT live-tuning command (sweep waveform variants over
serial without reflashing):
- Fast partials run 0x22 = 0x8C: skipping the enable-analog stage (its
HV-ready detection costs ~91 ms even with the rails already up) cuts the
per-update fixed cost to ~94 ms. The stage is mandatory after full
refreshes, whose sequence powers the analog down, so the bank-settle pass
keeps 0xCC and doubles as the analog power-up (fastSettleSequence).
- Built-in waveform is the owner-picked quality-ladder point: 4 drive
frames at +/-17 V source voltage (VCOM/VGH stay on the panel's OTP trim)
plus a 1-frame reinforcement poke, the only ladder variant whose
animation trail self-cleans: 194 ms/update, 5.1 fps (from 503 ms / 1.5
fps at the start of the effort). BUSY poll tightened to 1 ms; the
post-update RAM rewrite proved redundant (read-back-verified) and the
gate-scan-window experiment proved frame period is FR-fixed, so neither
ships.
- ssd1681_lut.h: pure constexpr builders for the 153-byte waveform layout
(fast differential LUT with kick/burst/rest/poke knobs, graded grayscale
LUT, source-voltage register codes), the single source of truth shared
by the controller tables, EINKLUT and EINKGRAY, with a native host test
suite pinning the layout against the datasheet.
- 2-bit grayscale rendering (EpaperPanelDriver::renderGrayImage +
display::renderEpaperGray + EINKGRAY): both RAM planes select one of
four LUT slots per pixel, so graded drive lengths yield 4 tones in one
update or 6 tones (white + 4 grays + black) in two stacked updates —
owner-validated on the panel. The command holds the tones until serial
input (returning lets the FSM repaint and collapse them).
- SSD1681 register read-back (readPanelRegister over the shared SDA line):
the 0x2F Status Bit Read chip-ID bits fingerprint the controller in
silicon, settling that this CoreInk unit carries the D67/SSD1681 panel
revision, and SPIRD now works on e-paper. ANIMTEST gains TAG plus an
on-panel firmware-revision letter so tuning-ladder observers always know
which build/step they are watching.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Give the CoreInk (GDEW0154D67 / SSD1681) an LCD-like animation path: a
custom differential waveform LUT uploaded over 0x32 (1 inverse-kick frame
+ chopped 4/rest/3 drive bursts, ~20 ms/frame at FR 2) driven through a
new EpaperPanelDriver::setFastPartial() mode that keeps the analog rails
on between updates (0x22 = 0xCC) and consolidates with a quality refresh
on exit. Partial updates drop from ~503 ms (OTP waveform, full-panel
flash every 8th) to a steady ~370 ms with no flashes: 1.5 -> 2.7 fps.
Correctness against the controller's real RAM model, verified by reading
the panel RAM back over SPI (0x41/0x27, half-duplex three-wire on the
shared SDA line):
- Mode-2 display updates ping-pong two B/W RAM banks; window-limited RAM
writes leave stale frames in the alternate bank that the differential
waveform re-drives as solid ghosts. Every update now reloads the full
B/W plane and rewrites it after the update; settleAlternateRamBank()
primes the alternate bank on fast-mode entry and after full refreshes.
- The controller maintains its old-image (RED) plane itself; the driver
never writes it. Analog calibration (VCOM/VGH/VSH/VSL) stays at the
panel's per-unit OTP values instead of generic overrides.
Fix aa::available() returning true on e-paper: coverage blending through
the thresholding 1 bpp store left fringe rings on every erase; e-paper
now always uses the crisp primitives, as documented at the call sites.
Debug console additions: ANIMTEST [frames|LOOP] [FAST] [HOLD] [DUMP]
[FILM] (timed bouncing-ball animation with per-frame flush latency,
endless mode with periodic deghost cleans, frozen-state and per-frame
panel-RAM dumps), EINKCLEAN [cycles] (full-panel deghosting flashes) and
EINKDUMP (base64 dump of panel BW/old RAM + CPU frame planes), plus the
display::setFastPartial()/readEpaperRam() e-paper facade hooks.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
- debug_console.py: TrkSelfTest dataclass, parse_trk_selftest, and the
dut.trk_selftest() helper (device token, pin mapping, click level, ISR-armed
state, PASS/FAIL) mirroring kbd_selftest/imu_selftest.
- test_trk_selftest_parse.py: no-hardware parser unit tests over a MockDut
(quadrature PASS, click-held informational, absent, not-armed FAIL, boot
noise, missing line).
- test_trackball.py: trackball-capability-gated self-test (PASS, device match,
ISRs armed — the load-bearing input-path evidence for a device whose motion
cannot be synthesized) plus an absent-graceful check for the rest of the
fleet. Motion is intentionally not driven — it needs a physical finger.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Mirror the keyboard suites, adapted to a passive device whose concrete driver
cannot link natively (its edge-ISR critical sections need FreeRTOS), exactly
like the BMI270:
- test_trackball_factory: the constexpr classifier decisions, the pin-validity
check that stands in for a passive device's impossible address probe, and the
factory wiring — with no family compiled it resolves the never-null
NullTrackball with an inert transport surface (amalgam-includes the factory).
- test_trackball_selftest: the pure pass/fail helpers (drain coherence,
informational click-idle level, overall gate — happy/absent) and the
NullTrackball "no trackball" default.
Both registered in the native test_filter. The concrete QuadratureTrackball
comms-free self-test is validated on-silicon by TRKRW via HIL.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Mirror IMURW/KBDRW: TRKRW (alias TRKSELFTEST, DEBUG_SERIAL_BUTTONS-gated) runs
the driver's passive-device self-test and prints the detail line plus PASS/FAIL.
A trackball has no bus/registers/identity and its motion needs a finger, so the
command verifies only what is verifiable without touch — pins configured, edge
ISRs armed, click line readable, accumulators drain coherently — and states so
in the detail string, e.g.
TRKRW ok=1 detail=QuadTrackball pins=3/15/1/2/0 lvl=1 isr=armed (motion needs touch) PASS
On a no-trackball board it reports "no trackball"/ok=0 without a crash. The
existing TRACKBALL? snapshot is retained. Documented in serial-debug-commands.md.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
The HAL wrapper now keeps only the app-seam policy — turning the driver's raw
quadrature snapshot into a clamped TrackballEvent (delta arithmetic, velocity
interval, center-click edge detection) — and consumes the pin-level driver via
ITrackball*. All baremetal (GPIO config, edge ISRs, accumulators) moved to
drivers/trackball/QuadratureTrackball. Trackball navigation on the T-Deck is
behavior-preserving: scroll, click and velocity feel are unchanged.
- board_def.h: add the mutable trackballDriver (ITrackball*) slot, resolved at
HAL bootstrap, mirroring keyboardController; the app-facing TrackballInput
slot is unchanged.
- hal_bootstrap_{tdeck,local}.cpp: resolve trackballDriver via the flag-gated
createTrackball factory on every board (NullTrackball where absent, so the
slot is never null and TRKRW/TRACKBALL? report absent gracefully), then init
it before the wrapper binds.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Bring the trackball to the same device-driver structure as PMIC/RTC/IMU/
keyboard. A trackball is a passive GPIO device — no bus, registers, or chip
identity — so the layer is honest about that: it cannot be electrically
probed, and its motion needs a physical finger.
- i_trackball.h: device-level ITrackball contract (init/isAvailable/name,
drainSteps raw quadrature snapshot, isClickDown, inject, selfTest) + the
QuadratureSteps transport struct.
- quadrature_trackball.{h,cpp}: generic four-direction + click GPIO quadrature
driver parameterized on its pin mapping (the T-Deck is one mapping). Holds
the falling-edge IRAM ISRs, ISR-resident step accumulators and velocity
timestamps (a hardware singleton), moved verbatim from the HAL wrapper so
ISR discipline and velocity math are unchanged.
- trackball_factory.{h,cpp}: flag-gated createTrackball (TRACKBALL_TYPE_*,
like the LilyGo keyboard leg) with a natively-testable constexpr classifier
and pin-validity check standing in for the impossible address probe; returns
the shared NullTrackball otherwise.
- trackball_selftest.h: pure, hardware-free pass/fail helpers (drain coherence,
informational click-idle level, overall gate) — the passive-device analogue
of imu_selftest.h / keyboard_selftest.h.
- null_trackball.h: never-null no-op driver for boards without a trackball.
- TRACKBALL_TYPE_QUADRATURE flag default (platform_defaults.h) + T-Deck enable;
drv_trackball Doxygen group.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
SLEEPTEST deliberately bypasses PowerManager::enterDeepSleep(), so the
new T-Deck hold path (rail-off latch, LoRa/ES7210 parking) had no
serial-reachable wake for HIL — the production wake source is a physical
key. The optional FULL argument arms the timer wake first and then
enters sleep through the per-board enterDeepSleep(), exercising the real
teardown while remaining remotely recoverable (ESP wake sources
accumulate). GPIO-power boards only; a PMIC VDD cut would defeat the
timer, as documented.
T-Deck HIL evidence (SLEEPTEST 15000 FULL): SYS reports
reason=DEEPSLEEP wake=TIMER from_deep_sleep=1, and post-wake the ES7210
registers re-read the power-down state (0x01=0x7F 0x06=0x07 0x40=0xC0
0x4B/0x4C=0xFF) — the holds released on wake, the rail re-powered, and
the bootstrap re-asserted the parking.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
- Gray (IP5306): test_battery_voltage_plausible asserted a LiPo voltage
range on every DUT, but the IP5306 has no battery-voltage ADC — the
backend structurally reports 0 mV. Key the assertion on the PMIC model
and assert the structural 0 for IP5306 (HIL: model=IP5306 batt_pct=100
charging=1, batt_mv=0).
- AXP backend: the boot-time PMIC probe can transiently NACK right after
a serial-open DTR/RTS reset, leaving the whole boot with no PMIC
(model=none, 0 readings) — observed intermittently on core2_v13 and
plus1. Add a bounded lazy re-probe (one retry on first telemetry read)
so a single missed probe no longer costs the boot's telemetry. Verified
on core2_v13: 3 consecutive full power-QA runs, 5/5 each.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
- Emit a boot log line when the Cardputer IR emitter is parked OFF
(GPIO44=0), giving on-silicon evidence in the absence of a GPIO-read
console command. HIL-confirmed: "IR emitter parked OFF (GPIO44=0)".
- Drop the redundant battery voltage/percent assertions from
test_battery_telemetry_matches_pmic_capability (the dedicated tests
above already cover them) and keep only the PMIC-capability-keyed
current / VBUS-current / die-temperature checks. This also makes the
test robust to a transient PMIC-probe miss (model=none, 0 reading),
which is a reset artifact, not a telemetry-capability regression.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
On-silicon (T-Deck, ES7210 at 0x40) the leading RESET (0x00=0xFF) held
the codec's digital core in reset, so the follow-up clock-off / analog /
power-down register writes never latched — I2CRD read 0x01=0x20,
0x06=0x00, 0x40=0x80 (reset defaults). Follow the authoritative ESP-ADF
es7210_stop() sequence with no reset: the mic-power, analog, clock-gate,
and power-down registers now latch and read back 0x01=0x7F, 0x06=0x07,
0x40=0xC0, 0x4B/0x4C=0xFF — a verifiable power-down state.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Extend the HIL power suite so each board's advertised battery data is
asserted capability-appropriately (wide bounds, never exact values):
- test_power.py: a PMIC-model-keyed test — AXP192 must return plausible
battery-current / VBUS-current / die-temperature; AXP2101 a die-temp
but batt_ma=na (no battery-current ADC); IP5306 (Gray) skips the
voltage assertion (no voltage ADC, structurally 0); M5PM1 / plain-ADC
assert percent + voltage only.
- test_pmic_accessors.py: assert batt_ma is a plausible number on AXP192
and the "na" literal on AXP2101 / M5PM1 / IP5306 / ADC boards, covering
the refactored axp192BatteryCurrentMilliamps path end-to-end.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
- Override pmicName() on the StickS3 M5PM1 backend so RES?/PMIC?
report "M5PM1" instead of "none"; the chip has no current/VBUS/temp/
coulomb ADCs, so percent + millivolts + charging remain its ceiling.
- Extract the AXP192 battery-current, VBUS-current, and VBUS-voltage
conversions (and the 13-bit discharge-ADC assembly) out of axp192.cpp
into pure detail:: helpers in pmic_common.h, completing the existing
decode-helper pattern, and cover them with native tests. The
arithmetic is unchanged; the AXP192 accessor math was previously the
only telemetry decode with no off-hardware test.
Audit note: the four ADC-battery boards (Plus2, CoreInk, Cardputer,
T-Deck) expose no charge/VBUS-sense GPIO, so isCharging() stays
false (unavailable) by hardware; IP5306 (Gray) and AXP2101 already
surface everything their silicon offers.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Kleidos never records audio or transmits IR, but those peripherals power
up on their boards and draw idle current. Hold them off:
- T-Deck: force the unused ES7210 mic-array ADC into I2C power-down at
bootstrap (probes 0x40 first; touches only its own address so the
shared keyboard/touch bus is undisturbed) and park its clock lines and
the reserved external-I2S pins as pulled-down inputs. MCLK gating alone
is not enough — the ES7210 analog front end stays biased until the
power-down registers are written.
- T-Deck deep sleep: re-assert LoRa RST low / CS high and latch the
peripheral-rail-off and radio-control levels with gpio::holdEnable so
they survive sleep; hal::begin() releases the holds on the next boot
before reconfiguring the pads.
- Cardputer: drive the unused IR LED (G44, active-high) to its inactive
LOW level and hold it through deep sleep. The ES8311 codec is never
instantiated and sits in its power-on-default mic-path-down state, so
it needs no action.
New ES7210 power-down driver and PIN_ES7210_*/PIN_I2S_*/PIN_IR_TX
device-config plumbing (default -1 on boards without the hardware).
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Expose gpio::holdEnable / holdDisable (wrapping gpio_hold_en/dis) so a
parked pad's level can be latched, and arm gpio_deep_sleep_hold_en() in
sleep::deepSleepStart() so held pads retain their level through deep
sleep. Previously the facade could only release a hold (resetPin), so any
pin parked before sleep floated back to its reset default during sleep.
This is the primitive that keeps unused-peripheral control lines pinned
to a safe state under the sub-20 uA budget.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Extend the pytest QA the same way the IMU self-test was formalized:
- A dut.kbd_selftest() helper plus a KbdSelfTest dataclass and parse_kbd_selftest
parser in qa_lib/dut/debug_console.py (mirrors imu_selftest + parse_imu_selftest).
- A no-hardware parser unit test (test_kbd_selftest_parse.py) pinning the TCA8418
and LilyGo detail decoding over a MockDut.
- A keyboard-capability-gated test_kbd_selftest + test_kbd_snapshot_alive plus a
test_kbd_absent_graceful for the no-keyboard fleet (test_keyboard.py).
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
- test_keyboard_factory: classifyKeyboard pure decisions (all families),
createKeyboardController probe wiring over a fake internal-I2C bus (TCA8418
detected/absent, never-nullptr, stable singleton), and the concrete
Tca8418::selfTest over the fake bus (happy / write-mismatch / comms-fail).
- test_keyboard_selftest: the pure write-verified + FIFO-plausible helpers and
the NullKeyboardController "no keyboard" default.
- Register both suites in the native test_filter (learning from the IMU round,
which initially forgot this).
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Add the KBDRW (alias KBDSELFTEST) DEBUG_SERIAL_BUTTONS command mirroring IMURW:
it runs the keyboard controller's selfTest via board::get().keyboardController and
prints KBDRW ok=<0|1> detail=<line> state=<...>. On a no-keyboard board the detail
is "no keyboard" and ok=0. The existing KBD? snapshot is kept. Read-back only;
never prints key data. Documented in docs/testing/serial-debug-commands.md.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Wire the new chip abstraction into the runtime, mirroring BoardDef::imu:
- Tca8418 and LilygoKeyboard now implement IKeyboardController (init/isAvailable/
name) and add a comms + register read/write selfTest. The TCA8418 self-test
does a functional CFG read, a write-readback-with-restore on the unused COL8-9
debounce register, and a FIFO sanity read; the register-less LilyGo proves an
address ACK + benign read and notes it has no write path.
- BoardDef gains a mutable keyboardController slot, populated at HAL bootstrap by
createKeyboardController (local + T-Deck backends), exactly like imu.
- The CardputerKeyboard / TDeckKeyboard wrappers no longer own a concrete driver
instance: they consume board::get().keyboardController via IKeyboardController*.
The app-facing BoardDef::keyboard (KeyboardInput*) contract is unchanged, so the
Cardputer Fn layer + Backspace-as-BtnB and the T-Deck keymap behave identically.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Introduce a chip-level keyboard-controller abstraction that brings the keyboard
drivers up to the PMIC/RTC/IMU bar, mirroring the IMU factory work:
- IKeyboardController: a minimal common core (init/isAvailable/name/selfTest)
plus capability-style optional accessors, since the two chips do not share an
event model (TCA8418 raw matrix FIFO vs LilyGo decoded keycode + backlight).
- NullKeyboardController: the never-nullptr no-op returned on no-keyboard boards.
- keyboard_factory: createKeyboardController(address, intPin) probes the internal
I2C bus and returns the matching driver. Detection is honest: the TCA8418 has
no chip-id register (NXP datasheet), so identity is a KEYBOARD_TYPE_TCA8418-gated
address-ACK + functional CFG read; the register-less LilyGo C3 is a
KEYBOARD_TYPE_LILYGO-gated address ACK + benign read. classifyKeyboard is a
pure, natively testable decision seam; an un-probed family can never false-match.
- keyboard_selftest: pure write-verified + FIFO-plausible helpers.
- KEYBOARD_TYPE_TCA8418 / KEYBOARD_TYPE_LILYGO gating flags mirror IMU_TYPE_*.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Add the IMU self-test to the pytest hardware suite so every IMU board is
regression-gated on explicit comms + WHO_AM_I READ + register WRITE-readback +
gravity data-path, not ad-hoc serial checks. qa_lib gains an ImuSelfTest parser +
DebugConsole.imu_selftest() (drives the new IMURW command); test_imu.py gains
test_imu_selftest (imu-gated, asserts the write value was read back + chip/family
oracle + gravity window) and test_imu_absent_graceful (no-IMU boards report absent
with no false detection); a no-hardware parser unit test pins the parsing.
devices.yaml flips cardputer + core2_v13 to imu:true (both carry a BMI270), so the
imu-gated tests fan out to them under --dut all. HIL: all 6 IMU boards PASS
(cardputer@0x69, core2_v13, plus1/plus2/gray, sticks3 pending a PWR tap); the 3
no-IMU boards skip cleanly + pass the absent check.
Web research (docs.m5stack.com/en/core/Cardputer-Adv, the store/CNX spec tables)
+ an on-device I2C scan confirm the M5Cardputer ADV carries a BMI270 6-axis IMU;
the original Cardputer and v1.1 have none. The variant gated it off ('keep
disabled until the ADV BMI270 path is migrated and smoke-tested') — the createImu
probe factory is that migration. Enable HAS_IMU/IMU_TYPE_BMI270. The chip answers
at 0x69 (SDO strapped high), NOT the 0x68 default — IMU_TYPE_ADDR 0x69 — on the
shared SDA=G8/SCL=G9 bus alongside the TCA8418 keyboard (0x34) and ES8311 codec
(0x18), all coexisting without contention. HIL-validated: IMURW self-test PASSes
(BMI270 chipid=0x24, write-readback restored, gravity 1.02g), keyboard intact.
Add IImu::selfTest (default reports "no IMU"), implemented in the MPU6886 and
BMI270 drivers. It proves the full I2C path in three ordered phases: a WHO_AM_I
/ chip-id READ, a non-destructive save/write/verify/restore of a safe config
register (MPU6886 sample-rate divider 0x19; BMI270 the unused INT2_IO_CTRL pin
register 0x54, writable field bits only) to exercise the WRITE path without
lasting config drift, and an accel-magnitude data-path check for gravity.
Extract the pure pass/fail predicates into imu_selftest.h so both drivers share
them and the decision logic stays unit-testable off-hardware (the concrete
drivers cannot link natively — MPU6886 needs the RTOS facade, BMI270 the Bosch
API), mirroring the classifyImu seam. Add the DEBUG-gated IMURW/IMUSELFTEST
console command, native test coverage, and a MAC-verified QA HIL runner.
Web research (docs.m5stack.com/en/core/Core2_v1.3) + HIL confirm the Core2 v1.3
carries a BMI270 6-axis IMU at 0x68 — the v1.0 shipped an MPU6886, v1.3 upgraded
to the BMI270. The variant had HAS_IMU 0 with only a leftover IMU_I2C_ADDR, so the
on-board IMU went completely unused (shake-to-lock, motion features dead). Set
HAS_IMU 1 / IMU_TYPE_BMI270 1; the new createImu WHO_AM_I probe factory resolves it
with no board_*.cpp change. Interrupt left off (poll-only) as the BMI270 INT is not
wired to a free ESP32 GPIO on the AXP192-based Core2. HIL-validated on the real
board (MAC a4:f0:0f:df:bb:60): boot log 'BMI270 detected at 0x68', driver init OK,
live IMU? reads mag~0.985g + live gyro.
Bring the IMU up to the PMIC/RTC abstraction bar. Add
drivers/imu/imu_factory.{h,cpp}: createImu(address) probes the internal
I2C bus and returns the matching driver, mirroring createPmic/createRtc.
Detection reuses the drivers' identity constants: BMI270 chip-id 0x24 at
reg 0x00, MPU6886-family WHO_AM_I 0x19/0x68/0x71 at reg 0x75, and QMI8658
0x05 at reg 0x00 (recognized but unimplemented -> NullImu). A pure
classifyImu() carries the decision; concrete instantiation stays
compile-gated on the board's IMU_TYPE_* flags (the BMI270 Bosch backend
is only linkable where built). The factory never returns nullptr.
The IMU is now resolved once at HAL bootstrap and written into the
runtime-settable BoardDef::imu slot (mutable), so the ~12 board::get().imu
consumers are unchanged. Remove the per-board static IMU instantiation,
#include, and .imu wiring from all 9 board_*.cpp files.
Add test/drivers/test_imu_factory: exhaustive classifyImu family coverage
(all families incl. BMI270/MPU, off-hardware) plus createImu probe wiring
driven by a fake I2C bus (QMI8658/unknown/read-fail -> NullImu).
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Two CI-infra fixes (backlog item 4):
Representative build matrix. The device-build matrix compiled 6 near-overlapping
variants (12 jobs) yet MISSED the e-paper HAL and AXP2101 compile paths entirely.
Replace it with the 5 variants that cover every compile-time feature branch —
sticks3 (S3/M5PM1/ST7789-DMA/buttons/full debug console), tdeck (S3/keyboard/
touch/trackball/SD/GPS), cores3_se (S3/AXP2101/PSRAM), m5core_ink (classic-ESP32
xtensa + e-paper, 4 MB no-OTA), m5stack_gray (classic-ESP32 + ST7789 full-frame
polling). Repoint the design-token guard from m5stickc_plus2 to sticks3, and add
the missing install_idf602.sh step to the tag-only release job (same latent
UnknownPackageError the other jobs had).
Shared PlatformIO home cache. ~/.platformio (the espressif32 platform, the ~1.9 GB
ESP-IDF 6.0.2 framework, the toolchains) is identical for every variant, but each
matrix job cached its own ~2 GB copy under a per-env key — ~10 duplicates that
overflowed GitHub's 10 GB repo cache limit and caused intermittent cache-save
failures (e.g. the cardputer_debug Post-Cache flake). Cache it once under a shared
repo-scoped key used by every device-building job across ci/codeql/quality, with a
small per-variant .pio for build output. The host native job keeps its own key
(no device framework).
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
The fleet builds with CONFIG_COMPILER_CXX_EXCEPTIONS and _RTTI unset, so the
device firmware has no exception-unwind machinery. The native host-test build did
not disable them, so it emitted an unwind branch pair on every potentially-throwing
call — phantom conditions that do not exist on-device yet gcov counted as
uncovered, dragging branch coverage on otherwise fully-tested files (notably the
pure-declaration headers). No host test or host-compiled source uses throw/catch,
so aligning the flags is behaviour-neutral: the native suite still passes 2023/2023.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Make the host AppUpdateSession stub scriptable (fakeota:: knobs + spy,
default-disabled to the original begin-fails posture): beginOk,
writeFailAtCall, endOk plus call/byte counters. This lets applyFirmware()
run its full control flow on the host with mock image bytes.
Add files-suite tests for the OTA success loop (begin -> four chunked
writes -> set-boot, asserting the progress-callback stream reaches 100%)
and each failure branch (write fails at chunk K -> abort; set-boot fails
-> no abort). Also add the audit-log row short-write test (header ok, row
truncated -> exportAuditLog returns false) using the shared seam.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Add a default-disabled short-write fault-injection seam to the shared
in-memory file fake (g_shortWriteAfter, reset in fakefs::reset()): once
armed, File::write() commits one byte short and returns a short count so
callers guarded by `written != n` take their failure branch. Mirrors the
existing g_failOpenWriteAfter seam and leaves every other suite untouched.
Drive it from the sync suite to exercise copyLittleFsToSd() and
writeManifest()'s short-write branches (truncated SD copy + unreadable
manifest via exportVault, whose per-file failures are non-fatal) and
copySdToLittleFs()'s branch (stageImport aborts and purges its staging).
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Add two topical SdVault suites covering the large previously-untested surface:
- test_sd_vault_files: the text-file browser (isTextFile / listDir /
readTextFile), vault presence + manifest read/parse (valid, malformed,
oversized, missing) and getVaultIterations, ensureDirectories + formatCard,
wordlist access (has/path/lineCount/readLine incl. last-line-no-newline and
out-of-range), key-file 2FA (has/generate/read/delete with all guards), and
firmware detection + applyFirmware guard paths (no firmware / undersized /
OTA-begin failure). A test-controlled audit source drives exportAuditLog with
real rows.
- test_sd_vault_sync: exportVault (file copy + manifest + stale-dir clean + the
T4.3 keyfile co-location refusal), and the staged import pipeline
(stageImport / stagedImportPinLength / commitImport / abortImport and the
importVault one-shot), including wrong-PIN rejection with the live vault left
intact.
The OTA write success path and the copy short-write error paths are genuine
device hardware / not injectable through the in-memory fake and are left to
on-device QA.
sd_vault.cpp: line 47% -> 82%, branch 30% -> 54%.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Extend the two existing SdVault suites:
- CSV import: cover the KeePass / 1Password / Firefox / Generic format
detectors (incl. the title/login/user/email and website/uri column
aliases), the Bitwarden/1Password type-column gate (login vs non-login),
quoted fields with embedded commas and doubled-quote escapes, header
whitespace trimming, the Firefox URL-domain display name, missing-name and
full-vault skip paths, and hasCsvFiles presence/mount guards.
- Encrypted backup: cover hasEncryptedBackup, the null/zero-length passphrase
guards, the undersized/oversized envelope size-range rejections, and the
null-passphrase export guard.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Add test/i18n/test_i18n covering i18n.cpp (previously 0% coverage): a
mechanical tr() sweep over every StringId across every supported language
(catching a missing English entry or a regressed column), the tr() out-of-range
and empty-string fallback paths, active-language selection, setLang range
guard, and langCode/langName mapping including out-of-range fallbacks. The
i18n runtime and generated string pool are amalgam-included; the ESP_PLATFORM
NVS persistence is compiled out on the host so no NVS fake is needed.
i18n.cpp: line 0% -> 97%, branch 0% -> 90%.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Add save/rebuild write-open failure branches (credential, TOTP, meta,
index) via the storage fault seam, null session-key rejections for
save/load/list TOTP, locked-session isFavorite, and the delete path where
the generation-bump meta write fails but the record is still unlinked.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Add export meta-only vs meta+bodies, unreadable-slot skip, and import
rejections (null meta, malformed JSON, unsupported version, meta wrong
size/bad format), out-of-range and invalid-secret skips, cross-device
round-trip restore, and full-replace slot purge.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Add STAGE/COMMIT/PROMOTE failure paths (commit-rename fail discards the
mutation, promote-rename fail rolls forward on next unlock), recover()
edges (bad-tag/short/wrong-version stage discard, torn marker), locked and
unlocked delete fallbacks, list skip of unloadable entries, and tampered
meta MAC rejection.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Add old-meta read failures (absent, wrong size, tampered MAC), per-slot
envelope rejection (bad version, tampered cipher, size/alignment), index
staging failure, empty-vault meta-only rekey, bulk multi-slot promotion,
roll-forward recovery edges, and first/later staging write-open failures.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Add argument guards, envelope/decoder rejection via crafted MAC-valid
indexes (bad version, count overflow, truncated row, trailing garbage),
stale-id eviction, rebuild-skip of undecryptable credentials, and the
rebuild/upsert write-open failure paths via the storage fault seam.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Add Writer/Reader overflow and truncated-decode branches for the binary
record codec; forge MAC-valid records to reach the strict credential and
TOTP decoders (bad plaintext version, field-length overflow, truncated
body, trailing garbage) plus pre-read size/alignment guards, listId cap,
and the write-open fail-closed path via the new storage fault seam.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Add zero-iteration fail-closed+zeroize checks across deriveKey /
deriveKeySoftware / deriveKeyMbedtls, a cross-backend byte-equality check
over a two-block (48-byte) PBKDF2 output exercising the final-block partial
take, and decryptWithContext freed-key and invalid-length rejection.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
Add g_failOpenWriteAfter to the in-memory LittleFS harness, mirroring the
existing g_failRenameAfter power-loss seam: when >= 0 it fails the Nth
open() in a write mode, modeling a full or worn-out flash. This lets the
vault suites exercise the fail-closed error paths behind every
open-for-write failure (save, index write, rekey staging). Default -1
keeps every existing suite unaffected.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4
The first de-flake of test_all_tracked_blocks_zeroed_on_deallocation still failed
on Linux CI (Expected 0x00 Was 0xA0): it freed all 2000 blocks and then re-acquired
them, but glibc consolidates a bulk of freed chunks and writes bin metadata (fd/bk
pointers — the 0xA0 low byte) beyond the first 16 bytes, into the payload tail the
test inspects. The wipe had run (no 0xCC survived), yet the allocator's own
bookkeeping tripped the assertion.
Switch to the single-block pattern already proven on this CI by
test_vault_backup_zeroize: free one block and re-acquire the same size immediately,
so the tcache returns that exact chunk with only its 16-byte link disturbed and the
tail still zeroed. Loop the cycle kLargeCount times — each cycle returns the heap to
the same state, exercising the wipe and the record()/forget() table churn without
the bulk-free metadata hazard.
Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4