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.

button entity now shows idle/short/long/double instead of "Unknown"

Switch the button discovery from `event` to `sensor`. Event entities
are designed for fire-and-forget transient triggers and don't carry a
meaningful "current state", so the device card was always showing
"Unknown" — useless for a glance at what just happened.

The sensor entity holds a stable resting value of "idle". On a
gesture, firmware publishes the event_type briefly (non-retained), and
the network task schedules a retained "idle" republish 800ms later
so the card snaps back to rest. Existing MQTT-trigger automations
(payload `{"event_type":"short"}` etc.) keep working unchanged — same
topic, same payload, just bracketed by idle states.

The retired-entities list now also clears the old `event` discovery
config so HA drops the placeholder entity automatically on first boot
of the new firmware.

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

+64 -11
+7 -3
firmware/src/discovery.rs
··· 40 40 } 41 41 42 42 fn button(device_id: &str, topic_prefix: &str, avail: &str, sw_version: &str) -> DiscoveryEntry { 43 - let topic = format!("homeassistant/event/{device_id}/button/config"); 43 + // Sensor (not event) entity. State values: idle / short / long / double. 44 + // The firmware publishes "idle" retained on connect and again ~800 ms 45 + // after each gesture, so the entity has a stable resting state instead 46 + // of the "Unknown" event entities show. 47 + let topic = format!("homeassistant/sensor/{device_id}/button/config"); 44 48 let payload = format!( 45 49 concat!( 46 50 r#"{{"name":"Button","unique_id":"{device_id}_button","#, 47 51 r#""state_topic":"{topic_prefix}/button","#, 48 - r#""event_types":["short","double","long"],"#, 49 52 r#""value_template":"{{{{ value_json.event_type }}}}","#, 53 + r#""icon":"mdi:gesture-tap-button","#, 50 54 r#""device":{{"identifiers":["{device_id}"],"name":"Nightstand","#, 51 55 r#""manufacturer":"guid.foo","model":"Sound Machine","sw_version":"{sw_version}"}},"#, 52 56 r#""availability_topic":"{avail}"}}"#, ··· 128 132 let entries = all("aabbccddeeff", "0.2.0"); 129 133 assert_eq!(entries.len(), 4); 130 134 let topics: Vec<&str> = entries.iter().map(|e| e.topic.as_str()).collect(); 131 - assert!(topics.contains(&"homeassistant/event/nightstand_aabbccddeeff/button/config")); 135 + assert!(topics.contains(&"homeassistant/sensor/nightstand_aabbccddeeff/button/config")); 132 136 assert!(topics.contains(&"homeassistant/switch/nightstand_aabbccddeeff/white_noise/config")); 133 137 assert!(topics.contains(&"homeassistant/number/nightstand_aabbccddeeff/volume/config")); 134 138 assert!(topics.contains(&"homeassistant/sensor/nightstand_aabbccddeeff/uptime/config"));
+57 -8
firmware/src/network.rs
··· 208 208 let mut last_snapshot: Option<StateSnapshot> = None; 209 209 let mut online = false; 210 210 let boot_at = Instant::now(); 211 + // After publishing a button gesture, set this to "now + reset window" so 212 + // we know to publish a retained "idle" once the deadline lapses. The 213 + // sensor-style button entity in HA needs a stable resting state to show 214 + // instead of the "Unknown" of the old event entity. 215 + let mut button_idle_at: Option<Instant> = None; 211 216 212 217 info!("network task: entering main loop"); 213 218 214 219 loop { 215 - let msg = match msg_rx.recv() { 216 - Some(m) => m, 217 - None => { 218 - warn!("network task: queue receive returned none unexpectedly"); 219 - continue; 220 + // Wait for either a queued message or, if a button reset is pending, 221 + // the moment we owe an idle publish. 222 + let msg = match button_idle_at { 223 + Some(deadline) => { 224 + let wait = deadline.saturating_duration_since(Instant::now()); 225 + msg_rx.recv_timeout(wait) 226 + } 227 + None => msg_rx.recv(), 228 + }; 229 + 230 + let Some(msg) = msg else { 231 + // Timed out -> button idle deadline lapsed. 232 + if online { 233 + publish_button_idle(&mut client, &button_topic); 220 234 } 235 + button_idle_at = None; 236 + continue; 221 237 }; 238 + 222 239 match msg { 223 240 NetTaskMsg::Connected => { 224 241 info!("MQTT connected"); ··· 228 245 &mut client, 229 246 &avail_topic, 230 247 &state_topic, 248 + &button_topic, 231 249 &cmd_filter, 232 250 &mac_hex, 233 251 sw_version, ··· 242 260 } 243 261 NetTaskMsg::Outbound(OutboundEvent::Button(e)) => { 244 262 handle_outbound_button(e, online, &mut client, &button_topic, &audio_tx); 263 + if online { 264 + button_idle_at = 265 + Some(Instant::now() + Duration::from_millis(BUTTON_IDLE_AFTER_MS)); 266 + } 245 267 } 246 268 NetTaskMsg::Outbound(OutboundEvent::State(snap)) => { 247 269 last_snapshot = Some(snap); ··· 252 274 } 253 275 } 254 276 } 277 + 278 + /// How long the button entity holds a gesture state before snapping back 279 + /// to "idle". Long enough for HA automations to fire on the transition, 280 + /// short enough that the device card always feels at-rest. 281 + const BUTTON_IDLE_AFTER_MS: u64 = 800; 255 282 256 283 /// Outbound forwarder: blocking-receive `OutboundEvent` from the rest of the 257 284 /// firmware, re-emit as `NetTaskMsg::Outbound` for the state thread. ··· 385 412 client: &mut EspMqttClient<'_>, 386 413 avail_topic: &str, 387 414 state_topic: &str, 415 + button_topic: &str, 388 416 cmd_filter: &str, 389 417 mac_hex: &str, 390 418 sw_version: &str, ··· 399 427 // retained payload on a discovery topic is the documented "remove this 400 428 // entity" signal. Add to this list whenever we remove an entity so the 401 429 // broker stops carrying its retained config. 402 - let retired = [format!( 403 - "homeassistant/sensor/nightstand_{mac_hex}/rssi/config" 404 - )]; 430 + let retired = [ 431 + // v0.2.0 dropped the diagnostic RSSI sensor. 432 + format!("homeassistant/sensor/nightstand_{mac_hex}/rssi/config"), 433 + // v0.2.1 changed the button from `event` (no resting state) to 434 + // `sensor` (idle/short/long/double). 435 + format!("homeassistant/event/nightstand_{mac_hex}/button/config"), 436 + ]; 405 437 for topic in &retired { 406 438 if let Err(e) = client.publish(topic, QoS::AtLeastOnce, true, b"") { 407 439 warn!("publish retired-entity removal {topic} failed: {e}"); ··· 419 451 } 420 452 } 421 453 454 + // Seed the button sensor with a retained "idle" so reconnecting clients 455 + // (and HA on first discovery) see a stable resting state. 456 + publish_button_idle(client, button_topic); 457 + 422 458 if let Some(snap) = last_snapshot { 423 459 publish_state(client, state_topic, snap, boot_at); 424 460 } 425 461 426 462 if let Err(e) = client.subscribe(cmd_filter, QoS::AtLeastOnce) { 427 463 warn!("subscribe {cmd_filter} failed: {e}"); 464 + } 465 + } 466 + 467 + /// Publish a retained "idle" state to the button topic. Called on connect 468 + /// and after the BUTTON_IDLE_AFTER_MS window following any gesture. 469 + fn publish_button_idle(client: &mut EspMqttClient<'_>, button_topic: &str) { 470 + if let Err(e) = client.publish( 471 + button_topic, 472 + QoS::AtLeastOnce, 473 + true, 474 + br#"{"event_type":"idle"}"#, 475 + ) { 476 + warn!("publish button idle failed: {e}"); 428 477 } 429 478 } 430 479