A nightstand noise generator based on M5Stack Atom Echo and integrating with Home Assistant
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