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.

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/<type>/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/<mac_hex>/...
  • Discovery unique_ids: nightstand_<mac_hex>_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/<mac_hex> and homeassistant/.../nightstand_<mac_hex>/...
  3. 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 concernstate 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. 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/<mac_hex>/cmd/update — receives the literal install.

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, 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/<mac_hex>/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/<mac_hex>/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/<mac_hex>/state (if cached)
  8. Subscribe to nightstand/<mac_hex>/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/<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 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.

- 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; 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 <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.

Sources#