Skip to content

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.