feat(adr-118/p5.8): availability topic + LWT integration (203/203 GREEN)

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/<node>/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/<node>/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 <ruv@ruv.net>
This commit is contained in:
ruv 2026-05-24 17:57:55 -04:00
parent d356e1d5fd
commit bc47812351
4 changed files with 219 additions and 1 deletions

View File

@ -0,0 +1,79 @@
//! `ruview/<node_id>/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/<node_id>/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<P: Publish>(
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<P: Publish>(
publisher: &mut P,
node_id: &str,
) -> Result<(), P::Error> {
publisher.publish(&offline_message(node_id))
}

View File

@ -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);
}

View File

@ -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};

View File

@ -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");
}