Robine Wake Bluetooth contract v1
This document is the compatibility boundary between the Robine Wake firmware and native clients. Version 1 is additive: implementations must ignore unknown JSON fields. A breaking change requires a new contract version and, if the transport itself changes, a new GATT service UUID.
Security is intentionally none for the feature prototype. The contract reserves
space for later pairing and authenticated requests; it must not be presented as a
secure control channel in version 1.
GATT service
Section titled “GATT service”| Role | UUID | Properties |
|---|---|---|
| Robine Wake service | 8AD00001-A7A9-4EF7-A259-67C9C65E4C10 |
Primary service |
| Command | 8AD00002-A7A9-4EF7-A259-67C9C65E4C10 |
Write with response |
| Response | 8AD00003-A7A9-4EF7-A259-67C9C65E4C10 |
Read |
Commands
Section titled “Commands”The command characteristic accepts one UTF-8 JSON object terminated by \n.
The firmware may receive that object over several GATT writes and limits a command
to 65,536 bytes.
{"v":1,"id":"client-generated-uuid","op":"state.get","body":{}}vis the contract version.idis generated by the client and echoed in the response.opis a namespaced operation.bodyis always an object. Mutating an existing alarm requires its currentrevision; stale writes fail instead of silently overwriting a newer edit.
Successful response:
{"v":1,"id":"client-generated-uuid","status":"ok","body":{}}Error response:
{"v":1,"id":"client-generated-uuid","status":"error","error":{"code":"stale_revision","details":{}}}Clients branch on error.code, never on a human-readable message.
Paged response transport
Section titled “Paged response transport”Each read of the response characteristic returns at most 480 bytes. Every page starts with this 20-byte big-endian header:
| Offset | Size | Field |
|---|---|---|
| 0 | 2 | ASCII magic RW |
| 2 | 1 | Transport version (1) |
| 3 | 1 | Flags; bit 0 means final page |
| 4 | 4 | Response sequence number |
| 8 | 2 | Zero-based page index |
| 10 | 2 | Page count |
| 12 | 4 | Total JSON byte length |
| 16 | 4 | CRC32 of the complete JSON payload |
| 20 | ≤460 | Page payload |
The client rejects mixed sequences, missing or duplicated pages, inconsistent metadata, invalid lengths, and CRC mismatches before decoding JSON. It reads until the final flag is present and the received page count and total length agree.
Version 1 operations
Section titled “Version 1 operations”capabilities.getstate.getalarm.create,alarm.update,alarm.delete,alarm.preview,alarm.triggernap.start(duration_minutes),nap.cancel— one-shotnapalarm, gentle ramp capped at two minutessession.get,session.snooze,session.dismisssettings.smartphone_dismiss.setclock.confirmcamera.calibrate— retired capability; reserved legacy operation, no capture side effectsbreathing.start,breathing.volume,breathing.stoplight.program— forwarded to the ESP32 LED strip, listed only when one is configuredlight.get— what the strip shows, read from the ESP32, listed withlight.programlight.ambience.volume—{"volume_percent":0-100}, the ambience playing with the striphistory.erase,device.factory_reset
The BLE state payload is a purpose-built client DTO, not a serialization of the firmware’s internal processes. Audio files, diagnostics archives, and firmware images remain Wi-Fi transfers; BLE is the nearby configuration and control plane.
alarm.trigger starts the exact same wake-session state machine as a scheduled
alarm. It carries no test flag: session controls and dismissal semantics are
therefore identical for manual tests and real wake-ups. While visible, clients may
poll the lightweight session.get operation to expose stop and snooze controls.
settings.smartphone_dismiss.set accepts {"enabled":true|false}. The command
is rejected with smartphone_dismiss_setting_locked while a wake session is
active. state.get exposes smartphone_dismiss.enabled and
smartphone_dismiss.locked; active session DTOs expose the permission captured
at start as smartphone_dismiss_allowed. When false, session.dismiss is
rejected with smartphone_dismiss_disabled.
Philips Hue was removed on 2026-10-20: the ESP32 LED strip lights the dawn
and the evening. hue.* operations are no longer listed and answer
unsupported_operation; saved bridge credentials are dropped at startup.
breathing.start accepts a cadence written hold empty-inhale-hold full-exhale in seconds from 0 to 60 ("0-4-0-6" for cardiac coherence, an
inhale and an exhale required), duration_minutes from 1 to 60, and an
optional volume_percent from 0 to 100 (60 by default). The strip breathes on
its own (light.program breathing) and ends on the last full breath, rounded
up from the duration, while the speaker plays the meditation ambience. Without
a reachable strip the ambience still plays. breathing.volume changes
volume_percent during a session, and breathing.stop is idempotent and
turns the strip off. A wake session ends a breathing session and takes the
strip; breathing.start answers wake_session_active while one rings.
state.get exposes the session under breathing: cadence, the four phase
durations (hold_empty_ms, inhale_ms, hold_full_ms, exhale_ms),
started_at, server_time, elapsed_ms, remaining_seconds,
volume_percent, and audio/light objects with active and error.
light.program starts a program the strip plays until the next one:
{"program":"dawn"|"sunset","duration_ms":…} (1 ms to 4 h),
{"program":"breathing","cadence":"0-4-0-6","duration_ms":…},
{"program":"reading","duration_ms":…,"sunset_ms":…} (a warm amber at the
bottom of the strip, orange then red towards the top, then a sunset of
sunset_ms: 30 minutes when absent, none at 0, the strip then fading out
in 5 s) or {"program":"off"}. Any program but off may
carry "ambience":{"sound_id":"storm_rain_lasonotheque_2719","volume_percent":40}:
the Raspberry Pi plays that sound with it, holds the volume while the light
holds, and lowers it to silence while the light fades (over a sunset, over
the sunset after a reading light); a dawn’s ambience stops at daylight.
off, another program, a breathing session or an alarm stop it. The ESP32
never sees the ambience. A program ends a breathing session in progress.
state.get exposes it under light_ambience: active, program,
sound_id, volume_percent, gain_percent (the volume now, along the fade),
hold_ms, fade_ms, elapsed_ms and audio with active and error.
Refusals: sound_not_found, invalid_ambience_volume,
wake_session_active. ESP32 refusals come back as their code
(invalid_program, invalid_duration, invalid_cadence); an unreachable
strip as light_unavailable.
light.get and light.program both answer with the strip as the ESP32
renders it: program (dawn, sunset, breathing, reading or null),
elapsed_ms and duration_ms of the program (a breathing session lasts its
whole breaths, a reading light includes its sunset), its cadence, and pixels, each pixel as rrggbb in a single hex
string (60 for the strip).
Alarm payloads may contain this additive object:
{ "light_profile": { "enabled": true, "end_behavior": "off" }}When enabled (the default), the strip plays a dawn over prewake_seconds,
reaching daylight at the alarm time; a session started late (restart, snooze,
strong test) plays only what is left of it. A snooze turns the strip off.
end_behavior is off (the strip turns off when the alarm stops) or leave;
alarms saved with the Hue fields (target_id, brightness, ramp_seconds) keep
their enabled switch and read restore as off. Unknown additive alarm
fields remain subject to the normal version 1 compatibility rule.
Camera-free product migration — specified 2026-09-06
Section titled “Camera-free product migration — specified 2026-09-06”The product removes camera capture and presence-based dismissal. V1’s legacy
camera.calibrate operation remains reserved for an explicit
capability_unavailable response; it must never start capture. New clients hide
camera calibration and presence controls. Persisted bed_exit / either alarm
policies migrate to manual; new writes of retired policies are rejected.
The new product state omits presence/confidence and camera/detector health, and adds separately bounded tactile configuration as specified in bed vibration. Missing tactile options keep old clients’ alarms audio-only. Do not advertise tactile support before the firmware implements it. A client/firmware compatibility review must identify whether removed state fields were mandatory to V1 clients; if so, publish a new contract version before shipping the breaking DTO change, following the versioning rule above. GATT framing changes are not required by the hardware decision alone.
This section specifies the migration; it does not claim the current V1 firmware already rejects calibration or implements vibration.
HTTP mirror and client routing
Section titled “HTTP mirror and client routing”POST /api/v1/command accepts and returns the exact same JSON envelopes as the
Bluetooth command boundary. In the security-zero prototype this endpoint has no
HTTP authentication or CSRF requirement. The state.get response advertises
reachable URLs in body.transport.http_endpoints.
Clients persist a URL learned through Bluetooth and prefer HTTP for subsequent
commands. A connection error, timeout, or non-2xx HTTP response falls back to BLE
with the same request identifier. After fallback, the next Bluetooth state.get
refreshes the saved address, allowing recovery after DHCP address changes.
The firmware keeps a bounded cache of request identifiers shared by both
transports. Replaying the same envelope returns the original response without
executing it twice. Reusing an identifier with a different envelope returns
request_id_reused. This makes a response-lost HTTP → BLE retry safe for mutations.
Server-Sent Events
Section titled “Server-Sent Events”Clients open GET /api/v1/events with Accept: text/event-stream. The server
sends ready, state.changed, and session.changed events plus a comment
heartbeat every 15 seconds. Event IDs are monotonic within a boot and the last 64
events are replayable through Last-Event-ID. The iPhone performs a focused
session.get or state.get only after an event; it never polls on a timer.