Firmware-owned catalogue¶
Current BLE security/host/bulk-transfer contract: bonded BLE transport. LE Secure Connections with numeric comparison and bonding is required. Current apps reject legacy Security 2 and unmarked plaintext; no fixed device passkeys are used.
The current manifest uses application protocol 2 and catalogue schema 1.2,
namespace dev.neggles.espurr. This is a coordinated replacement of protocol 1:
there are no legacy masks, effect aliases or old-firmware fallback models. Subsequent
compatible catalogue additions are discovered by the installed phone/watch clients.
The browser is a minimal remote and OTA/recovery page.
Identity and document¶
Entry identity is (namespace, kind, u32 ID). Control identity is (namespace, u32 ID);
its property name is immutable. Neither display order, registry position, property
index nor internal patch slot is an identity. UINT32_MAX is invalid as an identity;
ordinary u32 scalar values can use its complete range. Effect zero remains Static.
Effects 3, 4, 8 and 10 are permanently retired. Existing surviving IDs are unchanged.
Overlay IDs are 1 Enforcer, 2 Shake, 3 Nod, 4 Turn trail, 5 Sideways tilt (currently
presented as Raised tip), 6 Move shimmer and 7 Forward lean. A compatible behavioural
refinement increments the entry revision; an incompatible replacement needs a new
ID and an explicit stored-settings migration.
components/catalogue/manifest.json is the authoring source. Its private backend
records name native settings members/dense control fields and renderer dispatch.
tools/generate_catalogue.py removes those records, orders entries by kind/order/ID
and controls by ID, sorts object keys, uses compact separators and UTF-8, rejects
non-finite numbers, and emits bytes without a trailing newline. It generates flash
bytes, their SHA-256, identity constants, scalar bounds/defaults, native bindings
and private parameter storage. CMake regenerates when the manifest, generator or
sdkconfig changes. Configurable defaults use an explicit allowlist; credentials,
calibration and other device-specific state cannot enter the descriptor.
| Record | Fields |
|---|---|
| Root | schema:{major,minor}, namespace, effect_control, common_controls, entries, controls |
| Entry | kind, id, key, revision, label, watch_label, help, short_help, order, motion, calibration, palette, controls, optional requirements; overlays also have enable_control |
| Control | id, property, codec, editor, label, help, unit, default, min/max for numbers, step, precision, scope, optional choices and visible_when |
Motion/calibration are none, optional or required; palette behaviour is used,
ignored or generated. Requirements explain idle/degraded behaviour; they don't
prevent an effect or overlay being selected. Scope is shared, volatile,
effect:ID or overlay:ID. Conditions are {mode:"all"|"any",terms:[{control:ID,value:bool}]}.
Editors are toggle, slider, stepper, select (integer choices) and colour.
Codecs are bool, u8, i8, u16, u32, finite float32 and three-byte artistic sRGB (rgb).
UI step doesn't quantise firmware values. Float submissions/ranges are validated in
binary32 representation.
| Bound | Limit |
|---|---|
| UTF-8 document | 32768 bytes, uncompressed |
| Effects + overlays | 64 |
| Controls | 128 |
| References per entry | 12 |
| Choices per selector | 16 |
| Machine key / property | 48 bytes |
| Label / watch label | 48 / 24 UTF-8 bytes |
| Help / short help | 256 / 96 UTF-8 bytes |
| Unit | 16 UTF-8 bytes |
| Visibility | 4 boolean equality terms, one flat all/any |
| JSON nesting | 8 levels |
Generation and clients reject duplicate JSON keys/identities, malformed UTF-8, unresolved references, invalid ranges/defaults and exceeded bounds. Native/browser preflight checks nesting and duplicate keys before normal document decoding. Unknown optional fields are ignored. Unknown codecs/editors make controls unavailable; invalid selection/enable roles or incompatible property type/access bindings disable the affected edits. Unsupported schema majors are incompatible, never interpreted as a preview catalogue. Malformed BLE responses close the session.
The optional root led_layout carries independently versioned render geometry;
see LED layout. Generation uses the same
selected reviewed layout as the board, never runtime CAD or NVS. It shares the
catalogue digest, transfer bounds and exact-hash cache. Older clients ignore it;
new clients must keep unsupported layout metadata independent of ordinary editing.
INFO reports the generated catalogue major/minor, matching the downloaded document.
Bindings and retained values¶
Initial controls are 1 effect, 2 brightness, 3 speed, 4 motion sensitivity,
5 shared overlay offset, 6 gesture swing, 7 shake threshold, 8 nod threshold,
9 Raised tip start angle, 10 Raised tip colour, 11 volatile power, 12 Raised tip travel angle, and 100–106 the seven
ordered overlay switches. Common controls are [11,2,3,4]. Shared controls have
one authoritative value and coalescing key even if multiple entries reference them.
Add a private primitive by copying a numeric/bool/RGB control, assigning a fresh ID
and property, selecting its entry scope, and using "backend":{"private":true}.
The generator supplies a typed settings.parameters.p_ID and dense patch slot.
Reference the control from the relevant entry and consume that native value in its
renderer. Defaults, wire validation, binding, selected-field merge, JSON persistence
and native/browser editing then use the same definition. Values persist by immutable
property name inside the NVS parameters object and survive selection changes.
A new parameter still needs firmware behaviour and tests; it needs no new Swift case
or browser field. Existing shared settings keep their storage/meaning.
Native effect selection/previous state are u32. Explicit ID-to-renderer dispatch, registry membership and ordered button cycling replace range arithmetic and the 32-bit supported mask. A fixed 256-bit field set tracks internal dense slots; stable IDs are never bit positions. The catalogue depends on neither settings nor effects. Settings/accessors and rendering consume its metadata independently.
Settings schema 22 accepts schemas 1–22, preserving schema-qualified retired-ID migrations and field-by-field recovery. A newer-schema record blocks writes during rollback. NVS records, including the terminator, must fit 4096 bytes; save checks size before writing. JSON construction must complete before a save: allocation failure returns an error and leaves the stored record untouched. Schema 19 also binds saved calibration to sensor/bus identity, retaining legacy Atom calibration only on its matching MPU6886 bus. Private parameters recover individually; no load-time migration save or NVS erase is introduced.
Shared controls 13 (flashlight.enabled, volatile bool) and 14
(flashlight.brightness, saved u8 0–100, default 100) are additive within catalogue
schema 1.0/application protocol 2. Their IDs belong to the control namespace;
effect identities are unchanged. Scalar accessors take the selected live boolean
for controls 11 and 13; neither enters settings_t or NVS. Schema 20 persists only
the independent brightness preference. See the local-control contract
for power/restoration and physical output semantics.
Authenticated pagination and cache¶
espurr/catalogue is UUID suffix FF02 on the single bonded conn_mgr host.
Current native clients can fetch the descriptor object through OTS after fresh
GATT INFO; the same exact length, SHA-256, schema and cache validation apply.
Reserved operation 0x80 carries explicit diagnostic WAV reads, dispatched by the
transport separately from this catalogue protocol. Its independent framing is in
diagnostic recording; it is never fed
to the descriptor loader/cache. Reusing the endpoint preserves the GATT map for
older clients. The INFO/PAGE operations and catalogue versions below are unchanged.
Transfer version is 1, independent of application protocol and catalogue schema.
All fields are little endian. Request header is version:u8,operation:u8,request_id:u32;
response adds status:u16. IDs are nonzero and replies must echo operation/ID.
| Operation | Request body | Successful response body |
|---|---|---|
| INFO (1) | none | schema major/minor u16 each, byte length u32, SHA-256[32], entry count u16, control count u16, max page u16, reserved u16=0 |
| PAGE (2) | expected hash[32], offset u32, requested length u16 | hash[32], offset u32, total size u32, returned length u16, bytes |
INFO is 56 plaintext bytes. PAGE has 50 bytes of overhead and at most 446 bytes of content, retaining the bounded 496-byte reply ceiling inside the secured link. Offsets must address a byte inside the document; length must be 1–446. Final pages shorten to remaining bytes. Pages are stateless. Error replies are encrypted headers only: 1 unsupported transfer version, 2 malformed operation/request, 3 stale hash, 4 invalid offset/length. No hash computation, allocations or NVS writes occur in rendering; handlers allocate only a bounded 496-byte reply.
Native clients obtain fresh INFO on every authentication, then reuse only exact-hash validated bytes or download pages. They concatenate bytes before UTF-8 decoding and verify the full SHA-256. Cache is per namespace/schema-major directory, eight documents and 256 KiB; corrupt/incomplete blobs can't become live state. Browser GETs descriptors once per page load, using the response hash as an opaque version token; it has no persistent descriptor cache or client SHA-256.
Catalogue, properties, command results and OTA use one authenticated exchange owner, with session-generation checks and sequential nonces. Ambiguous transport/crypto failures close the session; no ciphertext/action is automatically replayed. Property name/index/type/access maps are rebuilt for every session, up to 256 properties, in four-property read batches. Entire encoded replies/requests must fit 496 bytes. Live settings and indices are never descriptor-cache data.
Writes and browser¶
Generic BLE edits contain at most 16 bindings plus command.id and use the ordinary validated atomic command ledger/render merge/result path. Duplicate logical bindings, invalid values and oversized wire messages fail before submission; an edit is never silently split. Application confirmation is separate from the two-second coalesced save and its dirty/error state. Palette/calibration/topology/output/OTA APIs remain specialised. Phone renders catalogue descriptions, overlays and primitive editors; watch renders all effects and overlay enables, brightness and shared offset. Foreground ownership and reciprocal handoff retain their existing lifecycle.
Browser displays common controls, selected-effect parameters, firmware/save status, OTA, reboot and return to BLE. Advanced configuration stays on phone. Routes:
| Route | Contract |
|---|---|
| GET /api/catalogue | immutable JSON; X-Espurr-Catalogue-Hash header |
| GET /api/settings | {catalogue_hash,revision,values:{property:value}}, one published snapshot |
| PUT /api/settings | {catalogue_hash,values:{...sparse edited properties...}}, at most 16 edits / 4096 bytes |
| GET /api/status | existing status API; page displays selected status only |
| POST /api/control | {action:"exit"|"reboot"} |
| POST /api/ota | existing raw image upload |
Status polling updates sound health in place. It preserves generated inputs, focus and local drafts; unchanged values do not rebuild the form. Remote settings or compiled-support changes reconcile when there is no draft or focused input/select. An explicit refresh after Apply & Save uses authoritative values immediately.
Apply & Save validates through the same scalar decoder and selected-field merge as
BLE, then synchronously asks the render owner to apply against its latest settings
and save. It has no fake BLE session or command ledger. Power is an explicit boolean.
A stale catalogue is 409; validation/application refusals are 400. An applied change
with failed persistence returns 200 with applied:true,saved:false,dirty:true, revision
and save error. The page refreshes authoritative values and explicitly reports the
save failure. Ambiguous mutations require refresh before retry. Host/Origin checks,
passworded AP, idle timeout, upload validation, render flash pauses, rollback gate
and reboot after final acknowledgement remain in force.
Verification and physical acceptance¶
Shared fixtures are tests/fixtures/catalogue-v1.json, catalogue-future.json and
local-control-v2.json. tests/catalogue_future.py generates an unshipped native
registry with IDs 31, 32, 255, 256, 65536, a new overlay, and float/bool/signed choice/
u16/u32/RGB parameters. The unchanged client source discovers and writes them.
Host checks cover page limits, final pages, hash errors, fields above bit 63,
private storage/retention/recovery, NVS ceilings and existing frame goldens. Swift
checks cover digest/cache corruption, interrupted transfer, optional bindings,
wide IDs, reordered properties, atomic writes and the established lifecycle.
Browser checks validate descriptor bounds and render the future fixture/save error.
Builds/signing/installations are distinct from physical acceptance. On hardware, verify bonded transfer/paging and cache reuse, exact restoration, reboot persistence, LED reliability under reads/writes, radio handovers and interrupted/final-ack OTA. No visual/electrical timing or mounted gesture acceptance follows from host tests.
Raised tip overlay 5 revision 2 (0.6.1) adds private u8 control 12,
overlays.tilt_travel_deg (5–30°, default 15°). Start/control 9 retains its saved
value and binding but is labelled Start tilt. The sum must be ≤75°; native settings
validation rejects contradictory atomic patches and both descriptors explain it.
The current metadata format cannot express dependent numeric bounds, so clients
retain the individual slider ranges and confirmed firmware refusal. See
tilt-driven behaviour.
Audio requirements and Sound bands¶
Schema 1.1 adds optional entry requirements: at most four unique machine strings,
48 UTF-8 bytes each. Omission means no extra requirement. The native manifest must
map every requirement to a compiled feature; clients preserve unknown names and
regard them as unavailable. Known names are topology.xy, topology.rings,
audio.air.bands and audio.contact.capture. Optional read-only device.features
reports compiled support separately from current sensor/processing health; see
the wire contract.
Effect 18 (effect.sound-bands, revision 1) requires audio.air.bands. Six linear
RMS bands animate the palette around each closed path, or a legacy straight strip;
the independent centre uses the mean inner-ring colour. Envelopes use 25 ms attack
and 220 ms release. Missing or unqualified input resets envelopes and shows an 8%
static palette; valid silence has the same dim appearance but distinct READY status.
Normal gamma, brightness, layers, trims and current limiting still apply.
Private float control 15 (audio.gain_db, 0–48 dB, default 24) affects lighting only.
Settings schema 21 seeds it when loading older settings and protects newer-schema
records on rollback. It never changes captured PCM/recordings or motion sensitivity.
Clients permit effect selection when requirements are unavailable, show the reason and make scoped controls unavailable. Physical next/previous cycling skips effects whose requirements the profile cannot supply. All effect identities remain present in every catalogue. Older schema-1 readers can ignore optional requirements and records; firmware still provides the dim fallback if they select this effect.