//! Home Assistant MQTT Discovery payloads. //! //! On every MQTT (re)connect, the device republishes one retained discovery //! config per entity. HA dedupes by retained-payload equality, so this is //! cheap and idempotent — it covers HA restarts, broker retained-message //! losses, and first-time provisioning equally. //! //! Topic identity uses the lowercase 12-char MAC hex string (e.g. //! `nightstand_aabbccddeeff_button`), which is stable across firmware //! versions and lets HA name the device however the user wants in the UI //! while keeping `unique_id` invariant. //! //! Payloads are hand-built with `format!` — they're small, infrequent, and //! avoiding `serde_json` saves ~30 KB of binary on the Xtensa target. extern crate alloc; use alloc::string::String; use alloc::vec::Vec; pub struct DiscoveryEntry { pub topic: String, pub payload: String, } /// Topic that carries the latest available firmware version. Same for every /// device on this firmware; the publisher pushes one retained message and /// every nightstand sees it. Per-device `installed_version` lives under /// `nightstand//update/installed`. pub const SHARED_LATEST_VERSION_TOPIC: &str = "sound-machine/firmware/latest"; /// Build the discovery entries (button, switch, number, uptime sensor, update) /// for the given device. `mac_hex` is lowercase hex with no separators. pub fn all(mac_hex: &str, sw_version: &str) -> Vec { let device_id = format!("nightstand_{mac_hex}"); let topic_prefix = format!("nightstand/{mac_hex}"); let avail_topic = format!("{topic_prefix}/available"); let state_topic = format!("{topic_prefix}/state"); vec![ button(&device_id, &topic_prefix, &avail_topic, sw_version), switch(&device_id, &topic_prefix, &avail_topic, &state_topic), number(&device_id, &topic_prefix, &avail_topic, &state_topic), uptime(&device_id, &avail_topic, &state_topic), update(&device_id, &topic_prefix, &avail_topic), ] } fn button(device_id: &str, topic_prefix: &str, avail: &str, sw_version: &str) -> DiscoveryEntry { // Sensor (not event) entity. State values: idle / short / long / double. // The firmware publishes "idle" retained on connect and again ~800 ms // after each gesture, so the entity has a stable resting state instead // of the "Unknown" event entities show. let topic = format!("homeassistant/sensor/{device_id}/button/config"); let payload = format!( concat!( r#"{{"name":"Button","unique_id":"{device_id}_button","#, r#""state_topic":"{topic_prefix}/button","#, r#""value_template":"{{{{ value_json.event_type }}}}","#, r#""icon":"mdi:gesture-tap-button","#, r#""device":{{"identifiers":["{device_id}"],"name":"Nightstand","#, r#""manufacturer":"guid.foo","model":"Sound Machine","sw_version":"{sw_version}"}},"#, r#""availability_topic":"{avail}"}}"#, ), device_id = device_id, topic_prefix = topic_prefix, avail = avail, sw_version = sw_version, ); DiscoveryEntry { topic, payload } } fn switch(device_id: &str, topic_prefix: &str, avail: &str, state_topic: &str) -> DiscoveryEntry { let topic = format!("homeassistant/switch/{device_id}/white_noise/config"); let payload = format!( concat!( r#"{{"name":"White Noise","unique_id":"{device_id}_white_noise","#, r#""state_topic":"{state_topic}","#, r#""value_template":"{{{{ value_json.playing }}}}","#, r#""command_topic":"{topic_prefix}/cmd/play","#, r#""payload_on":"ON","payload_off":"OFF","#, r#""state_on":"ON","state_off":"OFF","#, r#""device":{{"identifiers":["{device_id}"]}},"#, r#""availability_topic":"{avail}"}}"#, ), device_id = device_id, topic_prefix = topic_prefix, state_topic = state_topic, avail = avail, ); DiscoveryEntry { topic, payload } } fn number(device_id: &str, topic_prefix: &str, avail: &str, state_topic: &str) -> DiscoveryEntry { let topic = format!("homeassistant/number/{device_id}/volume/config"); let payload = format!( concat!( r#"{{"name":"Volume","unique_id":"{device_id}_volume","#, r#""state_topic":"{state_topic}","#, r#""value_template":"{{{{ value_json.volume }}}}","#, r#""command_topic":"{topic_prefix}/cmd/volume","#, r#""min":0,"max":100,"step":1,"mode":"slider","#, r#""device":{{"identifiers":["{device_id}"]}},"#, r#""availability_topic":"{avail}"}}"#, ), device_id = device_id, topic_prefix = topic_prefix, state_topic = state_topic, avail = avail, ); DiscoveryEntry { topic, payload } } fn uptime(device_id: &str, avail: &str, state_topic: &str) -> DiscoveryEntry { let topic = format!("homeassistant/sensor/{device_id}/uptime/config"); let payload = format!( concat!( r#"{{"name":"Uptime","unique_id":"{device_id}_uptime","#, r#""state_topic":"{state_topic}","#, r#""value_template":"{{{{ value_json.uptime_s }}}}","#, r#""unit_of_measurement":"s","device_class":"duration","#, r#""entity_category":"diagnostic","#, r#""device":{{"identifiers":["{device_id}"]}},"#, r#""availability_topic":"{avail}"}}"#, ), device_id = device_id, state_topic = state_topic, avail = avail, ); DiscoveryEntry { topic, payload } } /// HA `update` entity. The state_topic carries a single JSON payload with /// `installed_version`, `in_progress`, and (during a download) the /// `update_percentage` — that gives HA enough to render a progress bar /// while OTA runs. The latest_version comes from the shared topic so a /// single `make ota-publish` lights up the card on every nightstand at /// once. `cmd/update` receives `install` when the user clicks Install /// (`cmd/+` is already subscribed for play/volume). fn update(device_id: &str, topic_prefix: &str, avail: &str) -> DiscoveryEntry { let topic = format!("homeassistant/update/{device_id}/firmware/config"); let payload = format!( concat!( r#"{{"name":"Firmware","unique_id":"{device_id}_update","#, r#""state_topic":"{topic_prefix}/update/state","#, r#""latest_version_topic":"{shared}","#, r#""latest_version_template":"{{{{ value }}}}","#, r#""command_topic":"{topic_prefix}/cmd/update","#, r#""payload_install":"install","#, r#""device_class":"firmware","entity_category":"config","#, r#""device":{{"identifiers":["{device_id}"]}},"#, r#""availability_topic":"{avail}"}}"#, ), device_id = device_id, topic_prefix = topic_prefix, shared = SHARED_LATEST_VERSION_TOPIC, avail = avail, ); DiscoveryEntry { topic, payload } } #[cfg(test)] mod tests { use super::*; #[test] fn five_entries_with_correct_topics() { let entries = all("aabbccddeeff", "0.2.0"); assert_eq!(entries.len(), 5); let topics: Vec<&str> = entries.iter().map(|e| e.topic.as_str()).collect(); assert!(topics.contains(&"homeassistant/sensor/nightstand_aabbccddeeff/button/config")); assert!(topics.contains(&"homeassistant/switch/nightstand_aabbccddeeff/white_noise/config")); assert!(topics.contains(&"homeassistant/number/nightstand_aabbccddeeff/volume/config")); assert!(topics.contains(&"homeassistant/sensor/nightstand_aabbccddeeff/uptime/config")); assert!(topics.contains(&"homeassistant/update/nightstand_aabbccddeeff/firmware/config")); } #[test] fn update_entry_uses_shared_latest_and_json_state() { let entries = all("aabbccddeeff", "0.3.0"); let update = entries .iter() .find(|e| e.topic.contains("/update/")) .expect("update entry"); assert!( update.payload.contains(SHARED_LATEST_VERSION_TOPIC), "{}", update.payload ); assert!( update .payload .contains(r#""command_topic":"nightstand/aabbccddeeff/cmd/update""#), "{}", update.payload ); assert!( update .payload .contains(r#""state_topic":"nightstand/aabbccddeeff/update/state""#), "{}", update.payload ); } #[test] fn payloads_embed_mac_in_unique_id_and_topics() { let entries = all("aabbccddeeff", "0.2.0"); for entry in &entries { assert!( entry.payload.contains("nightstand_aabbccddeeff"), "payload missing device_id: {}", entry.payload ); } } #[test] fn switch_command_topic_uses_mac() { let entries = all("0123456789ab", "0.2.0"); let switch = entries .iter() .find(|e| e.topic.contains("white_noise")) .expect("switch entry"); assert!( switch .payload .contains(r#""command_topic":"nightstand/0123456789ab/cmd/play""#), "{}", switch.payload ); } #[test] fn payloads_have_no_stray_backslashes() { let entries = all("aabbccddeeff", "0.2.0"); for entry in &entries { assert!( !entry.payload.contains('\\'), "payload contains backslash: {}", entry.payload ); } } }