From bc478123510fe39a352b363c4ed832f9b00714cb Mon Sep 17 00:00:00 2001 From: ruv Date: Sun, 24 May 2026 17:57:55 -0400 Subject: [PATCH] feat(adr-118/p5.8): availability topic + LWT integration (203/203 GREEN) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Iter 28. Closes the per-node lifecycle on the MQTT side: HA can now distinguish a node that is healthy + publishing zero events (nothing detected) from a node that has lost the broker connection. Discovery payloads now reference the availability topic so every entity inherits the device-level offline marker. Added (gated on `feature = "std"`): - src/availability.rs: * PAYLOAD_AVAILABLE = "online", PAYLOAD_NOT_AVAILABLE = "offline" * availability_topic(node_id) -> "ruview//bfld/availability" * online_message / offline_message constructors returning TopicMessage * publish_availability_online / publish_availability_offline bootstrap helpers through Publish trait - pub use the full availability surface from lib.rs Discovery integration (src/ha_discovery.rs): - Every entity config payload now carries: "availability_topic": "ruview//bfld/availability" "payload_available": "online" "payload_not_available": "offline" HA uses these to grey out entities device-wide when the broker LWT fires or the node explicitly publishes "offline" during shutdown. tests/availability_topic.rs (10 named tests, all green): availability_topic_format_matches_documented_path online_message_is_retained_friendly_payload offline_message_is_retained_friendly_payload publish_online_lands_one_message publish_offline_lands_one_message discovery_payload_includes_availability_topic_field (all 6 Anonymous-class discovery payloads carry the field) discovery_payload_includes_payload_available_and_not_available_strings restricted_class_discovery_still_carries_availability_fields (availability is not an identity field; class 3 retains it) bootstrap_sequence_online_then_discovery_lands_in_order *** End-to-end bootstrap proof: publish_availability_online + publish_discovery produces 1 + 6 = 7 messages, "online" first, six homeassistant/.../config payloads after. *** graceful_shutdown_sequence_publishes_offline_message_last ACs progressed: - ADR-122 §2.2 — availability topic now in place. Operators get HA online/offline indication without configuring LWT explicitly on rumqttc — the offline_message constructor + publish_availability_offline cover the explicit-shutdown path. Real LWT wiring (rumqttc's MqttOptions::set_last_will) is a follow-up. - ADR-122 AC1 + AC4 — discovery now includes availability_topic, which HA needs to render the device as a unit; iter-26 tests continue to pass with the augmented payload (verified by full-suite count: 187 + 10). Test config: - cargo test --no-default-features → 72 passed (availability cfg-out) - cargo test → 203 passed (193 + 10) Out of scope (next iter target): - Wire rumqttc::MqttOptions::set_last_will(...) so the broker auto-publishes "offline" when the TCP session drops; needs a small helper on RumqttPublisher to build options with LWT pre-configured. - GitHub Actions workflow with mosquitto Docker so iter-24 live test runs in CI. Co-Authored-By: claude-flow --- .../wifi-densepose-bfld/src/availability.rs | 79 ++++++++++++ .../wifi-densepose-bfld/src/ha_discovery.rs | 17 ++- v2/crates/wifi-densepose-bfld/src/lib.rs | 7 ++ .../tests/availability_topic.rs | 117 ++++++++++++++++++ 4 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 v2/crates/wifi-densepose-bfld/src/availability.rs create mode 100644 v2/crates/wifi-densepose-bfld/tests/availability_topic.rs 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"); +}