MQTT contract#
The interface between each nightstand device and Home Assistant. Firmware and HA automations both target this spec.
Design principles#
- 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.
- 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.
- MQTT Discovery is the contract. At boot, the device publishes retained discovery configs under
homeassistant/<type>/nightstand_N/...and HA auto-creates entities. - Either unit can trigger a routine — HA's automations use wildcards (
nightstand/+/button) so it doesn't care which one. - 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/<mac_hex>/... - Discovery
unique_ids:nightstand_<mac_hex>_button,_white_noise,_volume,_uptime,_update. Stable across firmware upgrades. - Discovery
device.namedefaults 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_idis what HA uses to track entities, notname.
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):
- Flash the standard binary
- Device boots, logs its MAC, registers itself under
nightstand/<mac_hex>andhomeassistant/.../nightstand_<mac_hex>/... - HA auto-discovers it; rename in the HA UI to taste
Topic namespace#
homeassistant/<type>/nightstand_<mac_hex>/<object>/config ← discovery (retain=true)
nightstand/<mac_hex>/available ← LWT + online announce (retain=true)
nightstand/<mac_hex>/button ← gesture sensor JSON (retain=true)
nightstand/<mac_hex>/state ← audio state snapshot JSON (retain=true)
nightstand/<mac_hex>/update/state ← firmware update state JSON (retain=true)
nightstand/<mac_hex>/cmd/play ← inbound: "ON" / "OFF"
nightstand/<mac_hex>/cmd/volume ← inbound: integer 0-100
nightstand/<mac_hex>/cmd/update ← inbound: "install"
sound-machine/firmware/latest ← shared latest_version (retain=true)
<mac_hex> 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_<mac_hex>/button/config
{
"name": "Button",
"unique_id": "nightstand_<mac_hex>_button",
"state_topic": "nightstand/<mac_hex>/button",
"value_template": "{{ value_json.event_type }}",
"icon": "mdi:gesture-tap-button",
"device": {
"identifiers": ["nightstand_<mac_hex>"],
"name": "Nightstand",
"manufacturer": "guid.foo",
"model": "Sound Machine",
"sw_version": "0.3.4"
},
"availability_topic": "nightstand/<mac_hex>/available"
}
Payload (retained):
{"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_<mac_hex>/white_noise/config
{
"name": "White Noise",
"unique_id": "nightstand_<mac_hex>_white_noise",
"state_topic": "nightstand/<mac_hex>/state",
"value_template": "{{ value_json.playing }}",
"command_topic": "nightstand/<mac_hex>/cmd/play",
"payload_on": "ON",
"payload_off": "OFF",
"state_on": "ON",
"state_off": "OFF",
"device": {"identifiers": ["nightstand_<mac_hex>"]},
"availability_topic": "nightstand/<mac_hex>/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_<mac_hex>/volume/config
{
"name": "Volume",
"unique_id": "nightstand_<mac_hex>_volume",
"state_topic": "nightstand/<mac_hex>/state",
"value_template": "{{ value_json.volume }}",
"command_topic": "nightstand/<mac_hex>/cmd/volume",
"min": 0,
"max": 100,
"step": 1,
"mode": "slider",
"device": {"identifiers": ["nightstand_<mac_hex>"]},
"availability_topic": "nightstand/<mac_hex>/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_<mac_hex>/uptime/config:
{
"name": "Uptime",
"unique_id": "nightstand_<mac_hex>_uptime",
"state_topic": "nightstand/<mac_hex>/state",
"value_template": "{{ value_json.uptime_s }}",
"unit_of_measurement": "s",
"device_class": "duration",
"entity_category": "diagnostic",
"device": {"identifiers": ["nightstand_<mac_hex>"]},
"availability_topic": "nightstand/<mac_hex>/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/<mac_hex>/update/state— JSON, retained, written by the device on connect and during an OTA. Carriesinstalled_versionalways;in_progressandupdate_percentagewhile a download is underway.latest_version_topic:sound-machine/firmware/latest— plain string, retained, written bymake ota-publish. Shared across every device running this firmware.command_topic:nightstand/<mac_hex>/cmd/update— receives the literalinstall.
Discovery topic: homeassistant/update/nightstand_<mac_hex>/firmware/config
{
"name": "Firmware",
"unique_id": "nightstand_<mac_hex>_update",
"state_topic": "nightstand/<mac_hex>/update/state",
"latest_version_topic": "sound-machine/firmware/latest",
"latest_version_template": "{{ value }}",
"command_topic": "nightstand/<mac_hex>/cmd/update",
"payload_install": "install",
"device_class": "firmware",
"entity_category": "config",
"device": {"identifiers": ["nightstand_<mac_hex>"]},
"availability_topic": "nightstand/<mac_hex>/available"
}
State payload (idle):
{"installed_version":"0.3.4","in_progress":false}
State payload during a download:
{"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/<mac_hex>/state (retained) on every audio-state change:
{
"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/<mac_hex>/available, payloadoffline, retained, QoS 1 - On successful connect: publish
onlineto the same topic, retained
HA marks every entity unavailable within ~seconds of the device losing WiFi.
Connection / boot sequence#
- WiFi up → MQTT connect (with LWT registered)
- Publish retained
onlinetonightstand/<mac_hex>/available - Publish retained empty payloads to any retired discovery topics (clears stale HA entities from earlier firmware versions)
- Publish retained discovery configs for every current entity (cheap — broker dedupes retained messages)
- Publish retained
idletonightstand/<mac_hex>/buttonso the gesture sensor has a stable resting value - Publish retained
update/stateJSON with the runninginstalled_version - Publish retained audio state snapshot to
nightstand/<mac_hex>/state(if cached) - Subscribe to
nightstand/<mac_hex>/cmd/+and tosound-machine/firmware/latest - 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) - 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, orlong. A double-press does not also fire ashortfor its first press — the state machine transitions intoDOUBLE_PRESSINGbeforeWAITING_DOUBLE's 400ms timer can fireshort.
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 backnightstand/<mac_hex>/cmd/play ON/OFF. The device does not toggle locally — HA is authoritative.long→ cycle volume yo-yo locally (persist to NVS, publish newstate); 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; persistwas_playingto 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.
- 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#
<mac> below stands in for the device's lowercase 12-char MAC hex (e.g. aabbccddeeff).
┌────────────┐ ┌──────────┐ ┌──────────────┐
│ Nightstand │ │ Mosquitto│ │ HA │
└──────┬─────┘ └────┬─────┘ └──────┬───────┘
│ (button pressed, │ │
│ released < 2s) │ │
│─ publish │ │
│ nightstand/<mac>/button │
│ {"event_type":"short"} │
│───────────────────► │ │
│ │─ deliver ─────────► │
│ │ │── automation
│ │ │ triggers,
│ │ │ sun is below
│ │ │ horizon
│ │◄── publish cmd/play │
│ │ "ON" to each unit│
│◄── nightstand/<mac>/cmd/play │
│ payload "ON" │ │
│ │ │
│── start white noise │ │
│─ publish │ │
│ nightstand/<mac>/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
<ota_url_base>/sound-machine-<version>.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; oninstall, downloads + flashes + reboots; reports installed version + progress onupdate/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 <url>
│ ────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-<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.
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.