A nightstand noise generator based on M5Stack Atom Echo and integrating with Home Assistant
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 465 lines 26 kB view raw view rendered
1# MQTT contract 2 3The interface between each nightstand device and Home Assistant. Firmware and HA automations both target this spec. 4 5## Design principles 6 71. **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. 82. **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. 93. **MQTT Discovery** is the contract. At boot, the device publishes retained discovery configs under `homeassistant/<type>/nightstand_N/...` and HA auto-creates entities. 104. **Either unit can trigger a routine** — HA's automations use wildcards (`nightstand/+/button`) so it doesn't care which one. 115. **No TLS** — LAN-only broker, configured per-flash via `firmware/cfg.toml` (gitignored). No auth. 12 13## Division of labor 14 15| Responsibility | Device | HA | 16| --- | --- | --- | 17| Detect button press (short / long / double) | ✓ | | 18| Debounce | ✓ | | 19| Publish button event | ✓ | | 20| Decide what to do with the press | | ✓ | 21| Turn off house lights, thermostat, etc. | | ✓ | 22| Send "start white noise" command | | ✓ | 23| Generate white noise samples | ✓ | | 24| Drive speaker via I2S | ✓ | | 25| Track playing state | ✓ | | 26| Announce entities via discovery | ✓ | | 27| Download new firmware over HTTP, write to flash | ✓ | | 28| Decide when to push a new version | | ✓ (driven by `make ota-publish`) | 29 30## Device identity 31 32**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. 33 34- 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. 35- Topic prefix: `nightstand/<mac_hex>/...` 36- Discovery `unique_id`s: `nightstand_<mac_hex>_button`, `_white_noise`, `_volume`, `_uptime`, `_update`. Stable across firmware upgrades. 37- 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`. 38 39**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. 40 41**First-boot procedure** (per unit): 421. Flash the standard binary 432. Device boots, logs its MAC, registers itself under `nightstand/<mac_hex>` and `homeassistant/.../nightstand_<mac_hex>/...` 443. HA auto-discovers it; rename in the HA UI to taste 45 46## Topic namespace 47 48``` 49homeassistant/<type>/nightstand_<mac_hex>/<object>/config ← discovery (retain=true) 50nightstand/<mac_hex>/available ← LWT + online announce (retain=true) 51nightstand/<mac_hex>/button ← gesture sensor JSON (retain=true) 52nightstand/<mac_hex>/state ← audio state snapshot JSON (retain=true) 53nightstand/<mac_hex>/update/state ← firmware update state JSON (retain=true) 54nightstand/<mac_hex>/cmd/play ← inbound: "ON" / "OFF" 55nightstand/<mac_hex>/cmd/volume ← inbound: integer 0-100 56nightstand/<mac_hex>/cmd/update ← inbound: "install" 57sound-machine/firmware/latest ← shared latest_version (retain=true) 58``` 59 60`<mac_hex>` is the lowercase 12-char STA MAC with no separators (e.g. `aabbccddeeff`). 61 62Per-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. 63 64The 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. 65 66## Entities exposed 67 68### 1. Button — `sensor` type 69 70Carries 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. 71 72Discovery topic: `homeassistant/sensor/nightstand_<mac_hex>/button/config` 73```json 74{ 75 "name": "Button", 76 "unique_id": "nightstand_<mac_hex>_button", 77 "state_topic": "nightstand/<mac_hex>/button", 78 "value_template": "{{ value_json.event_type }}", 79 "icon": "mdi:gesture-tap-button", 80 "device": { 81 "identifiers": ["nightstand_<mac_hex>"], 82 "name": "Nightstand", 83 "manufacturer": "guid.foo", 84 "model": "Sound Machine", 85 "sw_version": "0.3.4" 86 }, 87 "availability_topic": "nightstand/<mac_hex>/available" 88} 89``` 90 91Payload (retained): 92```json 93{"event_type": "short"} 94``` 95…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. 96 97**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. 98 99### 2. White noise — `switch` 100 101Lets HA (and the HA UI) start/stop the noise and see its current state. 102 103Discovery topic: `homeassistant/switch/nightstand_<mac_hex>/white_noise/config` 104```json 105{ 106 "name": "White Noise", 107 "unique_id": "nightstand_<mac_hex>_white_noise", 108 "state_topic": "nightstand/<mac_hex>/state", 109 "value_template": "{{ value_json.playing }}", 110 "command_topic": "nightstand/<mac_hex>/cmd/play", 111 "payload_on": "ON", 112 "payload_off": "OFF", 113 "state_on": "ON", 114 "state_off": "OFF", 115 "device": {"identifiers": ["nightstand_<mac_hex>"]}, 116 "availability_topic": "nightstand/<mac_hex>/available" 117} 118``` 119 120### 3. Volume — `number` 121 1220–100 slider in HA. Device persists the last-set volume across reboots (NVS). 123 124Volume 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. 125 126Discovery topic: `homeassistant/number/nightstand_<mac_hex>/volume/config` 127```json 128{ 129 "name": "Volume", 130 "unique_id": "nightstand_<mac_hex>_volume", 131 "state_topic": "nightstand/<mac_hex>/state", 132 "value_template": "{{ value_json.volume }}", 133 "command_topic": "nightstand/<mac_hex>/cmd/volume", 134 "min": 0, 135 "max": 100, 136 "step": 1, 137 "mode": "slider", 138 "device": {"identifiers": ["nightstand_<mac_hex>"]}, 139 "availability_topic": "nightstand/<mac_hex>/available" 140} 141``` 142 143### 4. Uptime diagnostic — `sensor` 144 145Helpful for debugging power blips and reconnection. Marked as diagnostic so it hides in the default device view. 146 147`homeassistant/sensor/nightstand_<mac_hex>/uptime/config`: 148```json 149{ 150 "name": "Uptime", 151 "unique_id": "nightstand_<mac_hex>_uptime", 152 "state_topic": "nightstand/<mac_hex>/state", 153 "value_template": "{{ value_json.uptime_s }}", 154 "unit_of_measurement": "s", 155 "device_class": "duration", 156 "entity_category": "diagnostic", 157 "device": {"identifiers": ["nightstand_<mac_hex>"]}, 158 "availability_topic": "nightstand/<mac_hex>/available" 159} 160``` 161 162(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.) 163 164### 5. Firmware update — `update` 165 166Drives 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: 167 168- `state_topic`: `nightstand/<mac_hex>/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. 169- `latest_version_topic`: `sound-machine/firmware/latest` — plain string, retained, written by `make ota-publish`. Shared across every device running this firmware. 170- `command_topic`: `nightstand/<mac_hex>/cmd/update` — receives the literal `install`. 171 172Discovery topic: `homeassistant/update/nightstand_<mac_hex>/firmware/config` 173```json 174{ 175 "name": "Firmware", 176 "unique_id": "nightstand_<mac_hex>_update", 177 "state_topic": "nightstand/<mac_hex>/update/state", 178 "latest_version_topic": "sound-machine/firmware/latest", 179 "latest_version_template": "{{ value }}", 180 "command_topic": "nightstand/<mac_hex>/cmd/update", 181 "payload_install": "install", 182 "device_class": "firmware", 183 "entity_category": "config", 184 "device": {"identifiers": ["nightstand_<mac_hex>"]}, 185 "availability_topic": "nightstand/<mac_hex>/available" 186} 187``` 188 189State payload (idle): 190```json 191{"installed_version":"0.3.4","in_progress":false} 192``` 193 194State payload during a download: 195```json 196{"installed_version":"0.3.4","in_progress":true,"update_percentage":35} 197``` 198 199The 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. 200 201## State payload (audio) 202 203Published to `nightstand/<mac_hex>/state` (retained) on every audio-state change: 204 205```json 206{ 207 "playing": "ON", 208 "volume": 65, 209 "uptime_s": 12847 210} 211``` 212 213Single 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. 214 215## Availability (LWT) 216 217Set at MQTT connect time: 218- **Last Will**: topic `nightstand/<mac_hex>/available`, payload `offline`, retained, QoS 1 219- On successful connect: publish `online` to the same topic, retained 220 221HA marks every entity unavailable within ~seconds of the device losing WiFi. 222 223## Connection / boot sequence 224 2251. WiFi up → MQTT connect (with LWT registered) 2262. Publish retained `online` to `nightstand/<mac_hex>/available` 2273. Publish retained empty payloads to any retired discovery topics (clears stale HA entities from earlier firmware versions) 2284. Publish retained discovery configs for every current entity (cheap — broker dedupes retained messages) 2295. Publish retained `idle` to `nightstand/<mac_hex>/button` so the gesture sensor has a stable resting value 2306. Publish retained `update/state` JSON with the running `installed_version` 2317. Publish retained audio state snapshot to `nightstand/<mac_hex>/state` (if cached) 2328. Subscribe to `nightstand/<mac_hex>/cmd/+` and to `sound-machine/firmware/latest` 2339. 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) 23410. Enter main loop 235 236Republishing 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. 237 238## Button behavior 239 240### Firmware side 241 242Constants: 243- Debounce: 20ms 244- Double-press max gap (between first release and second press): **400ms** 245- Long-press threshold: **2000ms** 246 247State machine: 248 249``` 250IDLE ──button down──► PRESSING 251PRESSING: 252 released before 2s ──► WAITING_DOUBLE (note release time) 253 held 2s ──► fire "long" ──► HOLD (wait for release) ──► IDLE 254WAITING_DOUBLE: 255 button down in 400ms ──► DOUBLE_PRESSING 256 400ms elapsed ──► fire "short" ──► IDLE 257DOUBLE_PRESSING: 258 released ──► fire "double" ──► IDLE 259``` 260 261Notes: 262- **Short press has ~400ms of latency** after release (unavoidable cost of double-press detection). Imperceptible for sleepy-user use cases. 263- **Long press fires at the 2s mark**, not on release — crisper feedback. 264- **Double press fires on the second release** regardless of how long the second press is held. Simpler than distinguishing "double" from "double-then-long." 265- **Triple press** is treated as double-then-new-sequence — fine, users won't do this intentionally. 266- **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`. 267 268Behavior depends on mode: 269 270**ONLINE** — short-press round-trips through HA; long-press is local-plus-publish; double is publish-only. 271- `short` → publish `{"event_type":"short"}` only. HA's automation decides whether it's bedtime or morning and publishes back `nightstand/<mac_hex>/cmd/play ON`/`OFF`. The device does **not** toggle locally — HA is authoritative. 272- `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. 273- `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). 274 275**OFFLINE** — short and long are local; double is a no-op. 276- `short` → toggle white noise locally; persist `was_playing` to NVS. (The network task supplies this fallback so muscle memory still works without HA.) 277- `long` → cycle volume locally, same as online. 278- `double` → no-op apart from a serial-log note that HA isn't available. 279 280The 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. 281 282### HA-side (example — not firmware's responsibility) 283 284Shown here so it's clear what use cases the button events are targeting. Chris owns the HA automation side; this is just illustrative. 285 286```yaml 287- alias: "Nightstand: short press" 288 trigger: 289 - platform: mqtt 290 topic: nightstand/+/button 291 payload: '{"event_type":"short"}' 292 action: 293 - choose: 294 - conditions: 295 - condition: state 296 entity_id: sun.sun 297 state: below_horizon 298 sequence: 299 # Bedtime: toggle white noise on both nightstands, lights off 300 # (Entity ids reflect whatever you've renamed the devices to in 301 # HA — the MQTT contract just guarantees stable unique_ids per 302 # MAC; you choose the entity slugs.) 303 - service: switch.toggle 304 target: 305 entity_id: 306 - switch.bedroom_nightstand_white_noise 307 - switch.guest_nightstand_white_noise 308 - service: light.turn_off 309 target: 310 area_id: [bedroom, living_room, kitchen, hallway] 311 - conditions: 312 - condition: state 313 entity_id: sun.sun 314 state: above_horizon 315 sequence: 316 # Morning: lights on, white noise off 317 - service: light.turn_on 318 target: 319 area_id: [bedroom, hallway] 320 - service: switch.turn_off 321 target: 322 entity_id: 323 - switch.bedroom_nightstand_white_noise 324 - switch.guest_nightstand_white_noise 325 326- alias: "Nightstand: double press (late-night check)" 327 trigger: 328 - platform: mqtt 329 topic: nightstand/+/button 330 payload: '{"event_type":"double"}' 331 condition: 332 - condition: state 333 entity_id: sun.sun 334 state: below_horizon 335 action: 336 - service: light.turn_on 337 target: 338 area_id: [living_room, kitchen, outdoor, hallway] 339 data: 340 brightness_pct: 30 341``` 342 343(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.) 344 345The device is blissfully unaware of any of this. 346 347## Example flow: short press at night 348 349`<mac>` below stands in for the device's lowercase 12-char MAC hex (e.g. `aabbccddeeff`). 350 351``` 352 ┌────────────┐ ┌──────────┐ ┌──────────────┐ 353 │ Nightstand │ │ Mosquitto│ │ HA │ 354 └──────┬─────┘ └────┬─────┘ └──────┬───────┘ 355 │ (button pressed, │ │ 356 │ released < 2s) │ │ 357 │─ publish │ │ 358 │ nightstand/<mac>/button │ 359 │ {"event_type":"short"} │ 360 │───────────────────► │ │ 361 │ │─ deliver ─────────► │ 362 │ │ │── automation 363 │ │ │ triggers, 364 │ │ │ sun is below 365 │ │ │ horizon 366 │ │◄── publish cmd/play │ 367 │ │ "ON" to each unit│ 368 │◄── nightstand/<mac>/cmd/play │ 369 │ payload "ON" │ │ 370 │ │ │ 371 │── start white noise │ │ 372 │─ publish │ │ 373 │ nightstand/<mac>/state │ 374 │ {"playing":"ON",...} │ 375 │───────────────────► │── deliver ─────────►│ 376 │ │ (HA UI updates) 377``` 378 379Total latency: tens of milliseconds on LAN. Feels instant. 380 381## OTA workflow 382 383Firmware 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. 384 385### Roles 386 387- **Publisher** (Chris's dev machine): builds the binary, copies it to the static host, announces the new version on MQTT. 388- **Static HTTP host**: serves `<ota_url_base>/sound-machine-<version>.bin`. Plain HTTP, LAN-only. 389- **HA**: renders the update card from the MQTT entity, sends the install command on user click, watches the progress bar. 390- **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`. 391 392### Flow 393 394``` 395publisher static host broker HA device 396 │ make ota-publish: │ │ │ │ 397 │ espflash save-image │ │ │ │ 398 │ cp .../sound-machine-X.bin│ │ │ │ 399 │ ─────────────────────────► (file) │ │ │ 400 │ mosquitto_pub -L .../sound-machine/firmware/latest -m X │ │ 401 │ ──────────────────────────────────────────► retained ─────►│ │ 402 │ │ │ │ 403 │ │ │ ──cmp installed│ 404 │ │ │ vs latest───►│ 405 │ │ │ │ 406 │ (user clicks │ │ │ 407 │ Install in HA) │ │ │ 408 │ │ │ cmd/update │ 409 │ │ │ "install" │ 410 │ │ │ ─────────────► │ 411 │ │ │ │ esp_https_ota_begin 412 │ │ │ │ → GET <url> 413 │ ────HTTP 200────────────────────────────────── │ 414 │ │ │ │ chunks → ota_1 415 │ │ │ update/state │ (every 5%) 416 │ │ │ in_progress=true,update_percentage=N 417 │ │ │ ◄─────────────── │ 418 │ │ │ │ esp_https_ota_finish 419 │ │ │ │ esp_restart() 420 │ │ │ │ 421 │ │ │ │ (reboot from ota_1) 422 │ │ │ update/state │ 423 │ │ │ installed=X,in_progress=false 424 │ │ │ ◄─────────────── │ 425 │ │ │ │ esp_ota_mark_app_valid_ 426 │ │ │ │ cancel_rollback() 427``` 428 429### Boot validation and rollback 430 431Each 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. 432 433For wired-flashed firmware (`make flash`), the partition isn't in pending-verify state; `mark_app_valid` is a documented no-op. 434 435### Why one binary works for every unit 436 437MAC-derived identity (see Device Identity section) means the same `sound-machine-<version>.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. 438 439### Compile-time vs. runtime config 440 441`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.) 442 443## What we're deliberately NOT including 444 445- **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. 446- **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. 447- **Media player entity** — too much complexity for what is basically a toggle. Can revisit if we want HA TTS announcements on the device. 448- **Triple press patterns** — too much to remember. Single/double/long is the max. 449- **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. 450 451## Sources 452 453- [MQTT Discovery docs (Home Assistant)][ha-discovery] 454- [MQTT Event entity docs][ha-event] 455- [MQTT Switch entity docs][ha-switch] 456- [MQTT Number entity docs][ha-number] 457- [MQTT Sensor entity docs][ha-sensor] 458- [Availability (LWT) patterns in HA MQTT integration][ha-availability] 459 460[ha-discovery]: https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery 461[ha-event]: https://www.home-assistant.io/integrations/event.mqtt/ 462[ha-switch]: https://www.home-assistant.io/integrations/switch.mqtt/ 463[ha-number]: https://www.home-assistant.io/integrations/number.mqtt/ 464[ha-sensor]: https://www.home-assistant.io/integrations/sensor.mqtt/ 465[ha-availability]: https://www.home-assistant.io/integrations/mqtt/#using-availability-topics