Skip to content

ESPurr hardware and firmware interface handoff

Prepared 2026-10-16 for the separate espurr-hw project. Firmware source revision: 20264f4b5dcd49c1f2e828330bcd2ebb235d0409. Source paths below are relative to the root of the espurr firmware repository, so they remain meaningful when this file is copied elsewhere.

At the 2026-10-16 checkpoint, the v2 choices were ESP32-S3, 8 MB flash and WS2812C LEDs, with 5 mA per full-duty colour channel as the default current-model coefficient. Everything else below describes the existing implementation or identifies a decision the hardware project must supply. Existing profile defaults do not fix the v2 pinout, pixel layout, sensor selection or power architecture.

The current hardware selects WROOM-1-N16R8, 16MB flash / 8MB octal PSRAM. The source recheck below supersedes older implementation-status claims. Sections without a later explicit date describe the original 16 October firmware snapshot; they are not current configuration or feature-support claims.

Right LED GPIO swap, 2026-10-31 evening

The manually revised headband layout is checkpointed at 8c6fdc7. During routing completion, Andi approved swapping the right RMT outputs to avoid a crossing: GPIO6 / module pad 6 → right outer (R116); GPIO7 / pad 7 → right inner (R115). The cross-band connector pinout and right-board mapping are unchanged.

A read-only check of components/board/include/board.h with firmware HEAD 4c27fc423a04cd61b7706e001dd878945b3d3d03 still found BOARD_RIGHT_LED_GPIO = 7 and BOARD_RIGHT_INNER_LED_GPIO = 6. Those two v2 assignments must be exchanged for this hardware revision. No firmware file, Git state, build output or dependency was modified by the hardware task. Earlier dated firmware observations below retain their historical pin map.

Read-only source recheck, 2026-10-31

Inspected firmware main at de054bee50cd5eeac199baa2fe3f342c28b2d944 with concurrent uncommitted app, diagnostics, startup and documentation changes. No firmware files or Git state were changed, and no firmware build, test, install or generator was run by this hardware task. This is a targeted source/configuration recheck, not a full firmware audit or a claim about another task's build results.

  • components/board/include/board.h has the WROOM GPIO map recorded below, including two application buttons. components/board/include/board_leds.h selects four v2 physical chains; components/board/board_leds.c and components/board/data/v2-ear-69.json supply the 69-pixel mapping. components/leds/include/led_math.h permits 69 pixels for v2.
  • components/leds/transport_rmt.c starts all configured chains before waiting for them. components/leds/ear_rmt.c selects one S3 memory block per chain for the four-chain layout, retaining two per ear for legacy two-chain layouts.
  • CMakePresets.json has a v2-normal preset; sdkconfig.v2.defaults selects 16MB flash, the v2 partition table, octal PSRAM, BX and 69 pixels per ear. This supersedes the original 8MB/no-PSRAM configuration description below.
  • components/audio/audio_rx.c implements stereo PDM on I2S0 and eight-slot TDM on I2S1; components/imu/ includes the BX driver. Native receiver lifecycle, framing and bounded PCM handoff are implemented in source.
  • components/power/ implements HUSB320 and INA232 polling and allowance/fault policy. Its configuration leaves qualified power disabled by default; the 1.25A hardware path target is not an operating allowance. components/sensing/ implements LTR-F216A and experimental AW96105 polling on the two board buses.

These observations replace the earlier blanket "unimplemented" status for v2 mapping, audio and power/sensing drivers. They do not establish mounted cable continuity, audio framing/jitter, sensor performance, qualified current limits or simultaneous four-chain/audio/radio/flash operation. Review configuration and saved acceptance evidence in the firmware project before relying on a particular build. Firmware modifications remain separately authorised work.

Read-only source recheck, 2026-10-25

Historical audit; the 31 October source recheck above owns the later status.

Inspected firmware main at d20b333540c1396773aa257edef9997fe4377c05 without editing, building or testing it. The original 2026-10-16 snapshot below remains dated; this is a targeted interface delta, not a fresh full firmware audit.

  • components/leds/transport_rmt.c still owns two outputs and components/leds/include/led_math.h still limits a logical ear to 64 pixels. The v2 69-pixel, four-chain mapping remains unimplemented.
  • components/leds/ear_rmt.c reserves two 48-symbol S3 RMT blocks per output. IDF v6.1's components/esp_hal_rmt/esp32s3/include/hal/rmt_ll.h, components/soc/esp32s3/include/soc/soc_caps.h and components/esp_driver_rmt/src/rmt_tx.c establish four TX channels, 48-symbol blocks and shared allocation. Four outputs need a revised allocation; copying the existing two-block configuration would exhaust the available TX blocks.
  • S3 waveform timing has changed: components/leds/include/ws2812_timing.h now uses 20MHz resolution, 0.30/0.95µs zero high/low, 0.65/0.60µs one high/low, 300µs reset and 200µs scheduling margin. The older 10MHz/280µs timing below is not the current S3 waveform.
  • The inspected board/Kconfig/IMU/LED interfaces still have one application button and no BX driver/contact-audio, stereo PDM, HUSB320 current policy, INA701 feedback or LTR-F216A implementation. These remain separately authorised firmware integration work.

The current design reference owns the headband requirements. This source recheck does not validate simultaneous four-chain/audio operation or mounted hardware.

Selected current-monitor interface, 2026-10-27

The hardware cleanup replaces INA701 with INA232AIDDFR / C5447660 + 10mΩ Milliohm shunt / C2912774 per ear. Both remain on I2C1, with left A0→GND selecting 0x40 and right A0→VS selecting 0x41 for the INA232A variant. The saved hardware now has INA232A at 0x40/0x41 with R132/R202 shunts; this is not a drop-in or a completed firmware migration.

The canonical monitor contract selects ADCRANGE=1, nominal ±2.048A measurement range, 62.5µA/count, SHUNT_CAL=0x0800 and 2mW/count power scaling. Load calibration after power-up/reset and use the INA232 register map and signed current/shunt decoding. The continuous path design target is now 1.25A per ear, with qualification pending; source-dependent operating budgets remain separate from path and measurement ranges.

This is a hardware requirement update. The 2026-10-25 read-only firmware audit above is unchanged; no new source-support claim, firmware edit, build or test is made here.

Headband schematic interface, updated 2026-10-30

This records the hardware-interface change at that checkpoint. Its hardware-only and pending-implementation statements are superseded by the 31 October source recheck above; physical qualification remains open.

The allocation at this checkpoint assigned four LED outputs (left outer/inner GPIO5/4, right outer/inner GPIO7/6), with the later right-output swap recorded above. It also assigned I2S0 stereo PDM (CLK GPIO9 / DATA GPIO8), I2S1 BX TDM (BCLK GPIO13, WCLK GPIO14, DATA GPIO21), BX INT1/2 (GPIO38/39), CC interrupt (GPIO12) and two active-low application buttons (GPIO41/42). The BX/button assignments supersede the first draft after reviewing physical module pads against the mounting concept. The subsequent placement-informed LED/I2C/CC remap and the subsequent approved upper-edge routing reset are applied to hardware only. The 2026-10-29 manual revision then moves I2C0 SCL from GPIO17 to GPIO15. The 2026-10-30 WROOM migration moves CC_INT_N to GPIO12 and BX clocks to GPIO13/14; GPIO1/2/17/18/40 remain spare. Native USB uses GPIO19/20; GPIO0, EN and UART0 remain accessible for service.

I2C0 SDA/SCL is GPIO16/15: BX 0x6A, left ALS 0x53 and HUSB320 0x42. I2C1 is GPIO10/11: selected left/right INA232A 0x40/0x41 (applied hardware) and right ALS 0x53. Each ear sensor uses default 0x12 on its corresponding bus; see the canonical I2C bus/address table. Start at 100kHz and verify the actual cable load. Both band ear sockets now use 6=SCL and 7=SDA for the assumed 1→1 folded ear cables. Physical conductor continuity remains a bench check. Both LTR-F216A devices and INA232 monitors are polled; their interrupt/alert pins are unused. There is no hardware LED power gate.

The selected module is now ESP32-S3-WROOM-1-N16R8, with 16MB flash and 8MB octal PSRAM. Module pads 28–30 / GPIO35–37 are unconnected; GPIO47/48 are also unused to preserve a future WROOM-2 option without level shifting. BOOT and UART pads are on the rear; there is no programming header. This changes hardware requirements only; firmware board-profile validation and configuration need to account for the N16R8 memory and GPIO mapping when integration is authorised; no firmware source has been edited or tested here.

The selected mic is MD-DRA371-H10 on each side, with SEL=0 left and SEL=1 right, shared DATA and separate resistor-fed CLK branches. Initial capture targets are 16kHz PCM PDM RX at 128× oversampling, and BX 16kHz eight-slot/16-bit TDM (2.048MHz BCLK, Z/Y/X in slots 0/1/2). Receiver edge/framing setup, timestamps, buffering and concurrent four-chain/radio/flash performance require implementation and bench qualification, including BX's 4ns peak-to-peak BCLK jitter requirement against the actual S3-generated clock. The firmware snapshot above does not implement these interfaces; this hardware task has not edited, built or tested firmware.

Build and board configuration

Firmware uses bare ESP-IDF v6.1. The relevant presets are s3-normal for the AtomS3 Lite reference and custom-s3-normal for explicitly assigned custom-board pins. Both use sdkconfig.defaults plus sdkconfig.defaults.esp32s3; the custom preset also loads sdkconfig.custom-s3.defaults. S3 defaults select a 240 MHz CPU, 8 MB flash, the custom partition table and the native USB Serial/JTAG console. Shared flash defaults are DIO at 40 MHz. The inspected S3 configurations have PSRAM disabled; the present firmware does not require PSRAM.

Board profile, GPIOs, button polarity/pull, LED backend, physical APA102 segment lengths, LED current coefficients, IMU model/address/bus rate and optional services are build configuration. Runtime settings include active pixel counts, outer-edge counts, reversal, supported wire-order selection, brightness/gamma/trims, current budget, motion tuning and mounting calibration.

Validated NVS settings override first-boot defaults. Existing generated sdkconfig* files override defaults too: changing a default alone does not update an existing configured build. The current coefficient was explicitly set to 5 in the local normal, s3-normal, c6-normal and custom-s3-normal configurations. These local configurations are ignored files; portable defaults live in tracked source/configuration.

Sources: CMakePresets.json, main/Kconfig.projbuild, sdkconfig.defaults, sdkconfig.defaults.esp32s3, sdkconfig.custom-s3.defaults, components/settings/settings.c:settings_defaults and settings_load.

GPIO and peripheral resources

Function AtomS3 Lite reference Custom S3 first-boot default
Left WS2812 data / clocked-chain data GPIO5 Unassigned (-1)
Right WS2812 data / clocked-chain clock GPIO6 Unassigned (-1)
IMU SDA GPIO2 Unassigned (-1)
IMU SCL GPIO1 Unassigned (-1)
Wearable button GPIO41, active low, internal pull disabled Disabled (-1); active low and internal pull enabled if assigned
Onboard WS2812 GPIO35, one pixel Absent: GPIO -1, zero pixels

These reference pins are examples. The custom profile deliberately cannot start with enabled ears/IMU until their pins are assigned. The hardware-profile test script supplies GPIO5/6/2/1 only to exercise compilation.

board_validate() checks valid GPIOs, output capability, duplicate assigned pins and required signals. On S3 it rejects GPIO26–32 and GPIO19/20, reserving the former range for flash and the latter pair for USB. This is a software rejection list, not an exhaustive check of the selected module's flash/PSRAM wiring, strapping, connector routing or electrical suitability. Review those against the exact v2 module.

The IMU owns I2C controller 0. WS2812 ears own two non-DMA RMT TX channels; clocked LED output owns SPI2 with DMA, MOSI and SCLK, no MISO and no CS. There is no shared SPI-device arbitration with an IMU. An optional onboard WS2812 is cleared once using a temporary non-DMA RMT channel, then that channel is released and DIN is held low. Its colour format is fixed GRB; it is not a runtime status display.

Sources: components/board/include/board.h, components/board/board.c, main/Kconfig.projbuild, components/imu/imu.c:imu_start, components/leds/ear_rmt.c, components/leds/transport_spi.c, tools/test_hardware_profiles.py:cases.

Flash and recovery interfaces

The existing 8 MB layout occupies exactly 0x800000 bytes:

Partition Offset Size
nvs 0x9000 0x6000 / 24 KiB
otadata 0xf000 0x2000 / 8 KiB
phy_init 0x11000 0x1000 / 4 KiB
ota_0 0x20000 0x300000 / 3072 KiB
ota_1 0x320000 0x300000 / 3072 KiB
recovery (app,test) 0x620000 0x1e0000 / 1920 KiB

Normal BLE/browser OTA writes only the inactive normal OTA slot. The recovery partition is reserved: there is no recovery image in the build/USB flash plan, and the bootloader test-app button feature is disabled. A future independent recovery image and its button integration still require implementation.

Initial deployment needs the rollback-enabled bootloader and partition table. App-only OTA cannot install either. Preserve NVS during upgrades and recovery; firmware does not automatically erase incompatible/full NVS. Native USB is the configured console/programming path; the hardware needs a usable download/reset recovery path when the normal application cannot boot. The application wearable button's boot-held gesture only works when normal firmware reaches input startup.

Sources: partitions.8mb.csv, sdkconfig.defaults.esp32s3, sdkconfig.defaults, main/app_main.c, components/ota/, components/settings/settings.c.

Logical ears and physical LED wiring

Current hardware, rechecked 2026-10-31: each ear retains independent 48/21 chains; AW96105 replaces AW93205, INA232/shunt monitors replace INA701s, and T1/T2/T3 are covered copper-only anchors connected to the local AW. Both band and ear contacts 6/7 are SCL/SDA. See the maintained electrode guide, connector map and design reference. Firmware mapping and driver source status is recorded in the 31 October recheck above.

Historical hardware update, 2026-10-25 (superseded): each v2 ear has independent 48-pixel outer and 21-pixel inner/centre chains, fed by ear J1.1 LED_DAT1 and J1.5 LED_DAT2. The current ear master routes U1 AW93205 with local U2 XC6206; J1.6/7 carry SCL/SDA after the ear routing swap; J1.2–4 are GND and J1.8–10 +5V. Andi is handling the matching headband J102/J202 update separately; the preceding band checkpoint uses SDA/SCL on 6/7. Left ear sensing extends I2C0 alongside the band BX/left ALS/HUSB320; right ear sensing extends I2C1 alongside both INA701s and the right ALS. Both ear sensors use 0x12 on their separate buses. Three independent rear electrodes terminate at exposed manual wire pads T1/T2/T3 and connect to the local AW93205. Their signals do not pass directly through J1. See the electrode guide and current ear connector map. Existing LED-only prototype order outputs retain their historical population and pinout; they have not been regenerated from this master. The former raw-touch, Qvar/0x6A/0x6B and IQS231B contracts are superseded. The cross-band signal budget remains six, with four FFC grounds plus the separate 28AWG 5V/GND wire pair. Actual cable continuity, sensor qualification and four-chain firmware implementation remain open. The firmware snapshot below does not implement this 69-pixel, two-chain-per-ear layout or AW93205 sensing.

The renderer has exactly two logical ears, left index 0 and right index 1, each bounded to 64 active pixels. Zero disables an ear; unequal counts are supported. S3 first-boot defaults are 45 pixels per ear with 30 on the outer edge and 15 on the inner edge. These are configuration defaults, not measured v2 dimensions.

Logical order is outer base → outer edge → tip → inner edge → inner base. outer_pixels identifies the outer-edge portion used to derive height; zero selects a straight-strip fallback. Palette sequences traverse the whole outline; height-based motion effects use the derived height separately. There is no general two-dimensional pixel-position map.

Reversal belongs in the output layer. First-boot left is reversed and right is not; reversed means data enters at the inner base. Saved settings remain authoritative. Sensor axes, logical ear identity, physical connector side and strip reversal are separate mappings and must be checked separately on the assembly.

LED backend Physical connection and encoding
WS2812, default and planned v2 Independent left/right data wires. GRB normally, RGB with that ear's alternate-order setting. Eight bits per colour.
APA102, existing optional backend One left-then-right chain, data on the left-output GPIO and clock on the right-output GPIO. SPI mode 0, default 8 MHz, configurable 1–20 MHz. BGR normally, BRG with alternate order; global brightness is fixed at 31.

For WS2812 the configured count determines the addressed length. Reconfiguration clears the old configured length before shrinking/disabling/recreating an output; pixels beyond every known configured length cannot be discovered or reliably cleared. Configure the complete connected strip.

For APA102 physical segment lengths are compile-time values, default 45 + 45, independent of runtime active counts. The right offset always follows the full physical left segment. Inactive physical tails stay black and count towards idle current. Reversal is within the active part of each segment; disabling the left ear does not move the right ear's offset. Other clocked parts need qualification.

Sources: components/leds/include/led_math.h, components/leds/led_math.c:led_physical_index, components/leds/leds.c, components/leds/transport_rmt.c, components/leds/transport_spi.c, components/leds/apa102_frame.c, components/effects/effects.c:ear_pixel_height, main/Kconfig.projbuild.

Output timing and execution

Effects advance at 50 Hz. Optional temporal dithering targets 500 Hz / ten phases for WS2812 and 1000 Hz / twenty phases for APA102. These are transmitted output cadences; the firmware does not control or establish the LED part's internal PWM frequency or visible flicker performance.

The WS2812 encoder uses a 10 MHz clock: zero is 0.3 µs high + 0.9 µs low, one is 0.9 µs high + 0.3 µs low, followed by a 280 µs low reset. A 45-pixel transfer is therefore 1576 µs. Both ears launch before either is waited for. The scheduler budgets a conservative 30 µs × longest ear + 280 µs + 300 µs for wire time and software margin; 45 pixels fit a 2 ms slot, not a 1 ms slot. S3 allocates 96 RMT symbols per ear without DMA, with the full encoded payload in internal RAM.

The 90-pixel APA102 frame is 370 bytes and takes nominally 370 µs at 8 MHz. It uses a single queued transaction and an internal DMA-capable buffer. The same 300 µs scheduling margin determines whether its selected clock/chain fits 1 ms.

Long topologies, slow clocks or four consecutive late output slots select ordinary 50 Hz output without dithering. Missed time is discarded rather than replayed. Only a completed transfer advances the dither phase. Diagnostics, off and flash/update holds suppress dithering.

The render owner runs on core 1 on multicore builds, core 0 otherwise. It exclusively creates/reconfigures/transmits/destroys ear outputs and performs settings saves. Radio callbacks and timer callbacks enqueue/notify work rather than driving LEDs. New peripherals or higher sampling/processing loads must preserve that execution budget.

Sources: components/board/include/espurr_build.h, components/leds/ear_rmt.c, components/leds/transport_rmt.c, components/leds/transport_spi.c, components/runtime/render_timing.c, components/runtime/runtime.c.

Colour and power assumptions

Clients send artistic 8-bit sRGB. Firmware owns conversion, gamma/brightness, background/overlay composition, per-channel trims, shared current limiting and final wire quantisation. Fractional render precision supports low-brightness dithering. The physical ceiling is 100% / 255 per channel; first-boot main brightness is 20%, gamma 2.2 and trims 100%. A whole pixel is blanked if its peak after limiting is below 0.5 wire counts. Diagnostics and update indicators share the output limits.

All profiles now default to 1 mA idle per physical pixel + 5 mA per full-duty colour channel, with a 250 mA combined ear-strip budget. Both coefficients remain build-time configurable; the budget is a saved runtime setting. For another LED part, supply its appropriate coefficient explicitly, including when using the optional APA102 backend. This universal default records the selected WS2812C hardware; it is not a claim that every supported LED has the same electrical behaviour.

Estimated current is physical_pixels × idle_mA + sum(emitted_RGB_bytes) × channel_mA / 255. One common gain limits both ears, with rounding headroom for each dither phase. WS2812 physical-pixel accounting uses configured counts; APA102 uses the entire fixed chain, including black tails. Ninety pixels already account for 90 mA of estimated idle draw. Controller, onboard LED, radio, regulator and other peripheral loads are outside this estimate. There is no current-sense feedback.

The existing assembly expects regulated 5 V strip power, common ground and a direct supply distribution path. Software does not compensate for cable drop, supply transients, inadequate connectors or signal thresholds. The existing wiring guide allows short 3.3 V data runs as a prototype trial and identifies 74AHCT/74HCT level shifting when needed; the v2 power/signal circuit needs its own qualification.

Power off is LED blanking. The MCU, IMU and selected radio continue running; there is no LED load-switch GPIO, battery ADC, charger/fuel-gauge integration, temperature protection or application sleep/wake policy. Startup enables the LEDs again. Brownout/reset safety and actual supply isolation belong to the hardware design plus any future firmware support.

Sources: components/leds/include/led_math.h, components/leds/led_math.c, components/leds/leds.c:prepare_frame, leds_show and leds_off, components/runtime/runtime.c:set_power and runtime_start, main/Kconfig.projbuild, README.md wiring and output sections.

IMU bus and sample interface

Exactly one IMU backend is selected at build time: MPU6050, MPU6500, MPU6886, MPU9250, MPU9255, BMI270 or ICM-20948. External/S3 profiles default to MPU6050; the original Atom defaults to MPU6886. LSM6DSV16X and the proposed BX part have no driver, configuration choice or identity integration in this snapshot. Their exact parts, features and connections remain work for espurr-hw and subsequent firmware changes.

The current transport is I2C0 with explicit SDA/SCL, a fixed seven-bit address (default 0x68, configurable 0x08–0x77) and 100–400 kHz bus speed, default 400 kHz. Internal pull-ups are enabled in software; this does not specify suitable external pull-ups or sensor supply/logic voltage for a bare-chip PCB.

The sensor interface is sensor_init(bus) and sensor_read_sample(bus, accel_g[3], gyro_dps[3]). Drivers produce native sensor-axis acceleration in g and gyro in degrees/second, configured for ±4 g / ±1000°/s. A FreeRTOS task polls every 10 ms at priority 5 with a 4096-byte stack. ICM-20948 internally samples at approximately 102 Hz; the host still polls at 100 Hz.

The service timestamps successful reads with host time; it does not consume sensor timestamps, FIFO batches, data-ready interrupts, magnetometer data, quaternions or sensor-side event/fusion output. There is no IMU interrupt, reset or rail-enable GPIO in the board interface, and no SPI IMU transport. The current register write adapter accepts at most 32 data bytes per call, relevant to future configuration uploads.

A read error invalidates motion and forces reprobe/reinitialisation. Failed bring-up retries at two-second intervals; snapshots older than 100 ms become invalid. An absent sensor is an allowed degraded mode with usable idle effects, rather than a reason to block wearable startup or fail OTA health acceptance.

Sources: components/imu/sensor.h, components/imu/imu.c, components/imu/sensor_mpu.c, components/imu/sensor_bmi270.c, components/imu/sensor_icm20948.c, components/imu/CMakeLists.txt.

Motion coordinates and calibration

Motion processing runs on the MCU. It filters gravity and mean gyro, subtracts stored gyro bias, derives motion energy and evaluates shake/nod gestures. Defaults are 0.35 s gravity and 0.05 s gyro filter time constants. Renderers consume coherent motion_t snapshots containing raw/filtered vectors, roll/pitch, angular speed, energy, validity/stationarity and monotonic gesture counters. There is no absolute heading estimate or complete orientation-fusion API.

Mounting calibration captures neutral gravity and mean gyro, then gravity at a 10–60° right tilt. Each capture needs two seconds of continuous stillness, within a five-second request window. Stillness requires angular speed below 5°/s and acceleration magnitude within 0.08 g of 1 g; gaps over 100 ms reset settling/gestures. The projected right-tilt vector establishes right, and forward = up × right. Positive roll follows projected gravity along the learned right axis; pitch follows the learned forward axis. Shake yaw rate projects bias-corrected raw gyro onto up; nod rate projects onto right. Recentring updates neutral/bias while preserving the learned mounting basis.

NVS schema 19 binds calibration to target, sensor name, I2C0, SDA/SCL and address. Changing that identity clears mounting/bias calibration while preserving other settings. The identity does not include PCB revision or physical mounting rotation: a remount using the same part/pins/address still needs explicit recalibration. A future SPI sensor, sensor-side processing or additional motion channels needs an intentional update to this interface and persistence identity.

Sources: components/imu/include/motion_math.h, components/imu/motion_math.c, components/imu/imu.c:imu_calibrate, components/settings/settings.c, components/settings/settings_json.c:settings_sensor_identity and settings_calibration_matches, components/runtime/runtime.c calibration handling.

Physical button and radio behaviour

One optional wearable button is supported, with configurable polarity and internal pull. Custom S3 defaults to active low with the internal pull enabled. GPIO -1 disables it; that also removes the physical entry gesture for diagnostics/recovery.

Gesture Current application action
Single click Toggle LED output; leave diagnostics if active
Double click Next effect
Triple click Select BLE mode
Release after 2 to less than 5 seconds Toggle output; leave diagnostics if active
Five-second hold Toggle BLE/Wi-Fi configuration mode; consume the hold
Held at application startup Request Wi-Fi recovery when web and a valid saved AP password are available
Boot hold, release, then next press held five seconds Enter ordinary-firmware LED diagnostics

Continuing the initial boot hold cannot trigger another mode change. A shorter next press cancels the diagnostic-entry sequence. Disabled services limit available actions; a missing AP password leaves the normal BLE fallback on a BLE-enabled build. Neither hold gesture currently selects the reserved bootloader recovery partition.

BLE, web/Wi-Fi, OTA and IMU can be compiled out separately. OTA needs at least one enabled radio transport. BLE and Wi-Fi have exclusive runtime ownership; local button/effects operation remains useful without a client. Authentication uses application Security 2 patch 1; adding a board must preserve the existing control and update contracts. There is no simultaneous normal BLE/Wi-Fi operating mode.

Sources: components/input/input.c, components/input/input_policy.c, components/runtime/runtime.c, main/Kconfig.projbuild, components/ble/, components/web/, docs/local-control.md, docs/ble-ota.md.

Startup and flash coordination

Startup validates pins, clears the optional onboard LED, starts settings/input/IMU and waits for successful render-owned ear initialisation before starting network services. Ear creation failure aborts startup; selected-sensor unavailability allows degraded operation. A pending OTA image must reach input/render health within a five-second check window; health requires 150 successful animation health frames, not fast dither passes, and does not require an available IMU.

External flash work requests a render-owned pause: output is blanked and in-flight transactions complete first. Settings saves retain the last completed latched frame. OTA may latch progress only between completed writes. A new power-switch/reset circuit or peripheral task must respect those output/flash boundaries. Ordinary shutdown requests black output; it is not a physical power-disconnect mechanism.

Sources: main/app_main.c, components/runtime/runtime.c, components/leds/leds.c, components/ota/, components/settings/settings.c.

Decisions to return from the hardware project

The firmware can adopt a concrete v2 profile once the hardware project supplies:

  • Exact S3/module variant, flash interface, optional PSRAM, USB/download/reset path and the GPIO resource map checked against that module.
  • Actual left/right pixel counts, outer-edge lengths, connector identity, DIN direction, wire order, supply and signal-conditioning circuit.
  • Exact IMU part, bus/address, electrical levels, mounting axes, any interrupt/reset or power-control signals, and which new sensor capabilities firmware should use.
  • Button wiring and any deliberate changes to boot/recovery/wake gestures.
  • Power architecture, including any switched rails, battery/charging/measurement signals and sleep/wake requirements that need new firmware support.

This handoff establishes the software baseline. S3 bring-up, mounted directions, signal integrity, optical timing, electrical current/temperature, radio reliability and physical rollback still need checks on the assembled hardware. Existing software evidence and test entry points are in docs/hardware-profile-acceptance.md, docs/hardware-profiles.md, tests/CMakeLists.txt and tools/test_hardware_profiles.py; their historical results do not establish v2 acceptance.