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:
ruv
2026-05-24 17:37:26 -04:00
parent 4557f6f614
commit 05609ef51c
3 changed files with 293 additions and 0 deletions
@@ -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('"');
}
+4
View File
@@ -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;