diff --git a/v2/crates/wifi-densepose-bfld/src/ha_discovery.rs b/v2/crates/wifi-densepose-bfld/src/ha_discovery.rs new file mode 100644 index 00000000..1f4881d8 --- /dev/null +++ b/v2/crates/wifi-densepose-bfld/src/ha_discovery.rs @@ -0,0 +1,160 @@ +//! Home Assistant MQTT auto-discovery payload publisher. ADR-122 §2.1. +//! +//! Generates the JSON config messages HA expects on +//! `homeassistant///config` to auto-create the six BFLD +//! entities. Class-gated identically to the state-topic router +//! (`mqtt_topics.rs`): `identity_risk` discovery is only published at exactly +//! `PrivacyClass::Anonymous`. +//! +//! Discovery payloads should be published **once per node session**, retained +//! by the broker (`retain = true`) so HA finds them on next start. The +//! `RumqttPublisher` exposes a `with_retain(true)` builder for this; the +//! state-topic loop must keep `retain = false` to avoid stale-state flapping. + +#![cfg(feature = "std")] + +use crate::mqtt_topics::TopicMessage; +use crate::PrivacyClass; + +/// Render every HA-DISCO config message for the given node at `class`. Returns +/// an empty `Vec` for classes < `Anonymous` (HA doesn't see raw / derived). +#[must_use] +pub fn render_discovery_payloads(node_id: &str, class: PrivacyClass) -> Vec { + if class.as_u8() < PrivacyClass::Anonymous.as_u8() { + return Vec::new(); + } + + let mut out = Vec::with_capacity(6); + + out.push(config_message( + "binary_sensor", + node_id, + "presence", + "BFLD Presence", + Some("occupancy"), + None, + None, + )); + out.push(config_message( + "sensor", + node_id, + "motion", + "BFLD Motion", + None, + None, + Some("diagnostic"), + )); + out.push(config_message( + "sensor", + node_id, + "person_count", + "BFLD Person Count", + None, + Some("people"), + None, + )); + out.push(config_message( + "sensor", + node_id, + "zone_activity", + "BFLD Zone Activity", + None, + None, + Some("diagnostic"), + )); + out.push(config_message( + "sensor", + node_id, + "confidence", + "BFLD Confidence", + None, + None, + Some("diagnostic"), + )); + + // identity_risk discovery only at class 2. Class 3 computes but doesn't + // publish — therefore HA should not even see the entity exist. + if class == PrivacyClass::Anonymous { + out.push(config_message( + "sensor", + node_id, + "identity_risk", + "BFLD Identity Risk", + None, + None, + Some("diagnostic"), + )); + } + + out +} + +fn config_message( + ha_type: &str, + node_id: &str, + entity: &str, + name: &str, + device_class: Option<&str>, + unit_of_measurement: Option<&str>, + entity_category: Option<&str>, +) -> TopicMessage { + let unique_id = format!("{node_id}_bfld_{entity}"); + let topic = format!("homeassistant/{ha_type}/{unique_id}/config"); + let state_topic = format!("ruview/{node_id}/bfld/{entity}/state"); + + let mut payload = String::with_capacity(256); + payload.push('{'); + push_str_field(&mut payload, "name", name, true); + push_str_field(&mut payload, "unique_id", &unique_id, false); + push_str_field(&mut payload, "state_topic", &state_topic, false); + if let Some(dc) = device_class { + push_str_field(&mut payload, "device_class", dc, false); + } + if let Some(unit) = unit_of_measurement { + push_str_field(&mut payload, "unit_of_measurement", unit, false); + } + if let Some(cat) = entity_category { + push_str_field(&mut payload, "entity_category", cat, false); + } + payload.push_str(",\"device\":{"); + push_str_field(&mut payload, "identifiers", node_id, true); + push_str_field( + &mut payload, + "name", + &format!("RuView Seed {node_id}"), + false, + ); + push_str_field(&mut payload, "model", "BFLD", false); + push_str_field(&mut payload, "manufacturer", "RuView", false); + payload.push('}'); + payload.push('}'); + + TopicMessage { topic, payload } +} + +fn push_str_field(out: &mut String, key: &str, value: &str, first: bool) { + if !first { + out.push(','); + } + out.push('"'); + out.push_str(key); + out.push_str("\":\""); + // Minimal JSON escaping for the values BFLD controls — node_id is ASCII + // alphanumeric + dash by convention, names are operator-controlled. A + // future iter can swap to serde_json::to_string for full escape coverage. + for ch in value.chars() { + match ch { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => { + let escape = format!("\\u{:04x}", c as u32); + out.push_str(&escape); + } + c => out.push(c), + } + } + out.push('"'); +} diff --git a/v2/crates/wifi-densepose-bfld/src/lib.rs b/v2/crates/wifi-densepose-bfld/src/lib.rs index 4a593cd1..ba1d9cc8 100644 --- a/v2/crates/wifi-densepose-bfld/src/lib.rs +++ b/v2/crates/wifi-densepose-bfld/src/lib.rs @@ -22,6 +22,8 @@ pub mod emitter; pub mod event; pub mod frame; #[cfg(feature = "std")] +pub mod ha_discovery; +#[cfg(feature = "std")] pub mod mqtt_topics; #[cfg(feature = "std")] pub mod identity_features; @@ -45,6 +47,8 @@ pub use emitter::{BfldEmitter, SensingInputs}; #[cfg(feature = "std")] pub use event::BfldEvent; #[cfg(feature = "std")] +pub use ha_discovery::render_discovery_payloads; +#[cfg(feature = "std")] pub use mqtt_topics::{publish_event, render_events, CapturePublisher, Publish, TopicMessage}; #[cfg(feature = "mqtt")] pub use rumqttc_publisher::RumqttPublisher; diff --git a/v2/crates/wifi-densepose-bfld/tests/ha_discovery.rs b/v2/crates/wifi-densepose-bfld/tests/ha_discovery.rs new file mode 100644 index 00000000..9563757f --- /dev/null +++ b/v2/crates/wifi-densepose-bfld/tests/ha_discovery.rs @@ -0,0 +1,129 @@ +//! Acceptance tests for ADR-122 §2.1 — HA auto-discovery payloads. + +#![cfg(feature = "std")] + +use wifi_densepose_bfld::{render_discovery_payloads, PrivacyClass}; + +fn topics(class: PrivacyClass) -> Vec { + render_discovery_payloads("seed-01", class) + .into_iter() + .map(|m| m.topic) + .collect() +} + +#[test] +fn raw_and_derived_classes_produce_no_discovery_payloads() { + for class in [PrivacyClass::Raw, PrivacyClass::Derived] { + assert!( + render_discovery_payloads("seed-01", class).is_empty(), + "class {class:?} must not emit HA discovery", + ); + } +} + +#[test] +fn anonymous_class_produces_six_discovery_payloads() { + let ts = topics(PrivacyClass::Anonymous); + assert_eq!(ts.len(), 6); +} + +#[test] +fn restricted_class_omits_identity_risk_discovery() { + let ts = topics(PrivacyClass::Restricted); + assert_eq!(ts.len(), 5, "Restricted: 5 entities, no identity_risk"); + assert!( + !ts.iter().any(|t| t.contains("identity_risk")), + "Restricted must not advertise identity_risk entity to HA", + ); +} + +#[test] +fn discovery_topic_format_matches_ha_convention() { + let ts = topics(PrivacyClass::Anonymous); + assert!(ts.contains(&"homeassistant/binary_sensor/seed-01_bfld_presence/config".into())); + assert!(ts.contains(&"homeassistant/sensor/seed-01_bfld_motion/config".into())); + assert!(ts.contains(&"homeassistant/sensor/seed-01_bfld_person_count/config".into())); + assert!(ts.contains(&"homeassistant/sensor/seed-01_bfld_zone_activity/config".into())); + assert!(ts.contains(&"homeassistant/sensor/seed-01_bfld_confidence/config".into())); + assert!(ts.contains(&"homeassistant/sensor/seed-01_bfld_identity_risk/config".into())); +} + +#[test] +fn presence_payload_carries_occupancy_device_class() { + let msgs = render_discovery_payloads("seed-01", PrivacyClass::Anonymous); + let pres = msgs + .iter() + .find(|m| m.topic.contains("presence")) + .expect("presence config"); + assert!(pres.payload.contains("\"device_class\":\"occupancy\"")); +} + +#[test] +fn motion_payload_marked_as_diagnostic() { + let msgs = render_discovery_payloads("seed-01", PrivacyClass::Anonymous); + let motion = msgs + .iter() + .find(|m| m.topic.contains("motion")) + .expect("motion config"); + assert!(motion.payload.contains("\"entity_category\":\"diagnostic\"")); +} + +#[test] +fn person_count_payload_carries_unit_of_measurement() { + let msgs = render_discovery_payloads("seed-01", PrivacyClass::Anonymous); + let pc = msgs + .iter() + .find(|m| m.topic.contains("person_count")) + .expect("person_count config"); + assert!(pc.payload.contains("\"unit_of_measurement\":\"people\"")); +} + +#[test] +fn every_payload_contains_unique_id_and_state_topic_pointing_at_correct_state_topic() { + let msgs = render_discovery_payloads("seed-99", PrivacyClass::Anonymous); + for msg in &msgs { + // unique_id is required for HA to dedupe entity creation. + assert!( + msg.payload.contains("\"unique_id\":\""), + "missing unique_id in {msg:?}", + ); + // state_topic must point back at the BFLD `ruview//bfld//state` path. + assert!( + msg.payload.contains("\"state_topic\":\"ruview/seed-99/bfld/"), + "state_topic wrong in {msg:?}", + ); + // Device block ties all six entities to one HA device. + assert!(msg.payload.contains("\"device\":{")); + assert!(msg.payload.contains("\"identifiers\":\"seed-99\"")); + assert!(msg.payload.contains("\"manufacturer\":\"RuView\"")); + } +} + +#[test] +fn unique_id_matches_topic_segment() { + let msgs = render_discovery_payloads("seed-01", PrivacyClass::Anonymous); + for msg in &msgs { + // topic is homeassistant///config — the unique_id segment + // must appear in the payload too. + let parts: Vec<&str> = msg.topic.split('/').collect(); + assert_eq!(parts.len(), 4, "topic shape wrong: {}", msg.topic); + assert_eq!(parts[0], "homeassistant"); + assert_eq!(parts[3], "config"); + let unique_id_from_topic = parts[2]; + let needle = format!("\"unique_id\":\"{unique_id_from_topic}\""); + assert!( + msg.payload.contains(&needle), + "unique_id mismatch between topic and payload: {msg:?}", + ); + } +} + +#[test] +fn class_2_discovery_includes_identity_risk_explicitly() { + let msgs = render_discovery_payloads("seed-01", PrivacyClass::Anonymous); + let risk = msgs + .iter() + .find(|m| m.topic.contains("identity_risk")) + .expect("identity_risk config must be present at class 2"); + assert!(risk.payload.contains("\"entity_category\":\"diagnostic\"")); +}