mirror of
https://github.com/ruvnet/RuView
synced 2026-07-25 17:51:48 +00:00
bc47812351
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>
118 lines
3.8 KiB
Rust
118 lines
3.8 KiB
Rust
//! 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");
|
|
}
|