# MQTT contract The interface between each nightstand device and Home Assistant. Firmware and HA automations both target this spec. ## Design principles 1. **The device is dumb; HA is the brain.** The device reports button events and accepts commands. It does not know whether it's nighttime, what "bedtime routine" means, or what lights exist in the house. That's all HA. 2. **Local audio, remote control.** White noise is generated on the device (no dependency on HA media streaming). HA tells it when to start/stop and at what volume. 3. **MQTT Discovery** is the contract. At boot, the device publishes retained discovery configs under `homeassistant//nightstand_N/...` and HA auto-creates entities. 4. **Either unit can trigger a routine** — HA's automations use wildcards (`nightstand/+/button`) so it doesn't care which one. 5. **No TLS** — LAN-only broker, configured per-flash via `firmware/cfg.toml` (gitignored). No auth. ## Division of labor | Responsibility | Device | HA | | --- | --- | --- | | Detect button press (short / long / double) | ✓ | | | Debounce | ✓ | | | Publish button event | ✓ | | | Decide what to do with the press | | ✓ | | Turn off house lights, thermostat, etc. | | ✓ | | Send "start white noise" command | | ✓ | | Generate white noise samples | ✓ | | | Drive speaker via I2S | ✓ | | | Track playing state | ✓ | | | Announce entities via discovery | ✓ | | | Download new firmware over HTTP, write to flash | ✓ | | | Decide when to push a new version | | ✓ (driven by `make ota-publish`) | ## Device identity **MAC-derived, no lookup table.** The lowercase 12-char hex string of the device's STA MAC is used directly as the topic-prefix segment and the discovery `unique_id` root. - At boot, the firmware reads the ESP32's STA MAC and logs it loudly so it's visible in the serial monitor before any WiFi attempt. - Topic prefix: `nightstand//...` - Discovery `unique_id`s: `nightstand__button`, `_white_noise`, `_volume`, `_uptime`, `_update`. Stable across firmware upgrades. - Discovery `device.name` defaults to `"Nightstand"`. The HA UI lets the user rename each device per-unit ("Bedroom Nightstand", "Guest Room Nightstand", etc.) without breaking the MQTT contract — `unique_id` is what HA uses to track entities, not `name`. **Why:** one firmware binary works on every unit, no per-unit table to maintain, no reflash dance after first boot. The user names devices in the place that already understands renaming (HA) instead of in firmware source. **First-boot procedure** (per unit): 1. Flash the standard binary 2. Device boots, logs its MAC, registers itself under `nightstand/` and `homeassistant/.../nightstand_/...` 3. HA auto-discovers it; rename in the HA UI to taste ## Topic namespace ``` homeassistant//nightstand_//config ← discovery (retain=true) nightstand//available ← LWT + online announce (retain=true) nightstand//button ← gesture sensor JSON (retain=true) nightstand//state ← audio state snapshot JSON (retain=true) nightstand//update/state ← firmware update state JSON (retain=true) nightstand//cmd/play ← inbound: "ON" / "OFF" nightstand//cmd/volume ← inbound: integer 0-100 nightstand//cmd/update ← inbound: "install" sound-machine/firmware/latest ← shared latest_version (retain=true) ``` `` is the lowercase 12-char STA MAC with no separators (e.g. `aabbccddeeff`). Per-device state topics are split by *concern* — `state` for audio playback, `update/state` for firmware progress, `button` for the most recent gesture — because HA's update entity wants its progress fields in their own topic and mixing them would force every audio publish to also re-emit firmware fields. The shared `sound-machine/firmware/latest` topic carries the announced latest version once, retained, for every device on this firmware. One `make ota-publish` lights up the update card on every nightstand at the same time without per-device fanout. ## Entities exposed ### 1. Button — `sensor` type Carries the most-recent gesture as a sensor state (idle/short/long/double). The device publishes the gesture on press, then publishes a retained `idle` ~800 ms later so the entity has a stable resting value — HA's automations trigger on the state transition (e.g. `to: short`) rather than on event types. Discovery topic: `homeassistant/sensor/nightstand_/button/config` ```json { "name": "Button", "unique_id": "nightstand__button", "state_topic": "nightstand//button", "value_template": "{{ value_json.event_type }}", "icon": "mdi:gesture-tap-button", "device": { "identifiers": ["nightstand_"], "name": "Nightstand", "manufacturer": "guid.foo", "model": "Sound Machine", "sw_version": "0.3.4" }, "availability_topic": "nightstand//available" } ``` Payload (retained): ```json {"event_type": "short"} ``` …where the value is one of `idle`, `short`, `long`, `double`. After ~800 ms the device publishes `{"event_type":"idle"}` so the entity returns to a stable resting state instead of stuck on the gesture. **Why not `event`-type?** Earlier firmware (≤ 0.2.0) used HA's `event` entity, which is event-as-fact-without-resting-state. HA renders that as "Unknown" any time you look at the device card outside the brief moment of a press. The `sensor` + idle-after-N-ms pattern gives the same automation triggers (`to: short`) plus a sane idle reading. ### 2. White noise — `switch` Lets HA (and the HA UI) start/stop the noise and see its current state. Discovery topic: `homeassistant/switch/nightstand_/white_noise/config` ```json { "name": "White Noise", "unique_id": "nightstand__white_noise", "state_topic": "nightstand//state", "value_template": "{{ value_json.playing }}", "command_topic": "nightstand//cmd/play", "payload_on": "ON", "payload_off": "OFF", "state_on": "ON", "state_off": "OFF", "device": {"identifiers": ["nightstand_"]}, "availability_topic": "nightstand//available" } ``` ### 3. Volume — `number` 0–100 slider in HA. Device persists the last-set volume across reboots (NVS). Volume can also be changed by **long-pressing the physical button**, which cycles through a preset list defined in firmware (`[10, 25, 50, 75, 100]` — tunable in source). The cycle is **yo-yo**: each long-press advances in the current direction, and direction flips when it hits an end. Both index and direction are persisted in NVS so the cycle resumes from where you left off. Either slider or button is authoritative at any moment — device publishes the new volume to `state` whichever path set it. Discovery topic: `homeassistant/number/nightstand_/volume/config` ```json { "name": "Volume", "unique_id": "nightstand__volume", "state_topic": "nightstand//state", "value_template": "{{ value_json.volume }}", "command_topic": "nightstand//cmd/volume", "min": 0, "max": 100, "step": 1, "mode": "slider", "device": {"identifiers": ["nightstand_"]}, "availability_topic": "nightstand//available" } ``` ### 4. Uptime diagnostic — `sensor` Helpful for debugging power blips and reconnection. Marked as diagnostic so it hides in the default device view. `homeassistant/sensor/nightstand_/uptime/config`: ```json { "name": "Uptime", "unique_id": "nightstand__uptime", "state_topic": "nightstand//state", "value_template": "{{ value_json.uptime_s }}", "unit_of_measurement": "s", "device_class": "duration", "entity_category": "diagnostic", "device": {"identifiers": ["nightstand_"]}, "availability_topic": "nightstand//available" } ``` (Earlier firmware also exposed an RSSI sensor; it was dropped in v0.2.0 because the value was rarely meaningful — WiFi signal at the nightstand is consistent.) ### 5. Firmware update — `update` Drives HA's standard update card: shows installed-vs-latest version, an Install button, and a progress bar during a download. State is split between a per-device JSON state topic and the shared latest-version topic: - `state_topic`: `nightstand//update/state` — JSON, retained, written by the device on connect and during an OTA. Carries `installed_version` always; `in_progress` and `update_percentage` while a download is underway. - `latest_version_topic`: `sound-machine/firmware/latest` — plain string, retained, written by `make ota-publish`. Shared across every device running this firmware. - `command_topic`: `nightstand//cmd/update` — receives the literal `install`. Discovery topic: `homeassistant/update/nightstand_/firmware/config` ```json { "name": "Firmware", "unique_id": "nightstand__update", "state_topic": "nightstand//update/state", "latest_version_topic": "sound-machine/firmware/latest", "latest_version_template": "{{ value }}", "command_topic": "nightstand//cmd/update", "payload_install": "install", "device_class": "firmware", "entity_category": "config", "device": {"identifiers": ["nightstand_"]}, "availability_topic": "nightstand//available" } ``` State payload (idle): ```json {"installed_version":"0.3.4","in_progress":false} ``` State payload during a download: ```json {"installed_version":"0.3.4","in_progress":true,"update_percentage":35} ``` The device updates `update_percentage` in 5% steps (~20 publishes per upgrade) — smooth enough for HA's progress bar, light enough that the broker isn't drinking from a hose. ## State payload (audio) Published to `nightstand//state` (retained) on every audio-state change: ```json { "playing": "ON", "volume": 65, "uptime_s": 12847 } ``` Single JSON payload keeps discovery templates simple and lets HA parse any field with `value_template`. Firmware state lives in the separate `update/state` topic so an OTA progress publish doesn't churn the audio entities. ## Availability (LWT) Set at MQTT connect time: - **Last Will**: topic `nightstand//available`, payload `offline`, retained, QoS 1 - On successful connect: publish `online` to the same topic, retained HA marks every entity unavailable within ~seconds of the device losing WiFi. ## Connection / boot sequence 1. WiFi up → MQTT connect (with LWT registered) 2. Publish retained `online` to `nightstand//available` 3. Publish retained empty payloads to any retired discovery topics (clears stale HA entities from earlier firmware versions) 4. Publish retained discovery configs for every current entity (cheap — broker dedupes retained messages) 5. Publish retained `idle` to `nightstand//button` so the gesture sensor has a stable resting value 6. Publish retained `update/state` JSON with the running `installed_version` 7. Publish retained audio state snapshot to `nightstand//state` (if cached) 8. Subscribe to `nightstand//cmd/+` and to `sound-machine/firmware/latest` 9. Call `esp_ota_mark_app_valid_cancel_rollback` — confirms the running app is healthy and stops the bootloader's pending-rollback timer (no-op for wired flashes; meaningful only after an OTA reboot) 10. Enter main loop Republishing discovery every boot is fine — it's idempotent and makes entity config portable even after HA restores from backup or the broker loses retained state. Republishing empty payloads to retired topics keeps HA from carrying stale entities forward across firmware versions. ## Button behavior ### Firmware side Constants: - Debounce: 20ms - Double-press max gap (between first release and second press): **400ms** - Long-press threshold: **2000ms** State machine: ``` IDLE ──button down──► PRESSING PRESSING: released before 2s ──► WAITING_DOUBLE (note release time) held 2s ──► fire "long" ──► HOLD (wait for release) ──► IDLE WAITING_DOUBLE: button down in 400ms ──► DOUBLE_PRESSING 400ms elapsed ──► fire "short" ──► IDLE DOUBLE_PRESSING: released ──► fire "double" ──► IDLE ``` Notes: - **Short press has ~400ms of latency** after release (unavoidable cost of double-press detection). Imperceptible for sleepy-user use cases. - **Long press fires at the 2s mark**, not on release — crisper feedback. - **Double press fires on the second release** regardless of how long the second press is held. Simpler than distinguishing "double" from "double-then-long." - **Triple press** is treated as double-then-new-sequence — fine, users won't do this intentionally. - **Each interaction fires exactly one event**: either `short`, `double`, or `long`. A double-press does not also fire a `short` for its first press — the state machine transitions into `DOUBLE_PRESSING` before `WAITING_DOUBLE`'s 400ms timer can fire `short`. Behavior depends on mode: **ONLINE** — short-press round-trips through HA; long-press is local-plus-publish; double is publish-only. - `short` → publish `{"event_type":"short"}` only. HA's automation decides whether it's bedtime or morning and publishes back `nightstand//cmd/play ON`/`OFF`. The device does **not** toggle locally — HA is authoritative. - `long` → cycle volume yo-yo locally (persist to NVS, publish new `state`); also publish `{"event_type":"long"}` for HA logging. HA doesn't normally automate on it. - `double` → publish `{"event_type":"double"}` only. No local effect. HA's typical use: late-night-lights routine (raise outdoor + downstairs lights at low brightness for a 3 AM wake-up). **OFFLINE** — short and long are local; double is a no-op. - `short` → toggle white noise locally; persist `was_playing` to NVS. (The network task supplies this fallback so muscle memory still works without HA.) - `long` → cycle volume locally, same as online. - `double` → no-op apart from a serial-log note that HA isn't available. The two modes diverge on `short` deliberately: when HA is online we want it to decide ("is it bedtime? is it morning? toggle just this nightstand or both?"), and when HA is offline we want the device to stand alone. ### HA-side (example — not firmware's responsibility) Shown here so it's clear what use cases the button events are targeting. Chris owns the HA automation side; this is just illustrative. ```yaml - alias: "Nightstand: short press" trigger: - platform: mqtt topic: nightstand/+/button payload: '{"event_type":"short"}' action: - choose: - conditions: - condition: state entity_id: sun.sun state: below_horizon sequence: # Bedtime: toggle white noise on both nightstands, lights off # (Entity ids reflect whatever you've renamed the devices to in # HA — the MQTT contract just guarantees stable unique_ids per # MAC; you choose the entity slugs.) - service: switch.toggle target: entity_id: - switch.bedroom_nightstand_white_noise - switch.guest_nightstand_white_noise - service: light.turn_off target: area_id: [bedroom, living_room, kitchen, hallway] - conditions: - condition: state entity_id: sun.sun state: above_horizon sequence: # Morning: lights on, white noise off - service: light.turn_on target: area_id: [bedroom, hallway] - service: switch.turn_off target: entity_id: - switch.bedroom_nightstand_white_noise - switch.guest_nightstand_white_noise - alias: "Nightstand: double press (late-night check)" trigger: - platform: mqtt topic: nightstand/+/button payload: '{"event_type":"double"}' condition: - condition: state entity_id: sun.sun state: below_horizon action: - service: light.turn_on target: area_id: [living_room, kitchen, outdoor, hallway] data: brightness_pct: 30 ``` (Long-press doesn't need an HA automation by default — volume cycling happens locally on the device. HA can subscribe to it for logging or analytics if useful.) The device is blissfully unaware of any of this. ## Example flow: short press at night `` below stands in for the device's lowercase 12-char MAC hex (e.g. `aabbccddeeff`). ``` ┌────────────┐ ┌──────────┐ ┌──────────────┐ │ Nightstand │ │ Mosquitto│ │ HA │ └──────┬─────┘ └────┬─────┘ └──────┬───────┘ │ (button pressed, │ │ │ released < 2s) │ │ │─ publish │ │ │ nightstand//button │ │ {"event_type":"short"} │ │───────────────────► │ │ │ │─ deliver ─────────► │ │ │ │── automation │ │ │ triggers, │ │ │ sun is below │ │ │ horizon │ │◄── publish cmd/play │ │ │ "ON" to each unit│ │◄── nightstand//cmd/play │ │ payload "ON" │ │ │ │ │ │── start white noise │ │ │─ publish │ │ │ nightstand//state │ │ {"playing":"ON",...} │ │───────────────────► │── deliver ─────────►│ │ │ (HA UI updates) ``` Total latency: tens of milliseconds on LAN. Feels instant. ## OTA workflow Firmware is delivered over plain HTTP from a static file server on the LAN. The trust boundary is already the LAN (MQTT is also plain), so TLS would only protect transit, not authenticity — secure boot + signed images is the answer for tamper resistance and isn't in scope yet. ### Roles - **Publisher** (Chris's dev machine): builds the binary, copies it to the static host, announces the new version on MQTT. - **Static HTTP host**: serves `/sound-machine-.bin`. Plain HTTP, LAN-only. - **HA**: renders the update card from the MQTT entity, sends the install command on user click, watches the progress bar. - **Device**: subscribes to the shared latest topic and to its own `cmd/update`; on `install`, downloads + flashes + reboots; reports installed version + progress on `update/state`. ### Flow ``` publisher static host broker HA device │ make ota-publish: │ │ │ │ │ espflash save-image │ │ │ │ │ cp .../sound-machine-X.bin│ │ │ │ │ ─────────────────────────► (file) │ │ │ │ mosquitto_pub -L .../sound-machine/firmware/latest -m X │ │ │ ──────────────────────────────────────────► retained ─────►│ │ │ │ │ │ │ │ │ ──cmp installed│ │ │ │ vs latest───►│ │ │ │ │ │ (user clicks │ │ │ │ Install in HA) │ │ │ │ │ │ cmd/update │ │ │ │ "install" │ │ │ │ ─────────────► │ │ │ │ │ esp_https_ota_begin │ │ │ │ → GET │ ────HTTP 200────────────────────────────────── │ │ │ │ │ chunks → ota_1 │ │ │ update/state │ (every 5%) │ │ │ in_progress=true,update_percentage=N │ │ │ ◄─────────────── │ │ │ │ │ esp_https_ota_finish │ │ │ │ esp_restart() │ │ │ │ │ │ │ │ (reboot from ota_1) │ │ │ update/state │ │ │ │ installed=X,in_progress=false │ │ │ ◄─────────────── │ │ │ │ │ esp_ota_mark_app_valid_ │ │ │ │ cancel_rollback() ``` ### Boot validation and rollback Each OTA leaves the new firmware in **pending-verify** state. The device must explicitly call `esp_ota_mark_app_valid_cancel_rollback` once it confirms the new firmware works — the firmware does this on the first successful MQTT `Connected` event after boot. If the new firmware crashes before that point, or never connects to MQTT, the bootloader rolls back to the previous slot on next reset and the device comes up on the old version. Belt-and-braces against bricked devices. For wired-flashed firmware (`make flash`), the partition isn't in pending-verify state; `mark_app_valid` is a documented no-op. ### Why one binary works for every unit MAC-derived identity (see Device Identity section) means the same `sound-machine-.bin` runs correctly on both nightstands without per-unit builds. The shared `sound-machine/firmware/latest` topic means one publish notifies every device — no `nightstand/+/update` fanout required. ### Compile-time vs. runtime config `ota_url_base` lives in `firmware/cfg.toml` next to the WiFi and MQTT config — compile-time. Changing the firmware host is currently a wired-flash event, the same as changing WiFi credentials. (Putting the URL in the `latest_version` payload would make it pure runtime config; that's a future cleanup if hosts change often, which they don't.) ## What we're deliberately NOT including - **Noise type selection** (pink, brown, rain, etc.) — shipping with a single hand-tuned noise generator that Chris will iterate on to match what he and his wife actually want. Parameters live in source, not in MQTT; tuning = OTA, not a runtime knob. - **RGB LED control from HA** — the onboard SK6812 is used by firmware for local status (audio × net axes, OTA progress, error). No HA entity for it. - **Media player entity** — too much complexity for what is basically a toggle. Can revisit if we want HA TTS announcements on the device. - **Triple press patterns** — too much to remember. Single/double/long is the max. - **TLS / signed firmware** — LAN-only deployment; TLS without code signing only protects transit. Secure boot + signed images is the right answer when the threat model warrants it. ## Sources - [MQTT Discovery docs (Home Assistant)][ha-discovery] - [MQTT Event entity docs][ha-event] - [MQTT Switch entity docs][ha-switch] - [MQTT Number entity docs][ha-number] - [MQTT Sensor entity docs][ha-sensor] - [Availability (LWT) patterns in HA MQTT integration][ha-availability] [ha-discovery]: https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery [ha-event]: https://www.home-assistant.io/integrations/event.mqtt/ [ha-switch]: https://www.home-assistant.io/integrations/switch.mqtt/ [ha-number]: https://www.home-assistant.io/integrations/number.mqtt/ [ha-sensor]: https://www.home-assistant.io/integrations/sensor.mqtt/ [ha-availability]: https://www.home-assistant.io/integrations/mqtt/#using-availability-topics