ESPurr local-control contract — v2¶
Current BLE security/host/bulk-transfer contract: bonded BLE transport. LE Secure Connections with numeric comparison and bonding is required by current apps. Legacy Security 2 and unmarked plaintext are unsupported; no fixed device passkeys are used.
The current source implements application protocol 2 over bonded LE Secure Connections. The history index separates older authentication, installation and software checkpoints from current behaviour. The Swift mock is not a wire-protocol conformance implementation.
Discovery and session¶
Service UUID: 8A7D3B10-8F6D-4E6A-A1C4-9B27E8D05360.
Reserve it for ESPurr local control; never identify a device by advertised name alone.
BLE control is enabled at boot. Triple-click switches BLE/Wi-Fi on single-button
profiles; v2 uses a five-second button-1 hold. Both use a blue/green render-owned wave. Firmware 0.5.6 removes
passive ambient scanning. Wi-Fi suppresses BLE control; BLE OTA keeps the
authenticated host alive.
Radio ownership covers partial startup and teardown. Failed HTTP/Wi-Fi shutdown retains the Wi-Fi claim and retries at the existing 250 ms supervisor interval; successful HTTP shutdown invalidates its handle before any Wi-Fi-only retry. Pending teardown reasserts configuration exit, even if configuration was requested again, and completes before another AP entry or BLE handover. Each retry still requires the render owner's exit approval, so an active OTA defers cleanup. An OTA rejection before teardown begins leaves the AP running with its ordinary idle-window rules.
Use esp_local_ctrl/version, esp_local_ctrl/session,
esp_local_ctrl/control and authenticated espurr/catalogue (UUID suffix FF02), with endpoint discovery through GATT descriptors rather
than app assumptions about handles. Current version metadata explicitly declares
link_security: "lesc-bonded-v1", sec_ver: 0, sec_patch_ver: 0 above secured GATT.
Reject legacy Security 2, unmarked Security 0 and other modes. Read protocol version/capabilities after link authentication.
Each control characteristic publishes a read-only 0x2901 User Description with
its exact endpoint name. Descriptor reads require an encrypted, authenticated,
bonded connection, as do the characteristic values. Android completes bonding
before reading endpoint descriptors. Clients reject missing or mismatched names;
pairing alone does not establish application-protocol compatibility.
The transport sends standard Service Changed indications when subscribed. Clients invalidate stale endpoint metadata rather than caching GATT handles or property indices. Bond security records are persisted; subscriptions/GATT caches are not. Discovery-time service invalidation permits one bounded rediscovery; invalidation after discovery closes the session for explicit retry. Authentication and actions are never replayed. See BLE ownership.
Generate Swift protobuf types from the pinned IDF definitions:
components/esp_local_ctrl/proto/esp_local_ctrl.proto and protocomm's
constants.proto. The bonded link uses raw protocomm framing with no app-layer
crypto/password handshake; retired provisioning session schemas are not generated.
Each authenticated session rebuilds the property index/name map with count and
batched value reads. Never persist indices. Unknown optional properties may be
ignored; incompatible major versions and missing required properties block controls.
Application protocol 2, catalogue schema 1.2, firmware version, JSON settings schema 22
and NVS layout 23 are distinct. See settings storage.
Clients obtain fresh catalogue INFO after authentication and validate/cache descriptor bytes
before property discovery. See catalogue contract and authoring.
Discovery accepts up to 256 properties. A matching descriptor cache never supplies live
settings or session indices. Protocol 1 is unsupported; there are no compatibility aliases.
Artistic RGB remains 8-bit while render frames remain 16-bit. Schema-qualified
settings migrations live in components/settings/ and its host tests; their history
does not define protocol capability gates. Current feature encodings are below.
Values¶
Scalar bytes use explicit little endian. Boolean is exactly one byte 0 or 1.
Float is a finite IEEE-754 binary32, not a string or native struct. RGB is three
8-bit bytes R,G,B, independent of physical GRB ordering. No app gamma/current scaling.
Fixed-size fields reject wrong lengths; variable-size fields reject trailing data.
| Property | Encoding | Access / bounds |
|---|---|---|
protocol.version |
u16 | read-only; 2 |
state.revision |
u32 | read-only; increments on discrete applied changes and completed physical brightness ramps; live ramp steps remain readable within that operation |
power |
bool | read/write; actual lighting enable; off clears all lighting and ends flashlight |
effect |
u32 | read/write; membership from the catalogue; 3/4/8/10 permanently retired |
brightness |
u8 | read/write; 0–100 of the 100% PWM ceiling |
flashlight.enabled |
bool | optional catalogue control 13; volatile temporary warm-white overlay |
flashlight.brightness |
u8 | optional catalogue control 14; 0–100, saved independently, default 100 |
speed |
float32 | read/write; 0.1–5 |
output.gamma |
float32 | read/write; 1–4, default 2.2; capability bit 9 |
output.rgb_trim |
three u8 percentages R,G,B | read/write; each 0–100, default 100/100/100; capability bit 9 |
palette.preset |
u8 | read/write; 0–6 presets, read value 7 means custom |
palette |
count u8, bands bool, count × RGB | read/write; count 1–8; editing selects custom |
background.colour |
RGB | read/write |
background.brightness |
u8 | read/write; 0–100, zero disables background |
motion.sensitivity |
float32 | read/write; 0.1–5 |
motion.gravity_tau_s |
float32 | read/write; 0.05–2 |
motion.gyro_tau_s |
float32 | read/write; 0.01–1 |
ear.left, ear.right |
pixels u8, long-edge pixels u8, reverse bool, RGB order bool | legacy layout: read/write, total 0–64, long-edge count ≤ total; v2 fixed layout: read-only [69,0,0,0] |
current_limit_ma |
u16 | read/write; 130–1000; estimate for strips |
remote.timeout_s |
u16 | read/write; 30–3600 |
palette.cycle |
bool | read/write; optional hue cycling, default off |
palette.cycle_s |
float32 | read/write; 5–300 seconds per full turn, default 60 |
palette.saved.0 … palette.saved.7 |
named palette | read/write; capability bit 7; empty is one zero byte |
palette.restore |
u8 | reads 0; write 1 with ID only to clear saved slots and select purple/orange |
dither |
bool | read/write; output scheduler may fall back at runtime |
overlays.enforcer, overlays.shake, overlays.nod |
bool | read/write; independent switches, default off |
overlays.turn, overlays.tilt, overlays.shimmer |
bool | catalogue binding; read/write; palette accents, default off |
overlays.tilt_deg |
u8 | catalogue binding; read/write; calibrated Start tilt angle 5–60 degrees, default 20; start + travel ≤75° |
overlays.tilt_travel_deg |
u8 | catalogue control 12; read/write; additional angle 5–30 degrees, default 15; start + travel ≤75° |
overlays.tilt_colour |
RGB | catalogue binding; read/write; three sRGB bytes, default white; selected tip colour bypasses Enforcer |
overlays.forward |
bool | catalogue binding; read/write; calibrated forward lean, default off |
overlays.brightness_offset |
i8 | catalogue binding; -100..100 percentage points, default +20; shared by palette accents and shake/nod |
motion.shake_threshold_dps, motion.nod_threshold_dps |
float32 | read/write; 10–180 degrees/second |
motion.gesture_swing_deg |
float32 | read/write; 2–30 degrees per leg |
command.id |
u32 | required in every set; nonzero/increasing per connection |
command.action |
u8 | write with ID only; 1 neutral, 2 right, 3 clear basis, 4 recenter, 5 disable BLE |
session.generation |
u32 | read-only; connection generation |
command.result |
generation u32, ID u32, ESP error u32, revision u32, state u8 | read-only; 0 empty, 1 queued, 2 applied, 3 failed |
protocol.capabilities |
u32 | read-only; bits 0 dither, 1 motion, 2 background, 3 editable strip topology, 4 calibration, 6 palette hue cycling, 7 saved palettes, 8 brightness ceiling, 9 output tuning, 10 authenticated app OTA; 5 and 11–14 reserved/zero; full-feature legacy build 0x7df; IMU-off clears bits 1/4, fixed v2 wiring clears bit 3, OTA-off clears bit 10 |
hardware.brightness_cap |
u8 | read-only; capability bit 8, current value 100 |
firmware.version |
UTF-8, up to 32 bytes | read-only; no trailing NUL |
firmware.project |
UTF-8, 1–31 bytes | optional read-only running project, no NUL; normal families espurr, espurr-v2, espurr-v2-8mb; recovery adds -recovery; absence means legacy espurr |
firmware.recovery |
bool | optional read-only/type 1; true only in the dedicated recovery image, independent of the temporary status updating flag |
firmware.upload_project |
UTF-8, 1–31 bytes | optional read-only/type 11; normal app family accepted by OTA, without NUL; matches firmware.project in normal mode and its normal counterpart in recovery |
hardware.layout |
u8 | optional read-only; 0 legacy editable strips, 1 fixed v2 48+20+1 ears; absence means 0; unknown layouts require a compatible client |
power.ready |
bool | optional read-only supply-allowance readiness; absent on older firmware means no additional readiness gate is reported |
firmware.alerts |
16-byte versioned observation | optional read-only/type 21; active warnings and log availability; diagnostics contract |
power.status |
64-byte versioned observation | optional v2 read-only/type 13; source, policy and measured ear feeds, described below; absence means detailed power diagnostics are unavailable |
ear.left.status, ear.right.status |
64-byte versioned observations | optional v2 read-only/type 16; raw ear-electrode diagnostics; absent means no diagnostics, unsupported means sampling omitted |
light.left.status, light.right.status |
40-byte versioned observations | optional v2 read-only/type 15; independent ambient sensors; absent means no diagnostics, unsupported means sampling omitted |
motion.status |
40-byte versioned observation | optional read-only/type 17 on every profile; selected estimator and expiring backend health; absent means no detailed report |
status |
flags u16, six float32 values, save error u32 | read-only; 30 bytes |
The recovery and upload-project properties are a pair. Current firmware reports both
on normal and recovery builds. Clients reject a partial pair, wrong type/writable
metadata, invalid boolean, unknown upload family, or disagreement with the running
project. When both are absent, retain the older normal-firmware behaviour and use
firmware.project for upload preflight. Do not infer recovery from the temporary
update-mode status flag. Older recovery images without the explicit pair do not gain
an upload-family exception. Discovery and metadata remain authenticated.
In recovery, phone apps open the firmware updater directly and reject settings and calibration actions locally; firmware remains authoritative. Uploads still require a fresh device OTA status for chip and normal-slot capacity checks before Begin. Normal app images only are accepted: recovery binaries, cross-family images and attempts to replace the recovery slot through app OTA remain prohibited.
status flags: bits 0 power, 1 config mode, 2 BLE mode, 3 updating, 4 dirty,
5 healthy, 6 motion valid, 7 calibrated, 8 dither active, 9 dither fallback. Floats
in order: estimated strip mA, reserved legacy BLE activity (zero since 0.5.6), roll degrees, pitch degrees, energy,
output Hz. Missing/uncalibrated motion is explicit in the flags.
Property types are 0 u8, 1 bool, 2 u16, 3 u32, 4 float32, 5 RGB, 6 palette, 7 ear, 8 result, 9 status, 10 named palette, 11 UTF-8, 12 signed i8 (two's complement), 13 power status, 14 audio status, 15 ambient-light status, 16 ear-sensor status, 17 motion status. Flag bit 0 means read-only. Read a maximum of four properties in one request; larger batches are rejected.
Layout 1 requires both ear properties to have read-only/type-7 metadata and bit 3 clear. Their second byte stays zero; it does not become an outer-ring count. Clients validate this context before accepting 69 pixels. Legacy reads/edits keep their 64-pixel bound. The apps show fixed ring/centre counts instead of strip editors. The layout discriminator describes the assembly contract, not an XY map or geometry hash; those remain separate firmware topology data.
Power on and power.ready are independent. Power on is requested state;
readiness requires a supply allowance above physical pixel idle consumption.
Unknown v2 allowance emits black through the common output pipeline, including
flashlight, diagnostics and OTA progress. It does not disconnect the powered LED
rail. Neither the user current budget nor settings reset can lift that allowance.
Readiness does not establish measured current, thermal or cable acceptance.
Physical brightness ramps¶
Physical v2 brightness ramps publish one changing independent brightness property while the hold runs. They commit the configuration revision on completion, endpoint or cancellation, rather than on every 50 ms step. All other commands keep their normal atomic patch/revision semantics. With a stable revision, other settings are fixed and the one ramped field comes from the instant it was read; mode/target changes finish the old ramp. This preserves coherent multi-batch reads without starvation during a continuous hold. Clients must read live values even when a revision is unchanged, as both native clients already do. Browser responses remain single published snapshots. Ramp state/directions are volatile; only the selected brightness is persisted through the existing coalesced settings save. See the input contract.
Motion diagnostics v1¶
motion.status is an optional read-only/type-17 record, exactly 40 bytes,
little-endian. Current firmware registers it on legacy and v2 profiles, including
IMU-off builds. The application protocol, catalogue and NVS schemas are unchanged;
the existing four-property / 64-byte-per-value bounds remain sufficient. Clients
validate its type/read-only metadata at discovery and require stable metadata on
refresh. Older clients may ignore this optional property.
| Offset | Encoding | Meaning |
|---|---|---|
| 0 / 1 / 2 / 3 | four u8 | version 1 / source / state / flags |
| 4 / 8 / 12 / 16 | four u32 | generation / accepted frames / sample age ms / ESP error |
| 20 / 24 / 28 | three u32 | sample gaps / FIFO overruns / rejected records |
| 32 | u32 | configured sample rate in millihertz, rounded to nearest integer |
| 36 / 38 | u16 / u16 | clock uncertainty µs (zero unavailable) / reserved zero |
Source identities are 0 unsupported, 1 legacy host filter, 2 BX host filter and 3 experimental BX SFLP. State identities are 0 unsupported, 1 starting, 2 settling, 3 ready, 4 stale and 5 failed; these differ from light/ear states. Flag bit 0 means a sample timestamp is known; bit 1 means the interrupt was registered. Other flag bits are zero. Unsupported has source/state zero, unknown age and all other metadata zero. Starting has no sample and zero error. Ready/settling require a known age ≤100 ms, nonzero configured rate and zero error; settling can have raw frames without valid fused motion. Stale/failed require a nonzero error. A reported source must agree with the compiled motion capability; source zero is also possible before service startup.
Age uses unsigned 32-bit host-millisecond subtraction, including natural wrap.
The timestamp flag distinguishes a real timestamp of zero from no sample. Unknown
age is UINT32_MAX; the largest known age caps at UINT32_MAX - 1. Ready/settling
expire after 100 ms to stale / ESP_ERR_TIMEOUT without modifying the owner's
publication. A regressed clock therefore expires conservatively. These diagnostics
describe the last device update, not a continuously advancing phone clock.
Counters wrap through zero and are scoped to a sensor generation. Non-ready
records retain their diagnostic provenance; configured rate is not measured
delivery rate, and interrupt registration does not establish interrupt delivery.
Zero rate/uncertainty and a never-started generation are shown as unavailable.
Getters copy one coherent health publication per bounded batch through a short critical section. They do not acquire the IMU configuration mutex, wait for I2C or recovery, submit actions, change settings revision or write NVS. The owner retains its existing filtering/calibration and recovery policy.
Both phones show source/state/age and disclosed counters/rate/error on Control →
Motion settings → Motion filtering. SFLP replaces only the host gravity estimator:
the saved gravity time constant stays intact and its editor is inactive; the host
gyro filter remains adjustable. Host-filter builds enable both controls. IMU-off
builds disable both. When older firmware omits motion.status, clients show “Not
reported by this firmware” and retain the existing motion-capability behaviour.
Firmware still accepts dormant gravity settings for compatibility; current apps
refuse inactive edits rather than pretending they tune SFLP. Watch/browser retain
compact controls. See the IMU contract and shared
C/Swift/Kotlin vectors.
Ear-sensor diagnostics v1¶
ear.left.status / ear.right.status are optional read-only/type-16 observations.
Each record is exactly 64 bytes, little-endian; the protocol version, existing
64-byte per-value bound and four-property batches are unchanged. Older clients
can ignore these optional properties. Current clients require stable read-only/type
metadata and matching side identity. V2 retains unsupported records when sensing
is omitted; older firmware can omit the properties. Reads copy both ear snapshots
once per batch without I2C, settings/revision changes, actions or NVS writes.
| Offset | Encoding | Meaning |
|---|---|---|
| 0 / 1 | u8 / u8 | record version 1 / side: 0 left, 1 right |
| 2 / 3 | u8 / u8 | state / reserved zero |
| 4 / 8 | u32 / u32 | calibration generation / sample sequence |
| 12 / 16 | u32 / u32 | observation age in ms / ESP error |
| 20 / 24 | u32 / u32 | observed chip ID / packed channel quality |
| 28 / 40 / 52 | three i32 each | RAW / BASELINE / DIFF, signed S21.10, T1/T2/T3 order |
T1 short side is logical channel 0 / CS4; T2 long side is channel 1 / CS0; T3 tip is channel 2 / CS3. The fixed identities are not physical CS-number array indices. Quality bits 0–2 are channel saturation, 3–5 offset-capacitance error, 6–8 proximity during startup calibration and 9–11 steady-state flags. Bits 12–23 contain one four-bit chip proximity-level mask for each channel (T1 at 12–15, T2 at 16–19, T3 at 20–23); nibble bit 0 is chip level 0 through bit 3 level 3. Bits 24–31 are reserved zero. These are unqualified diagnostic flags, never firmware touch/proximity events. Saturation/offset errors remain visible alongside a fresh diagnostic scan rather than being represented as a valid user action.
States are 0 unsupported, 1 starting, 2 ready, 3 stale and 4 failed. READY means
only a complete post-calibration diagnostic scan with zero transport error,
known age ≤500 ms and the supported 0xA9610B00 chip identity. Automatic offset
compensation cannot be in progress in such a scan. Chip analogue/classifier flags
do not imply through-fur acceptance. All other states clear quality and measurement
bytes 24–63. Chip identity, counters and age retain provenance; a failed probe may
report an unknown identity. Unsupported clears all metadata/error/counters and has
unknown age. Starting has zero error; stale/failed have nonzero error, and stale
retains the supported identity. Counters wrap as unsigned 32-bit values, including
zero; age/identity/state distinguish an observed scan from startup. Fresh zero
counts remain distinct from absent data.
Age is rounded up from the conservative host timestamp. Unknown/future timestamps
use UINT32_MAX; older known ages cap at UINT32_MAX - 1. It describes the last
device update, not a phone clock or hardware synchronisation. DIFF is the chip bank;
it is not reconstructed from RAW. Firmware's internal observation also retains
VALID, offset codes, firmware version and full status/IRQ words; these are omitted
from this compact wire record to preserve older protocol-2 clients' 64-byte bound.
No control/catalogue/NVS schema change or automatic reaction is introduced.
Both phones provide Settings → Ear sensors with state/age and per-electrode Details for signed Q10 counts, identity, counters, quality and chip proximity flags. Watch and browser retain their compact scope. Shared wire vectors cover the C producer and strict Swift/Kotlin decoders. Raw analogue quality and through-fur calibration remain physical acceptance work; see sensing ownership.
Ambient-light diagnostics v1¶
The two optional read-only/type-15 properties contain independent, little-endian 40-byte records. Each bounded get batch copies both observations once without I2C. They do not change configuration revision, dirty state or NVS. V2 retains the properties when sensing is compiled out and reports unsupported; older firmware may omit them. Their logical side must match their property name. Metadata must remain stable on refresh, as with other typed observations.
| Offset | Encoding | Meaning |
|---|---|---|
| 0 / 1 | u8 / u8 | version 1 / side: 0 left, 1 right |
| 2 / 3 | u8 / u8 | state / flags: bit 0 digital full scale; other bits zero |
| 4 / 8 | u32 / u32 | configuration generation / sample sequence |
| 12 / 16 | u32 / u32 | sample age in ms / ESP error |
| 20 / 24 / 28 | u32 / u32 / u32 | ALS count / clear count / nominal millilux |
| 32 / 34 / 35 / 36 | u16 / u8 / u8 / u8 | integration ms / gain / ADC bits / part ID |
| 37–39 | three u8 | reserved, zero |
States are 0 unsupported, 1 starting, 2 ready, 3 stale and 4 failed. Only READY records expose ADC/lux/full-scale data. READY requires nonzero generation, zero error and known age ≤500 ms; zero counts are valid. Other states clear those payload fields rather than exposing an old reading as live. Starting has zero error; stale/failed have nonzero error. Stale requires a previous configured generation. Unsupported clears metadata/counters/error as well. Before first configuration, generation/sequence/exposure are zero and age is unknown; the identified part ID may already be available during startup.
Age is rounded up from the host timestamp, with UINT32_MAX for unknown/future
timestamps and older ages capped at UINT32_MAX - 1. Sequence may wrap through
zero. Configuration generation skips zero. Gain is one of 1/3/6/9/18; 16/17/18/19/20
bits map to 25/50/100/200/400 ms integration. Configured part IDs have high nibble
0xB. Counts must fit the advertised ADC width. Nominal millilux is integer floor
of ALS * 45000 / (gain * integration_ms); the full-scale flag must match either
channel reaching (1 << bits) - 1. Saturation is distinct from an ordinary lux
reading. The driver currently selects 3x/18-bit/100 ms; the record already describes
other documented exposures without changing schema. Fur/window compensation and
LED spill remain unqualified. These observations do not enable automatic brightness.
Both phones provide Settings → Light sensors with level/age and disclosed raw counts, state, exposure, generation/sequence and error. Watch remains a compact remote; the minimal browser has no ambient-sensor dashboard. The shared wire vectors cover C encoding and strict Swift/Kotlin decoding, including provenance, exposure, freshness and invalid data.
Audio diagnostics v1¶
audio.capture is an additive u8 property: 0 stops diagnostics, bit 0 requests
air microphones and bit 1 requests contact capture (0–3 only). Submit it with
command.id and no other edits/actions. A successful command atomically replaces
diagnostic source demand for 60 seconds in the current authenticated generation.
It does not change settings revision, dirty/save state, catalogue or NVS schema.
A new command ID restarts the lease; ledger-deduplicated retransmission does not.
Unsupported-source/consumer-startup refusal cannot partially change the requests.
Disconnect, radio handover/departure, OTA begin and shutdown cancel the lease;
ordinary flash holds pause capture within its existing expiry. Receiver allocation/read errors
after admission appear in source status and use bounded service recovery.
With audio compiled out, audio.capture is read-only zero and source records are
omitted. With audio enabled, audio.air.status and audio.contact.status are
optional read-only type 14, exactly 64 bytes each. An unsupported contact backend
still supplies its typed unsupported record. Discover these properties and their
metadata; no new capability bit or catalogue control is assigned. Clients keep
normal controls usable on older firmware. Writes require both records plus writable
u8 capture metadata and an available level consumer; telemetry requires type/read-only metadata and matching
source identity. Four-value get batches and their existing 64-byte per-value bound
are unchanged.
All multi-byte fields are little-endian. Record version 1:
| Offset | Encoding | Meaning |
|---|---|---|
| 0 | u8 | Version, 1 |
| 1 | u8 | Source: 0 air, 1 contact |
| 2 | u8 | State: 0 unsupported, 1 off, 2 waiting for sensor, 3 flash-paused, 4 starting, 5 settling/masked, 6 receiving, 7 stale, 8 failed |
| 3 | u8 | Flags: bit 0 requested, 1 receiver running, 2 levels valid, 3 current level block has a gap, 4 level consumer allocated; other bits zero |
| 4 | u32 | Receiver generation |
| 8 | u32 | Latest capture observation age in ms; 0xffffffff unknown/future, 0xfffffffe saturated |
| 12, 16, 20 | 3 × u32 | Decoded block count, queue drops, receiver overruns |
| 24 | u32 | Last ESP-IDF capture error; zero means no recorded error |
| 28 | u32 | Diagnostic lease remaining ms, rounded up, 0–60000; zero if unrequested/expired |
| 32, 33 | 2 × u8 | Channel count (air 2, contact 3), per-channel clipping bits |
| 34–35 | reserved | Zero |
| 36, 42 | 2 × (3 × u16) | AC RMS then raw absolute peak, PCM counts, each 0–32768 |
| 48 | 3 × i16 | Signed block mean, PCM counts |
| 54–55 | reserved | Zero |
| 56 | u32 | Displayed level-block observation age in ms; unknown if invalid |
| 60–63 | reserved | Zero |
Air channel order is PDM L/R; mounted microphone identity is unqualified. Contact order is native X/Y/Z, not wearer-relative axes. The unused third air channel is zero. RMS removes the mean of one 160-frame/10 ms block; peak retains DC, mean truncates toward zero and RMS rounds to nearest PCM count. No gain or smoothing is applied. Full-scale RMS reference is 32768, with zero represented as −∞ dBFS. These values do not establish SPL or speech activity.
Level validity requires an allocated consumer, requested/running/receiving state, a nonzero matching generation and both host observations no more than 100,000 µs old. The firmware checks full timestamps before quantising ages. Invalid levels have zero clipping, gap, RMS, peak and mean, and unknown level age. Unknown enum/version/flag bits, nonzero reserved fields, out-of-range levels/lease, mismatched source/channel count and inconsistent validity are rejected. Apps show “Unavailable” for absent levels, never a fabricated zero reading. Capture and level observations are not qualified acoustic acquisition timestamps, and independent source records are not synchronised audio. A failed stop retains failed/running status even with demand off; requested-off is not proof of receiver shutdown. The shared fixtures exercise C encoding and Swift/Kotlin decoding. Routine BLE never carries PCM.
Diagnostic recording v1¶
Recording is explicit and separate from audio.capture. Submit audio.record
(u8/type 0) with command.id alone: 0 clears/cancels, 1 records air, 2 records
contact. It always reads zero. Commands share the existing authenticated command
ledger and deduplication, change no settings revision/dirty state and write no NVS.
Audio-omitted profiles expose read-only zero and omit the recording status.
Public recording is fixed at five seconds. The selected source must already be requested by this session's diagnostic capture lease, with at least 6250 ms left (five seconds plus one-second startup allowance and 250 ms completion slack). Refusal does not renew/start capture. A new admitted recording replaces the old clip; failed/partial clips cannot be downloaded. One PSRAM-only buffer holds at most 480,044 bytes. Missing PSRAM/allocation does not disable ordinary lighting or level capture. Disconnect, mode departure, OTA and shutdown revoke access before worker-owned memory release. Source pause/failure/discontinuity aborts recording.
Optional read-only audio.record.status is type 18, exactly 64 little-endian bytes:
| Offset | Encoding | Meaning |
|---|---|---|
| 0 / 1 | u8 / bool | version 1 / recorder available |
| 2 / 3 | u8 / u8 | state: 0 idle, 1 waiting, 2 recording, 3 ready, 4 failed / source: 0 air, 1 contact |
| 4 / 8 / 12 | u32 each | clip ID / ESP error / receiver capture generation |
| 16 / 20 / 24 | u32 each | captured frames / target frames / complete WAV bytes (READY only) |
| 28 | u64 | first receiver frame offset |
| 36 / 44 | nonnegative i64 each | first / last host observation, microseconds |
| 52 / 53 | u8 each | per-channel clipping mask / channels (air 2, contact 3) |
| 54–63 | reserved | zero |
Idle has zero fields after availability; unavailable implies idle. Non-idle has a
nonzero clip ID; only failed has a nonzero error. A failed allocation may retain
only source/ID/error with no target/channels. Otherwise target is 1600–80000 in
160-frame blocks, captured frames do not exceed it, and channel/quality fields
match the source. No frames implies zero generation/offset/observation/quality.
Waiting has no frames; recording has a nonzero incomplete count; READY has the
target count and exactly 44 + frames * channels * 2 bytes. Nonempty observations
are ordered and firstFrame + frames must not overflow. Clients reject unknown
versions/states/flags, nonzero reserved bytes and inconsistent records; shared
C/Swift/Kotlin fixtures define boundaries.
Explicit download uses reserved operation 0x80 on existing espurr/catalogue.
There is no new GATT characteristic/service: older Apple clients require the
existing exact characteristic map. Audio is dispatched separately from catalogue
INFO/READ and is never descriptor/cache content. This operation uses the same authenticated transport as the catalogue.
Protocol and catalogue versions are independent of link security. All fields below are little-endian:
| Message | Fields |
|---|---|
| Request, exactly 18 bytes | [1,0x80,0,0], authenticated session generation u32 at 4, clip ID u32 at 8, byte offset u32 at 12, requested count u16 at 16 (1–256) |
| Reply, 24 + N bytes | [1,0x80,0,0], ESP error u32 at 4, echoed session/clip/offset u32 at 8/12/16, actual count u16 at 20, two reserved zero bytes, WAV bytes at 24 |
Malformed requests fail the transport handler. Well-formed application refusals are authenticated/encrypted replies with error and zero data. The handler checks the live session ledger before and after reading a completed immutable clip and copies at most 256 bytes under its short mutex; it never waits for render, I2C or configuration. Maximum reply inside the secured link is 280 bytes.
Phones re-read status before transfer, then validate exact session/clip/offset/ count and the final PCM WAV header/length. Cancel stops between complete encrypted exchanges; it never replays ciphertext or exposes partial files. Confirmed refusals retain the session; malformed/ambiguous transport failures close it. A completed download stays in phone memory until replacement/disconnect. Save/Share explicitly stages two private temporary files before opening the native picker/activity, so normal background connection release cannot destroy an in-progress export.
The WAV is raw signed-16 LE at nominal 16 kHz: air 0/1 or contact X/Y/Z. The JSON
sidecar version 1 carries source/channel labels, sampleRate, frames, captureGeneration,
clipID, clippedChannels, nominalSampleRate: true and the time-basis caveat. firstFrame,
firstObservedUS and lastObservedUS are decimal strings, preserving full 64-bit values.
No device/account identifiers are exported. Host observation is not qualified
acoustic acquisition time, separate clips are not synchronised sources, and these
controls do not claim VAD or calibrated SPL. Watch/browser have no recording flow.
Power observation v1¶
power.status is an additive, optional read-only property; it does not change the
application protocol or catalogue schema. Legacy profiles omit it. Older clients
may ignore it; clients that interpret it must require type 13/read-only metadata,
exactly 64 bytes, version 1, known enum values, zero reserved flag bits and valid
field combinations. Metadata must remain stable during an authenticated session.
No power observation or its active override flag is writable.
All multi-byte fields are little-endian. Signed current uses two's complement.
| Offset | Encoding | Meaning |
|---|---|---|
| 0 | u8 | version, 1 |
| 1 | u8 | source: 0 unknown, 1 detached, 2 default Rp, 3 advertised 1.5 A, 4 advertised 3 A, 5 unsupported connection |
| 2 | u8 | policy reason: 0 limits unconfigured, 1 CC unavailable, 2 detached, 3 unsupported connection, 4 monitors unavailable, 5 latched fault, 6 source allowance unknown, 7 allowance insufficient, 8 ready |
| 3 | u8 | flags: bit 0 CC valid, 1 left valid, 2 right valid, 3 override active, 4 fault latched, 5 valid limits configured; bits 6–7 zero |
| 4 | u32 | CC observation sequence, wrapping; not the settings revision |
| 8, 10, 12 | u16 each | CC/left/right sample age in ms at this observation, rounded up; 65535 unknown, 65534 saturated |
| 14 | u16 | advertised current in mA: 1500/3000 only with valid corresponding CC status, otherwise 0; default Rp implies no numeric allowance |
| 16 | u16 | source allowance selected by policy, mA; 0 when unavailable/unknown |
| 18 | u16 | combined LED supply allowance, mA, including pixel idle draw |
| 20, 22 | u16 each | left/right LED supply allowance, mA, including pixel idle draw |
| 24, 28 | u32 each | left/right bus voltage, µV |
| 32, 36 | i32 each | left/right measured current, nA |
| 40, 44 | u32 each | left/right measured/computed monitor power, µW |
| 48 | u16 | fault bits: 0 left overcurrent, 1 right overcurrent, 2 estimated input overcurrent, 3 left voltage, 4 right voltage, 5 left reverse current, 6 right reverse current; other bits zero |
| 50, 51 | u8 each | raw HUSB320 STATUS/TYPE for diagnostics |
| 52, 56, 60 | u32 each | last CC/left/right ESP error, unsigned bit pattern |
The configured flag describes a valid configured model, not evidence of physical qualification. A ready policy requires all three valid flags, configured limits, no fault, a supported attached source, positive source allowance and sufficient combined/per-ear idle headroom. Non-ready policies grant zero combined/per-ear allowance. Source/combined allowances are bounded by 3000 mA, ear allowances by 2047 mA. The user current budget is an additional bound and can be lower than these supply allowances. Override cannot bypass the configured limits or faults.
Validity is checked against the component's 300 ms deadline at snapshot and encoding time, independently of a running publisher. A valid flag needs a known age ≤300 ms. Missing/stale/error samples must never be interpreted as measured zero. Raw retained measurements may remain in binary fields when invalid; clients must gate them by the corresponding valid flag. Current spans −2,048,000,000 to 2,047,937,500 nA, bus voltage 0–52,427,200 µV and monitor power 0–131,070,000 µW. Stale data cancels overrides and clears granted allowances. Fresh confirmed detached/unsupported reasons take precedence over absent ear readings; unconfigured limits and latched faults retain their stronger explanation.
This is one atomic power-component observation with per-sensor ages, independent
of the settings revision and the render owner's last applied power.ready value.
It is not simultaneous ADC sampling or proof that a frame has reached the LEDs.
The phone/browser display identifies ages as belonging to the last device update;
it must not present a cached age as a continuously refreshed live measurement.
GET /api/status optionally includes the equivalent power JSON object on v2:
version, source, reason, flags, sequence, advertised_ma, source_ma,
total_ma, ear_ma[2], ages_ms[3], bus_uv[2], current_na[2], power_uw[2],
faults, status, type and errors[3]. The ordering is left/right for ear arrays
and CC/left/right for ages/errors. Unknown ages are JSON null. Invalid measurement
entries are JSON null, including retained old readings; valid measured zero is
numeric zero. HTTP and binary use the same expiration/encoding helper. Malformed
or unavailable browser status replaces the diagnostics with an unavailable state.
The shared vectors cover the C encoder, Swift/Kotlin decoders and HTTP/browser representation, including signed endpoints, valid zero, stale and never-observed data, enum/flag/version errors and override.
Brightness 20 permits 20% peak PWM; 100 permits 100%. Actual output is also affected by RGB, gamma and current limiting. Main zero may leave an enabled background visible. Power is separate. Background 5 permits 5% PWM for a full-intensity colour. Never truncate RGB to the physical cap on the phone. Runtime 16-bit rendering is internal to firmware; stored artistic colours remain 8-bit.
Output tuning applies in firmware after all layers, before current limiting. Gamma
is a common peak exponent; RGB trims attenuate each physical channel. Neither changes
saved palette RGB or the fixed inverse sRGB decoding. Capability bit 9 requires both
output.gamma and output.rgb_trim; absence leaves default 2.2/100/100/100 and disables
tuning in the app. Schema 10 persists both, with defaults for older records. Updating
one property preserves the other and follows ordinary command/save semantics.
Preset order: purple/orange/pink/blue, purple/orange, shades of purple, purple/pink,
lesbian flag, trans flag, enforcer yellow. Existing surviving effect IDs are unchanged.
IDs 13–17 are Motion twinkle, Liquid light, Inertia, Stardust and Motion aurora.
Schema 11 migrates stored 3→14, 4→13, 10→17; live retired-ID writes are rejected.
Effects/overlay availability, labels, parameter editors and bounds come from the
firmware descriptor. Future primitive properties are resolved by name/type/access,
not Swift cases. Invalid optional bindings disable their controls. Atomic edits carry
at most 16 fields plus command.id, and are rejected if the full wire payload exceeds
496 plaintext bytes; clients never split one edit silently.
Calibration actions remain queued while the renderer waits for ten idle seconds of
continuous stillness (five-second timeout), without blocking LED transmission.
Cancellation invalidates the pending ticket before a disconnected request can be
captured; completed calibration patches the latest settings. Capture timeout is
ESP_ERR_TIMEOUT (263); pose/basis failures retain their existing error codes.
The native calibration deadline is seven seconds. Authenticated application refusals
keep the encrypted session usable; transport/crypto/malformed-protocol failures and
ambiguous client timeouts still close it. Refused actions are never automatically
replayed. Gesture colours bypass enforcer remapping and share the overlay cap/current
limits. Descriptors explain calibration readiness without changing degraded behaviour.
Each BLE patch updates only the named field; toggle and threshold writes use the existing
command confirmation and ten-second idle save coalescing.
A Long edge count of zero selects a straight strip; otherwise coordinates are short-edge base → tip → long-edge base. Both v1 outputs default unreversed; reverse applies after rendering and is unavailable for fixed v2 wiring. See layout/migration. Changing a pixel count must submit a valid long-edge count in the same topology patch.
Submission and actions¶
Firmware validates an entire patch before queue admission, including duplicate or
conflicting palette/preset entries, lengths/ranges and session generation. Render
owner applies selected fields against its latest settings, never a stale full snapshot.
Unselected button/web changes survive. Set success initially means accepted; the
Swift ControlClient.apply API must wait for confirmed application before returning.
Every set includes command.id. The latest accepted request's command.result
must match both that ID and session.generation before its applied/failed state
confirms the operation. State revision alone cannot confirm an operation amid
physical-button changes. Serialize operations until confirmation; do not submit a
new ID while waiting for an older result. IDs strictly increase, never wrap within
a connection, and restart at 1 on reconnect. Eight retained records deduplicate
IDs whether queued, applied or failed. Reusing an ID ignores its payload; evicted
old IDs are rejected. A queue-admission failure is not successful application.
Calibration neutral/right/reset/recenter and explicitly disabling BLE are actions, not sticky booleans. Scope request deduplication to a session; discard queued commands on disconnect/mode closure. An ambiguous failed action is not automatically replayed. App discards pending slider edits when disconnected and refreshes before enabling UI.
Settings changes coalesce NVS writes after ten idle seconds. "Applied" and "saved" are
separate states; expose pending save and failure. BLE mode remains selected until a
physical mode switch or explicit close action. The existing remote.timeout_s property
sets Wi-Fi no-client timeout for compatibility. Retain firmware queue, save and
credential limits independent of UI.
The stock NimBLE transport's payload limit is 512 bytes. Budget protobuf/encryption overhead and page property discovery/status rather than fetching everything at once. Serialize request/response exchanges; verify long writes/reads and bonded exchanges on both real phone platforms. Firmware reports ESP errors through the result record; the application must distinguish those from protobuf status, GATT errors and lost connections.
Examples already covered by Swift tests¶
- RGB lilac:
b4 82 ff. - u16 timeout 300:
2c 01. - float32 speed 1:
00 00 80 3f. - Two band colours violet/orange:
02 01 60 00 ff ff 8c 00. - Default left topology:
12 0c 01 00(hexadecimal; 18 total, 12 long-edge pixels).
These are property-value bytes, not complete BLE/protocomm packets. Encoders
live in ios/EspurrControl/Sources/EspurrControl/PropertyCodec.swift and DeviceProperties.swift.
Shared C/Swift vectors live in tests/fixtures/local-control-v2.json.
Palette-cycle properties are optional without capability bit 6; when advertised, both are required and validated. They are separate from the RGB palette payload, so editing colours/presets does not disable cycling, and cycle patches leave RGB, bands, preset and animation speed unchanged. HSV hue rotates only main palette colours before linear-light decoding; background/gesture colours stay fixed. White/grey/black stay neutral. Rainbow ignores the palette. Swatches show saved RGB.
Named palettes encode nameLength u8, then that many UTF-8 bytes (1–24), followed
by the existing palette encoding: count u8, bands bool, count*RGB. Maximum value
is 51 bytes; empty slots encode exactly 00. Reject trailing bytes, malformed UTF-8,
NUL/control characters, leading/trailing ASCII spaces and duplicate nonempty names.
Name uniqueness compares UTF-8 bytes. All eight values and palette.restore are
required when capability bit 7 is advertised; absent specialised features remain disabled. Select a saved palette by applying its RGB palette;
palette.preset becomes 7. A slot write only patches that slot and does not change
active colours. Multiple slot patches validate atomically. Restore must be the only
payload besides command.id; it keeps unrelated settings and shares command/result,
deduplication and ten-second idle NVS save semantics. Internal selected fields use a fixed
256-bit set; dense slots are unrelated to stable control identities.
Authenticated firmware updates¶
Firmware advertising capability bit 10 exposes optional espurr/ota, UUID FF01,
protected by the same bonded link and live generation as property/catalogue control.
Version/session/control/catalogue are required; OTA remains specialised.
OTA serialises all encrypted exchanges against normal edits/polls. Its request IDs
are separate from property command.id; connection generation is shared.
See BLE OTA v1 for encodings, confirmation, cancellation and lost-final-
reply semantics. Browser/USB bootstrap this endpoint firmware. No separate BLE host,
component or ESP-BLEOTA compatibility is introduced.
Browser writes¶
The browser uses the same descriptor JSON and named scalar bindings. GET settings returns a coherent revision/hash/value dictionary. PUT settings supplies that hash and a sparse values dictionary; the render owner merges against its latest settings and synchronously saves. Power is an explicit bool. Applied/save-failed responses are distinct; stale catalogues return 409. Browser reboot/exit and OTA retain their existing AP origin, upload and acknowledgement rules. The full endpoint contract is in catalogue.md.
Optional hardware and services¶
Board/IMU/LED transport choices are compile-time configuration; application protocol remains version 2. IMU-off retains base motion property encodings and the effect catalogue, reports invalid motion, refuses calibration, and clears capabilities 1/4. Phone/watch disable motion-specific editors and calibration accordingly. A missing configured sensor is degraded operation, distinct from an IMU-off build.
OTA-off omits espurr/ota, its UUID lookup/handler and HTTP upload route; bit 10 is
zero. Web-off omits AP/browser startup; radio selection refuses unavailable modes
without changing the current one. BLE-off omits local-control startup. USB recovery
and the native pending-image rollback health gate remain available.
NVS schema 19 adds calibration sensor/bus identity; older original-Atom calibration is retained only on the matching MPU6886 bus. Other settings survive identity changes.
Schema 20 adds flashlight.brightness under the NVS flashlight object. Activation
is never stored. Flashlight preserves the normal effect, palette and brightness,
and uses the latest normal settings on dismissal. It can temporarily enable LEDs
from off; disabling flashlight then returns to the underlying power choice. An
explicit power=true while flashlight is active changes that choice to on.
power=false always clears flashlight. An atomic patch combining power=false
with flashlight.enabled=true is rejected before mutation. The separate live
properties supply flashlight state; the 30-byte status record and capability bits
are unchanged. Clients discover these controls by catalogue/property presence and
omit them on older firmware. Watch keeps its existing compact controls.
Flashlight replaces ordinary composition with a provisional warm RGB white, using its own brightness followed by physical trims, the hardware ceiling and the same estimated-current limiter/dither reserve. It covers all known physical pixels, using the configured physical counts. Flashlight does not bypass update/flash pauses, diagnostic output or shutdown. The current RGB tuple is not a measured colour temperature or a new electrical power allowance.
Device features and air bands v1¶
Optional read-only device.features (type 19, 8 bytes) reports version 1 at byte 0,
zero bytes 1–3, and a u32 LE compiled support mask at 4: bit 0 topology.xy, bit 1
topology.rings, bit 2 audio.air.bands, bit 3 audio.contact.capture. Unknown
bits are ignored; unknown requirement names are unavailable. All current profiles
publish the record. Absence on older firmware means no extra feature is established.
Support is independent of runtime receiver/DSP health, not physical qualification.
Optional read-only audio.features.status (audio-enabled profiles, type 20) is
exactly 64 bytes, little-endian. It adds no GATT characteristic or capture command.
| Offset | Size | Meaning |
|---|---|---|
| 0 | 1 | Version 1 |
| 1 | 1 | State: 0 unsupported, 1 off, 2 starting, 3 ready, 4 stale, 5 paused, 6 failed |
| 2 | 1 | Flags: bit 0 workspace available, 1 valid, 2 effect demand admitted, 3 source requested, 4 receiver running |
| 3 | 1 | Clipped input-channel mask, 0–3, valid windows only |
| 4 | 4 | Receiver generation |
| 8 | 4 | Capture observation age in ms |
| 12 | 4 | Oldest feature observation age in ms, valid only |
| 16 | 4 | Newest feature observation age in ms, valid only |
| 20 | 4 | Current error: effect admission, else receiver failure, else DSP failure |
| 24 | 8 | Exclusive next frame, valid only; full u64 range, no alignment requirement |
| 32 | 24 | Six IEEE float32 linear RMS bands, valid only, each finite 0–1 |
| 56 | 8 | Reserved zero |
Ages use UINT32_MAX for unknown. READY iff valid; validity requires workspace,
requested/running/READY source, matching nonzero generation, complete fresh window,
next frame ≥512, all three ages ≤100 ms and newest age ≤oldest age. Invalid status
has zero clip/frame/bands and unknown oldest/newest ages. Error is nonzero iff FAILED;
unsupported additionally has zero flags/generation/error and unknown capture age.
The wire encoder rechecks publication freshness. Separate snapshots can race a
receiver transition and conservatively produce unavailable data rather than a READY
record from mismatched generations.
Bands are nominal 80–250, 250–500, 500–1000, 1000–2000, 2000–4000, 4000–8000 Hz, relative to signed-16 full scale, before lighting gain. Valid zeros mean silence; unavailable data has no band values. This is not VAD, SPL, beat or wearer detection. The shared feature vectors and compiled-support vectors cover native encoding and both phone decoders, including unknown bits and the full frame range.
Effect demand is render-owned and combined with the diagnostic lease mask. Ending
a lease, disconnecting or leaving a radio mode preserves standalone effect demand.
audio.capture and its deadline describe only diagnostics; each source's remaining
time is zero unless that source has a diagnostic lease, even if it remains requested
by an effect. Recording still requires an explicit source lease with sufficient time.
Global off, changing effect, flashlight, diagnostics mode, OTA and shutdown clear
ordinary effect demand. Ordinary flash pauses retain demand and use the capture
owner's existing pause acknowledgements. No PCM transfer occurs during status reads.
The minimal browser settings response adds device_features:{version,mask} and,
when audio is compiled, audio_features:{version,state,flags,error}. It uses the same
encoder/state rules, omits band graphs/capture/recording controls, and preserves
sparse descriptor-based edits and existing OTA/security behaviour.
AP provisioning and storage¶
When web support is compiled in, optional read-only wifi.ap_state (u8) and
wifi.ap_ssid (UTF-8, 1–32 printable ASCII bytes) are published together. SSID is
computed from the actual SoftAP MAC, not the BLE address. States are 0 unconfigured,
1 configured, 2 storage error, 3 newer credential schema, 4 invalid credential record.
Clients without these properties retain older behaviour and do not offer setup.
No property reads the password. The credentials are for ESPurr's own AP, not a
home-network station configuration.
Bonded clients use the existing secured espurr/catalogue endpoint, version 1,
opcode 0x82. This narrow provisioning operation is permitted in recovery;
ordinary wearable settings and calibration remain prohibited. Request layout:
| Offset | Bytes | Meaning |
|---|---|---|
| 0 | 1 | version 1 |
| 1 | 1 | opcode 0x82 |
| 2 | 1 | operation: 0 query, 1 set |
| 3 | 1 | password length: 0 query, 8–63 set |
| 4 | 4 | nonzero current control generation, little endian |
| 8 | 4 | command ID: 0 query, nonzero set |
| 12 | length | printable ASCII password, no terminator or trailing bytes |
The fixed 20-byte response is version/opcode, result u8 (0 idle, 1 queued, 2 applied, 3 failed), AP state u8, generation u32, retained command ID u32, result ESP error u32 and storage ESP error u32, all words little endian. Idle has ID 0; other results have nonzero IDs. Only failed has a nonzero result error. Applied means the independent record committed and runtime state was published, not that the AP restarted or a phone joined it.
One bounded worker queue keeps NVS work out of GATT callbacks. Normal firmware writes through the render owner with output/audio paused; recovery acquires its flash hold. OTA rejects provisioning and provisioning rejects active OTA. The owner rechecks the live generation before writing. Exact duplicate ID/password returns the retained result without replay; changed payload for the same ID and older IDs are rejected. A new generation cannot replace queued work. This ledger is independent of the ordinary settings command ledger.
Clients query, choose retained ID + 1, submit once and poll for exact generation/ID confirmation. IDs never wrap. Ambiguous failures close the session and are never automatically replayed. Phone fields are transient, support password generation and explicit clipboard copy, and do not read back or persist the secret locally. Watch provisioning remains phone-only.
The AP record is JSON {"schema":1,"password":"..."} in namespace espurr_net,
key ap, bounded to 192 bytes including its terminator. It is independent of the
normal settings groups and SDK bond records. Legacy
espurr/record schema 1–22 documents migrate to the split layout on the next save.
Valid separate credentials override legacy settings/private defaults. Missing AP
records retain that fallback; invalid or future AP records do not silently use a
different password. Supported invalid AP records may be explicitly replaced;
future AP schemas are protected. NVS is not encrypted.
General settings saves omit the password. A legacy password is committed to the new record before replacing the legacy general document; migration failure leaves the original document intact. Unchanged records skip writes. Older firmware does not understand the new namespace and may require its old saved/private password; rolling back does not make it read the new credentials.
Startup repairs unusable supported settings records to defaults and normal firmware persists partial salvage while retaining valid fields, credentials and bonds. Recovery does not persist partial salvage because it lacks normal IMU identity; normal firmware completes that repair. Split-group repairs are deferred entirely in recovery for the same reason. Missing records need no repair. Unknown newer schemas are read-only; initialisation and I/O failures never cause an automatic partition reset. A failed repair stays reported. Whole-storage reset is explicit via the USB reset tool, which also removes AP credentials and bonds. Normal settings use separate controls, palette-library, calibration and hardware records with an atomic selection marker.