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.1.0: offline-mode firmware

Self-contained sound machine. Short press toggles noise on/off; long
press cycles volume yo-yo through [10, 25, 50, 75, 100]% (hold-to-
cycle at 1s cadence); double press is wired up but inert — logs a
TODO line for the eventual HA late-night-lights gesture. NVS persists
volume index, direction, and play state across reboots, with auto-
resume on boot. RGB LED on G27 shows status with a press-flash overlay.

Factored as independent task modules (audio / button / led / nvs)
communicating via mpsc channels carrying typed events. Adding MQTT
later is purely additive: another producer of AudioCommands and
publisher of ButtonEvents, no surgery on existing modules.

Pink-noise generator (Paul Kellet's IIR). Sounds rough on the onboard
NS4168 + tiny built-in driver — speaker bandwidth, not firmware —
but should be the right answer once external amps arrive.

Design docs updated to reflect the new gesture mapping: double-press
is the late-night-lights gesture, long-press is the volume cycle.

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

+1051 -116
+1
firmware/Cargo.lock
··· 1547 1547 dependencies = [ 1548 1548 "anyhow", 1549 1549 "embuild", 1550 + "esp-idf-hal", 1550 1551 "esp-idf-svc", 1551 1552 "log", 1552 1553 ]
+4
firmware/Cargo.toml
··· 27 27 [dependencies] 28 28 log = { version = "0.4", default-features = false } 29 29 esp-idf-svc = { version = "0.51", default-features = false } 30 + # esp-idf-hal is a transitive dep of esp-idf-svc; pulled in directly so we 31 + # can enable the rmt-legacy feature (the v5 RMT API isn't wrapped in 0.45.2 32 + # yet, and we need the legacy `TxRmtDriver` for SK6812 LED control). 33 + esp-idf-hal = { version = "0.45", default-features = false, features = ["rmt-legacy"] } 30 34 anyhow = "1" 31 35 32 36 [build-dependencies]
+24 -11
firmware/README.md
··· 6 6 7 7 ## Status 8 8 9 - **Milestone v0.0.1 — hello world (2026-04-25).** Boots, reads the button on G39, plays a clean 880 Hz beep through the onboard NS4168 + tiny built-in speaker. Validates the toolchain end-to-end on Ubuntu Questing. 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. 10 10 11 11 | Subsystem | State | 12 12 | --- | --- | 13 13 | Cargo build + flash | ✅ working | 14 - | GPIO input (button G39) | ✅ working — active-low, on-board pull-up | 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 - | DMA-fed silence to keep amp idle | ✅ working — no idle hum | 17 - | Audio playback (one-shot beep) | ✅ working | 18 - | RGB LED (SK6812 on G27 via RMT) | ❌ TBD | 19 - | WiFi | ❌ TBD | 20 - | MQTT client + HA discovery | ❌ TBD | 21 - | Continuous white noise generator | ❌ TBD | 22 - | Button state machine (single/double/long) | ❌ TBD — currently just edge detection | 23 - | NVS persistence | ❌ TBD | 24 - | OTA updates | ❌ TBD (v1.5 design lives in `mqtt-contract.md`) | 16 + | Continuous white noise generator | ✅ xorshift32, volume-scaled | 17 + | Button state machine (short / long / double) | ✅ working | 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 | 22 + | OTA updates | ❌ v1.5 (design in `reference/mqtt-contract.md`) | 25 23 | Hardware: external MAX98357A + 1314 speaker | ❌ amps in transit; using onboard for now | 24 + 25 + ## Module layout 26 + 27 + ``` 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) 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) 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 36 + ``` 37 + 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. 26 39 27 40 ## Build & flash 28 41
+305
firmware/src/audio.rs
··· 1 + //! Audio task: owns the I2S driver, generates white noise, applies volume, 2 + //! handles play/stop/cycle commands, persists state changes to NVS. 3 + //! 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. 7 + //! 8 + //! The main loop alternates between draining commands (non-blocking) and 9 + //! writing one ~23 ms buffer of audio (blocking on DMA). This is the same 10 + //! "I2S write IS our scheduling cadence" trick from hello-world, scaled up 11 + //! to handle continuous audio plus inbound commands. 12 + 13 + use crate::events::{AudioCommand, LedSignal}; 14 + use crate::nvs::{NvsStore, PersistedState}; 15 + use crate::state::{next_volume_index, VolumeDirection, VOLUME_PRESETS}; 16 + use anyhow::Result; 17 + use esp_idf_svc::hal::i2s::config::{ 18 + Config, DataBitWidth, SlotMode, StdClkConfig, StdConfig, StdGpioConfig, StdSlotConfig, 19 + }; 20 + use esp_idf_svc::hal::i2s::{I2sDriver, I2sTx}; 21 + use log::{info, warn}; 22 + use std::sync::mpsc::{Receiver, Sender}; 23 + use std::thread::{Builder, JoinHandle}; 24 + 25 + pub const SAMPLE_RATE: u32 = 44_100; 26 + const BUFFER_BYTES: usize = 4096; // ~23 ms at 44.1 kHz stereo 16-bit 27 + const STACK_SIZE: usize = 16 * 1024; 28 + 29 + /// Construct the I2S driver for the onboard NS4168 amp. 30 + /// 31 + /// Pin assignment matches the M5Stack Atom Echo schematic: 32 + /// BCLK = G19, DOUT = G22, WS/LRCK = G33, no MCLK. 33 + /// 34 + /// **Argument order to `new_std_tx` is `(bclk, dout, mclk, ws)`** — not 35 + /// the audio-convention `(bclk, ws, dout)` you'd guess. Getting it wrong 36 + /// produces static instead of sound; see firmware/README.md gotcha #3. 37 + pub fn make_onboard_i2s( 38 + i2s0: esp_idf_svc::hal::i2s::I2S0, 39 + bclk: esp_idf_svc::hal::gpio::Gpio19, 40 + dout: esp_idf_svc::hal::gpio::Gpio22, 41 + ws: esp_idf_svc::hal::gpio::Gpio33, 42 + ) -> Result<I2sDriver<'static, I2sTx>> { 43 + let config = StdConfig::new( 44 + Config::default(), 45 + StdClkConfig::from_sample_rate_hz(SAMPLE_RATE), 46 + StdSlotConfig::philips_slot_default(DataBitWidth::Bits16, SlotMode::Stereo), 47 + StdGpioConfig::default(), 48 + ); 49 + let i2s = I2sDriver::new_std_tx( 50 + i2s0, 51 + &config, 52 + bclk, 53 + dout, 54 + Option::<esp_idf_svc::hal::gpio::AnyIOPin>::None, 55 + ws, 56 + )?; 57 + Ok(i2s) 58 + } 59 + 60 + /// Spawn the audio task. Takes ownership of I2S, NVS, initial state, and 61 + /// the channels it'll use for the rest of its life. 62 + pub fn spawn( 63 + i2s: I2sDriver<'static, I2sTx>, 64 + nvs: NvsStore, 65 + initial_state: PersistedState, 66 + cmd_rx: Receiver<AudioCommand>, 67 + led_tx: Sender<LedSignal>, 68 + ) -> Result<JoinHandle<()>> { 69 + let handle = Builder::new() 70 + .name("audio".into()) 71 + .stack_size(STACK_SIZE) 72 + .spawn(move || { 73 + if let Err(e) = audio_loop(i2s, nvs, initial_state, cmd_rx, led_tx) { 74 + warn!("audio task exiting on error: {e:?}"); 75 + } 76 + })?; 77 + Ok(handle) 78 + } 79 + 80 + /// xorshift32 — fast, plenty for noise PCM. 81 + struct Rng { 82 + state: u32, 83 + } 84 + 85 + impl Rng { 86 + fn new(seed: u32) -> Self { 87 + Self { 88 + state: if seed == 0 { 0x12345678 } else { seed }, 89 + } 90 + } 91 + 92 + #[inline] 93 + fn next(&mut self) -> u32 { 94 + let mut x = self.state; 95 + x ^= x << 13; 96 + x ^= x >> 17; 97 + x ^= x << 5; 98 + self.state = x; 99 + x 100 + } 101 + 102 + /// One white sample in `[-1.0, 1.0)`. 103 + #[inline] 104 + fn next_white(&mut self) -> f32 { 105 + (self.next() as i32 as f32) / (i32::MAX as f32) 106 + } 107 + } 108 + 109 + /// Paul Kellet's classic pink-noise IIR filter (the "more accurate" variant). 110 + /// Takes white samples in `[-1, 1]` and emits pink samples in roughly the 111 + /// same range. Each output frame keeps its own state so L and R can be 112 + /// filtered independently. 113 + /// 114 + /// Source: <https://www.firstpr.com.au/dsp/pink-noise/> 115 + #[derive(Default)] 116 + struct PinkFilter { 117 + b0: f32, 118 + b1: f32, 119 + b2: f32, 120 + b3: f32, 121 + b4: f32, 122 + b5: f32, 123 + b6: f32, 124 + } 125 + 126 + impl PinkFilter { 127 + #[inline] 128 + fn process(&mut self, white: f32) -> f32 { 129 + self.b0 = 0.99886 * self.b0 + white * 0.0555179; 130 + self.b1 = 0.99332 * self.b1 + white * 0.0750759; 131 + self.b2 = 0.96900 * self.b2 + white * 0.1538520; 132 + self.b3 = 0.86650 * self.b3 + white * 0.3104856; 133 + self.b4 = 0.55000 * self.b4 + white * 0.5329522; 134 + self.b5 = -0.7616 * self.b5 - white * 0.0168980; 135 + let pink = 136 + self.b0 + self.b1 + self.b2 + self.b3 + self.b4 + self.b5 + self.b6 + white * 0.5362; 137 + self.b6 = white * 0.115926; 138 + // 0.11 is the canonical normalization to bring the filter back to 139 + // ~unity gain. Multiply by 1.5 so pink doesn't sound quieter than 140 + // the equivalent white at the same volume preset (the ear is less 141 + // sensitive to bass-heavy spectra). 142 + pink * 0.165 143 + } 144 + } 145 + 146 + /// Internal task state. Tracks only what the audio task itself needs to 147 + /// remember; the coordinator and other tasks have their own views. 148 + struct State { 149 + playing: bool, 150 + volume_index: u8, 151 + direction: VolumeDirection, 152 + } 153 + 154 + impl State { 155 + fn from_persisted(p: PersistedState) -> Self { 156 + Self { 157 + playing: p.was_playing, 158 + volume_index: p.volume_index, 159 + direction: p.volume_direction, 160 + } 161 + } 162 + 163 + fn current_volume_pct(&self) -> u8 { 164 + VOLUME_PRESETS[self.volume_index as usize] 165 + } 166 + } 167 + 168 + fn audio_loop( 169 + mut i2s: I2sDriver<'static, I2sTx>, 170 + nvs: NvsStore, 171 + initial: PersistedState, 172 + cmd_rx: Receiver<AudioCommand>, 173 + led_tx: Sender<LedSignal>, 174 + ) -> Result<()> { 175 + let mut state = State::from_persisted(initial); 176 + let mut rng = Rng::new(0xC0FFEE_u32); 177 + let mut pink_l = PinkFilter::default(); 178 + let mut pink_r = PinkFilter::default(); 179 + let mut buf = vec![0u8; BUFFER_BYTES]; 180 + 181 + // Pre-fill DMA buffers with silence before enabling TX, so the first 182 + // thing the amp sees is a clean zero level rather than uninitialized 183 + // memory (gotcha #4 in firmware/README.md). 184 + i2s.preload_data(&buf)?; 185 + i2s.tx_enable()?; 186 + 187 + info!( 188 + "audio task ready: playing={} volume_idx={} direction={:?}", 189 + state.playing, state.volume_index, state.direction 190 + ); 191 + 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 + }); 198 + 199 + loop { 200 + // Drain any pending commands without blocking. 201 + while let Ok(cmd) = cmd_rx.try_recv() { 202 + apply_command(cmd, &mut state, &nvs, &led_tx); 203 + } 204 + 205 + // Fill the buffer for the next 23 ms of audio. 206 + if state.playing { 207 + fill_noise( 208 + &mut buf, 209 + &mut rng, 210 + &mut pink_l, 211 + &mut pink_r, 212 + state.current_volume_pct(), 213 + ); 214 + } else { 215 + buf.iter_mut().for_each(|b| *b = 0); 216 + } 217 + 218 + // Write — this blocks for ~one buffer period while DMA consumes. 219 + let mut written = 0; 220 + while written < buf.len() { 221 + match i2s.write(&buf[written..], 1_000) { 222 + Ok(n) => written += n, 223 + Err(e) => { 224 + warn!("I2S write error: {e}"); 225 + break; 226 + } 227 + } 228 + } 229 + } 230 + } 231 + 232 + fn apply_command( 233 + cmd: AudioCommand, 234 + state: &mut State, 235 + nvs: &NvsStore, 236 + led_tx: &Sender<LedSignal>, 237 + ) { 238 + 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), 242 + AudioCommand::CycleVolume => { 243 + let (new_idx, new_dir) = next_volume_index(state.volume_index, state.direction); 244 + state.volume_index = new_idx; 245 + state.direction = new_dir; 246 + nvs.write_volume(new_idx, new_dir); 247 + info!( 248 + "volume cycled: idx={} pct={}% direction={:?}", 249 + new_idx, 250 + state.current_volume_pct(), 251 + new_dir 252 + ); 253 + } 254 + AudioCommand::SetVolumeIndex(idx) => { 255 + let clamped = (idx as usize).min(VOLUME_PRESETS.len() - 1) as u8; 256 + if clamped != state.volume_index { 257 + state.volume_index = clamped; 258 + nvs.write_volume(state.volume_index, state.direction); 259 + info!( 260 + "volume set: idx={} pct={}%", 261 + state.volume_index, 262 + state.current_volume_pct() 263 + ); 264 + } 265 + } 266 + } 267 + } 268 + 269 + fn set_playing(state: &mut State, nvs: &NvsStore, led_tx: &Sender<LedSignal>, target: bool) { 270 + if state.playing == target { 271 + return; 272 + } 273 + state.playing = target; 274 + nvs.write_playing(target); 275 + info!("playing={}", target); 276 + let _ = led_tx.send(if target { 277 + LedSignal::Playing 278 + } else { 279 + LedSignal::Idle 280 + }); 281 + } 282 + 283 + /// Fill the buffer with stereo pink noise scaled by `vol_pct` (0..=100). 284 + /// 285 + /// Pink noise (1/f spectrum) sounds calmer and more "shhhh"-like than raw 286 + /// white noise — most "sleep" noise machines use pink or brown. 287 + fn fill_noise( 288 + buf: &mut [u8], 289 + rng: &mut Rng, 290 + pink_l: &mut PinkFilter, 291 + pink_r: &mut PinkFilter, 292 + vol_pct: u8, 293 + ) { 294 + debug_assert!(buf.len() % 4 == 0); 295 + let vol_scale = vol_pct as f32 / 100.0; 296 + let i16_max = i16::MAX as f32; 297 + for chunk in buf.chunks_exact_mut(4) { 298 + let pl = pink_l.process(rng.next_white()) * vol_scale; 299 + let pr = pink_r.process(rng.next_white()) * vol_scale; 300 + let l = (pl * i16_max).clamp(i16::MIN as f32, i16::MAX as f32) as i16; 301 + let r = (pr * i16_max).clamp(i16::MIN as f32, i16::MAX as f32) as i16; 302 + chunk[0..2].copy_from_slice(&l.to_le_bytes()); 303 + chunk[2..4].copy_from_slice(&r.to_le_bytes()); 304 + } 305 + }
+179
firmware/src/button.rs
··· 1 + //! Button task: debounces, classifies short/long/double presses, emits 2 + //! `ButtonEvent` to the coordinator. 3 + //! 4 + //! Polls G39 (active-low, on-board pull-up, input-only pin) at 5 ms. 5 + //! State machine has 5 states; see firmware/README.md or 6 + //! reference/operating-modes.md for the diagram. 7 + //! 8 + //! Constants tunable here: 9 + //! - `DEBOUNCE_MS`: ignore edges within this window of the last edge. 10 + //! - `DOUBLE_WINDOW_MS`: time after release where a second press counts as a double. 11 + //! - `LONG_THRESHOLD_MS`: time held before firing Long mid-press. 12 + 13 + use crate::events::ButtonEvent; 14 + use anyhow::Result; 15 + use esp_idf_svc::hal::gpio::{Gpio39, Input, PinDriver}; 16 + use log::{info, warn}; 17 + use std::sync::mpsc::Sender; 18 + use std::thread::{Builder, JoinHandle}; 19 + use std::time::{Duration, Instant}; 20 + 21 + const POLL_INTERVAL: Duration = Duration::from_millis(5); 22 + const DEBOUNCE_MS: u128 = 20; 23 + const DOUBLE_WINDOW_MS: u128 = 400; 24 + /// Time the button must be held before the FIRST `Long` event fires. 25 + const LONG_THRESHOLD_MS: u128 = 1_000; 26 + /// While the button is still held after the first `Long`, fire another every 27 + /// this many ms — same as the initial threshold so the cadence is consistent 28 + /// (1 s to first cycle, +1 s each additional cycle). 29 + const LONG_REPEAT_MS: u128 = 1_000; 30 + const STACK_SIZE: usize = 4 * 1024; 31 + 32 + #[derive(Debug, Clone, Copy)] 33 + enum FsmState { 34 + Idle, 35 + /// Button is currently held; t = press timestamp. 36 + Pressing { t_press: Instant }, 37 + /// `Long` fired at threshold; auto-repeating until release. 38 + /// `t_last_long` is when the most recent `Long` was emitted. 39 + Hold { t_last_long: Instant }, 40 + /// Button was just released after a sub-LONG_THRESHOLD press; waiting 41 + /// to see if it's a double. 42 + WaitingDouble { t_release: Instant }, 43 + /// Second press in flight; will emit Double on release. 44 + DoublePressing, 45 + } 46 + 47 + pub fn spawn( 48 + button: PinDriver<'static, Gpio39, Input>, 49 + event_tx: Sender<ButtonEvent>, 50 + ) -> Result<JoinHandle<()>> { 51 + let handle = Builder::new() 52 + .name("button".into()) 53 + .stack_size(STACK_SIZE) 54 + .spawn(move || { 55 + if let Err(e) = button_loop(button, event_tx) { 56 + warn!("button task exiting on error: {e:?}"); 57 + } 58 + })?; 59 + Ok(handle) 60 + } 61 + 62 + fn button_loop( 63 + button: PinDriver<'static, Gpio39, Input>, 64 + event_tx: Sender<ButtonEvent>, 65 + ) -> Result<()> { 66 + let mut fsm = FsmState::Idle; 67 + // Active-low: high = released, low = pressed. 68 + let mut last_level_high = button.is_high(); 69 + let mut last_edge_at = Instant::now() - Duration::from_secs(1); 70 + 71 + info!("button task ready (polling G39 every {} ms)", POLL_INTERVAL.as_millis()); 72 + 73 + loop { 74 + std::thread::sleep(POLL_INTERVAL); 75 + let now = Instant::now(); 76 + let level_high = button.is_high(); 77 + 78 + // Detect an edge with debouncing. 79 + let edge = if level_high != last_level_high { 80 + if now.duration_since(last_edge_at).as_millis() < DEBOUNCE_MS { 81 + None 82 + } else { 83 + last_edge_at = now; 84 + last_level_high = level_high; 85 + Some(level_high) // true = H→L (release if previous was low... wait) 86 + } 87 + } else { 88 + None 89 + }; 90 + 91 + // edge is Some(now_high). If now_high == false, that means we just went 92 + // from high → low, i.e., button DOWN (active-low). If now_high == true, 93 + // we went from low → high, i.e., button UP. 94 + match edge { 95 + Some(false) => { 96 + // Press 97 + fsm = on_button_down(fsm, now); 98 + } 99 + Some(true) => { 100 + // Release 101 + fsm = on_button_up(fsm, now, &event_tx); 102 + } 103 + None => { 104 + // No edge — check timeouts. 105 + fsm = on_tick(fsm, now, &event_tx); 106 + } 107 + } 108 + } 109 + } 110 + 111 + fn on_button_down(fsm: FsmState, now: Instant) -> FsmState { 112 + match fsm { 113 + FsmState::Idle => FsmState::Pressing { t_press: now }, 114 + FsmState::WaitingDouble { .. } => FsmState::DoublePressing, 115 + // In any other state we shouldn't see a "down" edge (we'd already be down) 116 + // but if we somehow do, fall back to Pressing. 117 + _ => FsmState::Pressing { t_press: now }, 118 + } 119 + } 120 + 121 + fn on_button_up( 122 + fsm: FsmState, 123 + now: Instant, 124 + event_tx: &Sender<ButtonEvent>, 125 + ) -> FsmState { 126 + match fsm { 127 + FsmState::Pressing { .. } => { 128 + // Released before LONG_THRESHOLD. Wait to see if it's a double. 129 + FsmState::WaitingDouble { t_release: now } 130 + } 131 + FsmState::Hold { .. } => { 132 + // Long(s) already emitted; just clear state. 133 + FsmState::Idle 134 + } 135 + FsmState::DoublePressing => { 136 + // Second release — that's a double press. 137 + send_event(event_tx, ButtonEvent::Double); 138 + FsmState::Idle 139 + } 140 + _ => FsmState::Idle, 141 + } 142 + } 143 + 144 + fn on_tick(fsm: FsmState, now: Instant, event_tx: &Sender<ButtonEvent>) -> FsmState { 145 + match fsm { 146 + FsmState::Pressing { t_press } => { 147 + if now.duration_since(t_press).as_millis() >= LONG_THRESHOLD_MS { 148 + send_event(event_tx, ButtonEvent::Long); 149 + FsmState::Hold { t_last_long: now } 150 + } else { 151 + fsm 152 + } 153 + } 154 + FsmState::Hold { t_last_long } => { 155 + if now.duration_since(t_last_long).as_millis() >= LONG_REPEAT_MS { 156 + send_event(event_tx, ButtonEvent::Long); 157 + FsmState::Hold { t_last_long: now } 158 + } else { 159 + fsm 160 + } 161 + } 162 + FsmState::WaitingDouble { t_release } => { 163 + if now.duration_since(t_release).as_millis() >= DOUBLE_WINDOW_MS { 164 + send_event(event_tx, ButtonEvent::Short); 165 + FsmState::Idle 166 + } else { 167 + fsm 168 + } 169 + } 170 + _ => fsm, 171 + } 172 + } 173 + 174 + fn send_event(event_tx: &Sender<ButtonEvent>, event: ButtonEvent) { 175 + info!("button event: {event:?}"); 176 + if let Err(e) = event_tx.send(event) { 177 + warn!("button event channel closed: {e}"); 178 + } 179 + }
+51
firmware/src/events.rs
··· 1 + //! Type-level "wires" between the firmware modules. 2 + //! 3 + //! These enums define the shape of the cross-task message passing. Producers 4 + //! and consumers are independent; adding a new producer (e.g., MQTT in a 5 + //! later milestone) is a matter of adding another `Sender<AudioCommand>`, 6 + //! not changing any existing module. 7 + 8 + /// What the button task observes and reports to the coordinator. 9 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 10 + pub enum ButtonEvent { 11 + /// A single short press (released before 2s, no follow-up press within 400ms). 12 + Short, 13 + /// Held for 2s or more. 14 + Long, 15 + /// Two short presses within 400ms of each other. 16 + Double, 17 + } 18 + 19 + /// What the coordinator (or, later, an MQTT task) tells the audio task to do. 20 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 21 + pub enum AudioCommand { 22 + /// Start playing white noise at the current volume. 23 + Play, 24 + /// Stop playing. 25 + Stop, 26 + /// Toggle play state. 27 + Toggle, 28 + /// Advance the volume yo-yo by one step in the current direction. 29 + CycleVolume, 30 + /// Set volume directly to a specific preset index (0..VOLUME_PRESETS.len()). 31 + /// Used when HA sends a numeric volume; we snap to the nearest preset. 32 + SetVolumeIndex(u8), 33 + } 34 + 35 + /// What the coordinator tells the LED task to display. 36 + /// 37 + /// `PressFlash` is treated as an overlay — the LED task remembers the 38 + /// underlying signal and brightens briefly on top of it. 39 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 40 + 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, 47 + /// Brief brighter flash on top of whatever's currently being shown. 48 + PressFlash, 49 + /// Something's broken — slow red blink. 50 + Error, 51 + }
+179
firmware/src/led.rs
··· 1 + //! LED task: drives the SK6812 RGB LED behind the Atom Echo button cap on G27. 2 + //! 3 + //! Uses the ESP32 RMT peripheral directly (no third-party crate — the 4 + //! community wrappers had stale `esp-idf-hal` version constraints, and the 5 + //! protocol is small enough to roll). RMT clock divider of 2 → 40 MHz = 6 + //! 25 ns / tick, more than enough resolution for NeoPixel-compatible 7 + //! SK6812 timings. 8 + //! 9 + //! The RMT peripheral naturally drops the line LOW between transmissions 10 + //! (≥50 µs), which serves as the SK6812 reset; at our 30 Hz refresh rate 11 + //! the gap is 33 ms — vastly more than the ~80 µs spec. 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 19 + 20 + use crate::events::LedSignal; 21 + use anyhow::{anyhow, Result}; 22 + use esp_idf_svc::hal::gpio::Gpio27; 23 + use esp_idf_svc::hal::rmt::config::TransmitConfig; 24 + use esp_idf_svc::hal::rmt::{ 25 + FixedLengthSignal, PinState, Pulse, PulseTicks, TxRmtDriver, CHANNEL0, 26 + }; 27 + use log::{info, warn}; 28 + use std::sync::mpsc::{Receiver, TryRecvError}; 29 + use std::thread::{Builder, JoinHandle}; 30 + use std::time::{Duration, Instant}; 31 + 32 + const FRAME_INTERVAL: Duration = Duration::from_millis(33); // ~30 Hz 33 + const FLASH_DURATION: Duration = Duration::from_millis(150); 34 + const STACK_SIZE: usize = 4 * 1024; 35 + 36 + // SK6812 / NeoPixel-compatible bit timings at 40 MHz RMT clock (25 ns/tick). 37 + // Bit 1: ~800 ns high, ~450 ns low. 38 + // Bit 0: ~400 ns high, ~850 ns low. 39 + const T1H_TICKS: u16 = 32; 40 + const T1L_TICKS: u16 = 18; 41 + const T0H_TICKS: u16 = 16; 42 + const T0L_TICKS: u16 = 34; 43 + 44 + #[derive(Debug, Clone, Copy)] 45 + struct Rgb { 46 + r: u8, 47 + g: u8, 48 + b: u8, 49 + } 50 + 51 + impl Rgb { 52 + const fn new(r: u8, g: u8, b: u8) -> Self { 53 + Self { r, g, b } 54 + } 55 + } 56 + 57 + pub fn spawn( 58 + rmt_channel: CHANNEL0, 59 + pin: Gpio27, 60 + signal_rx: Receiver<LedSignal>, 61 + ) -> Result<JoinHandle<()>> { 62 + let handle = Builder::new() 63 + .name("led".into()) 64 + .stack_size(STACK_SIZE) 65 + .spawn(move || { 66 + if let Err(e) = led_loop(rmt_channel, pin, signal_rx) { 67 + warn!("LED task exiting on error: {e:?}"); 68 + } 69 + })?; 70 + Ok(handle) 71 + } 72 + 73 + fn led_loop( 74 + rmt_channel: CHANNEL0, 75 + pin: Gpio27, 76 + signal_rx: Receiver<LedSignal>, 77 + ) -> Result<()> { 78 + // Clock divider 2 → 40 MHz → 25 ns / tick. 79 + let config = TransmitConfig::new().clock_divider(2); 80 + let mut tx = TxRmtDriver::new(rmt_channel, pin, &config) 81 + .map_err(|e| anyhow!("RMT init: {e}"))?; 82 + 83 + let mut current = LedSignal::Boot; 84 + let mut flash_started: Option<Instant> = None; 85 + let start = Instant::now(); 86 + 87 + info!("LED task ready"); 88 + 89 + 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 + 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:?}"); 107 + } 108 + 109 + std::thread::sleep(FRAME_INTERVAL); 110 + } 111 + } 112 + 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 => { 118 + let phase = (t.as_millis() as f32 / 500.0) * std::f32::consts::PI; 119 + 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 + } 131 + } 132 + // PressFlash should never make it into base; treat as Idle if it does. 133 + LedSignal::PressFlash => Rgb::new(20, 8, 0), 134 + } 135 + } 136 + 137 + fn apply_flash_overlay(base: Rgb, flash_started: Option<Instant>, now: Instant) -> Rgb { 138 + let Some(t0) = flash_started else { return base; }; 139 + let elapsed = now.duration_since(t0); 140 + if elapsed >= FLASH_DURATION { 141 + return base; 142 + } 143 + // Linear decay over FLASH_DURATION; multiplies brightness by 1 + (0..0.5). 144 + let factor = 1.0 - (elapsed.as_millis() as f32 / FLASH_DURATION.as_millis() as f32); 145 + let bump = |c: u8| -> u8 { 146 + let v = c as f32 * (1.0 + 0.5 * factor); 147 + v.min(255.0) as u8 148 + }; 149 + Rgb::new(bump(base.r), bump(base.g), bump(base.b)) 150 + } 151 + 152 + /// Encode one RGB pixel as 24 RMT items (one per bit, in GRB order) and transmit. 153 + fn write_color(tx: &mut TxRmtDriver, color: Rgb) -> Result<()> { 154 + fn pulse(state: PinState, ticks: u16) -> Pulse { 155 + Pulse::new(state, PulseTicks::new(ticks).expect("valid pulse ticks")) 156 + } 157 + let one = ( 158 + pulse(PinState::High, T1H_TICKS), 159 + pulse(PinState::Low, T1L_TICKS), 160 + ); 161 + let zero = ( 162 + pulse(PinState::High, T0H_TICKS), 163 + pulse(PinState::Low, T0L_TICKS), 164 + ); 165 + 166 + let mut signal = FixedLengthSignal::<24>::new(); 167 + let bytes = [color.g, color.r, color.b]; // SK6812 wants GRB 168 + for (byte_i, byte) in bytes.iter().enumerate() { 169 + for bit_i in 0..8 { 170 + let bit_set = (byte >> (7 - bit_i)) & 1 == 1; 171 + let pair = if bit_set { &one } else { &zero }; 172 + signal 173 + .set(byte_i * 8 + bit_i, pair) 174 + .map_err(|e| anyhow!("set RMT pulse: {e}"))?; 175 + } 176 + } 177 + tx.start_blocking(&signal) 178 + .map_err(|e| anyhow!("RMT transmit: {e}")) 179 + }
+85 -84
firmware/src/main.rs
··· 1 - //! Sound Machine — hello-world milestone v0.0.1. 1 + //! Sound Machine — offline-mode firmware v0.1.0. 2 2 //! 3 - //! Press the onboard button, play a short 880 Hz beep through the onboard 4 - //! NS4168 amp + tiny built-in speaker. No WiFi, no MQTT yet — just enough 5 - //! to validate the Rust + ESP-IDF + flash + I2S toolchain end to end. 3 + //! Three tasks plus a coordinator in `main`: 4 + //! - **audio** owns I2S, generates white noise, applies volume cycles 5 + //! - **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) 6 10 //! 7 - //! Pin map (from the Atom Echo schematic): 8 - //! GPIO 39 — button (input-only, external pull-up on board, active low) 9 - //! GPIO 19 — I2S BCLK (NS4168) 10 - //! GPIO 33 — I2S LRCK (NS4168) 11 - //! GPIO 22 — I2S DOUT (NS4168) 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. 12 16 //! 13 - //! Note: this uses the onboard amp's pins for prototyping. The real firmware 14 - //! will route I2S to the external MAX98357A on G21/G26/G32 instead, leaving 15 - //! the onboard amp idle. See `reference/signal-chain.md`. 17 + //! See `firmware/README.md` and `reference/operating-modes.md` for the 18 + //! design and the gotchas. 19 + 20 + mod audio; 21 + mod button; 22 + mod events; 23 + mod led; 24 + mod nvs; 25 + mod state; 16 26 17 27 use anyhow::Result; 18 - use esp_idf_svc::hal::gpio::{AnyIOPin, PinDriver}; 19 - use esp_idf_svc::hal::i2s::config::{Config, DataBitWidth, StdClkConfig, StdConfig, StdGpioConfig, StdSlotConfig}; 20 - use esp_idf_svc::hal::i2s::I2sDriver; 28 + use esp_idf_svc::hal::gpio::PinDriver; 21 29 use esp_idf_svc::hal::peripherals::Peripherals; 30 + use events::{AudioCommand, ButtonEvent, LedSignal}; 22 31 use log::info; 23 - use std::f32::consts::PI; 24 - 25 - const SAMPLE_RATE: u32 = 44_100; 26 - const BEEP_FREQ_HZ: f32 = 880.0; 27 - const BEEP_DURATION_MS: u32 = 150; 28 - const BEEP_AMPLITUDE: f32 = 20_000.0; // ~60% of i16::MAX — louder, cleaner against quantization noise 29 - const ENVELOPE_MS: u32 = 10; // attack + release each, click suppression 30 - const SILENCE_CHUNK_BYTES: usize = 4096; // ~23 ms at 44.1 kHz stereo 16-bit — also our button-poll cadence 32 + use nvs::NvsStore; 33 + use std::sync::mpsc; 31 34 32 35 fn main() -> Result<()> { 33 36 esp_idf_svc::sys::link_patches(); 34 37 esp_idf_svc::log::EspLogger::initialize_default(); 35 38 36 - info!("sound-machine v0.0.1 boot"); 39 + info!("sound-machine v0.1.0 boot"); 40 + 41 + // Persistent state — read once at boot, then the audio task owns writes. 42 + let nvs = NvsStore::open()?; 43 + let initial_state = nvs.read(); 37 44 45 + // Peripherals split out, owned by the relevant tasks. 38 46 let peripherals = Peripherals::take()?; 39 47 let pins = peripherals.pins; 40 48 41 - let button = PinDriver::input(pins.gpio39)?; 49 + // 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>(); 42 53 43 - let i2s_config = StdConfig::new( 44 - Config::default(), 45 - StdClkConfig::from_sample_rate_hz(SAMPLE_RATE), 46 - StdSlotConfig::philips_slot_default(DataBitWidth::Bits16, esp_idf_svc::hal::i2s::config::SlotMode::Stereo), 47 - StdGpioConfig::default(), 48 - ); 54 + // Tell the LED to pulse blue while we set things up. 55 + let _ = led_tx.send(LedSignal::Boot); 49 56 50 - // Signature: new_std_tx(i2s, config, bclk, dout, mclk, ws) 51 - let mut i2s = I2sDriver::new_std_tx( 57 + // Audio task: I2S to the onboard NS4168 (prototype). Will move to the 58 + // external MAX98357A on G21/G26/G32 once amps arrive. 59 + let i2s = audio::make_onboard_i2s( 52 60 peripherals.i2s0, 53 - &i2s_config, 54 - pins.gpio19, // BCLK 55 - pins.gpio22, // DOUT (data to NS4168) 56 - Option::<AnyIOPin>::None, // no MCLK 57 - pins.gpio33, // WS / LRCK 61 + pins.gpio19, // BCLK 62 + pins.gpio22, // DOUT 63 + pins.gpio33, // WS / LRCK 58 64 )?; 65 + let _audio_handle = audio::spawn(i2s, nvs, initial_state, audio_rx, led_tx.clone())?; 59 66 60 - // Pre-fill DMA buffers with silence so the first thing the NS4168 sees 61 - // when TX enables is zeros, not uninitialized memory. 62 - let silence = vec![0u8; SILENCE_CHUNK_BYTES]; 63 - i2s.preload_data(&silence)?; 64 - i2s.tx_enable()?; 67 + // LED task on G27 via RMT channel 0. 68 + let _led_handle = led::spawn(peripherals.rmt.channel0, pins.gpio27, led_rx)?; 65 69 66 - let beep = build_beep_buffer(); 67 - info!("ready — press the button to beep ({} samples)", beep.len() / 4); 70 + // Button task on G39 (input-only; on-board pull-up; active-low). 71 + let button = PinDriver::input(pins.gpio39)?; 72 + let _button_handle = button::spawn(button, button_tx)?; 68 73 69 - let mut last_high = button.is_high(); 70 - loop { 71 - let now_high = button.is_high(); 72 - let press_edge = last_high && !now_high; // active-low: H→L = press 73 - last_high = now_high; 74 - 75 - if press_edge { 76 - info!("button pressed"); 77 - // Write the full beep buffer — i2s.write blocks while DMA consumes 78 - let mut w = 0; 79 - while w < beep.len() { 80 - w += i2s.write(&beep[w..], 1_000)?; 81 - } 82 - } 74 + info!("all tasks spawned; entering coordinator loop"); 83 75 84 - // Always feed silence so the DMA never replays stale data. 85 - // This call blocks for ~23 ms (one chunk's worth of audio time) and 86 - // doubles as our button-poll cadence — no explicit sleep needed. 87 - i2s.write(&silence, 1_000)?; 76 + // If the device was playing when power was cut, kick the audio task to 77 + // resume immediately. The audio task starts in its persisted-state's 78 + // `playing` value, so this is technically belt-and-braces — but it also 79 + // ensures the LED reflects "Playing" promptly. 80 + if initial_state.was_playing { 81 + let _ = audio_tx.send(AudioCommand::Play); 88 82 } 89 - } 90 83 91 - /// Build a stereo, 16-bit-LE PCM buffer of a single sine beep with attack/release 92 - /// envelopes to avoid pop/click artifacts. 93 - fn build_beep_buffer() -> Vec<u8> { 94 - let total_samples = (SAMPLE_RATE * BEEP_DURATION_MS / 1_000) as usize; 95 - let env_samples = (SAMPLE_RATE * ENVELOPE_MS / 1_000) as usize; 96 - let mut buf = Vec::with_capacity(total_samples * 4); // stereo × 2 bytes 97 - 98 - for i in 0..total_samples { 99 - let t = i as f32 / SAMPLE_RATE as f32; 100 - let envelope = if i < env_samples { 101 - i as f32 / env_samples as f32 102 - } else if i >= total_samples - env_samples { 103 - (total_samples - i) as f32 / env_samples as f32 104 - } else { 105 - 1.0 84 + // Coordinator loop. Receives button events, translates to audio 85 + // commands and LED flashes. Logs the TODO for the double-press. 86 + loop { 87 + let event = match button_rx.recv() { 88 + Ok(e) => e, 89 + Err(e) => { 90 + anyhow::bail!("button channel closed: {e}"); 91 + } 106 92 }; 107 - let s = ((2.0 * PI * BEEP_FREQ_HZ * t).sin() * BEEP_AMPLITUDE * envelope) as i16; 108 - let bytes = s.to_le_bytes(); 109 - buf.extend_from_slice(&bytes); // L 110 - buf.extend_from_slice(&bytes); // R (same — mono content, stereo carrier) 93 + 94 + // Every press gets a brief LED flash for tactile feedback. 95 + let _ = led_tx.send(LedSignal::PressFlash); 96 + 97 + match event { 98 + ButtonEvent::Short => { 99 + info!("coordinator: short → AudioCommand::Toggle"); 100 + let _ = audio_tx.send(AudioCommand::Toggle); 101 + } 102 + ButtonEvent::Long => { 103 + info!("coordinator: long → AudioCommand::CycleVolume"); 104 + let _ = audio_tx.send(AudioCommand::CycleVolume); 105 + } 106 + ButtonEvent::Double => { 107 + info!( 108 + "coordinator: double → TODO: late-night-lights routine \ 109 + (will trigger HA via MQTT once online)" 110 + ); 111 + } 112 + } 111 113 } 112 - buf 113 114 }
+102
firmware/src/nvs.rs
··· 1 + //! Persistent state in ESP32 NVS. 2 + //! 3 + //! All audio state lives in the `audio` namespace as plain u8 keys: 4 + //! - `vol_idx` — index 0..VOLUME_PRESETS.len() 5 + //! - `vol_dir` — 0 = Up, 1 = Down 6 + //! - `playing` — 0 = stopped, 1 = playing 7 + //! 8 + //! Reads are forgiving: missing keys return defaults, errors are logged but 9 + //! don't crash. Writes are best-effort — flash failure logs but doesn't 10 + //! propagate, since we'd rather degrade silently than panic on a sleeping 11 + //! user's nightstand. 12 + 13 + use crate::state::{VolumeDirection, DEFAULT_VOLUME_INDEX}; 14 + use anyhow::Result; 15 + use esp_idf_svc::nvs::{EspNvs, EspNvsPartition, NvsDefault}; 16 + use log::{info, warn}; 17 + 18 + const NAMESPACE: &str = "audio"; 19 + const KEY_VOL_IDX: &str = "vol_idx"; 20 + const KEY_VOL_DIR: &str = "vol_dir"; 21 + const KEY_PLAYING: &str = "playing"; 22 + 23 + #[derive(Debug, Clone, Copy)] 24 + pub struct PersistedState { 25 + pub volume_index: u8, 26 + pub volume_direction: VolumeDirection, 27 + pub was_playing: bool, 28 + } 29 + 30 + impl Default for PersistedState { 31 + fn default() -> Self { 32 + Self { 33 + volume_index: DEFAULT_VOLUME_INDEX, 34 + volume_direction: VolumeDirection::Up, 35 + was_playing: false, 36 + } 37 + } 38 + } 39 + 40 + pub struct NvsStore { 41 + nvs: EspNvs<NvsDefault>, 42 + } 43 + 44 + impl NvsStore { 45 + pub fn open() -> Result<Self> { 46 + let partition = EspNvsPartition::<NvsDefault>::take()?; 47 + let nvs = EspNvs::new(partition, NAMESPACE, /* read_write */ true)?; 48 + Ok(Self { nvs }) 49 + } 50 + 51 + /// Read everything in one shot. Defaults fill in for any missing keys. 52 + pub fn read(&self) -> PersistedState { 53 + let defaults = PersistedState::default(); 54 + let volume_index = self 55 + .nvs 56 + .get_u8(KEY_VOL_IDX) 57 + .ok() 58 + .flatten() 59 + .unwrap_or(defaults.volume_index); 60 + let volume_direction = self 61 + .nvs 62 + .get_u8(KEY_VOL_DIR) 63 + .ok() 64 + .flatten() 65 + .map(VolumeDirection::from_u8) 66 + .unwrap_or(defaults.volume_direction); 67 + let was_playing = self 68 + .nvs 69 + .get_u8(KEY_PLAYING) 70 + .ok() 71 + .flatten() 72 + .map(|v| v != 0) 73 + .unwrap_or(defaults.was_playing); 74 + 75 + info!( 76 + "NVS read: volume_index={} direction={:?} was_playing={}", 77 + volume_index, volume_direction, was_playing 78 + ); 79 + 80 + PersistedState { 81 + volume_index, 82 + volume_direction, 83 + was_playing, 84 + } 85 + } 86 + 87 + pub fn write_volume(&self, index: u8, direction: VolumeDirection) { 88 + if let Err(e) = self.nvs.set_u8(KEY_VOL_IDX, index) { 89 + warn!("NVS write vol_idx failed: {e}"); 90 + } 91 + if let Err(e) = self.nvs.set_u8(KEY_VOL_DIR, direction.to_u8()) { 92 + warn!("NVS write vol_dir failed: {e}"); 93 + } 94 + } 95 + 96 + pub fn write_playing(&self, playing: bool) { 97 + let val = if playing { 1u8 } else { 0u8 }; 98 + if let Err(e) = self.nvs.set_u8(KEY_PLAYING, val) { 99 + warn!("NVS write playing failed: {e}"); 100 + } 101 + } 102 + }
+89
firmware/src/state.rs
··· 1 + //! Persistent audio state — volume preset list and yo-yo cycling logic. 2 + //! 3 + //! The volume cycle is yo-yo: each long-press advances in the current 4 + //! direction; when the index hits an end of the preset list, direction flips. 5 + //! Both `volume_index` and `volume_direction` are persisted in NVS so the 6 + //! cycle resumes from where it left off after a reboot. 7 + 8 + pub const VOLUME_PRESETS: [u8; 5] = [10, 25, 50, 75, 100]; 9 + 10 + /// Default volume index when NVS is empty (50%). 11 + pub const DEFAULT_VOLUME_INDEX: u8 = 2; 12 + 13 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 14 + pub enum VolumeDirection { 15 + Up, 16 + Down, 17 + } 18 + 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 + pub fn to_u8(self) -> u8 { 28 + match self { 29 + Self::Up => 0, 30 + Self::Down => 1, 31 + } 32 + } 33 + 34 + pub fn from_u8(v: u8) -> Self { 35 + match v { 36 + 1 => Self::Down, 37 + _ => Self::Up, 38 + } 39 + } 40 + } 41 + 42 + /// Compute the next yo-yo step. 43 + /// 44 + /// Returns the new index and the (possibly flipped) direction. 45 + /// At an end, direction flips and we step inward; we never emit the 46 + /// out-of-bounds index. 47 + pub fn next_volume_index(current: u8, direction: VolumeDirection) -> (u8, VolumeDirection) { 48 + let last = (VOLUME_PRESETS.len() - 1) as u8; 49 + match direction { 50 + VolumeDirection::Up if current >= last => (last - 1, VolumeDirection::Down), 51 + VolumeDirection::Up => (current + 1, VolumeDirection::Up), 52 + VolumeDirection::Down if current == 0 => (1, VolumeDirection::Up), 53 + VolumeDirection::Down => (current - 1, VolumeDirection::Down), 54 + } 55 + } 56 + 57 + #[cfg(test)] 58 + mod tests { 59 + use super::*; 60 + 61 + #[test] 62 + fn yo_yo_cycle_full_round_trip() { 63 + let mut idx = 0u8; 64 + let mut dir = VolumeDirection::Up; 65 + let mut visited = vec![idx]; 66 + for _ in 0..10 { 67 + let (next, ndir) = next_volume_index(idx, dir); 68 + idx = next; 69 + dir = ndir; 70 + visited.push(idx); 71 + } 72 + // Going up: 0,1,2,3,4 then flips to going down: 3,2,1,0 then flips and goes up: 1,2 73 + assert_eq!(visited, vec![0, 1, 2, 3, 4, 3, 2, 1, 0, 1, 2]); 74 + } 75 + 76 + #[test] 77 + fn flip_on_top_end() { 78 + let (idx, dir) = next_volume_index(4, VolumeDirection::Up); 79 + assert_eq!(idx, 3); 80 + assert_eq!(dir, VolumeDirection::Down); 81 + } 82 + 83 + #[test] 84 + fn flip_on_bottom_end() { 85 + let (idx, dir) = next_volume_index(0, VolumeDirection::Down); 86 + assert_eq!(idx, 1); 87 + assert_eq!(dir, VolumeDirection::Up); 88 + } 89 + }
+16 -10
reference/mqtt-contract.md
··· 14 14 15 15 | Responsibility | Device | HA | 16 16 | --- | --- | --- | 17 - | Detect button press (short / long) | ✓ | | 17 + | Detect button press (short / long / double) | ✓ | | 18 18 | Debounce | ✓ | | 19 19 | Publish button event | ✓ | | 20 20 | Decide what to do with the press | | ✓ | ··· 120 120 121 121 0–100 slider in HA. Device persists the last-set volume across reboots (NVS). 122 122 123 - Volume can also be changed by **double-pressing the physical button**, which cycles through a preset list defined in firmware (currently `[30, 50, 70, 90]` — tunable in source). The cycle advances to the next preset strictly greater than current volume, wrapping to the smallest when past the end. Either slider or button is authoritative at any moment — device publishes the new volume to `state` whichever path set it. 123 + 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 124 125 125 Discovery topic: `homeassistant/number/nightstand_1/volume/config` 126 126 ```json ··· 237 237 - **Triple press** is treated as double-then-new-sequence — fine, users won't do this intentionally. 238 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. 239 239 240 - Firmware-local effects (regardless of online/offline): 241 - - `double` event → cycle volume to next preset, persist to NVS, publish new volume state to MQTT (if online) 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 242 244 243 - Online/offline-dependent behavior: 244 - - `short` → online: publish event; offline: toggle white noise locally 245 - - `long` → online: publish event; offline: no-op 246 - - `double` → online: publish event (informational); offline: just the volume cycle 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) 249 + 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. 247 251 248 252 ### HA-side (example — not firmware's responsibility) 249 253 ··· 286 290 - switch.nightstand_1_white_noise 287 291 - switch.nightstand_2_white_noise 288 292 289 - - alias: "Nightstand: long press (late-night check)" 293 + - alias: "Nightstand: double press (late-night check)" 290 294 trigger: 291 295 - platform: mqtt 292 296 topic: nightstand/+/button 293 - payload: '{"event_type":"long"}' 297 + payload: '{"event_type":"double"}' 294 298 condition: 295 299 - condition: state 296 300 entity_id: sun.sun ··· 302 306 data: 303 307 brightness_pct: 30 304 308 ``` 309 + 310 + (Long-press doesn't need an HA automation by default — volume cycling happens locally on the device. HA can subscribe to it for logging or analytics if useful.) 305 311 306 312 The device is blissfully unaware of any of this. 307 313
+16 -11
reference/operating-modes.md
··· 40 40 41 41 Entered on power-on or reset. Responsibilities: 42 42 1. Initialize I2S, GPIO, NVS, RGB LED 43 - 2. Read persistent state from NVS: `volume`, `was_playing` 43 + 2. Read persistent state from NVS: `volume_index`, `volume_direction`, `was_playing` 44 44 3. Look up this chip's MAC in `KNOWN_DEVICES` table → logical identity 45 45 4. **If `was_playing == true`**: start white noise generator immediately at saved volume (power-blip recovery — don't wake the user with silence) 46 46 5. Attempt WiFi connect with 30s timeout against stored credentials ··· 66 66 67 67 - Button events **not published** (there's nobody listening) 68 68 - Short press toggles white noise locally 69 - - Long press: see "Button behavior" below 70 - - Double press cycles volume (unchanged from ONLINE) 69 + - Long press cycles volume locally (yo-yo through preset list — see "Button behavior") 70 + - 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 71 - No commands are received 72 72 - Background task retries WiFi + MQTT every 60s; on success, transition to ONLINE 73 73 ··· 79 79 80 80 | Input | ONLINE | OFFLINE | 81 81 | --- | --- | --- | 82 - | Short press | Publish `{"event_type":"short"}` | Toggle white noise on/off | 83 - | Double press | Cycle volume preset (local) + publish `{"event_type":"double"}` | Cycle volume preset (local) | 84 - | Long press (≥2s) | Publish `{"event_type":"long"}` | **Fade out white noise over 3s**, then stop | 82 + | Short press | Toggle white noise locally + publish `{"event_type":"short"}` (HA bedtime / morning routine) | Toggle white noise locally | 83 + | Long press (≥2s) | Cycle volume preset locally (yo-yo) + publish `{"event_type":"long"}` | Cycle volume preset locally (yo-yo) | 84 + | Double press | Publish `{"event_type":"double"}` (HA late-night-lights routine — no local effect) | Detected, no-op (no lights to control offline) | 85 + 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 + 88 + **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. 85 89 86 - Rationale for long-press-in-offline = fade out: travel-friendly. You pressed the button to start the noise; same button with a long hold stops it gently without a jarring silence. Parallels "pull the plug slowly." The fade duration (3s) is firmware-tunable. 90 + **Double-press as the late-night gesture**: when one of us has to get up to check on something at 3 AM, double-tapping the nightstand button asks HA to bring up the outdoor and downstairs lights. Detected and emitted by firmware in both modes, but only meaningful when online — offline just logs a note that the routine isn't available. 87 91 88 - Volume cycle is consistent in both modes so the muscle memory doesn't change. 92 + **Latency note**: short-press has ~400 ms of detection latency (we have to wait for the double-press window to close before knowing it's a single). Imperceptible for sleepy-user use cases; the cost we pay for unambiguous gesture detection. 89 93 90 94 ## LED status colors 91 95 ··· 113 117 114 118 | Key | Type | Purpose | Written when | 115 119 | --- | --- | --- | --- | 116 - | `volume` | u8 | Current volume 0–100 | Changed via MQTT cmd or double-press | 117 - | `was_playing` | bool | Whether white noise was playing at last state change | Every play/stop transition | 120 + | `volume_index` | u8 | Index 0..=4 into `VOLUME_PRESETS = [10, 25, 50, 75, 100]` | Long-press cycles the index, or HA sets volume | 121 + | `volume_direction` | u8 | 0 = Up, 1 = Down — the current yo-yo direction | Flipped when index hits an end of the preset list | 122 + | `was_playing` | u8 (0/1) | Whether white noise was playing at last state change | Every play/stop transition | 118 123 | `wifi_ssid` | string | Known WiFi SSID | Provisioning (first flash or later update) | 119 124 | `wifi_password` | string | Known WiFi password | Provisioning | 120 125 ··· 156 161 | Error | Behavior | 157 162 | --- | --- | 158 163 | I2S driver init fails | Red blink, no audio. Stay in whatever connection mode works. Log loudly. | 159 - | NVS read fails | Use defaults (volume=50, was_playing=false). Log. | 164 + | NVS read fails | Use defaults (volume_index=2 (=50%), volume_direction=Up, was_playing=false). Log. | 160 165 | NVS write fails | Log, keep running. State won't persist across reboot but that's a graceful degradation. | 161 166 | WiFi password wrong | Stay OFFLINE forever until updated. No good recovery. | 162 167 | MQTT broker unreachable | Stay OFFLINE, retry per strategy above. |