Skip to content

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.

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

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":{}}
  • v is the contract version.
  • id is generated by the client and echoed in the response.
  • op is a namespaced operation.
  • body is always an object. Mutating an existing alarm requires its current revision; 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.

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.

  • capabilities.get
  • state.get
  • alarm.create, alarm.update, alarm.delete, alarm.preview, alarm.trigger
  • nap.start (duration_minutes), nap.cancel — one-shot nap alarm, gentle ramp capped at two minutes
  • session.get, session.snooze, session.dismiss
  • settings.smartphone_dismiss.set
  • clock.confirm
  • camera.calibrate — retired capability; reserved legacy operation, no capture side effects
  • breathing.start, breathing.volume, breathing.stop
  • light.program — forwarded to the ESP32 LED strip, listed only when one is configured
  • light.get — what the strip shows, read from the ESP32, listed with light.program
  • light.ambience.volume — {"volume_percent":0-100}, the ambience playing with the strip
  • history.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.

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.

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.