A nightstand noise generator based on M5Stack Atom Echo and integrating with Home Assistant
1//! Sound Machine — online-mode firmware v0.2.0.
2//!
3//! Five tasks plus a coordinator in `main`:
4//! - **audio** owns I2S, generates white noise, applies volume cycles,
5//! emits state snapshots to the network task on every change
6//! - **button** polls G39 and emits short / long / double events
7//! - **led** drives the SK6812 RGB LED, composing color from audio + net axes
8//! - **network** owns WiFi + MQTT; gatekeeps online/offline routing for
9//! button events, publishes state, subscribes to inbound commands
10//! - **coordinator** (this main loop) translates button events into audio
11//! commands (offline-only for short) and outbound events (always)
12//!
13//! All cross-task wiring is via FreeRTOS queue–backed channels (see
14//! `channels.rs`) carrying typed
15//! events / commands / signals. The network task is the gatekeeper: it
16//! decides whether short presses toggle audio locally (offline) or wait for
17//! HA to publish back via MQTT (online); long-press cycles volume locally in
18//! both modes; double-press is purely an MQTT gesture.
19//!
20//! See `firmware/README.md` and `reference/operating-modes.md` for the
21//! design and the gotchas.
22
23mod audio;
24mod button;
25mod channels;
26mod discovery;
27mod events;
28mod led;
29mod network;
30mod nvs;
31mod ota;
32mod secrets;
33mod state;
34
35use anyhow::Result;
36use esp_idf_svc::eventloop::EspSystemEventLoop;
37use esp_idf_svc::hal::gpio::PinDriver;
38use esp_idf_svc::hal::peripherals::Peripherals;
39use esp_idf_svc::nvs::{EspNvsPartition, NvsDefault};
40use events::{AudioCommand, ButtonEvent, LedSignal, NetStatus, OutboundEvent};
41use log::info;
42use nvs::NvsStore;
43use channels::channel;
44
45const FIRMWARE_VERSION: &str = env!("CARGO_PKG_VERSION");
46
47fn main() -> Result<()> {
48 esp_idf_svc::sys::link_patches();
49 esp_idf_svc::log::EspLogger::initialize_default();
50
51 info!("sound-machine v{FIRMWARE_VERSION} boot");
52
53 // Take the NVS partition once and clone it for both the audio NvsStore
54 // and the WiFi stack (which uses NVS internally for STA cred caching).
55 let nvs_partition = EspNvsPartition::<NvsDefault>::take()?;
56 let nvs = NvsStore::open_with_partition(nvs_partition.clone())?;
57 let initial_state = nvs.read();
58
59 // Peripherals split out, owned by the relevant tasks.
60 let peripherals = Peripherals::take()?;
61 let pins = peripherals.pins;
62 let sys_loop = EspSystemEventLoop::take()?;
63
64 // Channels.
65 let (button_tx, button_rx) = channel::<ButtonEvent>(8);
66 let (audio_tx, audio_rx) = channel::<AudioCommand>(16);
67 let (led_tx, led_rx) = channel::<LedSignal>(32);
68 let (out_tx, out_rx) = channel::<OutboundEvent>(16);
69
70 // Tell the LED to pulse cyan while we set things up. The network task
71 // will refresh this once it knows whether we landed online or offline.
72 let _ = led_tx.send(LedSignal::Net(NetStatus::Connecting));
73
74 let i2s = audio::make_i2s(
75 peripherals.i2s0,
76 pins.gpio26, // BCLK
77 pins.gpio21, // DOUT
78 pins.gpio32, // WS / LRCK
79 )?;
80 let _audio_handle = audio::spawn(
81 i2s,
82 nvs,
83 initial_state,
84 audio_rx,
85 led_tx.clone(),
86 out_tx.clone(),
87 )?;
88
89 // LED task on G27 via RMT channel 0.
90 let _led_handle = led::spawn(peripherals.rmt.channel0, pins.gpio27, led_rx)?;
91
92 // Button task on G39 (input-only; on-board pull-up; active-low).
93 let button = PinDriver::input(pins.gpio39)?;
94 let _button_handle = button::spawn(button, button_tx)?;
95
96 // Network task: WiFi + MQTT, gatekeeper for online/offline routing.
97 // Reads MAC and logs the topic prefix early, even if WiFi is unreachable.
98 let _network_handle = network::spawn(
99 peripherals.modem,
100 sys_loop,
101 nvs_partition,
102 FIRMWARE_VERSION,
103 audio_tx.clone(),
104 led_tx.clone(),
105 out_rx,
106 )?;
107
108 info!("all tasks spawned; entering coordinator loop");
109
110 // If the device was playing when power was cut, kick the audio task to
111 // resume immediately. The audio task starts in its persisted-state's
112 // `playing` value, so this is technically belt-and-braces — but it also
113 // ensures the LED reflects "Playing" promptly.
114 if initial_state.was_playing {
115 let _ = audio_tx.send(AudioCommand::Play);
116 }
117
118 // Coordinator loop. Receives button events; sends every event to the
119 // network task (which gatekeeps publish-vs-local-fallback by online
120 // state) and, for long-press, also fires the local volume-cycle.
121 loop {
122 let Some(event) = button_rx.recv() else {
123 anyhow::bail!("button channel closed");
124 };
125
126 // Every press gets a brief LED flash for tactile feedback.
127 let _ = led_tx.send(LedSignal::PressFlash);
128
129 match event {
130 ButtonEvent::Short => {
131 // Online: network task will publish; HA echoes back via cmd/play.
132 // Offline: network task will translate to AudioCommand::Toggle.
133 info!("coordinator: short → OutboundEvent (network gatekeeps)");
134 let _ = out_tx.send(OutboundEvent::Button(ButtonEvent::Short));
135 }
136 ButtonEvent::Long => {
137 // Volume cycles locally in both modes; network task publishes
138 // the event for HA logging when online.
139 info!("coordinator: long → AudioCommand::CycleVolume + OutboundEvent");
140 let _ = audio_tx.send(AudioCommand::CycleVolume);
141 let _ = out_tx.send(OutboundEvent::Button(ButtonEvent::Long));
142 }
143 ButtonEvent::Double => {
144 // Pure-MQTT gesture; network task publishes if online, no-op offline.
145 info!("coordinator: double → OutboundEvent");
146 let _ = out_tx.send(OutboundEvent::Button(ButtonEvent::Double));
147 }
148 }
149 }
150}