feat(adr-118/p5.6): HA auto-discovery payload publisher (187/187 GREEN)
Iter 26. Lands ADR-122 §2.1 HA-DISCO config-message generator.
Counterpart to iter 21's state-topic router: this produces the
homeassistant/<type>/<unique_id>/config messages HA reads on
startup to auto-create the six BFLD entities as a single device.
Discovery payloads are intended to be published once per node
session with retain = true (so HA finds them on subsequent starts).
The RumqttPublisher from iter 23 already exposes with_retain(true)
for this purpose; the state-topic loop must keep retain = false to
avoid stale-state flapping.
Added (gated on `feature = "std"`):
- src/ha_discovery.rs:
* render_discovery_payloads(node_id, class) -> Vec<TopicMessage>
class < Anonymous: empty vec (HA doesn't see raw/derived)
class == Anonymous: 6 entities incl. identity_risk
class == Restricted: 5 entities, no identity_risk
* Per-entity HA metadata:
presence binary_sensor, device_class: occupancy
motion sensor, entity_category: diagnostic
person_count sensor, unit_of_measurement: people
zone_activity sensor, entity_category: diagnostic
confidence sensor, entity_category: diagnostic
identity_risk sensor, entity_category: diagnostic
* Each payload carries:
name, unique_id, state_topic (pointing at the iter-21 path),
device block with identifiers / model: "BFLD" / manufacturer: "RuView"
* Manual JSON builder with minimal escape coverage — node_id is
ASCII alphanumeric + dash by convention; full escape via
serde_json is a follow-up if operator-controlled names ever land.
- pub use render_discovery_payloads from lib.rs
tests/ha_discovery.rs (10 named tests, all green):
raw_and_derived_classes_produce_no_discovery_payloads
anonymous_class_produces_six_discovery_payloads
restricted_class_omits_identity_risk_discovery
discovery_topic_format_matches_ha_convention
(validates all six homeassistant/.../config topics exist)
presence_payload_carries_occupancy_device_class
motion_payload_marked_as_diagnostic
person_count_payload_carries_unit_of_measurement
every_payload_contains_unique_id_and_state_topic_pointing_at_correct_state_topic
(the state_topic in the discovery payload must match the topic the
state-topic router from iter 21 actually publishes on — closes
the discovery↔state loop)
unique_id_matches_topic_segment
(the unique_id baked into the payload equals the topic segment so
HA dedupe works correctly across reboot/restart)
class_2_discovery_includes_identity_risk_explicitly
ACs progressed:
- ADR-122 §2.1 — HA auto-discovery surface now complete: an operator
can start mosquitto, publish-retained discovery once, and HA spins
up the entire BFLD device on next start with zero YAML config.
- ADR-122 AC1 (six entities per node) — discovery + state-topic
publishers are now symmetric: render_discovery_payloads emits the
same six entity definitions render_events emits state messages for.
- ADR-118 §1.5 — privacy_mode = Restricted strips identity_risk at
BOTH the discovery layer (entity not advertised to HA) AND the
state layer (no state messages). Two-layer defense.
Test config:
- cargo test --no-default-features → 72 passed (ha_discovery cfg-out)
- cargo test → 187 passed (177 + 10)
Out of scope (next iter target):
- HA discovery + state publish coordinator: a small function or
BfldPipelineHandle::publish_discovery(&mut self, retained: bool)
that calls render_discovery_payloads + publish_event(retained=true)
once at startup, then enters the per-frame loop.
- GitHub Actions workflow with mosquitto Docker service so the
iter-24 integration test runs in CI with BFLD_MQTT_BROKER set.
Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
4557f6f614
commit
05609ef51c
|
|
@ -0,0 +1,160 @@
|
|||
//! 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::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<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 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('"');
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
||||
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/<node>/bfld/<entity>/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/<type>/<unique_id>/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\""));
|
||||
}
|
||||
Loading…
Reference in New Issue