diff --git a/v2/crates/wifi-densepose-bfld/src/availability.rs b/v2/crates/wifi-densepose-bfld/src/availability.rs new file mode 100644 index 00000000..933a3e0b --- /dev/null +++ b/v2/crates/wifi-densepose-bfld/src/availability.rs @@ -0,0 +1,79 @@ +//! `ruview//bfld/availability` topic helpers. ADR-122 §2.2. +//! +//! HA expects each device to publish an availability topic so the UI can grey +//! out entities when the device is offline. Convention: +//! +//! - Publish `"online"` with `retain = true` immediately after broker CONNECT. +//! - Configure the MQTT client's Last Will and Testament (LWT) to publish +//! `"offline"` (also retained) so the broker auto-marks the device offline +//! when the TCP session drops without a clean DISCONNECT. +//! +//! HA discovery payloads (iter 26) reference this same topic via the +//! `availability_topic` field so every BFLD entity inherits the marker. + +#![cfg(feature = "std")] + +use crate::mqtt_topics::{Publish, TopicMessage}; + +/// Payload string published when the node is healthy. +pub const PAYLOAD_AVAILABLE: &str = "online"; + +/// Payload string published when the node has disconnected. +pub const PAYLOAD_NOT_AVAILABLE: &str = "offline"; + +/// Build the canonical `ruview//bfld/availability` topic string. +#[must_use] +pub fn availability_topic(node_id: &str) -> String { + let mut s = String::with_capacity(7 + node_id.len() + 19); + s.push_str("ruview/"); + s.push_str(node_id); + s.push_str("/bfld/availability"); + s +} + +/// Build the `(topic, "online")` pair to publish on broker connect. +#[must_use] +pub fn online_message(node_id: &str) -> TopicMessage { + TopicMessage { + topic: availability_topic(node_id), + payload: PAYLOAD_AVAILABLE.to_string(), + } +} + +/// Build the `(topic, "offline")` pair — usually configured as the broker LWT +/// rather than published explicitly, but provided here for explicit-shutdown +/// scenarios (graceful stop, planned maintenance) where the operator wants +/// HA to update immediately rather than waiting for the LWT keep-alive timeout. +#[must_use] +pub fn offline_message(node_id: &str) -> TopicMessage { + TopicMessage { + topic: availability_topic(node_id), + payload: PAYLOAD_NOT_AVAILABLE.to_string(), + } +} + +/// Bootstrap helper: publish the `"online"` availability marker through +/// `publisher`. Pairs with `publish_discovery` (iter 27) and `publish_event` +/// (iter 22) for the full startup sequence: +/// +/// ```ignore +/// publish_availability_online(&mut retained_pub, "seed-01")?; // "online", retained +/// publish_discovery(&mut retained_pub, "seed-01", PrivacyClass::Anonymous)?; +/// // ... then BfldPipelineHandle::spawn(pipeline, state_pub) for the per-frame loop +/// ``` +pub fn publish_availability_online( + publisher: &mut P, + node_id: &str, +) -> Result<(), P::Error> { + publisher.publish(&online_message(node_id)) +} + +/// Bootstrap helper: publish the `"offline"` availability marker through +/// `publisher`. Use during a graceful shutdown so HA reflects the state +/// immediately instead of waiting for the broker LWT timeout. +pub fn publish_availability_offline( + publisher: &mut P, + node_id: &str, +) -> Result<(), P::Error> { + publisher.publish(&offline_message(node_id)) +} diff --git a/v2/crates/wifi-densepose-bfld/src/ha_discovery.rs b/v2/crates/wifi-densepose-bfld/src/ha_discovery.rs index 7270251d..6dcdf10e 100644 --- a/v2/crates/wifi-densepose-bfld/src/ha_discovery.rs +++ b/v2/crates/wifi-densepose-bfld/src/ha_discovery.rs @@ -140,12 +140,27 @@ fn config_message( 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(256); + 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); } diff --git a/v2/crates/wifi-densepose-bfld/src/lib.rs b/v2/crates/wifi-densepose-bfld/src/lib.rs index 3c6679ea..1b40948a 100644 --- a/v2/crates/wifi-densepose-bfld/src/lib.rs +++ b/v2/crates/wifi-densepose-bfld/src/lib.rs @@ -19,6 +19,8 @@ pub mod embedding_ring; #[cfg(feature = "std")] pub mod emitter; #[cfg(feature = "std")] +pub mod availability; +#[cfg(feature = "std")] pub mod event; pub mod frame; #[cfg(feature = "std")] @@ -47,6 +49,11 @@ pub use emitter::{BfldEmitter, SensingInputs}; #[cfg(feature = "std")] pub use event::BfldEvent; #[cfg(feature = "std")] +pub use availability::{ + availability_topic, offline_message, online_message, publish_availability_offline, + publish_availability_online, PAYLOAD_AVAILABLE, PAYLOAD_NOT_AVAILABLE, +}; +#[cfg(feature = "std")] pub use ha_discovery::{publish_discovery, render_discovery_payloads}; #[cfg(feature = "std")] pub use mqtt_topics::{publish_event, render_events, CapturePublisher, Publish, TopicMessage}; diff --git a/v2/crates/wifi-densepose-bfld/tests/availability_topic.rs b/v2/crates/wifi-densepose-bfld/tests/availability_topic.rs new file mode 100644 index 00000000..eda001ad --- /dev/null +++ b/v2/crates/wifi-densepose-bfld/tests/availability_topic.rs @@ -0,0 +1,117 @@ +//! Acceptance tests for ADR-122 §2.2 availability topic + LWT integration. + +#![cfg(feature = "std")] + +use wifi_densepose_bfld::{ + availability_topic, offline_message, online_message, publish_availability_offline, + publish_availability_online, render_discovery_payloads, CapturePublisher, PrivacyClass, + PAYLOAD_AVAILABLE, PAYLOAD_NOT_AVAILABLE, +}; + +#[test] +fn availability_topic_format_matches_documented_path() { + assert_eq!( + availability_topic("seed-01"), + "ruview/seed-01/bfld/availability", + ); +} + +#[test] +fn online_message_is_retained_friendly_payload() { + let msg = online_message("seed-99"); + assert_eq!(msg.topic, "ruview/seed-99/bfld/availability"); + assert_eq!(msg.payload, "online"); + assert_eq!(msg.payload, PAYLOAD_AVAILABLE); +} + +#[test] +fn offline_message_is_retained_friendly_payload() { + let msg = offline_message("seed-99"); + assert_eq!(msg.payload, "offline"); + assert_eq!(msg.payload, PAYLOAD_NOT_AVAILABLE); +} + +#[test] +fn publish_online_lands_one_message() { + let mut p = CapturePublisher::default(); + publish_availability_online(&mut p, "seed-01").unwrap(); + assert_eq!(p.published.len(), 1); + assert_eq!(p.published[0].payload, "online"); +} + +#[test] +fn publish_offline_lands_one_message() { + let mut p = CapturePublisher::default(); + publish_availability_offline(&mut p, "seed-01").unwrap(); + assert_eq!(p.published.len(), 1); + assert_eq!(p.published[0].payload, "offline"); +} + +// --- discovery payload integration -------------------------------------- + +#[test] +fn discovery_payload_includes_availability_topic_field() { + let msgs = render_discovery_payloads("seed-01", PrivacyClass::Anonymous); + for msg in &msgs { + assert!( + msg.payload + .contains("\"availability_topic\":\"ruview/seed-01/bfld/availability\""), + "discovery payload must reference availability_topic, got: {}", + msg.payload, + ); + } +} + +#[test] +fn discovery_payload_includes_payload_available_and_not_available_strings() { + let msgs = render_discovery_payloads("seed-01", PrivacyClass::Anonymous); + for msg in &msgs { + assert!( + msg.payload.contains("\"payload_available\":\"online\""), + "discovery payload missing payload_available, got: {}", + msg.payload, + ); + assert!( + msg.payload.contains("\"payload_not_available\":\"offline\""), + "discovery payload missing payload_not_available, got: {}", + msg.payload, + ); + } +} + +#[test] +fn restricted_class_discovery_still_carries_availability_fields() { + // Availability isn't an identity field — class 3 retains it. + let msgs = render_discovery_payloads("seed-01", PrivacyClass::Restricted); + assert_eq!(msgs.len(), 5); + for msg in &msgs { + assert!(msg.payload.contains("\"availability_topic\":")); + } +} + +// --- bootstrap composition ---------------------------------------------- + +#[test] +fn bootstrap_sequence_online_then_discovery_lands_in_order() { + let mut p = CapturePublisher::default(); + publish_availability_online(&mut p, "seed-01").expect("online"); + let count = + wifi_densepose_bfld::publish_discovery(&mut p, "seed-01", PrivacyClass::Anonymous) + .expect("discovery"); + assert_eq!(count, 6); + assert_eq!(p.published.len(), 1 + 6); + assert_eq!(p.published[0].payload, "online"); + for msg in p.published.iter().skip(1) { + assert!(msg.topic.starts_with("homeassistant/")); + } +} + +#[test] +fn graceful_shutdown_sequence_publishes_offline_message_last() { + let mut p = CapturePublisher::default(); + publish_availability_online(&mut p, "seed-01").unwrap(); + publish_availability_offline(&mut p, "seed-01").unwrap(); + assert_eq!(p.published.len(), 2); + assert_eq!(p.published[0].payload, "online"); + assert_eq!(p.published[1].payload, "offline"); +}