Hardware profiles and optional services¶
ESP-IDF remains pinned to v6.1. ESP32 and ESP32-S3 have separate build profiles and SDK configuration files. Cross-builds establish compilation and image fit; mounted motion, electrical behaviour, optical dithering and radio reliability require acceptance on the exact hardware.
ESP32-C6 is no longer a supported target. AtomS3 Lite is the reference for a possible v1.1; the experimental v2 profiles use ESP32-S3 with the fixed WROOM GPIO map. ESP32-S31 remains a future experiment without a firmware build profile. Any port must qualify pins, driver capabilities, LED timing and OTA image identity separately.
Build and configure¶
| Board | Preset | First-boot ear topology |
|---|---|---|
| Original Atom Matrix | normal |
18 per ear, 12 long-edge pixels |
| AtomS3 Lite | s3-normal |
18 per ear, 12 long-edge pixels; both unreversed for short-edge DIN |
| AtomS3R | atoms3r-normal |
18 per ear, 12 long-edge pixels; onboard BMI270 and optional ST7735 status screen |
| Custom S3 | custom-s3-normal |
50 per ear with default WS2812, configure pins |
| ESPurr v2 WROOM-1-N16R8 | v2-normal (experimental) |
Fixed 69 per ear; four chains 48/21/48/21 |
| ESPurr v2 WROOM-1-N8 | v2-8mb (experimental) |
Same fixed geometry/pins; PDM on, contact audio and PSRAM off by default |
All profiles use WS2812 output; APA102 support has been retired. Existing SDK configs that still select APA102 are rejected before regeneration: retain that config and configure a separate build for actual WS2812 hardware. Saved topology settings retain precedence; changing first-boot defaults does not resize an existing assembly's saved counts or long edges.
The AtomS3 Lite v1 profile uses 18 active pixels per ear.
For the v1 WS2812 ears, physical DIN starts at the short-edge baseline: 6 pixels
up the short edge, then 12 down the long edge. Logical order follows that chain directly; outer_pixels=12 remains the long-edge
count. The v1 reverse switch is an optional output transform; v2 has no switch. Each ear's saved count,
long-edge split and direction remain independently editable.
idf.py --preset s3-normal menuconfig
idf.py --preset s3-normal build
Firmware outputs use build/firmware/<preset>; profiles retain their existing
ignored root-level SDK configuration files. The classic preset retains sdkconfig.
Never use set-target, delete configs or erase NVS to switch between these presets.
Saved runtime settings take precedence over first-boot defaults. Target-specific
dependency locks retain exact managed dependency versions; dependencies.lock
remains the classic-ESP32 lock.
The v1 controller upgrade now uses atoms3r-normal, with the internal BMI270
on SDA45/SCL0 and the existing GPIO2/1 Grove ear cable. The former Lite/MPU6500
local config remains separate. atoms3r-recovery is the matching module-only
recovery build. See AtomS3R hardware, status screen and bring-up.
For v2 optional services, run idf.py --preset v2-normal menuconfig and open
Espurr cat ears:
| Option | Build-time behaviour |
|---|---|
Audio capture, diagnostics and sound effects — ESPURR_AUDIO |
Off omits capture tasks/receivers, PCM processing and recording. Sound bands remains a stable catalogue identity but reports unavailable support; local effect cycling skips it. |
Dual PDM microphones and sound-reactive lighting — ESPURR_AUDIO_PDM |
Audio sub-option; off omits PDM receiver setup and spectral processing. Independent of contact capture, VAD and recognition. |
BX TDM contact-audio capture — ESPURR_AUDIO_CONTACT |
Audio sub-option requiring the BX IMU; off omits the TDM receiver path. Enables diagnostic capture only, not VAD/recognition. |
Ambient light and ear sensing service — ESPURR_SENSING |
Off omits the shared sensing task and all ALS/AW96105 sampling. |
Ear touch/proximity sampling — ESPURR_TOUCH |
Sub-option of sensing; off omits AW96105 handles/state/register traffic while keeping ALS sampling. Ear diagnostics report unsupported. |
The master switches and both source switches default on for v2 where supported,
preserving existing behaviour; v2-8mb explicitly defaults contact capture off.
They are unavailable on legacy profiles. Audio sources still start off and need explicit
effect or diagnostic demand. These switches are firmware build choices, independent
of saved user settings. Disabling audio does not disable BX motion/SFLP. No touch
gestures, automatic brightness or production contact VAD are enabled by these options.
LED diagnostics are always included in the ordinary image. Hold the wearable button at power-up to request Wi-Fi recovery, release it, then make the next press a fresh five-second hold to enter diagnostics. Legacy single-click resumes animations while retaining the radio mode; v2 button 1 single-click turns LEDs off and button 2 click resumes. A shorter first press cancels the sequence. With web compiled out or no AP password, the same diagnostic gesture works without an AP. Continuing the original boot hold never changes mode. See the diagnostic sequence. Four quick primary/state-button clicks also enter diagnostics on both profiles, including when the S3 bootloader consumes a reset-held press for recovery. A profile with no wearable button has no physical diagnostic gesture.
Flash layout and reserved recovery¶
Legacy S3 profiles assume at least 8 MB flash and select
partitions.8mb.csv. Both normal app slots are 3072 KiB;
a 1920 KiB recovery app partition occupies the remaining space. The original
4 MB Atom retains IDF's two 1700 KiB OTA slots.
V2 selects partitions.v2.csv on the N16R8's 16 MB flash:
two 4096 KiB OTA slots at 0x20000 and 0x420000, a 1920 KiB recovery test-app
reserve at 0x820000, and a 6144 KiB model data partition at 0xa00000.
NVS, OTA metadata and PHY locations stay
the same. The app project name is espurr-v2; CMake rejects a v2 board using the
legacy image family, and rejects that family on a legacy board.
The v2-8mb preset reuses partitions.8mb.csv: 3072 KiB OTA slots at 0x20000
and 0x320000, with a separate 1920 KiB recovery partition at 0x620000. It emits
espurr-v2-8mb, with 8 MB in the native image header. CMake checks the matching
flash/table/family combination. Both phones and browser reject cross-family OTA,
even for a small binary: changing installed layouts requires a deliberate USB
migration. The N8 layout has no model partition. Neither profile introduces a
production ESP-SR dependency.
The N16R8 model label and raw data partition follow
ESP-SR's packed-model convention.
This reserves space only: normal builds do not create/flash a model image, mount
or format the region, or load erased/unvalidated model data. Normal app OTA writes
only the inactive application slot; recovery and models are excluded. Firmware
and model data will be updated separately. The shared model region has
no A/B slots: an interrupted update, corrupt/incompatible data or failed load
must report an active error and disable voice recognition at runtime while
lighting, motion, controls and independently enabled PDM reactivity keep working.
A firmware rollback may therefore disable recognition until a compatible model is
installed. A future updater still needs authenticated, bounded writes under the
render/audio flash pause, integrity and compatibility checks, and explicit model
validation before loading. Neither model upload nor recognition is implemented.
See firmware diagnostics for warnings and recent logs.
The N8 preset deliberately disables PSRAM to build-check PDM reactivity without external RAM or speech recognition. Levels and Sound bands use internal bounded storage; diagnostic WAV recording still requires PSRAM and reports unavailable without it. A fitted octal PSRAM can be enabled through menuconfig using capability allocation; flash capacity and audio-source selection are separate choices. Enabling contact capture does not automatically enable VAD, require 16 MB flash or load a model.
The 16 MB experimental profile uses 80 MHz DIO flash and 80 MHz octal PSRAM, consistent with the N16R8's quad-flash/octal-RAM arrangement in the module datasheet. PSRAM is boot-initialised/tested with capability-only allocation. Existing malloc, task stacks, executable/static data and LED DMA/RMT payloads stay internal. Missing PSRAM may boot degraded; future audio allocation must check actual available capability memory rather than assuming 8 MB was initialised. This build is not memory/timing acceptance on a mounted module.
V2 currently starts with an unknown, zero supply allowance and therefore emits black, including flashlight/diagnostics/update indicators. The source/current service must supply a qualified valid allowance before lighting can operate. PCB-derived ring/centre rendering is connected. BX motion defaults to timestamped 120 Hz FIFO samples with the existing host gravity estimator; the menuconfig SFLP option remains experimental and off by default. See the BX ownership and qualification contract. Capture ownership supports independent internal air/contact requests, both off by default, with BX reset and flash pause acknowledgement. Both phones provide temporary audio capture and level diagnostics; v2 two-button gestures and independent brightness ramps are connected. Optional ambient/ear sensing provides LTR-F216A light and AW96105 raw/ baseline/electrode observations with independent health/recovery. Both phone apps include diagnostics, bounded audio recording/export and Sound bands controls. Automatic brightness, touch/proximity reactions and qualified contact VAD remain pending hardware/recordings; further VAD work is deferred until those inputs exist. This is a linked integration profile, not a completed wearable release.
First installation of this layout requires USB/ROM recovery, not app-only OTA.
Before an authorised install, verify the physical module and serial destination,
back up the existing NVS range (0x9000, 0x6000 bytes) into private ignored
artifacts, and verify the backup. Flashing the generated v2 plan installs the
bootloader/table/application and initialises OTA selection for the new layout;
it does not erase the NVS partition. Preserve credentials and do not run erase-flash.
Verify the installed table and NVS after reboot. Normal builds do not flash the
recovery partition; install its separate image only after confirming this layout.
The S3 web installer uses recovery-only bootstrap bundles with the native GPIO
hold enabled; see bootstrap plans. No first-install procedure
has been physically accepted yet.
Legacy 8 MB S3 layout:
| Partition | Offset | Size |
|---|---|---|
| NVS | 0x9000 |
24 KiB |
| OTA metadata | 0xf000 |
8 KiB |
| PHY data | 0x11000 |
4 KiB |
ota_0 |
0x20000 |
3072 KiB |
ota_1 |
0x320000 |
3072 KiB |
recovery |
0x620000 |
1920 KiB |
The recovery partition uses subtype app,test. Normal builds do not include it
in their USB flash plans; normal OTA cannot write it. Erased OTA metadata selects
ota_0, rather than an empty factory image.
Dedicated recovery builds¶
| Board/layout | Recovery preset | Image | USB application offset |
|---|---|---|---|
| AtomS3 Lite, 8 MB | s3-recovery |
espurr-recovery.bin |
0x620000 |
| Custom S3, 8 MB | custom-s3-recovery |
espurr-recovery.bin |
0x620000 |
| V2 N16R8, 16 MB | v2-recovery |
espurr-v2-recovery.bin |
0x820000 |
| V2 N8, 8 MB | v2-8mb-recovery |
espurr-v2-8mb-recovery.bin |
0x620000 |
Each is a separate application build with its own ignored sdkconfig.<preset>.
The board/flash family must match the installed module; a normal profile's local
menuconfig changes are not inherited. Only optional LED progress uses external pin
and output settings. They do not gate Wi-Fi or firmware upload.
The original 4 MB Atom Matrix layout has no recovery partition and no dedicated
recovery preset. Its existing normal-firmware Wi-Fi recovery remains available.
idf.py --preset v2-recovery menuconfig
idf.py --preset v2-recovery build
Set Espurr cat ears → Private configuration AP password locally in the recovery config before installation. This is a private fallback, not a shared repository password. Recovery first uses a valid saved AP password; missing, empty, corrupt or unsupported settings fall back to its configured password. Without either valid password the AP stays disabled and serial reports the missing fallback. Builds do not require or generate a password, and never enable an open AP.
Recovery starts the Wi-Fi updater without a boot-held button and keeps it open until reboot, including when there are no clients. A dedicated module-only runtime serves the existing offline browser, bounded logs and shared OTA worker. It does not start board buses, the IMU, power monitors, ambient/touch sensors, audio, matrix clearing or the animation task. PSRAM is omitted. Bonded BLE defaults on alongside Wi-Fi. Only the primary user button is started for fresh numeric-comparison confirmation; the boot hold must be released first. Button startup failure leaves Wi-Fi available. Disconnected or failed external hardware therefore cannot prevent AP startup or firmware recovery. The ESP32-S3 module, matching flash layout and usable saved/fallback AP credentials are sufficient; no wearable button confirmation is required.
Best-effort WS2812 recovery update progress (ESPURR_RECOVERY_LEDS) is enabled
by default on legacy WS2812 profiles and can be disabled in menuconfig. The OTA
worker owns it and attempts pin validation and LED initialisation only on its first
upload, after networking is available. Validation/allocation/transmission failures
disable the progress output for the rest of that boot and still return successful
progress acknowledgement to the updater. Recovery RMT waits have a 100 ms bound;
on a failed wait IDF is asked to stop the channel and its interrupts before the
next write. A failed output is never retried or its retained payload mutated.
Normal firmware retains its stricter render/flash-pause contract and existing waits.
V2 recovery builds are headless. V2 recovery does not run the external power-qualification service and cannot grant an LED allowance; it must not bypass that policy just to show a bar. The browser still displays upload progress on every profile. Recovery does not clear previously latched LEDs; disconnected/failed outputs and LEDs retaining an earlier frame are not evidence of failed recovery. Hardware/LED diagnostics remain in the normal firmware.
Settings load and migrate in memory, but recovery never saves the settings record or applies settings/control edits. The common browser's edit requests report unsupported without changing state. This protects newer normal-firmware settings from an older recovery image. The browser's Reboot action returns through the normal boot sequence; Exit remains unavailable because recovery stays active until reboot.
Recovery image names end in -recovery, so ordinary firmware/browser/phone update
preflight rejects them. Recovery accepts only its matching normal family (espurr,
espurr-v2 or espurr-v2-8mb) and S3 chip identity. It chooses the inactive OTA slot
relative to the selected normal boot partition, rather than always selecting
ota_0 from the running test partition. Native final image validation, interruption
handling and rollback remain in use. Recovery never updates itself or model data.
Both recovery flash and app-flash plans contain only the recovery app at the
offset above. They do not install a bootloader/table, reset OTA metadata or write
normal apps, NVS or models. Install only over a verified matching normal partition
table, after the usual private NVS backup and destination checks. Normal full USB
flashing continues to exclude recovery. No device was flashed during implementation.
Public S3 recovery presets enable the native five-second GPIO41 low hold by default. The web flasher uses recovery-only USB bootstrap bundles, with separate sparse reset/preserve manifests. Reset clears A/B and OTA selection so native image-validation fallback boots recovery; preserve retains those regions. Both preserve NVS, PHY and model storage and never mass-erase the chip. Preservation requires the same installed layout. Custom S3 recovery and the optional normal-image preparation helper retain explicit button configuration. First-use credentials and phone release discovery/download remain deferred. Both phone apps support BLE recovery with manually selected normal app files. No installation was performed.
With an enabled bootloader, short reset-held presses are consumed before normal input startup. Runtime radio gestures remain unchanged; four quick state-button clicks enter normal LED diagnostics. Default gesture-disabled bootloaders retain the original boot-held Wi-Fi and release/fresh-hold diagnostic sequence.
Existing generated SDK configs override defaults. To adopt the new layout, use the
selected preset's menuconfig: set Serial flasher config → Flash size to at least
8 MB, then Partition Table → Custom partition table CSV with filename
partitions.8mb.csv. A full USB flash installs the new bootloader/partition table,
initial OTA metadata and normal app; an app-only OTA cannot migrate the layout.
NVS, PHY and OTA-metadata offsets/sizes are preserved. Migration changes ota_1's
location and resets OTA selection during full flashing; no NVS erase is required.
Legacy profiles do not enable or require PSRAM. The packed SPI DMA buffer for two custom-S3 50-pixel ears totals 2880 bytes across two buffers, including reset and excluding handles/driver allocations. LED payloads and render/IMU state stay internal. External RAM could hold future noncritical web/catalogue or larger asset buffers. IDF supports external-RAM DMA on S3 with limitations and cannot place DMA descriptors there; external RAM is normally inaccessible while flash cache is disabled. See the pinned PSRAM guide.
Pins¶
These are configurable ESPurr external wiring defaults. Onboard assignments follow AtomS3 Lite documentation.
| Board | IMU SDA/SCL | WS2812 left/right | Button | Onboard RGB |
|---|---|---|---|---|
| Atom Matrix | 25/21 | 26/32 | 39, active low, external pull | 27, 25 pixels |
| AtomS3 Lite | 5/6 | 2/1 | 41, active low, external pull | 35, 1 pixel |
| AtomS3R | atoms3r-normal |
18 per ear, 12 long-edge pixels; onboard BMI270 and optional ST7735 status screen | ||
| Custom S3 | Explicit | Explicit | Optional | Optional |
AtomS3 Lite needs an external IMU. The custom S3 profile deliberately leaves required pins unset and rejects incomplete configuration before touching outputs. Button GPIO -1 disables button input; zero onboard pixels disables onboard clearing.
The AtomS3 Lite Grove yellow/white positions are GPIO2/1, matching the original Matrix ear cable's GPIO26/32 positions. Its external IMU uses rear-header GPIO5/6 for SDA/SCL instead. Existing generated configurations keep their explicit pin values; update those four fields through menuconfig when adopting this mapping. Validation rejects pin overlap, invalid outputs and reserved flash/USB pins.
Feature switches¶
Under Espurr cat ears, BLE, web/Wi-Fi, OTA and IMU sampling can be enabled independently; OTA requires at least one enabled radio transport. Both targets default to all enabled. BLE requires IDF Bluetooth with the NimBLE host, supplied by project SDK defaults.
Disabled services have no startup task, uploaded asset or registered endpoint. OTA-off still retains the pending-image rollback health gate and USB recovery. IMU-off publishes invalid motion and refuses calibration, while effects retain their degraded appearances. Available capability bits govern phone/watch editors. An unavailable radio request preserves the current mode; exiting a web-only build returns to standalone output. BLE/Wi-Fi remain mutually exclusive when both exist.
LED hardware¶
WS2812 uses independent data wires. Normal S3 profiles drive them from one SPI2 DMA transaction: dual for v1 ears, quad for v2's four outer/inner chains. The GPIO matrix retains the existing pin assignments; SPI3 remains available for the AtomS3R display. Each LED bit occupies four 3.2 MHz SPI clocks: zero is 312.5 ns high / 937.5 ns low, one is 625 ns high / 625 ns low. Shorter chains receive encoded black padding before the common 300 µs low reset. Both bit values take 1.25 µs; 50 pixels take 1800 µs including reset, leaving about 422 µs before the next slot in the 450 Hz, nine-phase schedule. The eligibility estimate reserves at least 200 µs for software. Counts above the estimated 57-pixel full-speed S3 limit select the highest fitting 50 Hz step instead; 64-pixel v1 ears fit 400 Hz. See output timing and ownership for buffer sizes and failure handling.
The original ESP32 retains independent non-DMA RMT channels with 256 symbols each, the existing 10 MHz, 1.2 µs waveform and 280 µs reset, with a 300 µs software allowance and a 47-pixel limit at 500 Hz; longer chains select a lower fitting rate. Optional S3 recovery progress retains RMT and its bounded timeout/stop path. The S3's sole DMA-capable RMT TX channel is hardware channel 3; normal packed SPI output avoids that restriction. The owner reports the original ESP32 timing works well on the mounted 18-pixel strings. The packed dual-SPI AtomS3R/v1 setup has a 450 Hz worker/output serial smoke check; active radio interaction, electrical measurements and v2 quad hardware checks remain pending.
Effects render at 50 Hz. S3 starts with up to nine phases at 450 Hz; ESP32 starts with up to ten phases at 500 Hz. Pulses are evenly distributed with pixel offsets. Only completed output advances the phase. Four consecutive overruns lower the rate one 50 Hz step. Ten clean seconds retry one step upward; ordinary edits do not reset the learned rate. The lowest step is non-dithered 50 Hz. Deliberate pauses are excluded from adaptation; missed time is discarded. Diagnostics and flash/update holds suppress dithering.
Gamma, composition, layer priorities, trims, the 0.5-count whole-pixel black floor
and instantaneous current bounds are shared. Existing profiles retain the legacy
1 mA idle/pixel and 5 mA/full-channel defaults. ESPURR_LED_IDLE_UA supplies an
explicit microamp idle coefficient: set it to 200 for the confirmed v2 WS2812C
0.2 mA/pixel figure. Zero selects the existing ESPURR_LED_IDLE_MA value multiplied
by 1000; it does not mean zero idle current. Existing mA configurations and channel
coefficients retain their meaning. Set both estimates for the actual LED part,
because package/interface alone does not establish current. Fractional mA are
retained through limiting and telemetry. Measure the complete strip to validate
the model. The budget remains 250 mA by default.
Package size “2020” does not establish internal PWM performance; verify optical dithering separately from transmitted frame cadence on the fitted LED part. Retain regulated strip power, common ground and suitable data signal level shifting; controller power and onboard LED current are outside the estimate.
IMUs¶
Legacy profiles select MPU6050, MPU6500, MPU6886, MPU9250, MPU9255, BMI270 or ICM-20948, with explicit I2C address/pins and a 100–400 kHz bus. Use a breakout with 3.3 V-compatible I2C signals and a supply matching that board. Only the selected driver is built. Motion uses acceleration and gyro, not magnetometer, FIFO or DMP features. All drivers publish g/degrees per second with native axes; mounting calibration establishes wearable axes. Host sampling remains 100 Hz, +/-4 g and +/-1000 degrees/second. ICM-20948's divider produces approximately 102 Hz internally; the host still polls at 100 Hz.
The board creates the configured I2C controller before runtime/device tasks start. Current legacy profiles still configure only I2C0 on the selected IMU pins, with the existing pull-ups/filter settings. The IMU borrows that bus and owns only its device handle; failed device/task creation and sensor reprobes do not delete or reset a bus another service may use. Bus creation failure is reported and permits degraded lighting/control, like an unavailable IMU.
The guarded v2 board contract uses I2C0 SDA/SCL 16/15 and I2C1 10/11, even when motion sampling is disabled. Both have external 4.7 kΩ pull-ups, so their internal pull-ups are disabled. Controller creation does not set transaction clock speed: new v2 device drivers must start at the handoff's 100 kHz and qualify cable loading. V2 pins are immutable board resources, including four outputs, both buttons, PDM, TDM and IRQs; GPIO35–37 are reserved for octal PSRAM and 47/48 remain unused for a possible WROOM-2 change. Native USB remains reserved. BX motion now has a single register/FIFO owner, 120 Hz acquisition and internal backend health; host gravity is the default, SFLP is experimental. The BX bus starts at 100 kHz; 400 kHz remains an explicit option requiring cable/loading qualification. Native audio capture and owner coordination are connected; sources start off until requested by an effect or diagnostic lease. Audio processing and recording/export and ambient/ear-sensor diagnostics are implemented, with matching phone controls. Contact VAD, automatic sensor reactions and physical qualification remain pending. See the BX contract.
The board registry can own both native controllers, with explicit port identity and no automatic allocation. Lifecycle operations are startup/owner-only; device tasks use borrowed native handles and bounded IDF transactions. The driver's transaction locking does not make multi-register/banked operations atomic: the single device owner must serialise those sequences. No render/control callback should wait on sensor I/O. Teardown requires quiescent clients and removed devices; failed deletions retain their handles for owner recovery. The registry does not provide a bus-fault scheduler; individual services own bounded device recovery.
MPU6886 remains the original Atom default; external profiles default to MPU6050.
BMI270 uses Bosch SensorAPI v2.86.1, including its configuration image and licence
under components/imu/vendor/bmi270. ICM-20948 uses its banked register interface.
Identity/configuration errors prevent readiness and trigger bounded recovery.
NVS schema 19 associates calibration with target, chip, bus pins and address. A changed identity clears mounting/bias calibration while retaining other settings. Records predating this metadata are attributed to the original Atom MPU6886 bus. Migration saves remain deferred, records remain bounded to 4096 bytes, and rollback firmware cannot overwrite an unknown newer schema.
Validation¶
With the pinned environment active, python3 tools/test_hardware_profiles.py runs
isolated target/transport, sensor and feature-off builds under
build/tests/hardware-profiles/<target>/<case>. Pass case names to select a subset.
It checks disabled-service symbols in the linked ELF and verifies generated
partition sizes/offsets and image fit. Normal USB plans exclude recovery/model
storage; recovery cases check that only the test-app partition is written and
Bluetooth/audio/sensing/PSRAM are omitted. S3 cases use the same target-grouped layout. The 2026-10-13
cleanup preserved old logs,
firmware/configuration snapshots and screenshots under build/artifacts/legacy/
and removed obsolete caches. A custom-board case supplies example pins only for
compilation. Host suites exercise sensor register sequences/scaling/failures,
configuration upload, RMT lifetime/failure handling, quantisation and NVS
identity migration.
Before hardware acceptance, capture wire cadence under BLE polling and optional Wi-Fi association; check animation cadence separately. Exercise saves, reconfiguration, off, shutdown and updates. Verify mounted calibration and sensor recovery. Measure optical low-brightness behaviour, electrical current/temperature and signal integrity on the exact 90-pixel chain. Record flashed revision, installed clients and physical results separately from successful builds and fixtures.