215 lines
6.7 KiB
Rust
215 lines
6.7 KiB
Rust
//! Home Assistant MQTT auto-discovery payload publisher. ADR-122 §2.1.
|
|
//!
|
|
//! Generates the JSON config messages HA expects on
|
|
//! `homeassistant/<type>/<unique_id>/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::{Publish, TopicMessage};
|
|
use crate::PrivacyClass;
|
|
|
|
/// Bootstrap helper: render the per-node HA-DISCO config payloads and forward
|
|
/// each through `publisher`. Returns the count published, or short-circuits
|
|
/// on the first publisher error.
|
|
///
|
|
/// Typical bootstrap pattern combining iter 25's `Arc<Mutex<P>>` adapter and
|
|
/// iter 23's retain-aware `RumqttPublisher`:
|
|
///
|
|
/// ```ignore
|
|
/// use std::sync::{Arc, Mutex};
|
|
/// use wifi_densepose_bfld::{
|
|
/// publish_discovery, BfldConfig, BfldPipeline, BfldPipelineHandle,
|
|
/// PrivacyClass, RumqttPublisher,
|
|
/// };
|
|
/// use rumqttc::MqttOptions;
|
|
///
|
|
/// let opts = MqttOptions::new("seed-01", "broker.local", 1883);
|
|
/// let (retained_pub, _conn) = RumqttPublisher::connect(opts.clone(), 64);
|
|
/// let mut retained_pub = retained_pub.with_retain(true);
|
|
/// publish_discovery(&mut retained_pub, "seed-01", PrivacyClass::Anonymous)?;
|
|
///
|
|
/// let (state_pub, _conn) = RumqttPublisher::connect(opts, 64);
|
|
/// let pipeline = BfldPipeline::new(BfldConfig::new("seed-01"));
|
|
/// let handle = BfldPipelineHandle::spawn(pipeline, state_pub);
|
|
/// // handle.send(...) from now on
|
|
/// # Ok::<(), rumqttc::ClientError>(())
|
|
/// ```
|
|
pub fn publish_discovery<P: Publish>(
|
|
publisher: &mut P,
|
|
node_id: &str,
|
|
class: PrivacyClass,
|
|
) -> Result<usize, P::Error> {
|
|
let mut count = 0;
|
|
for msg in render_discovery_payloads(node_id, class) {
|
|
publisher.publish(&msg)?;
|
|
count += 1;
|
|
}
|
|
Ok(count)
|
|
}
|
|
|
|
/// 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<TopicMessage> {
|
|
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 availability_topic_str = crate::availability::availability_topic(node_id);
|
|
|
|
let mut payload = String::with_capacity(384);
|
|
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);
|
|
// Availability — every entity inherits the device-level offline marker.
|
|
push_str_field(&mut payload, "availability_topic", &availability_topic_str, false);
|
|
push_str_field(
|
|
&mut payload,
|
|
"payload_available",
|
|
crate::availability::PAYLOAD_AVAILABLE,
|
|
false,
|
|
);
|
|
push_str_field(
|
|
&mut payload,
|
|
"payload_not_available",
|
|
crate::availability::PAYLOAD_NOT_AVAILABLE,
|
|
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('"');
|
|
}
|