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.

v0.2.0: online-mode firmware (WiFi + MQTT + HA Discovery) with fades

Both nightstands now appear in Home Assistant with a button event,
white-noise switch, volume slider (0-100, snapped to the existing
preset list), and an uptime sensor. Topic prefix is the lowercase MAC
hex (`nightstand/<mac_hex>/...`) — no KNOWN_DEVICES table to maintain.
HA users name and pair devices in the UI; `unique_id` stays MAC-stable.

Short press in online mode round-trips through HA: the device publishes
the event and waits for HA to send back `cmd/play ON`/`OFF`, so a single
gesture can do different things depending on time of day, occupancy,
etc. Long-press still cycles volume locally in both modes (with a
publish for HA logging when online); double-press is a pure HA gesture
for the late-night-lights routine. Offline, short-press toggles audio
locally as a fallback so muscle memory still works without HA.

Every amplitude transition is a 700 ms per-sample fade — start, stop,
and volume changes (preset cycle or HA slider). Constant step rate, so
small adjustments finish in proportionally less time and feel snappy
while big ones feel deliberate. Fade lives inside the noise generator
so the pink filter doesn't see any discontinuity; no clicks.

Cross-task channels are backed by FreeRTOS native queues
(`channels.rs`). `std::sync::mpsc` and `crossbeam-channel` are both
broken on esp-idf-rs because of the `PTHREAD_MUTEX_INITIALIZER`
mismatch (newlib's zeros vs. ESP-IDF's `0xffffffff`); the inner
`Mutex<Waker>` they rely on crashes the moment a multi-producer send
needs to wake a blocked receiver. FreeRTOS queues are the native
primitive on this platform, ISR-safe, and bypass the pthread layer
entirely. Wrapper API is intentionally close to mpsc so the call sites
didn't have to learn anything new.

WiFi creds + MQTT URL come from `cfg.toml` (gitignored, with a
`cfg.toml.example` placeholder) via the toml-cfg crate. Empty values
panic at boot; the public repo never sees real secrets.

Top-level + firmware Makefiles; `make firmware-flash` is the headless
flash entry point and `firmware-flash-monitor` is interactive. Both
auto-create the project-local libxml2.so.2 → libxml2.so.16 symlink
that ESP-IDF's bundled esp-clang needs on Ubuntu Questing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+1489 -272
+44
Makefile
··· 1 + # Top-level entry point for the sound-machine project. 2 + # 3 + # Right now this delegates to firmware/. As 3D-printable parts (OpenSCAD) 4 + # and other artifacts get added, give them sibling targets here so a bare 5 + # `make` rebuilds whatever's stale across the repo. 6 + 7 + MAKEFLAGS += --no-print-directory 8 + 9 + .PHONY: all firmware firmware-check firmware-flash firmware-flash-monitor firmware-monitor firmware-clean clean help 10 + 11 + all: firmware 12 + 13 + firmware: 14 + $(MAKE) -C firmware build 15 + 16 + firmware-check: 17 + $(MAKE) -C firmware check 18 + 19 + firmware-flash: 20 + $(MAKE) -C firmware flash 21 + 22 + firmware-flash-monitor: 23 + $(MAKE) -C firmware flash-monitor 24 + 25 + firmware-monitor: 26 + $(MAKE) -C firmware monitor 27 + 28 + firmware-clean: 29 + $(MAKE) -C firmware clean 30 + 31 + clean: firmware-clean 32 + 33 + help: 34 + @echo "sound-machine targets:" 35 + @echo " all (default) build firmware" 36 + @echo " firmware cargo build --release in firmware/" 37 + @echo " firmware-check cargo check" 38 + @echo " firmware-flash headless: build + flash, no monitor" 39 + @echo " firmware-flash-monitor interactive: build + flash + serial monitor (needs TTY)" 40 + @echo " firmware-monitor serial monitor only" 41 + @echo " firmware-clean cargo clean" 42 + @echo " clean clean everything" 43 + @echo "" 44 + @echo "Future: 3D model rendering, etc., will live as sibling targets."
+6 -4
README.md
··· 1 1 # sound-machine 2 2 3 - Two M5Stack Atom Echo–based nightstand white noise machines, replacing the Google Home speakers that used to live on our nightstands. Single button press triggers a Home Assistant routine (lights off, white noise on, etc., depending on time of day), with offline fallback for travel use. Long-press for late-night house lights, double-press to cycle volume presets. 3 + Two M5Stack Atom Echo–based nightstand white noise machines, replacing the Google Home speakers that used to live on our nightstands. Short press triggers a Home Assistant routine (lights off, white noise on, etc., depending on time of day), with offline fallback for travel use. Long-press cycles volume yo-yo through preset levels. Double-press is the late-night-lights gesture (raise outdoor + downstairs lights at low brightness for a 3 AM wake-up). 4 4 5 5 This repo holds everything for the project: firmware, design docs, hardware reference, and (eventually) 3D-printed enclosure models. 6 6 ··· 9 9 ``` 10 10 sound-machine/ 11 11 ├── README.md # this file 12 + ├── Makefile # top-level entry: `make` builds firmware (and future model rendering) 12 13 ├── .envrc # direnv: ESP toolchain env + libxml2 shim path 13 14 ├── firmware/ # Rust firmware (esp-idf-svc, std). See firmware/README.md 14 15 └── reference/ # Design docs and hardware reference ··· 25 26 - ✅ **Hardware research and selection complete** — see `reference/` 26 27 - ✅ **MQTT contract and operating modes designed** — `reference/mqtt-contract.md`, `reference/operating-modes.md` 27 28 - ✅ **Toolchain validated end-to-end** — `firmware/` builds, flashes, and runs on real hardware 28 - - ✅ **Hello-world milestone (v0.0.1)** — button + beep working — 2026-04-25 29 + - ✅ **v0.1.0 — offline-mode firmware** — button, audio, NVS, LED — 2026-04-25 30 + - ✅ **v0.2.0 — online-mode firmware** — WiFi + MQTT + HA Discovery 29 31 - 🚧 **Awaiting hardware** — MAX98357A amps on order from DigiKey 30 - - 🚧 **Firmware v1 build-out in progress** — see `firmware/README.md` for the per-subsystem state 32 + - 🚧 **Enclosure design** — 3D-printable case TBD 31 33 32 34 ## Architecture in one paragraph 33 35 34 36 Each device runs Rust firmware (esp-idf-svc, std mode) on an Atom Echo. WiFi connects to the home network, MQTT to a LAN-only broker (no TLS), HA Discovery announces entities. The button publishes events; HA decides what to do; HA sends back a "play white noise" command. Audio is generated locally on-device (no streaming dependency) and sent over I2S to an external MAX98357A amp driving a 3" 4Ω speaker. Onboard NS4168 amp is bypassed (no I2S data sent to its pins) — it's known not to be sized for sustained white noise. When WiFi or MQTT drops, the device falls into offline mode where the button toggles white noise locally; same code path as travel use. 35 37 36 - Both units run **the same firmware binary** — identity is derived at runtime from the chip's MAC address against a `KNOWN_DEVICES` table in source. One build, OTA-pushed to both. (OTA is v1.5; v1 ships USB-flashed.) 38 + Both units run **the same firmware binary** — identity is derived at runtime from the chip's STA MAC and used directly as the topic-prefix segment (`nightstand/<mac_hex>/...`). HA users name each device in the HA UI; the MQTT contract guarantees stable `unique_id`s per MAC. One build, OTA-pushed to both. (OTA is v1.5; v1 ships USB-flashed.) 37 39 38 40 For the gory details: `firmware/README.md`, `reference/mqtt-contract.md`, `reference/operating-modes.md`. 39 41
+1
firmware/.gitignore
··· 3 3 /.lib 4 4 .cargo/.cache 5 5 *.log 6 + cfg.toml
+59 -3
firmware/Cargo.lock
··· 174 174 checksum = "599aa35200ffff8f04c1925aa1acc92fa2e08874379ef42e210a80e527e60838" 175 175 dependencies = [ 176 176 "serde", 177 - "toml", 177 + "toml 0.7.8", 178 178 ] 179 179 180 180 [[package]] ··· 525 525 "strum 0.24.1", 526 526 "tempfile", 527 527 "thiserror 1.0.69", 528 - "toml", 528 + "toml 0.7.8", 529 529 "ureq", 530 530 "which", 531 531 ] ··· 1543 1543 1544 1544 [[package]] 1545 1545 name = "sound-machine" 1546 - version = "0.0.1" 1546 + version = "0.2.0" 1547 1547 dependencies = [ 1548 1548 "anyhow", 1549 1549 "embuild", 1550 1550 "esp-idf-hal", 1551 1551 "esp-idf-svc", 1552 1552 "log", 1553 + "toml-cfg", 1553 1554 ] 1554 1555 1555 1556 [[package]] ··· 1717 1718 ] 1718 1719 1719 1720 [[package]] 1721 + name = "toml" 1722 + version = "0.8.23" 1723 + source = "registry+https://github.com/rust-lang/crates.io-index" 1724 + checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" 1725 + dependencies = [ 1726 + "serde", 1727 + "serde_spanned", 1728 + "toml_datetime 0.6.11", 1729 + "toml_edit 0.22.27", 1730 + ] 1731 + 1732 + [[package]] 1733 + name = "toml-cfg" 1734 + version = "0.2.0" 1735 + source = "registry+https://github.com/rust-lang/crates.io-index" 1736 + checksum = "68c587298ddd135c156e92e8c3eae69614d6eecea8e2d8a09daab011e5e6a21d" 1737 + dependencies = [ 1738 + "heck 0.4.1", 1739 + "proc-macro2", 1740 + "quote", 1741 + "serde", 1742 + "syn 2.0.117", 1743 + "toml 0.8.23", 1744 + ] 1745 + 1746 + [[package]] 1720 1747 name = "toml_datetime" 1721 1748 version = "0.6.11" 1722 1749 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1749 1776 1750 1777 [[package]] 1751 1778 name = "toml_edit" 1779 + version = "0.22.27" 1780 + source = "registry+https://github.com/rust-lang/crates.io-index" 1781 + checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" 1782 + dependencies = [ 1783 + "indexmap", 1784 + "serde", 1785 + "serde_spanned", 1786 + "toml_datetime 0.6.11", 1787 + "toml_write", 1788 + "winnow 0.7.15", 1789 + ] 1790 + 1791 + [[package]] 1792 + name = "toml_edit" 1752 1793 version = "0.25.11+spec-1.1.0" 1753 1794 source = "registry+https://github.com/rust-lang/crates.io-index" 1754 1795 checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" ··· 1767 1808 dependencies = [ 1768 1809 "winnow 1.0.2", 1769 1810 ] 1811 + 1812 + [[package]] 1813 + name = "toml_write" 1814 + version = "0.1.2" 1815 + source = "registry+https://github.com/rust-lang/crates.io-index" 1816 + checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" 1770 1817 1771 1818 [[package]] 1772 1819 name = "uncased" ··· 2148 2195 version = "0.5.40" 2149 2196 source = "registry+https://github.com/rust-lang/crates.io-index" 2150 2197 checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" 2198 + dependencies = [ 2199 + "memchr", 2200 + ] 2201 + 2202 + [[package]] 2203 + name = "winnow" 2204 + version = "0.7.15" 2205 + source = "registry+https://github.com/rust-lang/crates.io-index" 2206 + checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" 2151 2207 dependencies = [ 2152 2208 "memchr", 2153 2209 ]
+2 -1
firmware/Cargo.toml
··· 1 1 [package] 2 2 name = "sound-machine" 3 - version = "0.0.1" 3 + version = "0.2.0" 4 4 edition = "2021" 5 5 resolver = "2" 6 6 rust-version = "1.77" ··· 32 32 # yet, and we need the legacy `TxRmtDriver` for SK6812 LED control). 33 33 esp-idf-hal = { version = "0.45", default-features = false, features = ["rmt-legacy"] } 34 34 anyhow = "1" 35 + toml-cfg = "0.2" 35 36 36 37 [build-dependencies] 37 38 embuild = "0.33"
+70
firmware/Makefile
··· 1 + # Firmware build entry points. 2 + # 3 + # Wraps cargo with the env tweaks needed on hosts where libxml2 has bumped 4 + # past .so.2 (Ubuntu Questing 25.10+ ships .so.16). The .lib/libxml2.so.2 5 + # symlink in this directory points at the system library so esp-clang loads. 6 + # 7 + # Cargo handles its own incremental rebuilds, so these are all phony. 8 + 9 + LIB_DIR := $(CURDIR)/.lib 10 + LIBXML2_COMPAT := $(LIB_DIR)/libxml2.so.2 11 + SYSTEM_LIBXML2 := $(firstword $(wildcard /usr/lib/x86_64-linux-gnu/libxml2.so.16 /usr/lib/libxml2.so.16)) 12 + 13 + export LD_LIBRARY_PATH := $(LIB_DIR):$(LD_LIBRARY_PATH) 14 + 15 + CARGO ?= cargo 16 + BIN := target/xtensa-esp32-espidf/release/sound-machine 17 + # First /dev/ttyUSB* / /dev/ttyACM* found, used for headless flashing. 18 + # Override on the command line: `make flash PORT=/dev/ttyUSB1` 19 + PORT ?= $(firstword $(wildcard /dev/ttyUSB* /dev/ttyACM*)) 20 + 21 + .PHONY: build check flash flash-monitor monitor clean help $(LIBXML2_COMPAT) 22 + 23 + build: $(LIBXML2_COMPAT) 24 + $(CARGO) build --release 25 + 26 + check: $(LIBXML2_COMPAT) 27 + $(CARGO) check 28 + 29 + # Headless flash — builds, then writes to the device with no interactive 30 + # monitor. Safe to run from any shell, including non-TTY contexts. Auto- 31 + # detects the first ttyUSB*/ttyACM*; override with PORT=/dev/... 32 + flash: build 33 + @if [ -z "$(PORT)" ]; then \ 34 + echo "no serial port found (looked for /dev/ttyUSB* and /dev/ttyACM*)"; \ 35 + exit 1; \ 36 + fi 37 + espflash flash --port $(PORT) $(BIN) 38 + 39 + # Interactive flash + monitor — builds, flashes, attaches the serial monitor. 40 + # Needs a TTY (the monitor writes to terminal and reads keyboard input). 41 + flash-monitor: $(LIBXML2_COMPAT) 42 + $(CARGO) run --release 43 + 44 + monitor: 45 + espflash monitor 46 + 47 + clean: 48 + $(CARGO) clean 49 + 50 + # The compat symlink. Created on demand if the system has libxml2.so.16. 51 + # Skipped silently if it already exists or the system has a newer libxml2. 52 + $(LIBXML2_COMPAT): 53 + @if [ ! -e "$@" ]; then \ 54 + if [ -n "$(SYSTEM_LIBXML2)" ]; then \ 55 + mkdir -p $(LIB_DIR); \ 56 + ln -sf $(SYSTEM_LIBXML2) $@; \ 57 + echo "linked $@ -> $(SYSTEM_LIBXML2)"; \ 58 + else \ 59 + echo "warning: no system libxml2.so.16 found; esp-clang may fail to load"; \ 60 + fi; \ 61 + fi 62 + 63 + help: 64 + @echo "firmware targets:" 65 + @echo " build cargo build --release (default)" 66 + @echo " check cargo check" 67 + @echo " flash headless: build + flash, no monitor (works in any shell)" 68 + @echo " flash-monitor interactive: build + flash + serial monitor (needs a TTY)" 69 + @echo " monitor espflash monitor only" 70 + @echo " clean cargo clean"
+37 -17
firmware/README.md
··· 6 6 7 7 ## Status 8 8 9 - **Milestone v0.1.0 — offline mode.** Self-contained white noise machine. Short press toggles noise; long press cycles volume yo-yo through `[10, 25, 50, 75, 100]`%; double press is detected and logs a TODO line (HA-driven late-night-lights gesture, wired in but inert until MQTT lands). NVS persists volume / direction / playing-state across reboots. SK6812 RGB LED indicates state with a brief flash on each press. 9 + **Milestone v0.2.0 — online mode (WiFi + MQTT + HA Discovery).** Adds WiFi/MQTT layered on the v0.1.0 offline foundation. Short press now round-trips through HA when online (button publishes `{"event_type":"short"}`, HA's automation publishes back `cmd/play ON`/`OFF`); offline, short-press still toggles audio locally as a fallback. Long-press cycles volume locally in both modes (with a publish for HA logging when online). Double-press is purely an MQTT gesture for HA's late-night-lights routine. The MAC-derived topic prefix means one binary works on every unit; HA names devices in its UI. 10 10 11 11 | Subsystem | State | 12 12 | --- | --- | 13 13 | Cargo build + flash | ✅ working | 14 14 | GPIO input (button G39) | ✅ working — debounced, active-low | 15 15 | I2S TX (16-bit / 44.1 kHz / stereo, Philips) | ✅ working into onboard NS4168 | 16 - | Continuous white noise generator | ✅ xorshift32, volume-scaled | 16 + | Continuous pink noise generator (Paul Kellet IIR) | ✅ xorshift32 white → pink filter, volume-scaled | 17 17 | Button state machine (short / long / double) | ✅ working | 18 18 | NVS persistence (volume + direction + playing) | ✅ working | 19 - | RGB LED (SK6812 on G27 via RMT) | ✅ working — boot pulse, idle/playing colors, press-flash overlay | 20 - | WiFi | ❌ next milestone | 21 - | MQTT client + HA discovery | ❌ next milestone | 19 + | RGB LED (SK6812 on G27 via RMT) | ✅ working — composes (net, audio) → color, press-flash overlay | 20 + | WiFi | ✅ working — STA, hostname `nightstand-<mac_hex>`, auto-reconnect | 21 + | MQTT client + HA discovery | ✅ working — LWT, retained discovery on (re)connect, `cmd/play` + `cmd/volume` subscribed | 22 22 | OTA updates | ❌ v1.5 (design in `reference/mqtt-contract.md`) | 23 23 | Hardware: external MAX98357A + 1314 speaker | ❌ amps in transit; using onboard for now | 24 24 ··· 26 26 27 27 ``` 28 28 firmware/src/ 29 - ├── main.rs — entry; spawns tasks; coordinator loop translates ButtonEvent → AudioCommand 30 - ├── events.rs — ButtonEvent, AudioCommand, LedSignal enums (the cross-task wire format) 31 - ├── state.rs — VOLUME_PRESETS, VolumeDirection, yo-yo cycle math (with unit tests) 29 + ├── main.rs — entry; spawns tasks; coordinator loop routes ButtonEvent → audio + outbound 30 + ├── events.rs — ButtonEvent / AudioCommand / LedSignal / OutboundEvent / StateSnapshot 31 + ├── state.rs — VOLUME_PRESETS, VolumeDirection, yo-yo cycle math, snap_to_preset_index 32 32 ├── nvs.rs — typed NVS wrapper for volume_index, volume_direction, was_playing 33 - ├── audio.rs — I2S setup + xorshift white noise + audio task (owns I2S + NVS) 33 + ├── audio.rs — I2S + xorshift white noise + Paul Kellet pink filter + audio task 34 34 ├── button.rs — 5-state button FSM + button task (owns G39 PinDriver) 35 - └── led.rs — SK6812 driver via ws2812-esp32-rmt-driver + LED task at ~30 Hz 35 + ├── led.rs — SK6812 RMT driver + LED task; (net, audio) → color composition 36 + ├── network.rs — WiFi + MQTT state machine; gatekeeper for online/offline routing 37 + ├── discovery.rs — HA Discovery JSON payloads (built via format!() — no serde_json) 38 + └── secrets.rs — toml-cfg config struct sourced from cfg.toml at compile time 36 39 ``` 37 40 38 - The deliberate factoring: each task owns its peripherals exclusively; cross-task communication is via `std::sync::mpsc` channels carrying typed events. When MQTT comes in, an MQTT task becomes a *second* `Sender<AudioCommand>` and a publisher of button events — no other module changes. 41 + The deliberate factoring: each task owns its peripherals exclusively; cross-task communication is via `std::sync::mpsc` channels carrying typed events. The network task is the gatekeeper — it decides whether short presses toggle audio locally (offline) or wait for HA to publish back via MQTT (online). Long-press cycles volume locally in both modes; double is a pure-MQTT gesture. 39 42 40 43 ## Build & flash 41 44 42 - Project uses direnv (`.envrc` at the project root). Open a terminal in any subdir of the project and the env loads automatically. 45 + Project uses direnv (`.envrc` at the project root). Open a terminal in any subdir of the project and the env loads automatically. There's also a `Makefile` at the repo root and in `firmware/` that exports the same env so you can build without direnv (CI, fresh clones, etc.). 43 46 44 47 ```bash 45 - # from project root or anywhere under it: 48 + # from anywhere in the repo: 49 + make firmware # cargo build --release 50 + make firmware-flash # cargo run --release (flash + monitor) 51 + make firmware-monitor # serial monitor only 52 + make firmware-check # cargo check 53 + make firmware-clean 54 + ``` 55 + 56 + Or directly with cargo: 57 + 58 + ```bash 46 59 cd firmware 47 60 cargo build --release 48 61 espflash flash --port /dev/ttyUSB0 target/xtensa-esp32-espidf/release/sound-machine 49 62 ``` 50 63 51 - Or in one step (uses our configured runner): 64 + Incremental builds are ~5 s. First-time builds are ~20 min — they download and compile ESP-IDF (~500 MB) plus all the Rust deps. 65 + 66 + ### `cfg.toml` — secrets 67 + 68 + Before the first build, copy [`cfg.toml.example`](cfg.toml.example) to `cfg.toml` and fill in real values: 52 69 53 - ```bash 54 - cargo run --release # builds, flashes, attaches serial monitor 70 + ```toml 71 + [sound-machine] 72 + wifi_ssid = "your-wifi-ssid" 73 + wifi_password = "your-wifi-password" 74 + mqtt_url = "mqtt://your-broker.lan:1883" 55 75 ``` 56 76 57 - Incremental builds are ~5 s. First-time builds are ~20 min — they download and compile ESP-IDF (~500 MB) plus all the Rust deps. 77 + `cfg.toml` is gitignored. The build will panic at boot if any of the three values is empty, so you can't accidentally flash a no-config binary. 58 78 59 79 ### Monitoring without re-flashing 60 80
+14
firmware/cfg.toml.example
··· 1 + # Compile-time secrets for the sound-machine firmware. 2 + # 3 + # Copy this file to `cfg.toml` (which is gitignored) and fill in the real 4 + # values for your network and MQTT broker. The build will read these via 5 + # the `toml-cfg` crate and bake them into the binary at compile time. 6 + # 7 + # Empty values cause the firmware to panic at boot — better than silently 8 + # trying to connect to nothing forever. 9 + 10 + [sound-machine] 11 + wifi_ssid = "your-wifi-ssid" 12 + wifi_password = "your-wifi-password" 13 + # Full URL form so we can swap to mqtts://host:8883 later without a schema change. 14 + mqtt_url = "mqtt://mqtt.example.local:1883"
+4
firmware/sdkconfig.defaults
··· 6 6 7 7 # Bigger main task stack — we'll allocate audio buffers on it 8 8 CONFIG_ESP_MAIN_TASK_STACK_SIZE=16384 9 + 10 + # Use the hostname we set on the netif for DHCP, so routers and HA show 11 + # something meaningful (nightstand-<mac_hex>) instead of generic ESP32-XXXX 12 + CONFIG_LWIP_LOCAL_HOSTNAME=y
+95 -27
firmware/src/audio.rs
··· 2 2 //! handles play/stop/cycle commands, persists state changes to NVS. 3 3 //! 4 4 //! Runs on a dedicated thread. Receives `AudioCommand`s from the coordinator 5 - //! (and, in a future milestone, from MQTT) on a channel. Broadcasts 6 - //! `LedSignal::Idle` / `LedSignal::Playing` on relevant transitions. 5 + //! and from the network task (inbound MQTT) on a channel. Broadcasts 6 + //! `LedSignal::Audio(Idle|Playing)` on relevant transitions, plus an 7 + //! `OutboundEvent::State` snapshot the network task uses for MQTT state 8 + //! publishes and reconnect-time republishes. 7 9 //! 8 10 //! The main loop alternates between draining commands (non-blocking) and 9 11 //! writing one ~23 ms buffer of audio (blocking on DMA). This is the same 10 12 //! "I2S write IS our scheduling cadence" trick from hello-world, scaled up 11 13 //! to handle continuous audio plus inbound commands. 12 14 13 - use crate::events::{AudioCommand, LedSignal}; 15 + use crate::events::{AudioCommand, AudioStatus, LedSignal, OutboundEvent, StateSnapshot}; 14 16 use crate::nvs::{NvsStore, PersistedState}; 15 17 use crate::state::{next_volume_index, VolumeDirection, VOLUME_PRESETS}; 16 18 use anyhow::Result; ··· 19 21 }; 20 22 use esp_idf_svc::hal::i2s::{I2sDriver, I2sTx}; 21 23 use log::{info, warn}; 22 - use std::sync::mpsc::{Receiver, Sender}; 24 + use crate::channels::{Receiver, Sender}; 23 25 use std::thread::{Builder, JoinHandle}; 24 26 25 27 pub const SAMPLE_RATE: u32 = 44_100; 26 28 const BUFFER_BYTES: usize = 4096; // ~23 ms at 44.1 kHz stereo 16-bit 27 29 const STACK_SIZE: usize = 16 * 1024; 28 30 31 + /// How long a full-scale (0%↔100%) volume change should take. Smaller 32 + /// changes (e.g. 50%→75%) finish proportionally faster at the same per- 33 + /// sample step rate, which gives a "snappy on small adjustments, soft on 34 + /// big ones" feel. Must be < the long-press cycle interval (1s) so a yo-yo 35 + /// burst doesn't queue fades faster than they can finish. 36 + const FADE_DURATION_S: f32 = 0.7; 37 + const FADE_STEP_PER_SAMPLE: f32 = 100.0 / (FADE_DURATION_S * SAMPLE_RATE as f32); 38 + 29 39 /// Construct the I2S driver for the onboard NS4168 amp. 30 40 /// 31 41 /// Pin assignment matches the M5Stack Atom Echo schematic: ··· 65 75 initial_state: PersistedState, 66 76 cmd_rx: Receiver<AudioCommand>, 67 77 led_tx: Sender<LedSignal>, 78 + out_tx: Sender<OutboundEvent>, 68 79 ) -> Result<JoinHandle<()>> { 69 80 let handle = Builder::new() 70 81 .name("audio".into()) 71 82 .stack_size(STACK_SIZE) 72 83 .spawn(move || { 73 - if let Err(e) = audio_loop(i2s, nvs, initial_state, cmd_rx, led_tx) { 84 + let err_tx = led_tx.clone(); 85 + if let Err(e) = audio_loop(i2s, nvs, initial_state, cmd_rx, led_tx, out_tx) { 74 86 warn!("audio task exiting on error: {e:?}"); 87 + let _ = err_tx.send(LedSignal::Error); 75 88 } 76 89 })?; 77 90 Ok(handle) ··· 146 159 /// Internal task state. Tracks only what the audio task itself needs to 147 160 /// remember; the coordinator and other tasks have their own views. 148 161 struct State { 162 + /// User intent for play state. Audio fades to silence after this goes 163 + /// false, then stops generating; fades up from silence after this goes 164 + /// true. 149 165 playing: bool, 150 166 volume_index: u8, 151 167 direction: VolumeDirection, 168 + /// Currently-rendered amplitude in 0..=100 percent. Smoothly tracked 169 + /// toward `target_amp()` per-sample to fade in/out and between presets. 170 + rendered_amp: f32, 152 171 } 153 172 154 173 impl State { ··· 157 176 playing: p.was_playing, 158 177 volume_index: p.volume_index, 159 178 direction: p.volume_direction, 179 + // Start silent; fade up to the saved level if was_playing. This 180 + // also avoids a power-blip click in the speaker. 181 + rendered_amp: 0.0, 160 182 } 161 183 } 162 184 163 185 fn current_volume_pct(&self) -> u8 { 164 186 VOLUME_PRESETS[self.volume_index as usize] 165 187 } 188 + 189 + /// What `rendered_amp` is fading toward right now: the preset volume 190 + /// when playing, silence when not. 191 + fn target_amp(&self) -> f32 { 192 + if self.playing { 193 + self.current_volume_pct() as f32 194 + } else { 195 + 0.0 196 + } 197 + } 166 198 } 167 199 168 200 fn audio_loop( ··· 171 203 initial: PersistedState, 172 204 cmd_rx: Receiver<AudioCommand>, 173 205 led_tx: Sender<LedSignal>, 206 + out_tx: Sender<OutboundEvent>, 174 207 ) -> Result<()> { 175 208 let mut state = State::from_persisted(initial); 176 209 let mut rng = Rng::new(0xC0FFEE_u32); ··· 189 222 state.playing, state.volume_index, state.direction 190 223 ); 191 224 192 - // Reflect the initial state to the LED. 193 - let _ = led_tx.send(if state.playing { 194 - LedSignal::Playing 195 - } else { 196 - LedSignal::Idle 197 - }); 225 + // Reflect the initial state to the LED and seed the network task's 226 + // state-snapshot cache so it has something to publish on first connect. 227 + let _ = led_tx.send(LedSignal::Audio(audio_status(&state))); 228 + let _ = out_tx.send(OutboundEvent::State(snapshot(&state))); 198 229 199 230 loop { 200 231 // Drain any pending commands without blocking. 201 - while let Ok(cmd) = cmd_rx.try_recv() { 202 - apply_command(cmd, &mut state, &nvs, &led_tx); 232 + while let Some(cmd) = cmd_rx.try_recv() { 233 + apply_command(cmd, &mut state, &nvs, &led_tx, &out_tx); 203 234 } 204 235 205 - // Fill the buffer for the next 23 ms of audio. 206 - if state.playing { 236 + // Fill the buffer for the next 23 ms of audio. Keep generating noise 237 + // while we're fading out (rendered_amp > 0) even after `playing` has 238 + // gone false; only short-circuit to silence once we've actually 239 + // reached zero amplitude. Saves CPU when fully off. 240 + let target = state.target_amp(); 241 + if state.rendered_amp > 0.0 || target > 0.0 { 207 242 fill_noise( 208 243 &mut buf, 209 244 &mut rng, 210 245 &mut pink_l, 211 246 &mut pink_r, 212 - state.current_volume_pct(), 247 + &mut state.rendered_amp, 248 + target, 213 249 ); 214 250 } else { 215 251 buf.iter_mut().for_each(|b| *b = 0); ··· 234 270 state: &mut State, 235 271 nvs: &NvsStore, 236 272 led_tx: &Sender<LedSignal>, 273 + out_tx: &Sender<OutboundEvent>, 237 274 ) { 238 275 match cmd { 239 - AudioCommand::Play => set_playing(state, nvs, led_tx, true), 240 - AudioCommand::Stop => set_playing(state, nvs, led_tx, false), 241 - AudioCommand::Toggle => set_playing(state, nvs, led_tx, !state.playing), 276 + AudioCommand::Play => set_playing(state, nvs, led_tx, out_tx, true), 277 + AudioCommand::Stop => set_playing(state, nvs, led_tx, out_tx, false), 278 + AudioCommand::Toggle => set_playing(state, nvs, led_tx, out_tx, !state.playing), 242 279 AudioCommand::CycleVolume => { 243 280 let (new_idx, new_dir) = next_volume_index(state.volume_index, state.direction); 244 281 state.volume_index = new_idx; ··· 250 287 state.current_volume_pct(), 251 288 new_dir 252 289 ); 290 + let _ = out_tx.send(OutboundEvent::State(snapshot(state))); 253 291 } 254 292 AudioCommand::SetVolumeIndex(idx) => { 255 293 let clamped = (idx as usize).min(VOLUME_PRESETS.len() - 1) as u8; ··· 261 299 state.volume_index, 262 300 state.current_volume_pct() 263 301 ); 302 + let _ = out_tx.send(OutboundEvent::State(snapshot(state))); 264 303 } 265 304 } 266 305 } 267 306 } 268 307 269 - fn set_playing(state: &mut State, nvs: &NvsStore, led_tx: &Sender<LedSignal>, target: bool) { 308 + fn set_playing( 309 + state: &mut State, 310 + nvs: &NvsStore, 311 + led_tx: &Sender<LedSignal>, 312 + out_tx: &Sender<OutboundEvent>, 313 + target: bool, 314 + ) { 270 315 if state.playing == target { 271 316 return; 272 317 } 273 318 state.playing = target; 274 319 nvs.write_playing(target); 275 320 info!("playing={}", target); 276 - let _ = led_tx.send(if target { 277 - LedSignal::Playing 321 + let _ = led_tx.send(LedSignal::Audio(audio_status(state))); 322 + let _ = out_tx.send(OutboundEvent::State(snapshot(state))); 323 + } 324 + 325 + fn audio_status(state: &State) -> AudioStatus { 326 + if state.playing { 327 + AudioStatus::Playing 278 328 } else { 279 - LedSignal::Idle 280 - }); 329 + AudioStatus::Idle 330 + } 331 + } 332 + 333 + fn snapshot(state: &State) -> StateSnapshot { 334 + StateSnapshot { 335 + playing: state.playing, 336 + volume_pct: state.current_volume_pct(), 337 + } 281 338 } 282 339 283 - /// Fill the buffer with stereo pink noise scaled by `vol_pct` (0..=100). 340 + /// Fill the buffer with stereo pink noise, advancing `rendered_amp` toward 341 + /// `target_amp` by `FADE_STEP_PER_SAMPLE` each sample. Both amplitudes are 342 + /// in 0..=100 percent units. The fade is per-sample so a 23 ms buffer 343 + /// contains a smooth ramp rather than a stair-step every buffer. 284 344 /// 285 345 /// Pink noise (1/f spectrum) sounds calmer and more "shhhh"-like than raw 286 346 /// white noise — most "sleep" noise machines use pink or brown. ··· 289 349 rng: &mut Rng, 290 350 pink_l: &mut PinkFilter, 291 351 pink_r: &mut PinkFilter, 292 - vol_pct: u8, 352 + rendered_amp: &mut f32, 353 + target_amp: f32, 293 354 ) { 294 355 debug_assert!(buf.len() % 4 == 0); 295 - let vol_scale = vol_pct as f32 / 100.0; 296 356 let i16_max = i16::MAX as f32; 297 357 for chunk in buf.chunks_exact_mut(4) { 358 + // Advance toward target. Saturate at target to avoid overshoot when 359 + // the per-sample step is larger than the remaining gap. 360 + if *rendered_amp < target_amp { 361 + *rendered_amp = (*rendered_amp + FADE_STEP_PER_SAMPLE).min(target_amp); 362 + } else if *rendered_amp > target_amp { 363 + *rendered_amp = (*rendered_amp - FADE_STEP_PER_SAMPLE).max(target_amp); 364 + } 365 + let vol_scale = *rendered_amp / 100.0; 298 366 let pl = pink_l.process(rng.next_white()) * vol_scale; 299 367 let pr = pink_r.process(rng.next_white()) * vol_scale; 300 368 let l = (pl * i16_max).clamp(i16::MIN as f32, i16::MAX as f32) as i16;
+1 -1
firmware/src/button.rs
··· 14 14 use anyhow::Result; 15 15 use esp_idf_svc::hal::gpio::{Gpio39, Input, PinDriver}; 16 16 use log::{info, warn}; 17 - use std::sync::mpsc::Sender; 17 + use crate::channels::Sender; 18 18 use std::thread::{Builder, JoinHandle}; 19 19 use std::time::{Duration, Instant}; 20 20
+106
firmware/src/channels.rs
··· 1 + //! Cross-task message channels backed by FreeRTOS native queues. 2 + //! 3 + //! `std::sync::mpsc` and `crossbeam-channel` both use `pthread_mutex_t` 4 + //! internally. ESP-IDF's pthread implementation defines 5 + //! `PTHREAD_MUTEX_INITIALIZER` as `0xffffffff` (a sentinel for lazy init via 6 + //! `pthread_mutex_init_if_static`), but the `libc` crate that Rust's std is 7 + //! built against assumes the GNU/newlib value (zeros) and a 40-byte 8 + //! `pthread_mutex_t`. ESP-IDF's `pthread_mutex_t` is 4 bytes (a pointer to a 9 + //! dynamically-allocated struct). The mismatch means the lazy init crashes 10 + //! the moment any `Mutex<Waker>` inside a channel needs to wake a blocked 11 + //! receiver — which happens on every multi-producer send under contention. 12 + //! 13 + //! FreeRTOS queues sidestep this entirely. They're the native primitive on 14 + //! the ESP32, ISR-safe, support multiple producers and consumers, and don't 15 + //! touch the pthread layer. 16 + //! 17 + //! The wrapper here gives us familiar `Sender`/`Receiver` ergonomics. Both 18 + //! ends are `Arc<Queue<T>>`; cloning is cheap; `T` must be `Copy` (FreeRTOS 19 + //! queues are byte-copy semantics, so non-`Copy` payloads would leak). 20 + 21 + use anyhow::{anyhow, Result}; 22 + use esp_idf_svc::hal::task::queue::Queue; 23 + use std::sync::Arc; 24 + use std::time::Duration; 25 + 26 + /// Block forever — FreeRTOS `portMAX_DELAY`. 27 + const PORT_MAX_DELAY: u32 = u32::MAX; 28 + 29 + pub struct Sender<T: Copy + Send + Sync + 'static> { 30 + queue: Arc<Queue<T>>, 31 + } 32 + 33 + impl<T: Copy + Send + Sync + 'static> Clone for Sender<T> { 34 + fn clone(&self) -> Self { 35 + Self { 36 + queue: self.queue.clone(), 37 + } 38 + } 39 + } 40 + 41 + /// Default send timeout — long enough that any sane receiver should drain in 42 + /// time, short enough that a wedged receiver doesn't hang the sender forever. 43 + const DEFAULT_SEND_TIMEOUT: Duration = Duration::from_millis(500); 44 + 45 + impl<T: Copy + Send + Sync + 'static> Sender<T> { 46 + /// Send with a reasonable default timeout. Mirrors `std::sync::mpsc::Sender::send` 47 + /// for call-site ergonomics — most call sites use `let _ = tx.send(...)` 48 + /// and don't want to think about timeouts. 49 + pub fn send(&self, item: T) -> Result<()> { 50 + self.send_timeout(item, DEFAULT_SEND_TIMEOUT) 51 + } 52 + 53 + /// Send with an explicit timeout. Returns Err on timeout / failure. 54 + pub fn send_timeout(&self, item: T, timeout: Duration) -> Result<()> { 55 + let ticks = duration_to_ticks(timeout); 56 + self.queue 57 + .send_back(item, ticks) 58 + .map(|_| ()) 59 + .map_err(|e| anyhow!("queue send failed (full?): {e}")) 60 + } 61 + 62 + /// Send without blocking. Returns Err if the queue is full. 63 + pub fn try_send(&self, item: T) -> Result<()> { 64 + self.queue 65 + .send_back(item, 0) 66 + .map(|_| ()) 67 + .map_err(|e| anyhow!("queue try_send failed (full): {e}")) 68 + } 69 + } 70 + 71 + pub struct Receiver<T: Copy + Send + Sync + 'static> { 72 + queue: Arc<Queue<T>>, 73 + } 74 + 75 + impl<T: Copy + Send + Sync + 'static> Receiver<T> { 76 + /// Block forever waiting for a message. 77 + pub fn recv(&self) -> Option<T> { 78 + self.queue.recv_front(PORT_MAX_DELAY).map(|(t, _)| t) 79 + } 80 + 81 + /// Block up to `timeout` waiting for a message. Returns `None` on timeout. 82 + pub fn recv_timeout(&self, timeout: Duration) -> Option<T> { 83 + self.queue 84 + .recv_front(duration_to_ticks(timeout)) 85 + .map(|(t, _)| t) 86 + } 87 + 88 + /// Non-blocking receive. Returns `None` immediately if empty. 89 + pub fn try_recv(&self) -> Option<T> { 90 + self.queue.recv_front(0).map(|(t, _)| t) 91 + } 92 + } 93 + 94 + /// Build an unbounded-ish channel with a fixed slot count. Pick a count that 95 + /// comfortably covers your peak in-flight messages — extra slots cost 96 + /// `count * size_of::<T>()` bytes of static heap. 97 + pub fn channel<T: Copy + Send + Sync + 'static>(slots: usize) -> (Sender<T>, Receiver<T>) { 98 + let q = Arc::new(Queue::<T>::new(slots)); 99 + (Sender { queue: q.clone() }, Receiver { queue: q }) 100 + } 101 + 102 + fn duration_to_ticks(d: Duration) -> u32 { 103 + // FreeRTOS tick rate is set in sdkconfig (we have CONFIG_FREERTOS_HZ=1000), 104 + // so 1 ms = 1 tick. Saturate on overflow rather than wrapping. 105 + d.as_millis().min(u32::MAX as u128 - 1) as u32 106 + }
+176
firmware/src/discovery.rs
··· 1 + //! Home Assistant MQTT Discovery payloads. 2 + //! 3 + //! On every MQTT (re)connect, the device republishes one retained discovery 4 + //! config per entity. HA dedupes by retained-payload equality, so this is 5 + //! cheap and idempotent — it covers HA restarts, broker retained-message 6 + //! losses, and first-time provisioning equally. 7 + //! 8 + //! Topic identity uses the lowercase 12-char MAC hex string (e.g. 9 + //! `nightstand_aabbccddeeff_button`), which is stable across firmware 10 + //! versions and lets HA name the device however the user wants in the UI 11 + //! while keeping `unique_id` invariant. 12 + //! 13 + //! Payloads are hand-built with `format!` — they're small, infrequent, and 14 + //! avoiding `serde_json` saves ~30 KB of binary on the Xtensa target. 15 + 16 + extern crate alloc; 17 + 18 + use alloc::string::String; 19 + use alloc::vec::Vec; 20 + 21 + pub struct DiscoveryEntry { 22 + pub topic: String, 23 + pub payload: String, 24 + } 25 + 26 + /// Build the discovery entries (button, switch, number, uptime sensor) for 27 + /// the given device. `mac_hex` is lowercase hex with no separators. 28 + pub fn all(mac_hex: &str, sw_version: &str) -> Vec<DiscoveryEntry> { 29 + let device_id = format!("nightstand_{mac_hex}"); 30 + let topic_prefix = format!("nightstand/{mac_hex}"); 31 + let avail_topic = format!("{topic_prefix}/available"); 32 + let state_topic = format!("{topic_prefix}/state"); 33 + 34 + vec![ 35 + button(&device_id, &topic_prefix, &avail_topic, sw_version), 36 + switch(&device_id, &topic_prefix, &avail_topic, &state_topic), 37 + number(&device_id, &topic_prefix, &avail_topic, &state_topic), 38 + uptime(&device_id, &avail_topic, &state_topic), 39 + ] 40 + } 41 + 42 + fn button(device_id: &str, topic_prefix: &str, avail: &str, sw_version: &str) -> DiscoveryEntry { 43 + let topic = format!("homeassistant/event/{device_id}/button/config"); 44 + let payload = format!( 45 + concat!( 46 + r#"{{"name":"Button","unique_id":"{device_id}_button","#, 47 + r#""state_topic":"{topic_prefix}/button","#, 48 + r#""event_types":["short","double","long"],"#, 49 + r#""value_template":"{{{{ value_json.event_type }}}}","#, 50 + r#""device":{{"identifiers":["{device_id}"],"name":"Nightstand","#, 51 + r#""manufacturer":"guid.foo","model":"Sound Machine","sw_version":"{sw_version}"}},"#, 52 + r#""availability_topic":"{avail}"}}"#, 53 + ), 54 + device_id = device_id, 55 + topic_prefix = topic_prefix, 56 + avail = avail, 57 + sw_version = sw_version, 58 + ); 59 + DiscoveryEntry { topic, payload } 60 + } 61 + 62 + fn switch(device_id: &str, topic_prefix: &str, avail: &str, state_topic: &str) -> DiscoveryEntry { 63 + let topic = format!("homeassistant/switch/{device_id}/white_noise/config"); 64 + let payload = format!( 65 + concat!( 66 + r#"{{"name":"White Noise","unique_id":"{device_id}_white_noise","#, 67 + r#""state_topic":"{state_topic}","#, 68 + r#""value_template":"{{{{ value_json.playing }}}}","#, 69 + r#""command_topic":"{topic_prefix}/cmd/play","#, 70 + r#""payload_on":"ON","payload_off":"OFF","#, 71 + r#""state_on":"ON","state_off":"OFF","#, 72 + r#""device":{{"identifiers":["{device_id}"]}},"#, 73 + r#""availability_topic":"{avail}"}}"#, 74 + ), 75 + device_id = device_id, 76 + topic_prefix = topic_prefix, 77 + state_topic = state_topic, 78 + avail = avail, 79 + ); 80 + DiscoveryEntry { topic, payload } 81 + } 82 + 83 + fn number(device_id: &str, topic_prefix: &str, avail: &str, state_topic: &str) -> DiscoveryEntry { 84 + let topic = format!("homeassistant/number/{device_id}/volume/config"); 85 + let payload = format!( 86 + concat!( 87 + r#"{{"name":"Volume","unique_id":"{device_id}_volume","#, 88 + r#""state_topic":"{state_topic}","#, 89 + r#""value_template":"{{{{ value_json.volume }}}}","#, 90 + r#""command_topic":"{topic_prefix}/cmd/volume","#, 91 + r#""min":0,"max":100,"step":1,"mode":"slider","#, 92 + r#""device":{{"identifiers":["{device_id}"]}},"#, 93 + r#""availability_topic":"{avail}"}}"#, 94 + ), 95 + device_id = device_id, 96 + topic_prefix = topic_prefix, 97 + state_topic = state_topic, 98 + avail = avail, 99 + ); 100 + DiscoveryEntry { topic, payload } 101 + } 102 + 103 + fn uptime(device_id: &str, avail: &str, state_topic: &str) -> DiscoveryEntry { 104 + let topic = format!("homeassistant/sensor/{device_id}/uptime/config"); 105 + let payload = format!( 106 + concat!( 107 + r#"{{"name":"Uptime","unique_id":"{device_id}_uptime","#, 108 + r#""state_topic":"{state_topic}","#, 109 + r#""value_template":"{{{{ value_json.uptime_s }}}}","#, 110 + r#""unit_of_measurement":"s","device_class":"duration","#, 111 + r#""entity_category":"diagnostic","#, 112 + r#""device":{{"identifiers":["{device_id}"]}},"#, 113 + r#""availability_topic":"{avail}"}}"#, 114 + ), 115 + device_id = device_id, 116 + state_topic = state_topic, 117 + avail = avail, 118 + ); 119 + DiscoveryEntry { topic, payload } 120 + } 121 + 122 + #[cfg(test)] 123 + mod tests { 124 + use super::*; 125 + 126 + #[test] 127 + fn four_entries_with_correct_topics() { 128 + let entries = all("aabbccddeeff", "0.2.0"); 129 + assert_eq!(entries.len(), 4); 130 + let topics: Vec<&str> = entries.iter().map(|e| e.topic.as_str()).collect(); 131 + assert!(topics.contains(&"homeassistant/event/nightstand_aabbccddeeff/button/config")); 132 + assert!(topics.contains(&"homeassistant/switch/nightstand_aabbccddeeff/white_noise/config")); 133 + assert!(topics.contains(&"homeassistant/number/nightstand_aabbccddeeff/volume/config")); 134 + assert!(topics.contains(&"homeassistant/sensor/nightstand_aabbccddeeff/uptime/config")); 135 + } 136 + 137 + #[test] 138 + fn payloads_embed_mac_in_unique_id_and_topics() { 139 + let entries = all("aabbccddeeff", "0.2.0"); 140 + for entry in &entries { 141 + assert!( 142 + entry.payload.contains("nightstand_aabbccddeeff"), 143 + "payload missing device_id: {}", 144 + entry.payload 145 + ); 146 + } 147 + } 148 + 149 + #[test] 150 + fn switch_command_topic_uses_mac() { 151 + let entries = all("0123456789ab", "0.2.0"); 152 + let switch = entries 153 + .iter() 154 + .find(|e| e.topic.contains("white_noise")) 155 + .expect("switch entry"); 156 + assert!( 157 + switch 158 + .payload 159 + .contains(r#""command_topic":"nightstand/0123456789ab/cmd/play""#), 160 + "{}", 161 + switch.payload 162 + ); 163 + } 164 + 165 + #[test] 166 + fn payloads_have_no_stray_backslashes() { 167 + let entries = all("aabbccddeeff", "0.2.0"); 168 + for entry in &entries { 169 + assert!( 170 + !entry.payload.contains('\\'), 171 + "payload contains backslash: {}", 172 + entry.payload 173 + ); 174 + } 175 + } 176 + }
+57 -10
firmware/src/events.rs
··· 16 16 Double, 17 17 } 18 18 19 + impl ButtonEvent { 20 + /// The wire-format string used in MQTT button event payloads. 21 + pub fn as_event_type(self) -> &'static str { 22 + match self { 23 + Self::Short => "short", 24 + Self::Long => "long", 25 + Self::Double => "double", 26 + } 27 + } 28 + } 29 + 19 30 /// What the coordinator (or, later, an MQTT task) tells the audio task to do. 20 31 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 21 32 pub enum AudioCommand { ··· 32 43 SetVolumeIndex(u8), 33 44 } 34 45 35 - /// What the coordinator tells the LED task to display. 46 + /// Audio playback state, sent from the audio task to the LED task. 47 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 48 + pub enum AudioStatus { 49 + Idle, 50 + Playing, 51 + } 52 + 53 + /// Network connection state, sent from the network task to the LED task. 54 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 55 + pub enum NetStatus { 56 + /// WiFi or MQTT not yet up — initial state and during reconnect attempts. 57 + Connecting, 58 + /// WiFi + MQTT both connected. 59 + Online, 60 + /// Was online, lost connection, or never connected and giving up for now. 61 + Offline, 62 + } 63 + 64 + /// What the audio / network tasks tell the LED task to display. 36 65 /// 37 - /// `PressFlash` is treated as an overlay — the LED task remembers the 38 - /// underlying signal and brightens briefly on top of it. 66 + /// The LED task tracks the most recent `Audio(_)` and `Net(_)` separately and 67 + /// renders the combined color from a 2-axis lookup. `Error` overrides both; 68 + /// `PressFlash` is an overlay that brightens whatever is currently shown. 39 69 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 40 70 pub enum LedSignal { 41 - /// Connecting / starting up — slow blue pulse. 42 - Boot, 43 - /// Offline, idle (silent) — dim amber. 44 - Idle, 45 - /// Offline, playing — brighter amber. 46 - Playing, 71 + /// Audio task reporting current playback state. 72 + Audio(AudioStatus), 73 + /// Network task reporting current connection state. 74 + Net(NetStatus), 47 75 /// Brief brighter flash on top of whatever's currently being shown. 48 76 PressFlash, 49 - /// Something's broken — slow red blink. 77 + /// Something's broken — slow red blink. Reserved for unrecoverable 78 + /// failures (I2S init, etc.); network outages are just `Net(Offline)`. 50 79 Error, 51 80 } 81 + 82 + /// A snapshot of the audio task's state — what gets published to the 83 + /// `nightstand/<mac>/state` MQTT topic. 84 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 85 + pub struct StateSnapshot { 86 + pub playing: bool, 87 + pub volume_pct: u8, 88 + } 89 + 90 + /// What the coordinator and audio task send to the network task for outbound 91 + /// publication. The network task is the gatekeeper: when offline, button 92 + /// events for `Short` are translated into a local `AudioCommand::Toggle`; 93 + /// state snapshots are cached for republish on reconnect. 94 + #[derive(Debug, Clone, Copy)] 95 + pub enum OutboundEvent { 96 + Button(ButtonEvent), 97 + State(StateSnapshot), 98 + }
+67 -45
firmware/src/led.rs
··· 10 10 //! (≥50 µs), which serves as the SK6812 reset; at our 30 Hz refresh rate 11 11 //! the gap is 33 ms — vastly more than the ~80 µs spec. 12 12 //! 13 - //! Receives `LedSignal`s from a channel and renders status colors: 14 - //! Boot — slow blue pulse, ~1 Hz 15 - //! Idle — dim amber 16 - //! Playing — brighter amber 17 - //! Error — red blink, ~2 Hz 18 - //! PressFlash — overlay; brightens whatever's currently shown for ~150 ms 13 + //! Receives `LedSignal`s from a channel and tracks two orthogonal axes — 14 + //! audio playback state and network connection state — composing the base 15 + //! color from the (net, audio) pair. `Error` overrides both; `PressFlash` is 16 + //! an overlay that brightens whatever's currently shown for ~150 ms. 17 + //! 18 + //! Color mapping: 19 + //! (Connecting, _) — slow cyan pulse (boot / reconnect) 20 + //! (Online, Idle/Playing) — green dim / medium 21 + //! (Offline, Idle/Playing) — amber dim / medium 22 + //! Error — red blink, ~2 Hz 19 23 20 - use crate::events::LedSignal; 24 + use crate::events::{AudioStatus, LedSignal, NetStatus}; 21 25 use anyhow::{anyhow, Result}; 22 26 use esp_idf_svc::hal::gpio::Gpio27; 23 27 use esp_idf_svc::hal::rmt::config::TransmitConfig; ··· 25 29 FixedLengthSignal, PinState, Pulse, PulseTicks, TxRmtDriver, CHANNEL0, 26 30 }; 27 31 use log::{info, warn}; 28 - use std::sync::mpsc::{Receiver, TryRecvError}; 32 + use crate::channels::Receiver; 29 33 use std::thread::{Builder, JoinHandle}; 30 34 use std::time::{Duration, Instant}; 31 35 ··· 80 84 let mut tx = TxRmtDriver::new(rmt_channel, pin, &config) 81 85 .map_err(|e| anyhow!("RMT init: {e}"))?; 82 86 83 - let mut current = LedSignal::Boot; 87 + // Two orthogonal pieces of state, plus the optional overriding Error and 88 + // the transient PressFlash overlay. 89 + let mut audio = AudioStatus::Idle; 90 + let mut net = NetStatus::Connecting; 91 + let mut error = false; 84 92 let mut flash_started: Option<Instant> = None; 85 93 let start = Instant::now(); 86 94 87 95 info!("LED task ready"); 88 96 97 + // We render at ~30 Hz. Between frames, block on the channel with a 98 + // FRAME_INTERVAL timeout instead of try_recv polling. Rapid try_recv on 99 + // esp-idf-rs's pthread mutexes corrupts channel state and crashes other 100 + // senders; recv_timeout serializes access cleanly. 101 + let mut next_frame_at = Instant::now(); 89 102 loop { 90 - // Drain any pending signals; latest wins. PressFlash is special: 91 - // it doesn't replace `current`, just kicks the overlay timer. 92 - loop { 93 - match signal_rx.try_recv() { 94 - Ok(LedSignal::PressFlash) => flash_started = Some(Instant::now()), 95 - Ok(other) => current = other, 96 - Err(TryRecvError::Empty) => break, 97 - Err(TryRecvError::Disconnected) => return Ok(()), 98 - } 99 - } 100 - 101 103 let now = Instant::now(); 102 - let base = base_color_for(current, now.duration_since(start)); 103 - let final_color = apply_flash_overlay(base, flash_started, now); 104 - 105 - if let Err(e) = write_color(&mut tx, final_color) { 106 - warn!("LED write error: {e:?}"); 104 + let wait = next_frame_at.saturating_duration_since(now); 105 + match signal_rx.recv_timeout(wait) { 106 + Some(LedSignal::Audio(a)) => audio = a, 107 + Some(LedSignal::Net(n)) => net = n, 108 + Some(LedSignal::PressFlash) => flash_started = Some(Instant::now()), 109 + Some(LedSignal::Error) => error = true, 110 + None => { 111 + // Timeout — render the next frame. 112 + let elapsed = Instant::now().duration_since(start); 113 + let base = if error { 114 + error_color(elapsed) 115 + } else { 116 + base_color_for(net, audio, elapsed) 117 + }; 118 + let final_color = apply_flash_overlay(base, flash_started, Instant::now()); 119 + if let Err(e) = write_color(&mut tx, final_color) { 120 + warn!("LED write error: {e:?}"); 121 + } 122 + next_frame_at = Instant::now() + FRAME_INTERVAL; 123 + } 107 124 } 108 - 109 - std::thread::sleep(FRAME_INTERVAL); 110 125 } 111 126 } 112 127 113 - /// Map a logical signal + animation phase to a base RGB color (no flash). 114 - fn base_color_for(signal: LedSignal, t: Duration) -> Rgb { 115 - match signal { 116 - // Slow pulse blue, ~1 Hz, ranging 5..30 on the B channel. 117 - LedSignal::Boot => { 128 + /// Map (net, audio) plus animation phase to the base RGB color (no flash, no error). 129 + fn base_color_for(net: NetStatus, audio: AudioStatus, t: Duration) -> Rgb { 130 + match net { 131 + // Connecting overrides audio state — show network activity until we 132 + // know which way things landed. Slow cyan pulse, ~1 Hz. 133 + NetStatus::Connecting => { 118 134 let phase = (t.as_millis() as f32 / 500.0) * std::f32::consts::PI; 119 135 let pulse = (phase.sin() * 0.5 + 0.5) * 25.0 + 5.0; 120 - Rgb::new(0, 0, pulse as u8) 121 - } 122 - LedSignal::Idle => Rgb::new(20, 8, 0), // dim amber 123 - LedSignal::Playing => Rgb::new(60, 24, 0), // brighter amber 124 - // ~2 Hz blink on red. 125 - LedSignal::Error => { 126 - if (t.as_millis() / 250) % 2 == 0 { 127 - Rgb::new(60, 0, 0) 128 - } else { 129 - Rgb::new(0, 0, 0) 130 - } 136 + let v = pulse as u8; 137 + Rgb::new(0, v, v) 131 138 } 132 - // PressFlash should never make it into base; treat as Idle if it does. 133 - LedSignal::PressFlash => Rgb::new(20, 8, 0), 139 + NetStatus::Online => match audio { 140 + AudioStatus::Idle => Rgb::new(0, 20, 0), // dim green 141 + AudioStatus::Playing => Rgb::new(0, 60, 0), // brighter green 142 + }, 143 + NetStatus::Offline => match audio { 144 + AudioStatus::Idle => Rgb::new(20, 8, 0), // dim amber 145 + AudioStatus::Playing => Rgb::new(60, 24, 0), // brighter amber 146 + }, 147 + } 148 + } 149 + 150 + /// ~2 Hz red blink for unrecoverable failures (I2S init, etc.). 151 + fn error_color(t: Duration) -> Rgb { 152 + if (t.as_millis() / 250) % 2 == 0 { 153 + Rgb::new(60, 0, 0) 154 + } else { 155 + Rgb::new(0, 0, 0) 134 156 } 135 157 } 136 158
+74 -37
firmware/src/main.rs
··· 1 - //! Sound Machine — offline-mode firmware v0.1.0. 1 + //! Sound Machine — online-mode firmware v0.2.0. 2 2 //! 3 - //! Three tasks plus a coordinator in `main`: 4 - //! - **audio** owns I2S, generates white noise, applies volume cycles 3 + //! Five tasks plus a coordinator in `main`: 4 + //! - **audio** owns I2S, generates white noise, applies volume cycles, 5 + //! emits state snapshots to the network task on every change 5 6 //! - **button** polls G39 and emits short / long / double events 6 - //! - **led** drives the SK6812 RGB LED with status colors 7 - //! - **coordinator** (this main loop) translates button events into 8 - //! audio commands and LED flashes, and logs a TODO for `Double` (which 9 - //! will become an MQTT publish in a future milestone) 7 + //! - **led** drives the SK6812 RGB LED, composing color from audio + net axes 8 + //! - **network** owns WiFi + MQTT; gatekeeps online/offline routing for 9 + //! button events, publishes state, subscribes to inbound commands 10 + //! - **coordinator** (this main loop) translates button events into audio 11 + //! commands (offline-only for short) and outbound events (always) 10 12 //! 11 - //! All cross-task wiring is via `std::sync::mpsc` channels carrying typed 12 - //! events / commands / signals. Adding network control later is purely 13 - //! additive: an MQTT task becomes a second producer of `AudioCommand`s 14 - //! and a publisher of button events; nothing in audio / button / led 15 - //! changes. 13 + //! All cross-task wiring is via FreeRTOS queue–backed channels (see 14 + //! `channels.rs`) carrying typed 15 + //! events / commands / signals. The network task is the gatekeeper: it 16 + //! decides whether short presses toggle audio locally (offline) or wait for 17 + //! HA to publish back via MQTT (online); long-press cycles volume locally in 18 + //! both modes; double-press is purely an MQTT gesture. 16 19 //! 17 20 //! See `firmware/README.md` and `reference/operating-modes.md` for the 18 21 //! design and the gotchas. 19 22 20 23 mod audio; 21 24 mod button; 25 + mod channels; 26 + mod discovery; 22 27 mod events; 23 28 mod led; 29 + mod network; 24 30 mod nvs; 31 + mod secrets; 25 32 mod state; 26 33 27 34 use anyhow::Result; 35 + use esp_idf_svc::eventloop::EspSystemEventLoop; 28 36 use esp_idf_svc::hal::gpio::PinDriver; 29 37 use esp_idf_svc::hal::peripherals::Peripherals; 30 - use events::{AudioCommand, ButtonEvent, LedSignal}; 38 + use esp_idf_svc::nvs::{EspNvsPartition, NvsDefault}; 39 + use events::{AudioCommand, ButtonEvent, LedSignal, NetStatus, OutboundEvent}; 31 40 use log::info; 32 41 use nvs::NvsStore; 33 - use std::sync::mpsc; 42 + use channels::channel; 43 + 44 + const FIRMWARE_VERSION: &str = env!("CARGO_PKG_VERSION"); 34 45 35 46 fn main() -> Result<()> { 36 47 esp_idf_svc::sys::link_patches(); 37 48 esp_idf_svc::log::EspLogger::initialize_default(); 38 49 39 - info!("sound-machine v0.1.0 boot"); 50 + info!("sound-machine v{FIRMWARE_VERSION} boot"); 40 51 41 - // Persistent state — read once at boot, then the audio task owns writes. 42 - let nvs = NvsStore::open()?; 52 + // Take the NVS partition once and clone it for both the audio NvsStore 53 + // and the WiFi stack (which uses NVS internally for STA cred caching). 54 + let nvs_partition = EspNvsPartition::<NvsDefault>::take()?; 55 + let nvs = NvsStore::open_with_partition(nvs_partition.clone())?; 43 56 let initial_state = nvs.read(); 44 57 45 58 // Peripherals split out, owned by the relevant tasks. 46 59 let peripherals = Peripherals::take()?; 47 60 let pins = peripherals.pins; 61 + let sys_loop = EspSystemEventLoop::take()?; 48 62 49 63 // Channels. 50 - let (button_tx, button_rx) = mpsc::channel::<ButtonEvent>(); 51 - let (audio_tx, audio_rx) = mpsc::channel::<AudioCommand>(); 52 - let (led_tx, led_rx) = mpsc::channel::<LedSignal>(); 64 + let (button_tx, button_rx) = channel::<ButtonEvent>(8); 65 + let (audio_tx, audio_rx) = channel::<AudioCommand>(16); 66 + let (led_tx, led_rx) = channel::<LedSignal>(32); 67 + let (out_tx, out_rx) = channel::<OutboundEvent>(16); 53 68 54 - // Tell the LED to pulse blue while we set things up. 55 - let _ = led_tx.send(LedSignal::Boot); 69 + // Tell the LED to pulse cyan while we set things up. The network task 70 + // will refresh this once it knows whether we landed online or offline. 71 + let _ = led_tx.send(LedSignal::Net(NetStatus::Connecting)); 56 72 57 73 // Audio task: I2S to the onboard NS4168 (prototype). Will move to the 58 74 // external MAX98357A on G21/G26/G32 once amps arrive. ··· 62 78 pins.gpio22, // DOUT 63 79 pins.gpio33, // WS / LRCK 64 80 )?; 65 - let _audio_handle = audio::spawn(i2s, nvs, initial_state, audio_rx, led_tx.clone())?; 81 + let _audio_handle = audio::spawn( 82 + i2s, 83 + nvs, 84 + initial_state, 85 + audio_rx, 86 + led_tx.clone(), 87 + out_tx.clone(), 88 + )?; 66 89 67 90 // LED task on G27 via RMT channel 0. 68 91 let _led_handle = led::spawn(peripherals.rmt.channel0, pins.gpio27, led_rx)?; ··· 71 94 let button = PinDriver::input(pins.gpio39)?; 72 95 let _button_handle = button::spawn(button, button_tx)?; 73 96 97 + // Network task: WiFi + MQTT, gatekeeper for online/offline routing. 98 + // Reads MAC and logs the topic prefix early, even if WiFi is unreachable. 99 + let _network_handle = network::spawn( 100 + peripherals.modem, 101 + sys_loop, 102 + nvs_partition, 103 + FIRMWARE_VERSION, 104 + audio_tx.clone(), 105 + led_tx.clone(), 106 + out_rx, 107 + )?; 108 + 74 109 info!("all tasks spawned; entering coordinator loop"); 75 110 76 111 // If the device was playing when power was cut, kick the audio task to ··· 81 116 let _ = audio_tx.send(AudioCommand::Play); 82 117 } 83 118 84 - // Coordinator loop. Receives button events, translates to audio 85 - // commands and LED flashes. Logs the TODO for the double-press. 119 + // Coordinator loop. Receives button events; sends every event to the 120 + // network task (which gatekeeps publish-vs-local-fallback by online 121 + // state) and, for long-press, also fires the local volume-cycle. 86 122 loop { 87 - let event = match button_rx.recv() { 88 - Ok(e) => e, 89 - Err(e) => { 90 - anyhow::bail!("button channel closed: {e}"); 91 - } 123 + let Some(event) = button_rx.recv() else { 124 + anyhow::bail!("button channel closed"); 92 125 }; 93 126 94 127 // Every press gets a brief LED flash for tactile feedback. ··· 96 129 97 130 match event { 98 131 ButtonEvent::Short => { 99 - info!("coordinator: short → AudioCommand::Toggle"); 100 - let _ = audio_tx.send(AudioCommand::Toggle); 132 + // Online: network task will publish; HA echoes back via cmd/play. 133 + // Offline: network task will translate to AudioCommand::Toggle. 134 + info!("coordinator: short → OutboundEvent (network gatekeeps)"); 135 + let _ = out_tx.send(OutboundEvent::Button(ButtonEvent::Short)); 101 136 } 102 137 ButtonEvent::Long => { 103 - info!("coordinator: long → AudioCommand::CycleVolume"); 138 + // Volume cycles locally in both modes; network task publishes 139 + // the event for HA logging when online. 140 + info!("coordinator: long → AudioCommand::CycleVolume + OutboundEvent"); 104 141 let _ = audio_tx.send(AudioCommand::CycleVolume); 142 + let _ = out_tx.send(OutboundEvent::Button(ButtonEvent::Long)); 105 143 } 106 144 ButtonEvent::Double => { 107 - info!( 108 - "coordinator: double → TODO: late-night-lights routine \ 109 - (will trigger HA via MQTT once online)" 110 - ); 145 + // Pure-MQTT gesture; network task publishes if online, no-op offline. 146 + info!("coordinator: double → OutboundEvent"); 147 + let _ = out_tx.send(OutboundEvent::Button(ButtonEvent::Double)); 111 148 } 112 149 } 113 150 }
+489
firmware/src/network.rs
··· 1 + //! Network task: WiFi + MQTT, online/offline state machine, gatekeeper for 2 + //! button events. 3 + //! 4 + //! Two threads: 5 + //! - **state thread** owns WiFi + MQTT, processes `NetTaskMsg` from a single 6 + //! FreeRTOS queue. Routes connection-state changes (Connected/Disconnected) 7 + //! and outbound publishes (button events, state snapshots). 8 + //! - **forwarder thread** reads `OutboundEvent` from the rest of the firmware 9 + //! and re-emits as `NetTaskMsg::Outbound` so the state thread sees a 10 + //! single stream. 11 + //! 12 + //! The MQTT client is constructed with `new_cb`. The callback runs in the 13 + //! ESP-IDF MQTT task and forwards Connected/Disconnected to the state queue 14 + //! and routes inbound commands directly to `audio_tx` — no extra pump 15 + //! thread or borrowed-string copy required. 16 + //! 17 + //! All cross-thread channels are FreeRTOS queues (see `channels.rs`). 18 + //! `std::sync::mpsc` is broken on esp-idf-rs because of the 19 + //! `PTHREAD_MUTEX_INITIALIZER` mismatch between newlib (zeros) and ESP-IDF 20 + //! (`0xffffffff`); see channels.rs for the gory details. 21 + //! 22 + //! WiFi auto-reconnects via the default ESP-IDF behavior; the MQTT C client 23 + //! auto-reconnects on its own (default `reconnect_timeout` is non-zero), so 24 + //! we observe `Connected`/`Disconnected` events instead of running explicit 25 + //! retry loops. If the very first WiFi attempt fails, we retry every 60s. 26 + 27 + use crate::channels::{channel, Receiver, Sender}; 28 + use crate::discovery; 29 + use crate::events::{ 30 + AudioCommand, ButtonEvent, LedSignal, NetStatus, OutboundEvent, StateSnapshot, 31 + }; 32 + use crate::secrets::CONFIG; 33 + use crate::state::snap_to_preset_index; 34 + use anyhow::{anyhow, Result}; 35 + use esp_idf_svc::eventloop::EspSystemEventLoop; 36 + use esp_idf_svc::hal::modem::Modem; 37 + use esp_idf_svc::mqtt::client::{ 38 + Details, EspMqttClient, EventPayload, LwtConfiguration, MqttClientConfiguration, QoS, 39 + }; 40 + use esp_idf_svc::nvs::{EspNvsPartition, NvsDefault}; 41 + use esp_idf_svc::wifi::{ 42 + BlockingWifi, ClientConfiguration, Configuration, EspWifi, WifiDeviceId, 43 + }; 44 + use log::{info, warn}; 45 + use std::thread::{Builder, JoinHandle}; 46 + use std::time::{Duration, Instant}; 47 + 48 + const WIFI_RETRY_INTERVAL: Duration = Duration::from_secs(60); 49 + const STATE_THREAD_STACK: usize = 16 * 1024; 50 + const FORWARDER_THREAD_STACK: usize = 4 * 1024; 51 + 52 + /// Spawn the network task. Takes ownership of the modem, an NVS partition 53 + /// clone, and the channel endpoints it'll need for the rest of its life. 54 + /// The MAC address is read inside the task and logged loudly so first-boot 55 + /// units show their identity in the serial monitor before any connect. 56 + pub fn spawn( 57 + modem: Modem, 58 + sys_loop: EspSystemEventLoop, 59 + nvs_partition: EspNvsPartition<NvsDefault>, 60 + sw_version: &'static str, 61 + audio_tx: Sender<AudioCommand>, 62 + led_tx: Sender<LedSignal>, 63 + out_rx: Receiver<OutboundEvent>, 64 + ) -> Result<JoinHandle<()>> { 65 + let handle = Builder::new() 66 + .name("network".into()) 67 + .stack_size(STATE_THREAD_STACK) 68 + .spawn(move || { 69 + if let Err(e) = run( 70 + modem, 71 + sys_loop, 72 + nvs_partition, 73 + sw_version, 74 + audio_tx, 75 + led_tx, 76 + out_rx, 77 + ) { 78 + warn!("network task exiting on error: {e:?}"); 79 + } 80 + })?; 81 + Ok(handle) 82 + } 83 + 84 + /// Unified message type for the network state thread. All variants are `Copy` 85 + /// so they fit in a FreeRTOS queue (which uses byte-copy semantics). 86 + #[derive(Debug, Clone, Copy)] 87 + enum NetTaskMsg { 88 + Connected, 89 + Disconnected, 90 + Outbound(OutboundEvent), 91 + } 92 + 93 + fn run( 94 + modem: Modem, 95 + sys_loop: EspSystemEventLoop, 96 + nvs_partition: EspNvsPartition<NvsDefault>, 97 + sw_version: &'static str, 98 + audio_tx: Sender<AudioCommand>, 99 + led_tx: Sender<LedSignal>, 100 + out_rx: Receiver<OutboundEvent>, 101 + ) -> Result<()> { 102 + info!("network task: starting; broker={}", CONFIG.mqtt_url); 103 + if CONFIG.wifi_ssid.is_empty() { 104 + warn!("cfg.toml: wifi_ssid is empty; network task exiting"); 105 + return Err(anyhow!("wifi_ssid empty")); 106 + } 107 + if CONFIG.wifi_password.is_empty() { 108 + warn!("cfg.toml: wifi_password is empty; network task exiting"); 109 + return Err(anyhow!("wifi_password empty")); 110 + } 111 + if CONFIG.mqtt_url.is_empty() { 112 + warn!("cfg.toml: mqtt_url is empty; network task exiting"); 113 + return Err(anyhow!("mqtt_url empty")); 114 + } 115 + 116 + let _ = led_tx.send(LedSignal::Net(NetStatus::Connecting)); 117 + let esp_wifi = EspWifi::new(modem, sys_loop.clone(), Some(nvs_partition)) 118 + .map_err(|e| anyhow!("EspWifi::new failed: {e}"))?; 119 + let mut wifi = BlockingWifi::wrap(esp_wifi, sys_loop) 120 + .map_err(|e| anyhow!("BlockingWifi::wrap failed: {e}"))?; 121 + 122 + let mac = wifi 123 + .wifi() 124 + .driver() 125 + .get_mac(WifiDeviceId::Sta) 126 + .map_err(|e| anyhow!("get_mac: {e}"))?; 127 + let mac_hex = format_mac(mac); 128 + 129 + let topic_prefix = format!("nightstand/{mac_hex}"); 130 + let avail_topic = format!("{topic_prefix}/available"); 131 + let state_topic = format!("{topic_prefix}/state"); 132 + let button_topic = format!("{topic_prefix}/button"); 133 + let cmd_filter = format!("{topic_prefix}/cmd/+"); 134 + let cmd_play_topic = format!("{topic_prefix}/cmd/play"); 135 + let cmd_volume_topic = format!("{topic_prefix}/cmd/volume"); 136 + let client_id = format!("nightstand_{mac_hex}"); 137 + let hostname = format!("nightstand-{mac_hex}"); 138 + 139 + info!( 140 + "device MAC = {mac_hex} → topic prefix {topic_prefix}/, client_id {client_id}, broker {}", 141 + CONFIG.mqtt_url 142 + ); 143 + 144 + set_hostname(wifi.wifi().sta_netif(), &hostname); 145 + wifi.set_configuration(&Configuration::Client(ClientConfiguration { 146 + ssid: CONFIG 147 + .wifi_ssid 148 + .try_into() 149 + .map_err(|_| anyhow!("wifi_ssid too long for heapless::String<32>"))?, 150 + password: CONFIG 151 + .wifi_password 152 + .try_into() 153 + .map_err(|_| anyhow!("wifi_password too long for heapless::String<64>"))?, 154 + // Let the driver auto-detect WPA2/WPA3/etc. based on what the AP 155 + // advertises. Forcing WPA2Personal can hang association on WPA3 156 + // and WPA2/WPA3-mixed networks. 157 + ..Default::default() 158 + }))?; 159 + 160 + connect_wifi_with_retry(&mut wifi); 161 + 162 + // Unified queue for the state thread. The MQTT callback pushes 163 + // Connected/Disconnected into it; the forwarder thread pushes Outbound. 164 + let (msg_tx, msg_rx) = channel::<NetTaskMsg>(32); 165 + 166 + // Spawn the outbound forwarder: pulls OutboundEvent from the outside 167 + // world and re-emits as NetTaskMsg::Outbound. 168 + let fwd_msg_tx = msg_tx.clone(); 169 + let _fwd_handle = Builder::new() 170 + .name("net-fwd".into()) 171 + .stack_size(FORWARDER_THREAD_STACK) 172 + .spawn(move || forward_outbound(out_rx, fwd_msg_tx))?; 173 + 174 + // Build the MQTT callback. It runs in the ESP-IDF MQTT task and routes: 175 + // Connected/Disconnected → state-thread queue 176 + // Received(/cmd/play) → audio_tx::Play/Stop 177 + // Received(/cmd/volume) → audio_tx::SetVolumeIndex(snap_to_preset(pct)) 178 + let cb_msg_tx = msg_tx.clone(); 179 + let cb_audio_tx = audio_tx.clone(); 180 + let cb_cmd_play = cmd_play_topic.clone(); 181 + let cb_cmd_volume = cmd_volume_topic.clone(); 182 + let mqtt_lwt_payload = b"offline"; 183 + let mqtt_config = MqttClientConfiguration { 184 + client_id: Some(&client_id), 185 + lwt: Some(LwtConfiguration { 186 + topic: &avail_topic, 187 + payload: mqtt_lwt_payload, 188 + qos: QoS::AtLeastOnce, 189 + retain: true, 190 + }), 191 + keep_alive_interval: Some(Duration::from_secs(60)), 192 + ..Default::default() 193 + }; 194 + let mut client = EspMqttClient::new_cb(CONFIG.mqtt_url, &mqtt_config, move |event| { 195 + mqtt_callback( 196 + event, 197 + &cb_msg_tx, 198 + &cb_audio_tx, 199 + &cb_cmd_play, 200 + &cb_cmd_volume, 201 + ); 202 + }) 203 + .map_err(|e| anyhow!("EspMqttClient::new_cb: {e}"))?; 204 + 205 + // Drop our extra Sender so msg_rx will know if everyone hangs up. 206 + drop(msg_tx); 207 + 208 + let mut last_snapshot: Option<StateSnapshot> = None; 209 + let mut online = false; 210 + let boot_at = Instant::now(); 211 + 212 + info!("network task: entering main loop"); 213 + 214 + loop { 215 + let msg = match msg_rx.recv() { 216 + Some(m) => m, 217 + None => { 218 + warn!("network task: queue receive returned none unexpectedly"); 219 + continue; 220 + } 221 + }; 222 + match msg { 223 + NetTaskMsg::Connected => { 224 + info!("MQTT connected"); 225 + online = true; 226 + let _ = led_tx.send(LedSignal::Net(NetStatus::Online)); 227 + publish_online_announce( 228 + &mut client, 229 + &avail_topic, 230 + &state_topic, 231 + &cmd_filter, 232 + &mac_hex, 233 + sw_version, 234 + last_snapshot, 235 + boot_at, 236 + ); 237 + } 238 + NetTaskMsg::Disconnected => { 239 + info!("MQTT disconnected"); 240 + online = false; 241 + let _ = led_tx.send(LedSignal::Net(NetStatus::Offline)); 242 + } 243 + NetTaskMsg::Outbound(OutboundEvent::Button(e)) => { 244 + handle_outbound_button(e, online, &mut client, &button_topic, &audio_tx); 245 + } 246 + NetTaskMsg::Outbound(OutboundEvent::State(snap)) => { 247 + last_snapshot = Some(snap); 248 + if online { 249 + publish_state(&mut client, &state_topic, snap, boot_at); 250 + } 251 + } 252 + } 253 + } 254 + } 255 + 256 + /// Outbound forwarder: blocking-receive `OutboundEvent` from the rest of the 257 + /// firmware, re-emit as `NetTaskMsg::Outbound` for the state thread. 258 + fn forward_outbound(out_rx: Receiver<OutboundEvent>, msg_tx: Sender<NetTaskMsg>) { 259 + info!("outbound forwarder thread ready"); 260 + loop { 261 + let Some(ev) = out_rx.recv() else { continue }; 262 + info!("forwarder: got OutboundEvent {ev:?}"); 263 + if msg_tx.send(NetTaskMsg::Outbound(ev)).is_err() { 264 + warn!("outbound forwarder: state-queue send failed; exiting"); 265 + return; 266 + } 267 + } 268 + } 269 + 270 + /// MQTT event handler — runs in the ESP-IDF MQTT task. Must be quick. Uses 271 + /// `try_send` so it never blocks the MQTT task even if our state queue is 272 + /// briefly backed up; messages are events, not durable state. 273 + fn mqtt_callback( 274 + event: esp_idf_svc::mqtt::client::EspMqttEvent<'_>, 275 + msg_tx: &Sender<NetTaskMsg>, 276 + audio_tx: &Sender<AudioCommand>, 277 + cmd_play: &str, 278 + cmd_volume: &str, 279 + ) { 280 + match event.payload() { 281 + EventPayload::Connected(_) => { 282 + let _ = msg_tx.try_send(NetTaskMsg::Connected); 283 + } 284 + EventPayload::Disconnected => { 285 + let _ = msg_tx.try_send(NetTaskMsg::Disconnected); 286 + } 287 + EventPayload::Received { 288 + topic, 289 + data, 290 + details, 291 + .. 292 + } => { 293 + if !matches!(details, Details::Complete) { 294 + return; 295 + } 296 + let Some(topic) = topic else { 297 + return; 298 + }; 299 + if topic == cmd_play { 300 + let cmd = match data { 301 + b"ON" => Some(AudioCommand::Play), 302 + b"OFF" => Some(AudioCommand::Stop), 303 + _ => None, 304 + }; 305 + if let Some(cmd) = cmd { 306 + let _ = audio_tx.try_send(cmd); 307 + } 308 + } else if topic == cmd_volume { 309 + if let Ok(s) = std::str::from_utf8(data) { 310 + if let Ok(pct) = s.trim().parse::<u32>() { 311 + let idx = snap_to_preset_index(pct.min(100) as u8); 312 + let _ = audio_tx.try_send(AudioCommand::SetVolumeIndex(idx)); 313 + } 314 + } 315 + } 316 + } 317 + EventPayload::Error(e) => { 318 + warn!("MQTT error event: {e:?}"); 319 + } 320 + _ => {} 321 + } 322 + } 323 + 324 + fn connect_wifi_with_retry(wifi: &mut BlockingWifi<EspWifi<'static>>) { 325 + loop { 326 + match try_connect_wifi(wifi) { 327 + Ok(()) => { 328 + info!("WiFi up"); 329 + return; 330 + } 331 + Err(e) => { 332 + warn!("WiFi connect failed: {e:?}; retrying in {WIFI_RETRY_INTERVAL:?}"); 333 + std::thread::sleep(WIFI_RETRY_INTERVAL); 334 + } 335 + } 336 + } 337 + } 338 + 339 + fn try_connect_wifi(wifi: &mut BlockingWifi<EspWifi<'static>>) -> Result<()> { 340 + if !wifi.is_started()? { 341 + info!("WiFi: starting driver"); 342 + wifi.start()?; 343 + info!("WiFi: driver started"); 344 + } 345 + info!("WiFi: associating with AP"); 346 + wifi.connect()?; 347 + info!("WiFi: associated; waiting for DHCP lease"); 348 + wifi.wait_netif_up()?; 349 + Ok(()) 350 + } 351 + 352 + fn handle_outbound_button( 353 + e: ButtonEvent, 354 + online: bool, 355 + client: &mut EspMqttClient<'_>, 356 + button_topic: &str, 357 + audio_tx: &Sender<AudioCommand>, 358 + ) { 359 + info!("handle_outbound_button: {e:?} online={online}"); 360 + if online { 361 + let payload = format!(r#"{{"event_type":"{}"}}"#, e.as_event_type()); 362 + match client.publish(button_topic, QoS::AtMostOnce, false, payload.as_bytes()) { 363 + Ok(msg_id) => info!("published button event {e:?} (msg_id={msg_id})"), 364 + Err(err) => warn!("publish button event failed: {err}"), 365 + } 366 + } else { 367 + // Offline fallback: only short needs translation to a local toggle. 368 + // Long already cycles volume locally (coordinator did that). Double 369 + // is a pure-MQTT gesture and is a no-op offline per spec. 370 + match e { 371 + ButtonEvent::Short => { 372 + info!("offline short → AudioCommand::Toggle (local fallback)"); 373 + let _ = audio_tx.send(AudioCommand::Toggle); 374 + } 375 + ButtonEvent::Long => {} // already handled locally 376 + ButtonEvent::Double => { 377 + info!("offline double → no-op (HA late-night-lights gesture unavailable)"); 378 + } 379 + } 380 + } 381 + } 382 + 383 + #[allow(clippy::too_many_arguments)] 384 + fn publish_online_announce( 385 + client: &mut EspMqttClient<'_>, 386 + avail_topic: &str, 387 + state_topic: &str, 388 + cmd_filter: &str, 389 + mac_hex: &str, 390 + sw_version: &str, 391 + last_snapshot: Option<StateSnapshot>, 392 + boot_at: Instant, 393 + ) { 394 + if let Err(e) = client.publish(avail_topic, QoS::AtLeastOnce, true, b"online") { 395 + warn!("publish availability=online failed: {e}"); 396 + } 397 + 398 + // Tell HA to drop entities we used to ship but no longer do. An empty 399 + // retained payload on a discovery topic is the documented "remove this 400 + // entity" signal. Add to this list whenever we remove an entity so the 401 + // broker stops carrying its retained config. 402 + let retired = [format!( 403 + "homeassistant/sensor/nightstand_{mac_hex}/rssi/config" 404 + )]; 405 + for topic in &retired { 406 + if let Err(e) = client.publish(topic, QoS::AtLeastOnce, true, b"") { 407 + warn!("publish retired-entity removal {topic} failed: {e}"); 408 + } 409 + } 410 + 411 + for entry in discovery::all(mac_hex, sw_version) { 412 + if let Err(e) = client.publish( 413 + &entry.topic, 414 + QoS::AtLeastOnce, 415 + true, 416 + entry.payload.as_bytes(), 417 + ) { 418 + warn!("publish discovery {} failed: {e}", entry.topic); 419 + } 420 + } 421 + 422 + if let Some(snap) = last_snapshot { 423 + publish_state(client, state_topic, snap, boot_at); 424 + } 425 + 426 + if let Err(e) = client.subscribe(cmd_filter, QoS::AtLeastOnce) { 427 + warn!("subscribe {cmd_filter} failed: {e}"); 428 + } 429 + } 430 + 431 + fn publish_state( 432 + client: &mut EspMqttClient<'_>, 433 + state_topic: &str, 434 + snap: StateSnapshot, 435 + boot_at: Instant, 436 + ) { 437 + let playing = if snap.playing { "ON" } else { "OFF" }; 438 + let uptime_s = boot_at.elapsed().as_secs(); 439 + let payload = format!( 440 + r#"{{"playing":"{}","volume":{},"uptime_s":{}}}"#, 441 + playing, snap.volume_pct, uptime_s 442 + ); 443 + if let Err(e) = client.publish(state_topic, QoS::AtLeastOnce, true, payload.as_bytes()) { 444 + warn!("publish state failed: {e}"); 445 + } 446 + } 447 + 448 + /// Set the netif hostname via the raw C API. The Rust trait method is 449 + /// crate-private in esp-idf-svc 0.51, so we punch through to the C call. 450 + /// Failure is logged and ignored — DHCP-visible hostname is a nicety, not 451 + /// a correctness requirement. 452 + fn set_hostname(netif: &esp_idf_svc::netif::EspNetif, hostname: &str) { 453 + use esp_idf_svc::handle::RawHandle; 454 + use esp_idf_svc::sys::{esp, esp_netif_set_hostname}; 455 + use std::ffi::CString; 456 + let Ok(cstr) = CString::new(hostname) else { 457 + warn!("hostname contains nul byte; skipping set_hostname"); 458 + return; 459 + }; 460 + let res = unsafe { esp!(esp_netif_set_hostname(netif.handle(), cstr.as_ptr())) }; 461 + if let Err(e) = res { 462 + warn!("set_hostname failed (continuing): {e:?}"); 463 + } 464 + } 465 + 466 + /// Format a 6-byte MAC as a 12-char lowercase hex string. 467 + fn format_mac(mac: [u8; 6]) -> String { 468 + let mut s = String::with_capacity(12); 469 + for b in mac { 470 + use std::fmt::Write; 471 + let _ = write!(s, "{:02x}", b); 472 + } 473 + s 474 + } 475 + 476 + #[cfg(test)] 477 + mod tests { 478 + use super::*; 479 + 480 + #[test] 481 + fn format_mac_lowercase_hex() { 482 + assert_eq!( 483 + format_mac([0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]), 484 + "aabbccddeeff" 485 + ); 486 + assert_eq!(format_mac([0, 0, 0, 0, 0, 0]), "000000000000"); 487 + assert_eq!(format_mac([0x01, 0x02, 0x03, 0x04, 0x05, 0x06]), "010203040506"); 488 + } 489 + }
+5 -2
firmware/src/nvs.rs
··· 42 42 } 43 43 44 44 impl NvsStore { 45 - pub fn open() -> Result<Self> { 46 - let partition = EspNvsPartition::<NvsDefault>::take()?; 45 + /// Open the audio namespace using a caller-provided partition handle. 46 + /// `main` takes the default partition once and clones it for both this 47 + /// store and the WiFi stack (which uses NVS internally for STA cred 48 + /// caching). 49 + pub fn open_with_partition(partition: EspNvsPartition<NvsDefault>) -> Result<Self> { 47 50 let nvs = EspNvs::new(partition, NAMESPACE, /* read_write */ true)?; 48 51 Ok(Self { nvs }) 49 52 }
+19
firmware/src/secrets.rs
··· 1 + //! Compile-time configuration ingested from `firmware/cfg.toml` via `toml-cfg`. 2 + //! 3 + //! Real values live in `cfg.toml` (gitignored); a `cfg.toml.example` shows the 4 + //! shape. Defaults are empty strings on purpose — the firmware asserts 5 + //! non-empty at boot so we panic loudly instead of silently trying to connect 6 + //! to nothing. 7 + //! 8 + //! `mqtt_url` is a full URL so adding TLS later (`mqtts://host:8883`) is a 9 + //! config-only change. 10 + 11 + #[toml_cfg::toml_config] 12 + pub struct Config { 13 + #[default("")] 14 + pub wifi_ssid: &'static str, 15 + #[default("")] 16 + pub wifi_password: &'static str, 17 + #[default("")] 18 + pub mqtt_url: &'static str, 19 + }
+47 -7
firmware/src/state.rs
··· 17 17 } 18 18 19 19 impl VolumeDirection { 20 - pub fn flip(self) -> Self { 21 - match self { 22 - Self::Up => Self::Down, 23 - Self::Down => Self::Up, 24 - } 25 - } 26 - 27 20 pub fn to_u8(self) -> u8 { 28 21 match self { 29 22 Self::Up => 0, ··· 37 30 _ => Self::Up, 38 31 } 39 32 } 33 + } 34 + 35 + /// Snap a 0..=100 percentage (e.g. inbound from MQTT volume slider) to the 36 + /// nearest preset index. Out-of-range values clamp to the ends. 37 + /// 38 + /// Ties go to the lower index — so `snap(17)` between `[10, 25]` returns the 39 + /// `25` index because `|17-25|=8` and `|17-10|=7`; `snap(20)` (mid-tie of 40 + /// 17.5) lands on `25` too. The behavior matters less than that it's 41 + /// deterministic and unit-tested. 42 + pub fn snap_to_preset_index(pct: u8) -> u8 { 43 + let mut best_idx: u8 = 0; 44 + let mut best_dist: u16 = u16::MAX; 45 + for (i, preset) in VOLUME_PRESETS.iter().enumerate() { 46 + let dist = (pct as i16 - *preset as i16).unsigned_abs(); 47 + if dist < best_dist { 48 + best_dist = dist; 49 + best_idx = i as u8; 50 + } 51 + } 52 + best_idx 40 53 } 41 54 42 55 /// Compute the next yo-yo step. ··· 85 98 let (idx, dir) = next_volume_index(0, VolumeDirection::Down); 86 99 assert_eq!(idx, 1); 87 100 assert_eq!(dir, VolumeDirection::Up); 101 + } 102 + 103 + #[test] 104 + fn snap_exact_preset_values() { 105 + assert_eq!(snap_to_preset_index(10), 0); 106 + assert_eq!(snap_to_preset_index(25), 1); 107 + assert_eq!(snap_to_preset_index(50), 2); 108 + assert_eq!(snap_to_preset_index(75), 3); 109 + assert_eq!(snap_to_preset_index(100), 4); 110 + } 111 + 112 + #[test] 113 + fn snap_extremes_clamp() { 114 + assert_eq!(snap_to_preset_index(0), 0); 115 + assert_eq!(snap_to_preset_index(255), 4); 116 + } 117 + 118 + #[test] 119 + fn snap_intermediates() { 120 + // 17 is closer to 10 than to 25 (dist 7 vs 8) → index 0 121 + assert_eq!(snap_to_preset_index(17), 0); 122 + // 18 is closer to 25 than to 10 (dist 7 vs 8) → index 1 123 + assert_eq!(snap_to_preset_index(18), 1); 124 + // 65 sits between 50 and 75 — closer to 75 (dist 10 vs 15) → index 3 125 + assert_eq!(snap_to_preset_index(65), 3); 126 + // 60 — equidistant from 50 (dist 10) and 75 (dist 15) → 50 wins 127 + assert_eq!(snap_to_preset_index(60), 2); 88 128 } 89 129 }
+107 -108
reference/mqtt-contract.md
··· 8 8 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. 9 9 3. **MQTT Discovery** is the contract. At boot, the device publishes retained discovery configs under `homeassistant/<type>/nightstand_N/...` and HA auto-creates entities. 10 10 4. **Either unit can trigger a routine** — HA's automations use wildcards (`nightstand/+/button`) so it doesn't care which one. 11 - 5. **No TLS** — LAN-only broker at `mqtt.home.theguidrys.us`, per project decision. 11 + 5. **No TLS** — LAN-only broker, configured per-flash via `firmware/cfg.toml` (gitignored). No auth. 12 12 13 13 ## Division of labor 14 14 ··· 27 27 28 28 ## Device identity 29 29 30 - **MAC-derived, with a known-device lookup table in firmware.** 30 + **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. 31 31 32 - - At boot, read the ESP32's base MAC address. 33 - - Look it up in a `KNOWN_DEVICES` table hardcoded in the firmware: 34 - ```rust 35 - const KNOWN_DEVICES: &[(u64, &str)] = &[ 36 - (0xAABBCCDDEEFF, "nightstand_1"), 37 - (0x112233445566, "nightstand_2"), 38 - ]; 39 - ``` 40 - - If found: logical name is `nightstand_1` / `nightstand_2`, topic prefix is `nightstand/1/...` / `nightstand/2/...`. 41 - - If not found: fall back to `nightstand_<mac_hex>` (so the device still works and shows up in HA under its MAC, user can see it and add to the table). 42 - - `unique_id` fields in discovery always include the MAC to guarantee HA-side uniqueness even if names collide. 32 + - 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. 33 + - Topic prefix: `nightstand/<mac_hex>/...` 34 + - Discovery `unique_id`s: `nightstand_<mac_hex>_button`, `_white_noise`, `_volume`, `_rssi`, `_uptime`. Stable across firmware upgrades. 35 + - 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`. 43 36 44 - **Why:** one firmware binary works on both units. OTA pushes the same `.bin` to both; each identifies itself. Eliminates per-unit build configs. 37 + **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. 45 38 46 39 **First-boot procedure** (per unit): 47 - 1. Flash with a stub `KNOWN_DEVICES` table 48 - 2. Device boots, logs its MAC to serial, publishes MQTT under `nightstand/<mac_hex>` 49 - 3. Add the MAC → name mapping to the table, reflash 50 - 4. Subsequent boots have stable identity 40 + 1. Flash the standard binary 41 + 2. Device boots, logs its MAC, registers itself under `nightstand/<mac_hex>` and `homeassistant/.../nightstand_<mac_hex>/...` 42 + 3. HA auto-discovers it; rename in the HA UI to taste 51 43 52 44 ## Topic namespace 53 45 54 46 ``` 55 - homeassistant/<type>/nightstand_N/<object>/config ← discovery (retain=true) 56 - nightstand/N/available ← LWT + online announce (retain=true) 57 - nightstand/N/button ← event stream (retain=false) 58 - nightstand/N/state ← JSON state snapshot (retain=true) 59 - nightstand/N/cmd/play ← inbound: "ON" / "OFF" 60 - nightstand/N/cmd/volume ← inbound: integer 0-100 47 + homeassistant/<type>/nightstand_<mac_hex>/<object>/config ← discovery (retain=true) 48 + nightstand/<mac_hex>/available ← LWT + online announce (retain=true) 49 + nightstand/<mac_hex>/button ← event stream (retain=false) 50 + nightstand/<mac_hex>/state ← JSON state snapshot (retain=true) 51 + nightstand/<mac_hex>/cmd/play ← inbound: "ON" / "OFF" 52 + nightstand/<mac_hex>/cmd/volume ← inbound: integer 0-100 61 53 ``` 62 54 63 - Where `N` is `1` or `2`. 55 + `<mac_hex>` is the lowercase 12-char STA MAC with no separators (e.g. `aabbccddeeff`). 64 56 65 57 Keeping all state in a single `state` JSON topic (rather than one topic per field) simplifies the device's publish logic and HA's `value_template` wiring. 66 58 ··· 70 62 71 63 Distinguishes short press, double press, and long press (≥ 2s hold). HA automations trigger on event type. 72 64 73 - Discovery topic: `homeassistant/event/nightstand_1/button/config` 65 + Discovery topic: `homeassistant/event/nightstand_<mac_hex>/button/config` 74 66 ```json 75 67 { 76 68 "name": "Button", 77 - "unique_id": "nightstand_1_button", 78 - "state_topic": "nightstand/1/button", 69 + "unique_id": "nightstand_<mac_hex>_button", 70 + "state_topic": "nightstand/<mac_hex>/button", 79 71 "event_types": ["short", "double", "long"], 80 72 "value_template": "{{ value_json.event_type }}", 81 73 "device": { 82 - "identifiers": ["nightstand_1"], 83 - "name": "Nightstand 1", 74 + "identifiers": ["nightstand_<mac_hex>"], 75 + "name": "Nightstand", 84 76 "manufacturer": "guid.foo", 85 77 "model": "Sound Machine v1", 86 - "sw_version": "0.1.0" 78 + "sw_version": "0.2.0" 87 79 }, 88 - "availability_topic": "nightstand/1/available" 80 + "availability_topic": "nightstand/<mac_hex>/available" 89 81 } 90 82 ``` 91 83 92 - Event payload (published to `nightstand/1/button`, not retained): 84 + Event payload (published to `nightstand/<mac_hex>/button`, not retained): 93 85 ```json 94 86 {"event_type": "short"} 95 87 ``` ··· 99 91 100 92 Lets HA (and the HA UI) start/stop the noise and see its current state. 101 93 102 - Discovery topic: `homeassistant/switch/nightstand_1/white_noise/config` 94 + Discovery topic: `homeassistant/switch/nightstand_<mac_hex>/white_noise/config` 103 95 ```json 104 96 { 105 97 "name": "White Noise", 106 - "unique_id": "nightstand_1_white_noise", 107 - "state_topic": "nightstand/1/state", 98 + "unique_id": "nightstand_<mac_hex>_white_noise", 99 + "state_topic": "nightstand/<mac_hex>/state", 108 100 "value_template": "{{ value_json.playing }}", 109 - "command_topic": "nightstand/1/cmd/play", 101 + "command_topic": "nightstand/<mac_hex>/cmd/play", 110 102 "payload_on": "ON", 111 103 "payload_off": "OFF", 112 104 "state_on": "ON", 113 105 "state_off": "OFF", 114 - "device": {"identifiers": ["nightstand_1"]}, 115 - "availability_topic": "nightstand/1/available" 106 + "device": {"identifiers": ["nightstand_<mac_hex>"]}, 107 + "availability_topic": "nightstand/<mac_hex>/available" 116 108 } 117 109 ``` 118 110 ··· 122 114 123 115 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. 124 116 125 - Discovery topic: `homeassistant/number/nightstand_1/volume/config` 117 + Discovery topic: `homeassistant/number/nightstand_<mac_hex>/volume/config` 126 118 ```json 127 119 { 128 120 "name": "Volume", 129 - "unique_id": "nightstand_1_volume", 130 - "state_topic": "nightstand/1/state", 121 + "unique_id": "nightstand_<mac_hex>_volume", 122 + "state_topic": "nightstand/<mac_hex>/state", 131 123 "value_template": "{{ value_json.volume }}", 132 - "command_topic": "nightstand/1/cmd/volume", 124 + "command_topic": "nightstand/<mac_hex>/cmd/volume", 133 125 "min": 0, 134 126 "max": 100, 135 127 "step": 1, 136 128 "mode": "slider", 137 - "device": {"identifiers": ["nightstand_1"]}, 138 - "availability_topic": "nightstand/1/available" 129 + "device": {"identifiers": ["nightstand_<mac_hex>"]}, 130 + "availability_topic": "nightstand/<mac_hex>/available" 139 131 } 140 132 ``` 141 133 ··· 143 135 144 136 Helpful for debugging; marked as diagnostic so they hide in the default device view. 145 137 146 - `homeassistant/sensor/nightstand_1/rssi/config`: 138 + `homeassistant/sensor/nightstand_<mac_hex>/rssi/config`: 147 139 ```json 148 140 { 149 141 "name": "WiFi Signal", 150 - "unique_id": "nightstand_1_rssi", 151 - "state_topic": "nightstand/1/state", 142 + "unique_id": "nightstand_<mac_hex>_rssi", 143 + "state_topic": "nightstand/<mac_hex>/state", 152 144 "value_template": "{{ value_json.rssi }}", 153 145 "unit_of_measurement": "dBm", 154 146 "device_class": "signal_strength", 155 147 "entity_category": "diagnostic", 156 - "device": {"identifiers": ["nightstand_1"]}, 157 - "availability_topic": "nightstand/1/available" 148 + "device": {"identifiers": ["nightstand_<mac_hex>"]}, 149 + "availability_topic": "nightstand/<mac_hex>/available" 158 150 } 159 151 ``` 160 152 161 - `homeassistant/sensor/nightstand_1/uptime/config`: 153 + `homeassistant/sensor/nightstand_<mac_hex>/uptime/config`: 162 154 ```json 163 155 { 164 156 "name": "Uptime", 165 - "unique_id": "nightstand_1_uptime", 166 - "state_topic": "nightstand/1/state", 157 + "unique_id": "nightstand_<mac_hex>_uptime", 158 + "state_topic": "nightstand/<mac_hex>/state", 167 159 "value_template": "{{ value_json.uptime_s }}", 168 160 "unit_of_measurement": "s", 169 161 "device_class": "duration", 170 162 "entity_category": "diagnostic", 171 - "device": {"identifiers": ["nightstand_1"]}, 172 - "availability_topic": "nightstand/1/available" 163 + "device": {"identifiers": ["nightstand_<mac_hex>"]}, 164 + "availability_topic": "nightstand/<mac_hex>/available" 173 165 } 174 166 ``` 175 167 176 168 ## State payload 177 169 178 - Published to `nightstand/1/state` (retained) on every state change: 170 + Published to `nightstand/<mac_hex>/state` (retained) on every state change: 179 171 180 172 ```json 181 173 { ··· 191 183 ## Availability (LWT) 192 184 193 185 Set at MQTT connect time: 194 - - **Last Will**: topic `nightstand/1/available`, payload `offline`, retained, QoS 1 186 + - **Last Will**: topic `nightstand/<mac_hex>/available`, payload `offline`, retained, QoS 1 195 187 - On successful connect: publish `online` to the same topic, retained 196 188 197 189 HA marks every entity unavailable within ~seconds of the device losing WiFi. ··· 199 191 ## Connection / boot sequence 200 192 201 193 1. WiFi up → MQTT connect (with LWT registered) 202 - 2. Publish retained `online` to `nightstand/1/available` 194 + 2. Publish retained `online` to `nightstand/<mac_hex>/available` 203 195 3. Publish retained discovery configs for every entity (cheap — broker dedupes retained messages) 204 - 4. Publish retained initial state snapshot to `nightstand/1/state` 205 - 5. Subscribe to `nightstand/1/cmd/+` 196 + 4. Publish retained initial state snapshot to `nightstand/<mac_hex>/state` 197 + 5. Subscribe to `nightstand/<mac_hex>/cmd/+` 206 198 6. Enter main loop 207 199 208 200 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. ··· 235 227 - **Long press fires at the 2s mark**, not on release — crisper feedback. 236 228 - **Double press fires on the second release** regardless of how long the second press is held. Simpler than distinguishing "double" from "double-then-long." 237 229 - **Triple press** is treated as double-then-new-sequence — fine, users won't do this intentionally. 238 - - **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`. Offline mode: double-press cycles volume and does **not** toggle the white noise, even though a single short-press does toggle it. 230 + - **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`. 239 231 240 - Firmware-local effects (identical in both modes — muscle memory doesn't change): 241 - - `short` → toggle white noise on/off; persist `was_playing` to NVS 242 - - `long` → advance volume in the current yo-yo direction; persist `volume_index` and `volume_direction` to NVS 243 - - `double` → no local effect 232 + Behavior depends on mode: 233 + 234 + **ONLINE** — short-press round-trips through HA; long-press is local-plus-publish; double is publish-only. 235 + - `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. 236 + - `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. 237 + - `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). 244 238 245 - When **online**, all three events are also published as `{"event_type": "..."}` to `nightstand/N/button`. HA decides what (if anything) to do with each: 246 - - `short` → typical use: time-of-day-aware bedtime / morning routine (lights, etc.) 247 - - `long` → typical use: HA logs it, no automation needed since the volume change happened locally 248 - - `double` → typical use: late-night-lights routine (turn on outdoor + downstairs lights at low brightness when one of us has to get up) 239 + **OFFLINE** — short and long are local; double is a no-op. 240 + - `short` → toggle white noise locally; persist `was_playing` to NVS. (The network task supplies this fallback so muscle memory still works without HA.) 241 + - `long` → cycle volume locally, same as online. 242 + - `double` → no-op apart from a serial-log note that HA isn't available. 249 243 250 - When **offline**, no events are published. Local effects still happen. `double` becomes a no-op apart from a serial log noting that the late-night gesture isn't available without HA. 244 + 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. 251 245 252 246 ### HA-side (example — not firmware's responsibility) 253 247 ··· 266 260 entity_id: sun.sun 267 261 state: below_horizon 268 262 sequence: 269 - # Bedtime: toggle white noise on both, lights off 263 + # Bedtime: toggle white noise on both nightstands, lights off 264 + # (Entity ids reflect whatever you've renamed the devices to in 265 + # HA — the MQTT contract just guarantees stable unique_ids per 266 + # MAC; you choose the entity slugs.) 270 267 - service: switch.toggle 271 268 target: 272 269 entity_id: 273 - - switch.nightstand_1_white_noise 274 - - switch.nightstand_2_white_noise 270 + - switch.bedroom_nightstand_white_noise 271 + - switch.guest_nightstand_white_noise 275 272 - service: light.turn_off 276 273 target: 277 274 area_id: [bedroom, living_room, kitchen, hallway] ··· 287 284 - service: switch.turn_off 288 285 target: 289 286 entity_id: 290 - - switch.nightstand_1_white_noise 291 - - switch.nightstand_2_white_noise 287 + - switch.bedroom_nightstand_white_noise 288 + - switch.guest_nightstand_white_noise 292 289 293 290 - alias: "Nightstand: double press (late-night check)" 294 291 trigger: ··· 312 309 The device is blissfully unaware of any of this. 313 310 314 311 ## Example flow: short press at night 312 + 313 + `<mac>` below stands in for the device's lowercase 12-char MAC hex (e.g. `aabbccddeeff`). 315 314 316 315 ``` 317 - ┌──────────────┐ ┌──────────┐ ┌──────────────┐ 318 - │ Nightstand 1 │ │ Mosquitto│ │ HA │ 319 - └──────┬───────┘ └────┬─────┘ └──────┬───────┘ 320 - │ (button pressed, │ │ 321 - │ released < 2s) │ │ 322 - │─ publish │ │ 323 - │ nightstand/1/button │ │ 324 - │ {"event_type":"short"} │ 325 - │─────────────────────► │ │ 326 - │ │─ deliver ─────────► │ 327 - │ │ │── automation 328 - │ │ │ triggers, 329 - │ │ │ sun is below 330 - │ │ │ horizon 331 - │ │◄── publish cmd/play │ 332 - │ │ "ON" to each unit│ 333 - │◄── "nightstand/1/ │ │ 334 - │ cmd/play" = "ON" │ │ 335 - │ │ │ 336 - │── start white noise │ │ 337 - │─ publish │ │ 338 - │ nightstand/1/state │ │ 339 - │ {"playing":"ON",...} │ │ 340 - │─────────────────────► │── deliver ─────────►│ 341 - │ │ (HA UI updates) 316 + ┌────────────┐ ┌──────────┐ ┌──────────────┐ 317 + │ Nightstand │ │ Mosquitto│ │ HA │ 318 + └──────┬─────┘ └────┬─────┘ └──────┬───────┘ 319 + │ (button pressed, │ │ 320 + │ released < 2s) │ │ 321 + │─ publish │ │ 322 + │ nightstand/<mac>/button │ 323 + │ {"event_type":"short"} │ 324 + │───────────────────► │ │ 325 + │ │─ deliver ─────────► │ 326 + │ │ │── automation 327 + │ │ │ triggers, 328 + │ │ │ sun is below 329 + │ │ │ horizon 330 + │ │◄── publish cmd/play │ 331 + │ │ "ON" to each unit│ 332 + │◄── nightstand/<mac>/cmd/play │ 333 + │ payload "ON" │ │ 334 + │ │ │ 335 + │── start white noise │ │ 336 + │─ publish │ │ 337 + │ nightstand/<mac>/state │ 338 + │ {"playing":"ON",...} │ 339 + │───────────────────► │── deliver ─────────►│ 340 + │ │ (HA UI updates) 342 341 ``` 343 342 344 343 Total latency: tens of milliseconds on LAN. Feels instant. ··· 351 350 352 351 - Chris builds firmware locally, copies `.bin` to HA's `/config/www/firmware/`, publishes a `latest_version` announcement to MQTT. 353 352 - HA's MQTT `update` entity compares `installed_version` vs `latest_version` and shows an "Install" button on the device card. 354 - - User clicks Install → HA publishes to `nightstand/N/cmd/update` → firmware downloads from `http://homeassistant.local:8123/local/firmware/sound-machine-<ver>.bin` → `esp_ota_*` writes to the inactive partition → reboot into new firmware → device reports new `installed_version`. 353 + - User clicks Install → HA publishes to `nightstand/<mac_hex>/cmd/update` → firmware downloads from `http://homeassistant.local:8123/local/firmware/sound-machine-<ver>.bin` → `esp_ota_*` writes to the inactive partition → reboot into new firmware → device reports new `installed_version`. 355 354 - ESP-IDF's OTA handles two-partition rollback automatically — a bootloop reverts to the previous good firmware. 356 355 357 356 ### Additional entity 358 357 359 - `homeassistant/update/nightstand_1/firmware/config`: 358 + `homeassistant/update/nightstand_<mac_hex>/firmware/config`: 360 359 ```json 361 360 { 362 361 "name": "Firmware", 363 - "unique_id": "nightstand_1_firmware", 364 - "state_topic": "nightstand/1/update", 365 - "command_topic": "nightstand/1/cmd/update", 362 + "unique_id": "nightstand_<mac_hex>_firmware", 363 + "state_topic": "nightstand/<mac_hex>/update", 364 + "command_topic": "nightstand/<mac_hex>/cmd/update", 366 365 "payload_install": "INSTALL", 367 - "latest_version_topic": "nightstand/1/update", 366 + "latest_version_topic": "nightstand/<mac_hex>/update", 368 367 "latest_version_template": "{{ value_json.latest_version }}", 369 368 "value_template": "{{ value_json.installed_version }}", 370 369 "release_url": "", 371 - "device": {"identifiers": ["nightstand_1"]}, 372 - "availability_topic": "nightstand/1/available" 370 + "device": {"identifiers": ["nightstand_<mac_hex>"]}, 371 + "availability_topic": "nightstand/<mac_hex>/available" 373 372 } 374 373 ``` 375 374
+9 -10
reference/operating-modes.md
··· 53 53 54 54 WiFi up, MQTT connected, discovery configs published, subscribed to command topics. 55 55 56 - - Button events publish to `nightstand/N/button` 57 - - Commands from `nightstand/N/cmd/+` are received and acted on 58 - - State changes publish to `nightstand/N/state` (retained) 56 + - Button events publish to `nightstand/<mac_hex>/button` 57 + - Commands from `nightstand/<mac_hex>/cmd/+` are received and acted on 58 + - State changes publish to `nightstand/<mac_hex>/state` (retained) 59 59 - Availability topic shows `online` 60 + - **Short press round-trips through HA**: button publishes `{"event_type":"short"}`, HA decides whether it's bedtime or morning and publishes back `cmd/play ON`/`OFF`; the device does NOT toggle audio locally. Long-press still cycles volume locally (and publishes the event for HA logging). Double-press is publish-only — pure HA gesture. 60 61 61 62 If MQTT drops (broker down, network partition, etc.): transition to OFFLINE. 62 63 ··· 65 66 WiFi or MQTT not available. Device keeps working locally. 66 67 67 68 - Button events **not published** (there's nobody listening) 68 - - Short press toggles white noise locally 69 + - **Short press toggles white noise locally** — the network task supplies this fallback so muscle memory still works without HA 69 70 - Long press cycles volume locally (yo-yo through preset list — see "Button behavior") 70 71 - Double press is detected by firmware but has no local effect (no lights to control offline; serial log notes that the late-night-lights routine is online-only) 71 72 - No commands are received ··· 79 80 80 81 | Input | ONLINE | OFFLINE | 81 82 | --- | --- | --- | 82 - | Short press | Toggle white noise locally + publish `{"event_type":"short"}` (HA bedtime / morning routine) | Toggle white noise locally | 83 + | Short press | Publish `{"event_type":"short"}` only; HA decides and publishes back `cmd/play ON`/`OFF` (round-trip) | Toggle white noise locally (network task supplies the fallback) | 83 84 | Long press (≥2s) | Cycle volume preset locally (yo-yo) + publish `{"event_type":"long"}` | Cycle volume preset locally (yo-yo) | 84 85 | Double press | Publish `{"event_type":"double"}` (HA late-night-lights routine — no local effect) | Detected, no-op (no lights to control offline) | 85 86 86 - Local effects (toggle, volume cycle) happen identically in both modes — muscle memory doesn't change. The MQTT publish is purely additive when online. 87 + **Long-press cycles volume identically in both modes** — muscle memory doesn't change for volume. **Short-press is the deliberate divergence**: when HA is online we let it decide (is it bedtime? morning? toggle just this nightstand or both?), and when HA is offline the device stands alone. **Double-press is online-only** — it's a pure HA gesture with no useful local fallback. 87 88 88 89 **Volume cycle**: long-press advances through `[10%, 25%, 50%, 75%, 100%]` in the current direction; when it hits an end, the next long-press flips direction (yo-yo). Both volume index and direction are persisted in NVS so the cycle resumes from where you left off after a reboot. 89 90 ··· 120 121 | `volume_index` | u8 | Index 0..=4 into `VOLUME_PRESETS = [10, 25, 50, 75, 100]` | Long-press cycles the index, or HA sets volume | 121 122 | `volume_direction` | u8 | 0 = Up, 1 = Down — the current yo-yo direction | Flipped when index hits an end of the preset list | 122 123 | `was_playing` | u8 (0/1) | Whether white noise was playing at last state change | Every play/stop transition | 123 - | `wifi_ssid` | string | Known WiFi SSID | Provisioning (first flash or later update) | 124 - | `wifi_password` | string | Known WiFi password | Provisioning | 125 124 126 125 Not stored (computed / volatile): 127 126 - Logical name (derived from MAC) 128 127 - Connection state 129 128 - RSSI, uptime 130 129 131 - **Multi-SSID support**: `wifi_ssid` and `wifi_password` are actually a list of up to 3 networks (home + travel router + backup). Firmware tries them in order during BOOT's WiFi connect. A single pair is fine for now; extending to a list is a non-breaking NVS schema bump. 130 + **WiFi credentials**: not in NVS — they live in `firmware/cfg.toml` (gitignored) and are baked into the binary at compile time via `toml-cfg`. This is a v1 simplification; an NVS-backed multi-SSID list is a future v2+ enhancement (home + travel router + backup, tried in order during BOOT). 132 131 133 132 ## Boot-time state restoration 134 133 ··· 150 149 - On MQTT success, transition to ONLINE: 151 150 - Re-publish retained discovery configs (idempotent, covers HA restart during our offline window) 152 151 - Re-publish retained `online` to availability topic 153 - - Re-publish retained state snapshot to `nightstand/N/state` 152 + - Re-publish retained state snapshot to `nightstand/<mac_hex>/state` 154 153 - Re-subscribe to command topics 155 154 - On MQTT fail, stay OFFLINE; try again in 60s 156 155