diff --git a/firmware/Cargo.lock b/firmware/Cargo.lock index 9a5a35b..1f25149 100644 --- a/firmware/Cargo.lock +++ b/firmware/Cargo.lock @@ -1547,6 +1547,7 @@ version = "0.0.1" dependencies = [ "anyhow", "embuild", + "esp-idf-hal", "esp-idf-svc", "log", ] diff --git a/firmware/Cargo.toml b/firmware/Cargo.toml index 76deb3d..cbf87a1 100644 --- a/firmware/Cargo.toml +++ b/firmware/Cargo.toml @@ -27,6 +27,10 @@ experimental = ["esp-idf-svc/experimental"] [dependencies] log = { version = "0.4", default-features = false } esp-idf-svc = { version = "0.51", default-features = false } +# esp-idf-hal is a transitive dep of esp-idf-svc; pulled in directly so we +# can enable the rmt-legacy feature (the v5 RMT API isn't wrapped in 0.45.2 +# yet, and we need the legacy `TxRmtDriver` for SK6812 LED control). +esp-idf-hal = { version = "0.45", default-features = false, features = ["rmt-legacy"] } anyhow = "1" [build-dependencies] diff --git a/firmware/README.md b/firmware/README.md index 1bc493c..2eccc27 100644 --- a/firmware/README.md +++ b/firmware/README.md @@ -6,24 +6,37 @@ For the *what* and *why* (architecture, MQTT contract, operating modes, signal c ## Status -**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. +**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. | Subsystem | State | | --- | --- | | Cargo build + flash | ✅ working | -| GPIO input (button G39) | ✅ working — active-low, on-board pull-up | +| GPIO input (button G39) | ✅ working — debounced, active-low | | I2S TX (16-bit / 44.1 kHz / stereo, Philips) | ✅ working into onboard NS4168 | -| DMA-fed silence to keep amp idle | ✅ working — no idle hum | -| Audio playback (one-shot beep) | ✅ working | -| RGB LED (SK6812 on G27 via RMT) | ❌ TBD | -| WiFi | ❌ TBD | -| MQTT client + HA discovery | ❌ TBD | -| Continuous white noise generator | ❌ TBD | -| Button state machine (single/double/long) | ❌ TBD — currently just edge detection | -| NVS persistence | ❌ TBD | -| OTA updates | ❌ TBD (v1.5 design lives in `mqtt-contract.md`) | +| Continuous white noise generator | ✅ xorshift32, volume-scaled | +| Button state machine (short / long / double) | ✅ working | +| NVS persistence (volume + direction + playing) | ✅ working | +| RGB LED (SK6812 on G27 via RMT) | ✅ working — boot pulse, idle/playing colors, press-flash overlay | +| WiFi | ❌ next milestone | +| MQTT client + HA discovery | ❌ next milestone | +| OTA updates | ❌ v1.5 (design in `reference/mqtt-contract.md`) | | Hardware: external MAX98357A + 1314 speaker | ❌ amps in transit; using onboard for now | +## Module layout + +``` +firmware/src/ +├── main.rs — entry; spawns tasks; coordinator loop translates ButtonEvent → AudioCommand +├── events.rs — ButtonEvent, AudioCommand, LedSignal enums (the cross-task wire format) +├── state.rs — VOLUME_PRESETS, VolumeDirection, yo-yo cycle math (with unit tests) +├── nvs.rs — typed NVS wrapper for volume_index, volume_direction, was_playing +├── audio.rs — I2S setup + xorshift white noise + audio task (owns I2S + NVS) +├── button.rs — 5-state button FSM + button task (owns G39 PinDriver) +└── led.rs — SK6812 driver via ws2812-esp32-rmt-driver + LED task at ~30 Hz +``` + +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` and a publisher of button events — no other module changes. + ## Build & flash Project uses direnv (`.envrc` at the project root). Open a terminal in any subdir of the project and the env loads automatically. diff --git a/firmware/src/audio.rs b/firmware/src/audio.rs new file mode 100644 index 0000000..34db312 --- /dev/null +++ b/firmware/src/audio.rs @@ -0,0 +1,305 @@ +//! Audio task: owns the I2S driver, generates white noise, applies volume, +//! handles play/stop/cycle commands, persists state changes to NVS. +//! +//! Runs on a dedicated thread. Receives `AudioCommand`s from the coordinator +//! (and, in a future milestone, from MQTT) on a channel. Broadcasts +//! `LedSignal::Idle` / `LedSignal::Playing` on relevant transitions. +//! +//! The main loop alternates between draining commands (non-blocking) and +//! writing one ~23 ms buffer of audio (blocking on DMA). This is the same +//! "I2S write IS our scheduling cadence" trick from hello-world, scaled up +//! to handle continuous audio plus inbound commands. + +use crate::events::{AudioCommand, LedSignal}; +use crate::nvs::{NvsStore, PersistedState}; +use crate::state::{next_volume_index, VolumeDirection, VOLUME_PRESETS}; +use anyhow::Result; +use esp_idf_svc::hal::i2s::config::{ + Config, DataBitWidth, SlotMode, StdClkConfig, StdConfig, StdGpioConfig, StdSlotConfig, +}; +use esp_idf_svc::hal::i2s::{I2sDriver, I2sTx}; +use log::{info, warn}; +use std::sync::mpsc::{Receiver, Sender}; +use std::thread::{Builder, JoinHandle}; + +pub const SAMPLE_RATE: u32 = 44_100; +const BUFFER_BYTES: usize = 4096; // ~23 ms at 44.1 kHz stereo 16-bit +const STACK_SIZE: usize = 16 * 1024; + +/// Construct the I2S driver for the onboard NS4168 amp. +/// +/// Pin assignment matches the M5Stack Atom Echo schematic: +/// BCLK = G19, DOUT = G22, WS/LRCK = G33, no MCLK. +/// +/// **Argument order to `new_std_tx` is `(bclk, dout, mclk, ws)`** — not +/// the audio-convention `(bclk, ws, dout)` you'd guess. Getting it wrong +/// produces static instead of sound; see firmware/README.md gotcha #3. +pub fn make_onboard_i2s( + i2s0: esp_idf_svc::hal::i2s::I2S0, + bclk: esp_idf_svc::hal::gpio::Gpio19, + dout: esp_idf_svc::hal::gpio::Gpio22, + ws: esp_idf_svc::hal::gpio::Gpio33, +) -> Result> { + let config = StdConfig::new( + Config::default(), + StdClkConfig::from_sample_rate_hz(SAMPLE_RATE), + StdSlotConfig::philips_slot_default(DataBitWidth::Bits16, SlotMode::Stereo), + StdGpioConfig::default(), + ); + let i2s = I2sDriver::new_std_tx( + i2s0, + &config, + bclk, + dout, + Option::::None, + ws, + )?; + Ok(i2s) +} + +/// Spawn the audio task. Takes ownership of I2S, NVS, initial state, and +/// the channels it'll use for the rest of its life. +pub fn spawn( + i2s: I2sDriver<'static, I2sTx>, + nvs: NvsStore, + initial_state: PersistedState, + cmd_rx: Receiver, + led_tx: Sender, +) -> Result> { + let handle = Builder::new() + .name("audio".into()) + .stack_size(STACK_SIZE) + .spawn(move || { + if let Err(e) = audio_loop(i2s, nvs, initial_state, cmd_rx, led_tx) { + warn!("audio task exiting on error: {e:?}"); + } + })?; + Ok(handle) +} + +/// xorshift32 — fast, plenty for noise PCM. +struct Rng { + state: u32, +} + +impl Rng { + fn new(seed: u32) -> Self { + Self { + state: if seed == 0 { 0x12345678 } else { seed }, + } + } + + #[inline] + fn next(&mut self) -> u32 { + let mut x = self.state; + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + self.state = x; + x + } + + /// One white sample in `[-1.0, 1.0)`. + #[inline] + fn next_white(&mut self) -> f32 { + (self.next() as i32 as f32) / (i32::MAX as f32) + } +} + +/// Paul Kellet's classic pink-noise IIR filter (the "more accurate" variant). +/// Takes white samples in `[-1, 1]` and emits pink samples in roughly the +/// same range. Each output frame keeps its own state so L and R can be +/// filtered independently. +/// +/// Source: +#[derive(Default)] +struct PinkFilter { + b0: f32, + b1: f32, + b2: f32, + b3: f32, + b4: f32, + b5: f32, + b6: f32, +} + +impl PinkFilter { + #[inline] + fn process(&mut self, white: f32) -> f32 { + self.b0 = 0.99886 * self.b0 + white * 0.0555179; + self.b1 = 0.99332 * self.b1 + white * 0.0750759; + self.b2 = 0.96900 * self.b2 + white * 0.1538520; + self.b3 = 0.86650 * self.b3 + white * 0.3104856; + self.b4 = 0.55000 * self.b4 + white * 0.5329522; + self.b5 = -0.7616 * self.b5 - white * 0.0168980; + let pink = + self.b0 + self.b1 + self.b2 + self.b3 + self.b4 + self.b5 + self.b6 + white * 0.5362; + self.b6 = white * 0.115926; + // 0.11 is the canonical normalization to bring the filter back to + // ~unity gain. Multiply by 1.5 so pink doesn't sound quieter than + // the equivalent white at the same volume preset (the ear is less + // sensitive to bass-heavy spectra). + pink * 0.165 + } +} + +/// Internal task state. Tracks only what the audio task itself needs to +/// remember; the coordinator and other tasks have their own views. +struct State { + playing: bool, + volume_index: u8, + direction: VolumeDirection, +} + +impl State { + fn from_persisted(p: PersistedState) -> Self { + Self { + playing: p.was_playing, + volume_index: p.volume_index, + direction: p.volume_direction, + } + } + + fn current_volume_pct(&self) -> u8 { + VOLUME_PRESETS[self.volume_index as usize] + } +} + +fn audio_loop( + mut i2s: I2sDriver<'static, I2sTx>, + nvs: NvsStore, + initial: PersistedState, + cmd_rx: Receiver, + led_tx: Sender, +) -> Result<()> { + let mut state = State::from_persisted(initial); + let mut rng = Rng::new(0xC0FFEE_u32); + let mut pink_l = PinkFilter::default(); + let mut pink_r = PinkFilter::default(); + let mut buf = vec![0u8; BUFFER_BYTES]; + + // Pre-fill DMA buffers with silence before enabling TX, so the first + // thing the amp sees is a clean zero level rather than uninitialized + // memory (gotcha #4 in firmware/README.md). + i2s.preload_data(&buf)?; + i2s.tx_enable()?; + + info!( + "audio task ready: playing={} volume_idx={} direction={:?}", + state.playing, state.volume_index, state.direction + ); + + // Reflect the initial state to the LED. + let _ = led_tx.send(if state.playing { + LedSignal::Playing + } else { + LedSignal::Idle + }); + + loop { + // Drain any pending commands without blocking. + while let Ok(cmd) = cmd_rx.try_recv() { + apply_command(cmd, &mut state, &nvs, &led_tx); + } + + // Fill the buffer for the next 23 ms of audio. + if state.playing { + fill_noise( + &mut buf, + &mut rng, + &mut pink_l, + &mut pink_r, + state.current_volume_pct(), + ); + } else { + buf.iter_mut().for_each(|b| *b = 0); + } + + // Write — this blocks for ~one buffer period while DMA consumes. + let mut written = 0; + while written < buf.len() { + match i2s.write(&buf[written..], 1_000) { + Ok(n) => written += n, + Err(e) => { + warn!("I2S write error: {e}"); + break; + } + } + } + } +} + +fn apply_command( + cmd: AudioCommand, + state: &mut State, + nvs: &NvsStore, + led_tx: &Sender, +) { + match cmd { + AudioCommand::Play => set_playing(state, nvs, led_tx, true), + AudioCommand::Stop => set_playing(state, nvs, led_tx, false), + AudioCommand::Toggle => set_playing(state, nvs, led_tx, !state.playing), + AudioCommand::CycleVolume => { + let (new_idx, new_dir) = next_volume_index(state.volume_index, state.direction); + state.volume_index = new_idx; + state.direction = new_dir; + nvs.write_volume(new_idx, new_dir); + info!( + "volume cycled: idx={} pct={}% direction={:?}", + new_idx, + state.current_volume_pct(), + new_dir + ); + } + AudioCommand::SetVolumeIndex(idx) => { + let clamped = (idx as usize).min(VOLUME_PRESETS.len() - 1) as u8; + if clamped != state.volume_index { + state.volume_index = clamped; + nvs.write_volume(state.volume_index, state.direction); + info!( + "volume set: idx={} pct={}%", + state.volume_index, + state.current_volume_pct() + ); + } + } + } +} + +fn set_playing(state: &mut State, nvs: &NvsStore, led_tx: &Sender, target: bool) { + if state.playing == target { + return; + } + state.playing = target; + nvs.write_playing(target); + info!("playing={}", target); + let _ = led_tx.send(if target { + LedSignal::Playing + } else { + LedSignal::Idle + }); +} + +/// Fill the buffer with stereo pink noise scaled by `vol_pct` (0..=100). +/// +/// Pink noise (1/f spectrum) sounds calmer and more "shhhh"-like than raw +/// white noise — most "sleep" noise machines use pink or brown. +fn fill_noise( + buf: &mut [u8], + rng: &mut Rng, + pink_l: &mut PinkFilter, + pink_r: &mut PinkFilter, + vol_pct: u8, +) { + debug_assert!(buf.len() % 4 == 0); + let vol_scale = vol_pct as f32 / 100.0; + let i16_max = i16::MAX as f32; + for chunk in buf.chunks_exact_mut(4) { + let pl = pink_l.process(rng.next_white()) * vol_scale; + let pr = pink_r.process(rng.next_white()) * vol_scale; + let l = (pl * i16_max).clamp(i16::MIN as f32, i16::MAX as f32) as i16; + let r = (pr * i16_max).clamp(i16::MIN as f32, i16::MAX as f32) as i16; + chunk[0..2].copy_from_slice(&l.to_le_bytes()); + chunk[2..4].copy_from_slice(&r.to_le_bytes()); + } +} diff --git a/firmware/src/button.rs b/firmware/src/button.rs new file mode 100644 index 0000000..71f5248 --- /dev/null +++ b/firmware/src/button.rs @@ -0,0 +1,179 @@ +//! Button task: debounces, classifies short/long/double presses, emits +//! `ButtonEvent` to the coordinator. +//! +//! Polls G39 (active-low, on-board pull-up, input-only pin) at 5 ms. +//! State machine has 5 states; see firmware/README.md or +//! reference/operating-modes.md for the diagram. +//! +//! Constants tunable here: +//! - `DEBOUNCE_MS`: ignore edges within this window of the last edge. +//! - `DOUBLE_WINDOW_MS`: time after release where a second press counts as a double. +//! - `LONG_THRESHOLD_MS`: time held before firing Long mid-press. + +use crate::events::ButtonEvent; +use anyhow::Result; +use esp_idf_svc::hal::gpio::{Gpio39, Input, PinDriver}; +use log::{info, warn}; +use std::sync::mpsc::Sender; +use std::thread::{Builder, JoinHandle}; +use std::time::{Duration, Instant}; + +const POLL_INTERVAL: Duration = Duration::from_millis(5); +const DEBOUNCE_MS: u128 = 20; +const DOUBLE_WINDOW_MS: u128 = 400; +/// Time the button must be held before the FIRST `Long` event fires. +const LONG_THRESHOLD_MS: u128 = 1_000; +/// While the button is still held after the first `Long`, fire another every +/// this many ms — same as the initial threshold so the cadence is consistent +/// (1 s to first cycle, +1 s each additional cycle). +const LONG_REPEAT_MS: u128 = 1_000; +const STACK_SIZE: usize = 4 * 1024; + +#[derive(Debug, Clone, Copy)] +enum FsmState { + Idle, + /// Button is currently held; t = press timestamp. + Pressing { t_press: Instant }, + /// `Long` fired at threshold; auto-repeating until release. + /// `t_last_long` is when the most recent `Long` was emitted. + Hold { t_last_long: Instant }, + /// Button was just released after a sub-LONG_THRESHOLD press; waiting + /// to see if it's a double. + WaitingDouble { t_release: Instant }, + /// Second press in flight; will emit Double on release. + DoublePressing, +} + +pub fn spawn( + button: PinDriver<'static, Gpio39, Input>, + event_tx: Sender, +) -> Result> { + let handle = Builder::new() + .name("button".into()) + .stack_size(STACK_SIZE) + .spawn(move || { + if let Err(e) = button_loop(button, event_tx) { + warn!("button task exiting on error: {e:?}"); + } + })?; + Ok(handle) +} + +fn button_loop( + button: PinDriver<'static, Gpio39, Input>, + event_tx: Sender, +) -> Result<()> { + let mut fsm = FsmState::Idle; + // Active-low: high = released, low = pressed. + let mut last_level_high = button.is_high(); + let mut last_edge_at = Instant::now() - Duration::from_secs(1); + + info!("button task ready (polling G39 every {} ms)", POLL_INTERVAL.as_millis()); + + loop { + std::thread::sleep(POLL_INTERVAL); + let now = Instant::now(); + let level_high = button.is_high(); + + // Detect an edge with debouncing. + let edge = if level_high != last_level_high { + if now.duration_since(last_edge_at).as_millis() < DEBOUNCE_MS { + None + } else { + last_edge_at = now; + last_level_high = level_high; + Some(level_high) // true = H→L (release if previous was low... wait) + } + } else { + None + }; + + // edge is Some(now_high). If now_high == false, that means we just went + // from high → low, i.e., button DOWN (active-low). If now_high == true, + // we went from low → high, i.e., button UP. + match edge { + Some(false) => { + // Press + fsm = on_button_down(fsm, now); + } + Some(true) => { + // Release + fsm = on_button_up(fsm, now, &event_tx); + } + None => { + // No edge — check timeouts. + fsm = on_tick(fsm, now, &event_tx); + } + } + } +} + +fn on_button_down(fsm: FsmState, now: Instant) -> FsmState { + match fsm { + FsmState::Idle => FsmState::Pressing { t_press: now }, + FsmState::WaitingDouble { .. } => FsmState::DoublePressing, + // In any other state we shouldn't see a "down" edge (we'd already be down) + // but if we somehow do, fall back to Pressing. + _ => FsmState::Pressing { t_press: now }, + } +} + +fn on_button_up( + fsm: FsmState, + now: Instant, + event_tx: &Sender, +) -> FsmState { + match fsm { + FsmState::Pressing { .. } => { + // Released before LONG_THRESHOLD. Wait to see if it's a double. + FsmState::WaitingDouble { t_release: now } + } + FsmState::Hold { .. } => { + // Long(s) already emitted; just clear state. + FsmState::Idle + } + FsmState::DoublePressing => { + // Second release — that's a double press. + send_event(event_tx, ButtonEvent::Double); + FsmState::Idle + } + _ => FsmState::Idle, + } +} + +fn on_tick(fsm: FsmState, now: Instant, event_tx: &Sender) -> FsmState { + match fsm { + FsmState::Pressing { t_press } => { + if now.duration_since(t_press).as_millis() >= LONG_THRESHOLD_MS { + send_event(event_tx, ButtonEvent::Long); + FsmState::Hold { t_last_long: now } + } else { + fsm + } + } + FsmState::Hold { t_last_long } => { + if now.duration_since(t_last_long).as_millis() >= LONG_REPEAT_MS { + send_event(event_tx, ButtonEvent::Long); + FsmState::Hold { t_last_long: now } + } else { + fsm + } + } + FsmState::WaitingDouble { t_release } => { + if now.duration_since(t_release).as_millis() >= DOUBLE_WINDOW_MS { + send_event(event_tx, ButtonEvent::Short); + FsmState::Idle + } else { + fsm + } + } + _ => fsm, + } +} + +fn send_event(event_tx: &Sender, event: ButtonEvent) { + info!("button event: {event:?}"); + if let Err(e) = event_tx.send(event) { + warn!("button event channel closed: {e}"); + } +} diff --git a/firmware/src/events.rs b/firmware/src/events.rs new file mode 100644 index 0000000..892a84b --- /dev/null +++ b/firmware/src/events.rs @@ -0,0 +1,51 @@ +//! Type-level "wires" between the firmware modules. +//! +//! These enums define the shape of the cross-task message passing. Producers +//! and consumers are independent; adding a new producer (e.g., MQTT in a +//! later milestone) is a matter of adding another `Sender`, +//! not changing any existing module. + +/// What the button task observes and reports to the coordinator. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ButtonEvent { + /// A single short press (released before 2s, no follow-up press within 400ms). + Short, + /// Held for 2s or more. + Long, + /// Two short presses within 400ms of each other. + Double, +} + +/// What the coordinator (or, later, an MQTT task) tells the audio task to do. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AudioCommand { + /// Start playing white noise at the current volume. + Play, + /// Stop playing. + Stop, + /// Toggle play state. + Toggle, + /// Advance the volume yo-yo by one step in the current direction. + CycleVolume, + /// Set volume directly to a specific preset index (0..VOLUME_PRESETS.len()). + /// Used when HA sends a numeric volume; we snap to the nearest preset. + SetVolumeIndex(u8), +} + +/// What the coordinator tells the LED task to display. +/// +/// `PressFlash` is treated as an overlay — the LED task remembers the +/// underlying signal and brightens briefly on top of it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LedSignal { + /// Connecting / starting up — slow blue pulse. + Boot, + /// Offline, idle (silent) — dim amber. + Idle, + /// Offline, playing — brighter amber. + Playing, + /// Brief brighter flash on top of whatever's currently being shown. + PressFlash, + /// Something's broken — slow red blink. + Error, +} diff --git a/firmware/src/led.rs b/firmware/src/led.rs new file mode 100644 index 0000000..538d0dd --- /dev/null +++ b/firmware/src/led.rs @@ -0,0 +1,179 @@ +//! LED task: drives the SK6812 RGB LED behind the Atom Echo button cap on G27. +//! +//! Uses the ESP32 RMT peripheral directly (no third-party crate — the +//! community wrappers had stale `esp-idf-hal` version constraints, and the +//! protocol is small enough to roll). RMT clock divider of 2 → 40 MHz = +//! 25 ns / tick, more than enough resolution for NeoPixel-compatible +//! SK6812 timings. +//! +//! The RMT peripheral naturally drops the line LOW between transmissions +//! (≥50 µs), which serves as the SK6812 reset; at our 30 Hz refresh rate +//! the gap is 33 ms — vastly more than the ~80 µs spec. +//! +//! Receives `LedSignal`s from a channel and renders status colors: +//! Boot — slow blue pulse, ~1 Hz +//! Idle — dim amber +//! Playing — brighter amber +//! Error — red blink, ~2 Hz +//! PressFlash — overlay; brightens whatever's currently shown for ~150 ms + +use crate::events::LedSignal; +use anyhow::{anyhow, Result}; +use esp_idf_svc::hal::gpio::Gpio27; +use esp_idf_svc::hal::rmt::config::TransmitConfig; +use esp_idf_svc::hal::rmt::{ + FixedLengthSignal, PinState, Pulse, PulseTicks, TxRmtDriver, CHANNEL0, +}; +use log::{info, warn}; +use std::sync::mpsc::{Receiver, TryRecvError}; +use std::thread::{Builder, JoinHandle}; +use std::time::{Duration, Instant}; + +const FRAME_INTERVAL: Duration = Duration::from_millis(33); // ~30 Hz +const FLASH_DURATION: Duration = Duration::from_millis(150); +const STACK_SIZE: usize = 4 * 1024; + +// SK6812 / NeoPixel-compatible bit timings at 40 MHz RMT clock (25 ns/tick). +// Bit 1: ~800 ns high, ~450 ns low. +// Bit 0: ~400 ns high, ~850 ns low. +const T1H_TICKS: u16 = 32; +const T1L_TICKS: u16 = 18; +const T0H_TICKS: u16 = 16; +const T0L_TICKS: u16 = 34; + +#[derive(Debug, Clone, Copy)] +struct Rgb { + r: u8, + g: u8, + b: u8, +} + +impl Rgb { + const fn new(r: u8, g: u8, b: u8) -> Self { + Self { r, g, b } + } +} + +pub fn spawn( + rmt_channel: CHANNEL0, + pin: Gpio27, + signal_rx: Receiver, +) -> Result> { + let handle = Builder::new() + .name("led".into()) + .stack_size(STACK_SIZE) + .spawn(move || { + if let Err(e) = led_loop(rmt_channel, pin, signal_rx) { + warn!("LED task exiting on error: {e:?}"); + } + })?; + Ok(handle) +} + +fn led_loop( + rmt_channel: CHANNEL0, + pin: Gpio27, + signal_rx: Receiver, +) -> Result<()> { + // Clock divider 2 → 40 MHz → 25 ns / tick. + let config = TransmitConfig::new().clock_divider(2); + let mut tx = TxRmtDriver::new(rmt_channel, pin, &config) + .map_err(|e| anyhow!("RMT init: {e}"))?; + + let mut current = LedSignal::Boot; + let mut flash_started: Option = None; + let start = Instant::now(); + + info!("LED task ready"); + + loop { + // Drain any pending signals; latest wins. PressFlash is special: + // it doesn't replace `current`, just kicks the overlay timer. + loop { + match signal_rx.try_recv() { + Ok(LedSignal::PressFlash) => flash_started = Some(Instant::now()), + Ok(other) => current = other, + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => return Ok(()), + } + } + + let now = Instant::now(); + let base = base_color_for(current, now.duration_since(start)); + let final_color = apply_flash_overlay(base, flash_started, now); + + if let Err(e) = write_color(&mut tx, final_color) { + warn!("LED write error: {e:?}"); + } + + std::thread::sleep(FRAME_INTERVAL); + } +} + +/// Map a logical signal + animation phase to a base RGB color (no flash). +fn base_color_for(signal: LedSignal, t: Duration) -> Rgb { + match signal { + // Slow pulse blue, ~1 Hz, ranging 5..30 on the B channel. + LedSignal::Boot => { + let phase = (t.as_millis() as f32 / 500.0) * std::f32::consts::PI; + let pulse = (phase.sin() * 0.5 + 0.5) * 25.0 + 5.0; + Rgb::new(0, 0, pulse as u8) + } + LedSignal::Idle => Rgb::new(20, 8, 0), // dim amber + LedSignal::Playing => Rgb::new(60, 24, 0), // brighter amber + // ~2 Hz blink on red. + LedSignal::Error => { + if (t.as_millis() / 250) % 2 == 0 { + Rgb::new(60, 0, 0) + } else { + Rgb::new(0, 0, 0) + } + } + // PressFlash should never make it into base; treat as Idle if it does. + LedSignal::PressFlash => Rgb::new(20, 8, 0), + } +} + +fn apply_flash_overlay(base: Rgb, flash_started: Option, now: Instant) -> Rgb { + let Some(t0) = flash_started else { return base; }; + let elapsed = now.duration_since(t0); + if elapsed >= FLASH_DURATION { + return base; + } + // Linear decay over FLASH_DURATION; multiplies brightness by 1 + (0..0.5). + let factor = 1.0 - (elapsed.as_millis() as f32 / FLASH_DURATION.as_millis() as f32); + let bump = |c: u8| -> u8 { + let v = c as f32 * (1.0 + 0.5 * factor); + v.min(255.0) as u8 + }; + Rgb::new(bump(base.r), bump(base.g), bump(base.b)) +} + +/// Encode one RGB pixel as 24 RMT items (one per bit, in GRB order) and transmit. +fn write_color(tx: &mut TxRmtDriver, color: Rgb) -> Result<()> { + fn pulse(state: PinState, ticks: u16) -> Pulse { + Pulse::new(state, PulseTicks::new(ticks).expect("valid pulse ticks")) + } + let one = ( + pulse(PinState::High, T1H_TICKS), + pulse(PinState::Low, T1L_TICKS), + ); + let zero = ( + pulse(PinState::High, T0H_TICKS), + pulse(PinState::Low, T0L_TICKS), + ); + + let mut signal = FixedLengthSignal::<24>::new(); + let bytes = [color.g, color.r, color.b]; // SK6812 wants GRB + for (byte_i, byte) in bytes.iter().enumerate() { + for bit_i in 0..8 { + let bit_set = (byte >> (7 - bit_i)) & 1 == 1; + let pair = if bit_set { &one } else { &zero }; + signal + .set(byte_i * 8 + bit_i, pair) + .map_err(|e| anyhow!("set RMT pulse: {e}"))?; + } + } + tx.start_blocking(&signal) + .map_err(|e| anyhow!("RMT transmit: {e}")) +} diff --git a/firmware/src/main.rs b/firmware/src/main.rs index 835a29c..5631cae 100644 --- a/firmware/src/main.rs +++ b/firmware/src/main.rs @@ -1,113 +1,114 @@ -//! Sound Machine — hello-world milestone v0.0.1. +//! Sound Machine — offline-mode firmware v0.1.0. //! -//! Press the onboard button, play a short 880 Hz beep through the onboard -//! NS4168 amp + tiny built-in speaker. No WiFi, no MQTT yet — just enough -//! to validate the Rust + ESP-IDF + flash + I2S toolchain end to end. +//! Three tasks plus a coordinator in `main`: +//! - **audio** owns I2S, generates white noise, applies volume cycles +//! - **button** polls G39 and emits short / long / double events +//! - **led** drives the SK6812 RGB LED with status colors +//! - **coordinator** (this main loop) translates button events into +//! audio commands and LED flashes, and logs a TODO for `Double` (which +//! will become an MQTT publish in a future milestone) //! -//! Pin map (from the Atom Echo schematic): -//! GPIO 39 — button (input-only, external pull-up on board, active low) -//! GPIO 19 — I2S BCLK (NS4168) -//! GPIO 33 — I2S LRCK (NS4168) -//! GPIO 22 — I2S DOUT (NS4168) +//! All cross-task wiring is via `std::sync::mpsc` channels carrying typed +//! events / commands / signals. Adding network control later is purely +//! additive: an MQTT task becomes a second producer of `AudioCommand`s +//! and a publisher of button events; nothing in audio / button / led +//! changes. //! -//! Note: this uses the onboard amp's pins for prototyping. The real firmware -//! will route I2S to the external MAX98357A on G21/G26/G32 instead, leaving -//! the onboard amp idle. See `reference/signal-chain.md`. +//! See `firmware/README.md` and `reference/operating-modes.md` for the +//! design and the gotchas. + +mod audio; +mod button; +mod events; +mod led; +mod nvs; +mod state; use anyhow::Result; -use esp_idf_svc::hal::gpio::{AnyIOPin, PinDriver}; -use esp_idf_svc::hal::i2s::config::{Config, DataBitWidth, StdClkConfig, StdConfig, StdGpioConfig, StdSlotConfig}; -use esp_idf_svc::hal::i2s::I2sDriver; +use esp_idf_svc::hal::gpio::PinDriver; use esp_idf_svc::hal::peripherals::Peripherals; +use events::{AudioCommand, ButtonEvent, LedSignal}; use log::info; -use std::f32::consts::PI; - -const SAMPLE_RATE: u32 = 44_100; -const BEEP_FREQ_HZ: f32 = 880.0; -const BEEP_DURATION_MS: u32 = 150; -const BEEP_AMPLITUDE: f32 = 20_000.0; // ~60% of i16::MAX — louder, cleaner against quantization noise -const ENVELOPE_MS: u32 = 10; // attack + release each, click suppression -const SILENCE_CHUNK_BYTES: usize = 4096; // ~23 ms at 44.1 kHz stereo 16-bit — also our button-poll cadence +use nvs::NvsStore; +use std::sync::mpsc; fn main() -> Result<()> { esp_idf_svc::sys::link_patches(); esp_idf_svc::log::EspLogger::initialize_default(); - info!("sound-machine v0.0.1 boot"); + info!("sound-machine v0.1.0 boot"); + + // Persistent state — read once at boot, then the audio task owns writes. + let nvs = NvsStore::open()?; + let initial_state = nvs.read(); + // Peripherals split out, owned by the relevant tasks. let peripherals = Peripherals::take()?; let pins = peripherals.pins; - let button = PinDriver::input(pins.gpio39)?; + // Channels. + let (button_tx, button_rx) = mpsc::channel::(); + let (audio_tx, audio_rx) = mpsc::channel::(); + let (led_tx, led_rx) = mpsc::channel::(); - let i2s_config = StdConfig::new( - Config::default(), - StdClkConfig::from_sample_rate_hz(SAMPLE_RATE), - StdSlotConfig::philips_slot_default(DataBitWidth::Bits16, esp_idf_svc::hal::i2s::config::SlotMode::Stereo), - StdGpioConfig::default(), - ); + // Tell the LED to pulse blue while we set things up. + let _ = led_tx.send(LedSignal::Boot); - // Signature: new_std_tx(i2s, config, bclk, dout, mclk, ws) - let mut i2s = I2sDriver::new_std_tx( + // Audio task: I2S to the onboard NS4168 (prototype). Will move to the + // external MAX98357A on G21/G26/G32 once amps arrive. + let i2s = audio::make_onboard_i2s( peripherals.i2s0, - &i2s_config, - pins.gpio19, // BCLK - pins.gpio22, // DOUT (data to NS4168) - Option::::None, // no MCLK - pins.gpio33, // WS / LRCK + pins.gpio19, // BCLK + pins.gpio22, // DOUT + pins.gpio33, // WS / LRCK )?; + let _audio_handle = audio::spawn(i2s, nvs, initial_state, audio_rx, led_tx.clone())?; - // Pre-fill DMA buffers with silence so the first thing the NS4168 sees - // when TX enables is zeros, not uninitialized memory. - let silence = vec![0u8; SILENCE_CHUNK_BYTES]; - i2s.preload_data(&silence)?; - i2s.tx_enable()?; + // LED task on G27 via RMT channel 0. + let _led_handle = led::spawn(peripherals.rmt.channel0, pins.gpio27, led_rx)?; - let beep = build_beep_buffer(); - info!("ready — press the button to beep ({} samples)", beep.len() / 4); + // Button task on G39 (input-only; on-board pull-up; active-low). + let button = PinDriver::input(pins.gpio39)?; + let _button_handle = button::spawn(button, button_tx)?; - let mut last_high = button.is_high(); - loop { - let now_high = button.is_high(); - let press_edge = last_high && !now_high; // active-low: H→L = press - last_high = now_high; - - if press_edge { - info!("button pressed"); - // Write the full beep buffer — i2s.write blocks while DMA consumes - let mut w = 0; - while w < beep.len() { - w += i2s.write(&beep[w..], 1_000)?; - } - } + info!("all tasks spawned; entering coordinator loop"); - // Always feed silence so the DMA never replays stale data. - // This call blocks for ~23 ms (one chunk's worth of audio time) and - // doubles as our button-poll cadence — no explicit sleep needed. - i2s.write(&silence, 1_000)?; + // If the device was playing when power was cut, kick the audio task to + // resume immediately. The audio task starts in its persisted-state's + // `playing` value, so this is technically belt-and-braces — but it also + // ensures the LED reflects "Playing" promptly. + if initial_state.was_playing { + let _ = audio_tx.send(AudioCommand::Play); } -} -/// Build a stereo, 16-bit-LE PCM buffer of a single sine beep with attack/release -/// envelopes to avoid pop/click artifacts. -fn build_beep_buffer() -> Vec { - let total_samples = (SAMPLE_RATE * BEEP_DURATION_MS / 1_000) as usize; - let env_samples = (SAMPLE_RATE * ENVELOPE_MS / 1_000) as usize; - let mut buf = Vec::with_capacity(total_samples * 4); // stereo × 2 bytes - - for i in 0..total_samples { - let t = i as f32 / SAMPLE_RATE as f32; - let envelope = if i < env_samples { - i as f32 / env_samples as f32 - } else if i >= total_samples - env_samples { - (total_samples - i) as f32 / env_samples as f32 - } else { - 1.0 + // Coordinator loop. Receives button events, translates to audio + // commands and LED flashes. Logs the TODO for the double-press. + loop { + let event = match button_rx.recv() { + Ok(e) => e, + Err(e) => { + anyhow::bail!("button channel closed: {e}"); + } }; - let s = ((2.0 * PI * BEEP_FREQ_HZ * t).sin() * BEEP_AMPLITUDE * envelope) as i16; - let bytes = s.to_le_bytes(); - buf.extend_from_slice(&bytes); // L - buf.extend_from_slice(&bytes); // R (same — mono content, stereo carrier) + + // Every press gets a brief LED flash for tactile feedback. + let _ = led_tx.send(LedSignal::PressFlash); + + match event { + ButtonEvent::Short => { + info!("coordinator: short → AudioCommand::Toggle"); + let _ = audio_tx.send(AudioCommand::Toggle); + } + ButtonEvent::Long => { + info!("coordinator: long → AudioCommand::CycleVolume"); + let _ = audio_tx.send(AudioCommand::CycleVolume); + } + ButtonEvent::Double => { + info!( + "coordinator: double → TODO: late-night-lights routine \ + (will trigger HA via MQTT once online)" + ); + } + } } - buf } diff --git a/firmware/src/nvs.rs b/firmware/src/nvs.rs new file mode 100644 index 0000000..6e3f67a --- /dev/null +++ b/firmware/src/nvs.rs @@ -0,0 +1,102 @@ +//! Persistent state in ESP32 NVS. +//! +//! All audio state lives in the `audio` namespace as plain u8 keys: +//! - `vol_idx` — index 0..VOLUME_PRESETS.len() +//! - `vol_dir` — 0 = Up, 1 = Down +//! - `playing` — 0 = stopped, 1 = playing +//! +//! Reads are forgiving: missing keys return defaults, errors are logged but +//! don't crash. Writes are best-effort — flash failure logs but doesn't +//! propagate, since we'd rather degrade silently than panic on a sleeping +//! user's nightstand. + +use crate::state::{VolumeDirection, DEFAULT_VOLUME_INDEX}; +use anyhow::Result; +use esp_idf_svc::nvs::{EspNvs, EspNvsPartition, NvsDefault}; +use log::{info, warn}; + +const NAMESPACE: &str = "audio"; +const KEY_VOL_IDX: &str = "vol_idx"; +const KEY_VOL_DIR: &str = "vol_dir"; +const KEY_PLAYING: &str = "playing"; + +#[derive(Debug, Clone, Copy)] +pub struct PersistedState { + pub volume_index: u8, + pub volume_direction: VolumeDirection, + pub was_playing: bool, +} + +impl Default for PersistedState { + fn default() -> Self { + Self { + volume_index: DEFAULT_VOLUME_INDEX, + volume_direction: VolumeDirection::Up, + was_playing: false, + } + } +} + +pub struct NvsStore { + nvs: EspNvs, +} + +impl NvsStore { + pub fn open() -> Result { + let partition = EspNvsPartition::::take()?; + let nvs = EspNvs::new(partition, NAMESPACE, /* read_write */ true)?; + Ok(Self { nvs }) + } + + /// Read everything in one shot. Defaults fill in for any missing keys. + pub fn read(&self) -> PersistedState { + let defaults = PersistedState::default(); + let volume_index = self + .nvs + .get_u8(KEY_VOL_IDX) + .ok() + .flatten() + .unwrap_or(defaults.volume_index); + let volume_direction = self + .nvs + .get_u8(KEY_VOL_DIR) + .ok() + .flatten() + .map(VolumeDirection::from_u8) + .unwrap_or(defaults.volume_direction); + let was_playing = self + .nvs + .get_u8(KEY_PLAYING) + .ok() + .flatten() + .map(|v| v != 0) + .unwrap_or(defaults.was_playing); + + info!( + "NVS read: volume_index={} direction={:?} was_playing={}", + volume_index, volume_direction, was_playing + ); + + PersistedState { + volume_index, + volume_direction, + was_playing, + } + } + + pub fn write_volume(&self, index: u8, direction: VolumeDirection) { + if let Err(e) = self.nvs.set_u8(KEY_VOL_IDX, index) { + warn!("NVS write vol_idx failed: {e}"); + } + if let Err(e) = self.nvs.set_u8(KEY_VOL_DIR, direction.to_u8()) { + warn!("NVS write vol_dir failed: {e}"); + } + } + + pub fn write_playing(&self, playing: bool) { + let val = if playing { 1u8 } else { 0u8 }; + if let Err(e) = self.nvs.set_u8(KEY_PLAYING, val) { + warn!("NVS write playing failed: {e}"); + } + } +} diff --git a/firmware/src/state.rs b/firmware/src/state.rs new file mode 100644 index 0000000..d9a4110 --- /dev/null +++ b/firmware/src/state.rs @@ -0,0 +1,89 @@ +//! Persistent audio state — volume preset list and yo-yo cycling logic. +//! +//! The volume cycle is yo-yo: each long-press advances in the current +//! direction; when the index hits an end of the preset list, direction flips. +//! Both `volume_index` and `volume_direction` are persisted in NVS so the +//! cycle resumes from where it left off after a reboot. + +pub const VOLUME_PRESETS: [u8; 5] = [10, 25, 50, 75, 100]; + +/// Default volume index when NVS is empty (50%). +pub const DEFAULT_VOLUME_INDEX: u8 = 2; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VolumeDirection { + Up, + Down, +} + +impl VolumeDirection { + pub fn flip(self) -> Self { + match self { + Self::Up => Self::Down, + Self::Down => Self::Up, + } + } + + pub fn to_u8(self) -> u8 { + match self { + Self::Up => 0, + Self::Down => 1, + } + } + + pub fn from_u8(v: u8) -> Self { + match v { + 1 => Self::Down, + _ => Self::Up, + } + } +} + +/// Compute the next yo-yo step. +/// +/// Returns the new index and the (possibly flipped) direction. +/// At an end, direction flips and we step inward; we never emit the +/// out-of-bounds index. +pub fn next_volume_index(current: u8, direction: VolumeDirection) -> (u8, VolumeDirection) { + let last = (VOLUME_PRESETS.len() - 1) as u8; + match direction { + VolumeDirection::Up if current >= last => (last - 1, VolumeDirection::Down), + VolumeDirection::Up => (current + 1, VolumeDirection::Up), + VolumeDirection::Down if current == 0 => (1, VolumeDirection::Up), + VolumeDirection::Down => (current - 1, VolumeDirection::Down), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn yo_yo_cycle_full_round_trip() { + let mut idx = 0u8; + let mut dir = VolumeDirection::Up; + let mut visited = vec![idx]; + for _ in 0..10 { + let (next, ndir) = next_volume_index(idx, dir); + idx = next; + dir = ndir; + visited.push(idx); + } + // Going up: 0,1,2,3,4 then flips to going down: 3,2,1,0 then flips and goes up: 1,2 + assert_eq!(visited, vec![0, 1, 2, 3, 4, 3, 2, 1, 0, 1, 2]); + } + + #[test] + fn flip_on_top_end() { + let (idx, dir) = next_volume_index(4, VolumeDirection::Up); + assert_eq!(idx, 3); + assert_eq!(dir, VolumeDirection::Down); + } + + #[test] + fn flip_on_bottom_end() { + let (idx, dir) = next_volume_index(0, VolumeDirection::Down); + assert_eq!(idx, 1); + assert_eq!(dir, VolumeDirection::Up); + } +} diff --git a/reference/mqtt-contract.md b/reference/mqtt-contract.md index 06b2285..e8dabc9 100644 --- a/reference/mqtt-contract.md +++ b/reference/mqtt-contract.md @@ -14,7 +14,7 @@ The interface between each nightstand device and Home Assistant. Firmware and HA | Responsibility | Device | HA | | --- | --- | --- | -| Detect button press (short / long) | ✓ | | +| Detect button press (short / long / double) | ✓ | | | Debounce | ✓ | | | Publish button event | ✓ | | | Decide what to do with the press | | ✓ | @@ -120,7 +120,7 @@ Discovery topic: `homeassistant/switch/nightstand_1/white_noise/config` 0–100 slider in HA. Device persists the last-set volume across reboots (NVS). -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. +Volume can also be changed by **long-pressing the physical button**, which cycles through a preset list defined in firmware (`[10, 25, 50, 75, 100]` — tunable in source). The cycle is **yo-yo**: each long-press advances in the current direction, and direction flips when it hits an end. Both index and direction are persisted in NVS so the cycle resumes from where you left off. Either slider or button is authoritative at any moment — device publishes the new volume to `state` whichever path set it. Discovery topic: `homeassistant/number/nightstand_1/volume/config` ```json @@ -237,13 +237,17 @@ Notes: - **Triple press** is treated as double-then-new-sequence — fine, users won't do this intentionally. - **Each interaction fires exactly one event**: either `short`, `double`, or `long`. A double-press does not also fire a `short` for its first press — the state machine transitions into `DOUBLE_PRESSING` before `WAITING_DOUBLE`'s 400ms timer can fire `short`. Offline mode: double-press cycles volume and does **not** toggle the white noise, even though a single short-press does toggle it. -Firmware-local effects (regardless of online/offline): -- `double` event → cycle volume to next preset, persist to NVS, publish new volume state to MQTT (if online) +Firmware-local effects (identical in both modes — muscle memory doesn't change): +- `short` → toggle white noise on/off; persist `was_playing` to NVS +- `long` → advance volume in the current yo-yo direction; persist `volume_index` and `volume_direction` to NVS +- `double` → no local effect -Online/offline-dependent behavior: -- `short` → online: publish event; offline: toggle white noise locally -- `long` → online: publish event; offline: no-op -- `double` → online: publish event (informational); offline: just the volume cycle +When **online**, all three events are also published as `{"event_type": "..."}` to `nightstand/N/button`. HA decides what (if anything) to do with each: +- `short` → typical use: time-of-day-aware bedtime / morning routine (lights, etc.) +- `long` → typical use: HA logs it, no automation needed since the volume change happened locally +- `double` → typical use: late-night-lights routine (turn on outdoor + downstairs lights at low brightness when one of us has to get up) + +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. ### HA-side (example — not firmware's responsibility) @@ -286,11 +290,11 @@ Shown here so it's clear what use cases the button events are targeting. Chris o - switch.nightstand_1_white_noise - switch.nightstand_2_white_noise -- alias: "Nightstand: long press (late-night check)" +- alias: "Nightstand: double press (late-night check)" trigger: - platform: mqtt topic: nightstand/+/button - payload: '{"event_type":"long"}' + payload: '{"event_type":"double"}' condition: - condition: state entity_id: sun.sun @@ -303,6 +307,8 @@ Shown here so it's clear what use cases the button events are targeting. Chris o brightness_pct: 30 ``` +(Long-press doesn't need an HA automation by default — volume cycling happens locally on the device. HA can subscribe to it for logging or analytics if useful.) + The device is blissfully unaware of any of this. ## Example flow: short press at night diff --git a/reference/operating-modes.md b/reference/operating-modes.md index 9893aaf..9bde0c8 100644 --- a/reference/operating-modes.md +++ b/reference/operating-modes.md @@ -40,7 +40,7 @@ Does not cover: the Rust implementation details (that's firmware code), the audi Entered on power-on or reset. Responsibilities: 1. Initialize I2S, GPIO, NVS, RGB LED -2. Read persistent state from NVS: `volume`, `was_playing` +2. Read persistent state from NVS: `volume_index`, `volume_direction`, `was_playing` 3. Look up this chip's MAC in `KNOWN_DEVICES` table → logical identity 4. **If `was_playing == true`**: start white noise generator immediately at saved volume (power-blip recovery — don't wake the user with silence) 5. Attempt WiFi connect with 30s timeout against stored credentials @@ -66,8 +66,8 @@ WiFi or MQTT not available. Device keeps working locally. - Button events **not published** (there's nobody listening) - Short press toggles white noise locally -- Long press: see "Button behavior" below -- Double press cycles volume (unchanged from ONLINE) +- Long press cycles volume locally (yo-yo through preset list — see "Button behavior") +- 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) - No commands are received - Background task retries WiFi + MQTT every 60s; on success, transition to ONLINE @@ -79,13 +79,17 @@ Full combined table (pairs with the state machine in `mqtt-contract.md`): | Input | ONLINE | OFFLINE | | --- | --- | --- | -| Short press | Publish `{"event_type":"short"}` | Toggle white noise on/off | -| Double press | Cycle volume preset (local) + publish `{"event_type":"double"}` | Cycle volume preset (local) | -| Long press (≥2s) | Publish `{"event_type":"long"}` | **Fade out white noise over 3s**, then stop | +| Short press | Toggle white noise locally + publish `{"event_type":"short"}` (HA bedtime / morning routine) | Toggle white noise locally | +| Long press (≥2s) | Cycle volume preset locally (yo-yo) + publish `{"event_type":"long"}` | Cycle volume preset locally (yo-yo) | +| Double press | Publish `{"event_type":"double"}` (HA late-night-lights routine — no local effect) | Detected, no-op (no lights to control offline) | -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. +Local effects (toggle, volume cycle) happen identically in both modes — muscle memory doesn't change. The MQTT publish is purely additive when online. -Volume cycle is consistent in both modes so the muscle memory doesn't change. +**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. + +**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. + +**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. ## LED status colors @@ -113,8 +117,9 @@ Stored in ESP32's NVS flash partition. Survives power loss, restarts, even OTA u | Key | Type | Purpose | Written when | | --- | --- | --- | --- | -| `volume` | u8 | Current volume 0–100 | Changed via MQTT cmd or double-press | -| `was_playing` | bool | Whether white noise was playing at last state change | Every play/stop transition | +| `volume_index` | u8 | Index 0..=4 into `VOLUME_PRESETS = [10, 25, 50, 75, 100]` | Long-press cycles the index, or HA sets volume | +| `volume_direction` | u8 | 0 = Up, 1 = Down — the current yo-yo direction | Flipped when index hits an end of the preset list | +| `was_playing` | u8 (0/1) | Whether white noise was playing at last state change | Every play/stop transition | | `wifi_ssid` | string | Known WiFi SSID | Provisioning (first flash or later update) | | `wifi_password` | string | Known WiFi password | Provisioning | @@ -156,7 +161,7 @@ No exponential backoff — device is wall-powered, we don't care about battery l | Error | Behavior | | --- | --- | | I2S driver init fails | Red blink, no audio. Stay in whatever connection mode works. Log loudly. | -| NVS read fails | Use defaults (volume=50, was_playing=false). Log. | +| NVS read fails | Use defaults (volume_index=2 (=50%), volume_direction=Up, was_playing=false). Log. | | NVS write fails | Log, keep running. State won't persist across reboot but that's a graceful degradation. | | WiFi password wrong | Stay OFFLINE forever until updated. No good recovery. | | MQTT broker unreachable | Stay OFFLINE, retry per strategy above. |