652 lines
25 KiB
Rust
652 lines
25 KiB
Rust
//! HA MQTT auto-discovery payload generators.
|
|
//!
|
|
//! Per ADR-115 §3.1 — §3.4 each RuView node becomes one HA `device` and
|
|
//! each capability (presence, person count, heart rate, breathing rate,
|
|
//! motion, fall, RSSI, zone occupancy, pose) becomes one entity on that
|
|
//! device. This module owns the JSON-serializable structures HA expects
|
|
//! on the `homeassistant/<component>/<object_id>/<entity>/config` topic.
|
|
//!
|
|
//! The structures are `Serialize`-only; we never need to parse them
|
|
//! back. Field names match Home Assistant's published MQTT-discovery
|
|
//! schema (https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery)
|
|
//! pinned to the version the project tests against (v2025.5 as of this
|
|
//! ADR; bump in `docs/integrations/home-assistant.md` when the test
|
|
//! matrix moves).
|
|
|
|
use serde::Serialize;
|
|
|
|
use super::{MANUFACTURER, ORIGIN_NAME, SUPPORT_URL};
|
|
|
|
/// HA component kinds we publish today. Strings match the HA URL slug.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum DiscoveryComponent {
|
|
BinarySensor,
|
|
Sensor,
|
|
Event,
|
|
}
|
|
|
|
impl DiscoveryComponent {
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
DiscoveryComponent::BinarySensor => "binary_sensor",
|
|
DiscoveryComponent::Sensor => "sensor",
|
|
DiscoveryComponent::Event => "event",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Top-level HA discovery payload. Serialised to JSON and published
|
|
/// retained, QoS 1 on `<prefix>/<component>/<object_id>/<entity>/config`.
|
|
///
|
|
/// We only model the fields ADR-115 §3.3 examples touch. HA's schema has
|
|
/// many more optional fields; we add them on a per-entity-need basis to
|
|
/// keep payloads small (some retained brokers cap message size).
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct DiscoveryConfig {
|
|
pub name: String,
|
|
pub unique_id: String,
|
|
pub object_id: String,
|
|
pub state_topic: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub availability_topic: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub payload_available: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub payload_not_available: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub payload_on: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub payload_off: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub device_class: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub state_class: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub unit_of_measurement: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub icon: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub value_template: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub json_attributes_topic: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub event_types: Option<Vec<String>>,
|
|
pub qos: u8,
|
|
pub device: DeviceMeta,
|
|
pub origin: OriginMeta,
|
|
}
|
|
|
|
/// HA `device` block. Multiple entities pointing at the same
|
|
/// `identifiers` are grouped into one device card in the HA UI.
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct DeviceMeta {
|
|
pub identifiers: Vec<String>,
|
|
pub name: String,
|
|
pub manufacturer: String,
|
|
pub model: String,
|
|
pub sw_version: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub via_device: Option<String>,
|
|
}
|
|
|
|
/// HA `origin` block. Tells HA users which software emitted the entities.
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct OriginMeta {
|
|
pub name: String,
|
|
pub sw_version: String,
|
|
pub support_url: String,
|
|
}
|
|
|
|
/// Per-entity availability payload. Used as MQTT LWT so the broker
|
|
/// publishes `offline` automatically if our connection drops.
|
|
#[derive(Debug, Clone)]
|
|
pub struct AvailabilityPayload {
|
|
pub topic: String,
|
|
pub online: &'static str,
|
|
pub offline: &'static str,
|
|
}
|
|
|
|
impl AvailabilityPayload {
|
|
pub fn for_entity(prefix: &str, component: DiscoveryComponent, node_id: &str, entity: &str) -> Self {
|
|
Self {
|
|
topic: format!(
|
|
"{prefix}/{}/wifi_densepose_{node_id}/{entity}/availability",
|
|
component.as_str()
|
|
),
|
|
online: "online",
|
|
offline: "offline",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// All entity kinds RuView publishes via MQTT. Used by [`DiscoveryBuilder`]
|
|
/// to generate matching `config` and `state` topic strings.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
pub enum EntityKind {
|
|
Presence,
|
|
PersonCount,
|
|
BreathingRate,
|
|
HeartRate,
|
|
MotionLevel,
|
|
MotionEnergy,
|
|
FallDetected,
|
|
PresenceScore,
|
|
Rssi,
|
|
ZoneOccupancy,
|
|
PoseKeypoints,
|
|
// Semantic primitives (ADR-115 §3.12).
|
|
SomeoneSleeping,
|
|
PossibleDistress,
|
|
RoomActive,
|
|
ElderlyInactivityAnomaly,
|
|
MeetingInProgress,
|
|
BathroomOccupied,
|
|
FallRiskElevated,
|
|
BedExit,
|
|
NoMovement,
|
|
MultiRoomTransition,
|
|
}
|
|
|
|
impl EntityKind {
|
|
pub fn topic_slug(self) -> &'static str {
|
|
match self {
|
|
EntityKind::Presence => "presence",
|
|
EntityKind::PersonCount => "person_count",
|
|
EntityKind::BreathingRate => "breathing_rate",
|
|
EntityKind::HeartRate => "heart_rate",
|
|
EntityKind::MotionLevel => "motion_level",
|
|
EntityKind::MotionEnergy => "motion_energy",
|
|
EntityKind::FallDetected => "fall",
|
|
EntityKind::PresenceScore => "presence_score",
|
|
EntityKind::Rssi => "rssi",
|
|
EntityKind::ZoneOccupancy => "zone_occupancy",
|
|
EntityKind::PoseKeypoints => "pose",
|
|
EntityKind::SomeoneSleeping => "someone_sleeping",
|
|
EntityKind::PossibleDistress => "possible_distress",
|
|
EntityKind::RoomActive => "room_active",
|
|
EntityKind::ElderlyInactivityAnomaly => "elderly_inactivity_anomaly",
|
|
EntityKind::MeetingInProgress => "meeting_in_progress",
|
|
EntityKind::BathroomOccupied => "bathroom_occupied",
|
|
EntityKind::FallRiskElevated => "fall_risk_elevated",
|
|
EntityKind::BedExit => "bed_exit",
|
|
EntityKind::NoMovement => "no_movement",
|
|
EntityKind::MultiRoomTransition => "multi_room_transition",
|
|
}
|
|
}
|
|
|
|
pub fn component(self) -> DiscoveryComponent {
|
|
match self {
|
|
// Boolean states → binary_sensor.
|
|
EntityKind::Presence
|
|
| EntityKind::ZoneOccupancy
|
|
| EntityKind::SomeoneSleeping
|
|
| EntityKind::PossibleDistress
|
|
| EntityKind::RoomActive
|
|
| EntityKind::ElderlyInactivityAnomaly
|
|
| EntityKind::MeetingInProgress
|
|
| EntityKind::BathroomOccupied
|
|
| EntityKind::NoMovement => DiscoveryComponent::BinarySensor,
|
|
// One-shot triggers → event.
|
|
EntityKind::FallDetected
|
|
| EntityKind::BedExit
|
|
| EntityKind::MultiRoomTransition => DiscoveryComponent::Event,
|
|
// Numeric measurements → sensor.
|
|
EntityKind::PersonCount
|
|
| EntityKind::BreathingRate
|
|
| EntityKind::HeartRate
|
|
| EntityKind::MotionLevel
|
|
| EntityKind::MotionEnergy
|
|
| EntityKind::PresenceScore
|
|
| EntityKind::Rssi
|
|
| EntityKind::PoseKeypoints
|
|
| EntityKind::FallRiskElevated => DiscoveryComponent::Sensor,
|
|
}
|
|
}
|
|
|
|
/// True iff this entity carries biometric data that `--privacy-mode`
|
|
/// must suppress per ADR-115 §3.10 and §3.12.3. Semantic primitives
|
|
/// stay published even in privacy mode because they're inferred
|
|
/// states, not raw values.
|
|
pub fn is_biometric(self) -> bool {
|
|
matches!(
|
|
self,
|
|
EntityKind::BreathingRate | EntityKind::HeartRate | EntityKind::PoseKeypoints
|
|
)
|
|
}
|
|
|
|
/// Human-readable HA entity name shown in the UI.
|
|
pub fn display_name(self) -> &'static str {
|
|
match self {
|
|
EntityKind::Presence => "Presence",
|
|
EntityKind::PersonCount => "Person count",
|
|
EntityKind::BreathingRate => "Breathing rate",
|
|
EntityKind::HeartRate => "Heart rate",
|
|
EntityKind::MotionLevel => "Motion level",
|
|
EntityKind::MotionEnergy => "Motion energy",
|
|
EntityKind::FallDetected => "Fall detected",
|
|
EntityKind::PresenceScore => "Presence score",
|
|
EntityKind::Rssi => "Signal strength",
|
|
EntityKind::ZoneOccupancy => "Zone occupancy",
|
|
EntityKind::PoseKeypoints => "Pose",
|
|
EntityKind::SomeoneSleeping => "Someone sleeping",
|
|
EntityKind::PossibleDistress => "Possible distress",
|
|
EntityKind::RoomActive => "Room active",
|
|
EntityKind::ElderlyInactivityAnomaly => "Elderly inactivity anomaly",
|
|
EntityKind::MeetingInProgress => "Meeting in progress",
|
|
EntityKind::BathroomOccupied => "Bathroom occupied",
|
|
EntityKind::FallRiskElevated => "Fall risk elevated",
|
|
EntityKind::BedExit => "Bed exit",
|
|
EntityKind::NoMovement => "No movement",
|
|
EntityKind::MultiRoomTransition => "Room transition",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Builds HA discovery payloads for a specific RuView node.
|
|
pub struct DiscoveryBuilder<'a> {
|
|
pub discovery_prefix: &'a str,
|
|
pub node_id: &'a str,
|
|
pub node_friendly_name: Option<&'a str>,
|
|
pub sw_version: &'a str,
|
|
pub model: &'a str,
|
|
pub via_device: Option<&'a str>,
|
|
}
|
|
|
|
impl<'a> DiscoveryBuilder<'a> {
|
|
fn unique_id(&self, entity: EntityKind) -> String {
|
|
format!("wifi_densepose_{}_{}", self.node_id, entity.topic_slug())
|
|
}
|
|
|
|
fn state_topic(&self, entity: EntityKind) -> String {
|
|
format!(
|
|
"{}/{}/wifi_densepose_{}/{}/state",
|
|
self.discovery_prefix,
|
|
entity.component().as_str(),
|
|
self.node_id,
|
|
entity.topic_slug(),
|
|
)
|
|
}
|
|
|
|
pub fn config_topic(&self, entity: EntityKind) -> String {
|
|
format!(
|
|
"{}/{}/wifi_densepose_{}/{}/config",
|
|
self.discovery_prefix,
|
|
entity.component().as_str(),
|
|
self.node_id,
|
|
entity.topic_slug(),
|
|
)
|
|
}
|
|
|
|
pub fn availability_topic(&self, entity: EntityKind) -> String {
|
|
format!(
|
|
"{}/{}/wifi_densepose_{}/{}/availability",
|
|
self.discovery_prefix,
|
|
entity.component().as_str(),
|
|
self.node_id,
|
|
entity.topic_slug(),
|
|
)
|
|
}
|
|
|
|
fn device(&self) -> DeviceMeta {
|
|
let display = self
|
|
.node_friendly_name
|
|
.map(|n| n.to_string())
|
|
.unwrap_or_else(|| format!("RuView node {}", self.node_id));
|
|
DeviceMeta {
|
|
identifiers: vec![format!("wifi_densepose_{}", self.node_id)],
|
|
name: display,
|
|
manufacturer: MANUFACTURER.to_string(),
|
|
model: self.model.to_string(),
|
|
sw_version: self.sw_version.to_string(),
|
|
via_device: self.via_device.map(|s| s.to_string()),
|
|
}
|
|
}
|
|
|
|
fn origin(&self) -> OriginMeta {
|
|
OriginMeta {
|
|
name: ORIGIN_NAME.to_string(),
|
|
sw_version: env!("CARGO_PKG_VERSION").to_string(),
|
|
support_url: SUPPORT_URL.to_string(),
|
|
}
|
|
}
|
|
|
|
/// Build a discovery config payload for one entity on this node.
|
|
pub fn build(&self, entity: EntityKind) -> DiscoveryConfig {
|
|
let component = entity.component();
|
|
let mut cfg = DiscoveryConfig {
|
|
name: entity.display_name().to_string(),
|
|
unique_id: self.unique_id(entity),
|
|
object_id: self.unique_id(entity),
|
|
state_topic: self.state_topic(entity),
|
|
availability_topic: Some(self.availability_topic(entity)),
|
|
payload_available: Some("online".into()),
|
|
payload_not_available: Some("offline".into()),
|
|
payload_on: None,
|
|
payload_off: None,
|
|
device_class: None,
|
|
state_class: None,
|
|
unit_of_measurement: None,
|
|
icon: None,
|
|
value_template: None,
|
|
json_attributes_topic: None,
|
|
event_types: None,
|
|
qos: match component {
|
|
DiscoveryComponent::BinarySensor | DiscoveryComponent::Event => 1,
|
|
DiscoveryComponent::Sensor => 0,
|
|
},
|
|
device: self.device(),
|
|
origin: self.origin(),
|
|
};
|
|
|
|
match entity {
|
|
EntityKind::Presence
|
|
| EntityKind::ZoneOccupancy
|
|
| EntityKind::SomeoneSleeping
|
|
| EntityKind::RoomActive
|
|
| EntityKind::MeetingInProgress
|
|
| EntityKind::BathroomOccupied => {
|
|
cfg.payload_on = Some("ON".into());
|
|
cfg.payload_off = Some("OFF".into());
|
|
cfg.device_class = Some("occupancy".into());
|
|
cfg.icon = Some(match entity {
|
|
EntityKind::SomeoneSleeping => "mdi:sleep",
|
|
EntityKind::MeetingInProgress => "mdi:account-group",
|
|
EntityKind::BathroomOccupied => "mdi:shower",
|
|
EntityKind::RoomActive => "mdi:home-account",
|
|
EntityKind::ZoneOccupancy => "mdi:map-marker",
|
|
_ => "mdi:motion-sensor",
|
|
}.into());
|
|
}
|
|
EntityKind::PossibleDistress
|
|
| EntityKind::ElderlyInactivityAnomaly
|
|
| EntityKind::NoMovement => {
|
|
cfg.payload_on = Some("ON".into());
|
|
cfg.payload_off = Some("OFF".into());
|
|
cfg.device_class = Some("problem".into());
|
|
cfg.icon = Some("mdi:alert-octagon".into());
|
|
}
|
|
EntityKind::FallDetected => {
|
|
cfg.event_types = Some(vec!["fall_detected".into()]);
|
|
cfg.icon = Some("mdi:human-fall".into());
|
|
}
|
|
EntityKind::BedExit => {
|
|
cfg.event_types = Some(vec!["bed_exit".into()]);
|
|
cfg.icon = Some("mdi:bed-empty".into());
|
|
}
|
|
EntityKind::MultiRoomTransition => {
|
|
cfg.event_types = Some(vec!["transition".into()]);
|
|
cfg.icon = Some("mdi:transit-transfer".into());
|
|
}
|
|
EntityKind::PersonCount => {
|
|
cfg.state_class = Some("measurement".into());
|
|
cfg.unit_of_measurement = Some("persons".into());
|
|
cfg.icon = Some("mdi:account-group".into());
|
|
cfg.value_template = Some("{{ value_json.n_persons }}".into());
|
|
}
|
|
EntityKind::BreathingRate => {
|
|
cfg.state_class = Some("measurement".into());
|
|
cfg.unit_of_measurement = Some("bpm".into());
|
|
cfg.icon = Some("mdi:lungs".into());
|
|
cfg.value_template = Some("{{ value_json.bpm }}".into());
|
|
cfg.json_attributes_topic = Some(cfg.state_topic.clone());
|
|
}
|
|
EntityKind::HeartRate => {
|
|
cfg.state_class = Some("measurement".into());
|
|
cfg.unit_of_measurement = Some("bpm".into());
|
|
cfg.icon = Some("mdi:heart-pulse".into());
|
|
cfg.value_template = Some("{{ value_json.bpm }}".into());
|
|
cfg.json_attributes_topic = Some(cfg.state_topic.clone());
|
|
}
|
|
EntityKind::MotionLevel => {
|
|
cfg.state_class = Some("measurement".into());
|
|
cfg.unit_of_measurement = Some("%".into());
|
|
cfg.icon = Some("mdi:run".into());
|
|
cfg.value_template = Some("{{ value_json.level_pct }}".into());
|
|
}
|
|
EntityKind::MotionEnergy => {
|
|
cfg.state_class = Some("measurement".into());
|
|
cfg.icon = Some("mdi:waveform".into());
|
|
cfg.value_template = Some("{{ value_json.energy }}".into());
|
|
}
|
|
EntityKind::PresenceScore => {
|
|
cfg.state_class = Some("measurement".into());
|
|
cfg.unit_of_measurement = Some("%".into());
|
|
cfg.icon = Some("mdi:gauge".into());
|
|
cfg.value_template = Some("{{ value_json.score_pct }}".into());
|
|
}
|
|
EntityKind::Rssi => {
|
|
cfg.state_class = Some("measurement".into());
|
|
cfg.device_class = Some("signal_strength".into());
|
|
cfg.unit_of_measurement = Some("dBm".into());
|
|
cfg.icon = Some("mdi:wifi".into());
|
|
cfg.value_template = Some("{{ value_json.dbm }}".into());
|
|
}
|
|
EntityKind::PoseKeypoints => {
|
|
cfg.icon = Some("mdi:human".into());
|
|
cfg.json_attributes_topic = Some(cfg.state_topic.clone());
|
|
cfg.value_template = Some("{{ value_json.n_keypoints }}".into());
|
|
}
|
|
EntityKind::FallRiskElevated => {
|
|
cfg.state_class = Some("measurement".into());
|
|
cfg.unit_of_measurement = Some("score".into());
|
|
cfg.icon = Some("mdi:human-fall".into());
|
|
cfg.value_template = Some("{{ value_json.score }}".into());
|
|
}
|
|
}
|
|
|
|
cfg
|
|
}
|
|
|
|
/// All entity kinds this builder will publish, given a `privacy_mode`
|
|
/// flag and a `publish_pose` flag. Used by the publisher to drive the
|
|
/// discovery-emission loop.
|
|
pub fn enabled_entities(privacy_mode: bool, publish_pose: bool, semantic_disabled: &[String]) -> Vec<EntityKind> {
|
|
let all = [
|
|
EntityKind::Presence,
|
|
EntityKind::PersonCount,
|
|
EntityKind::BreathingRate,
|
|
EntityKind::HeartRate,
|
|
EntityKind::MotionLevel,
|
|
EntityKind::MotionEnergy,
|
|
EntityKind::FallDetected,
|
|
EntityKind::PresenceScore,
|
|
EntityKind::Rssi,
|
|
EntityKind::ZoneOccupancy,
|
|
EntityKind::PoseKeypoints,
|
|
EntityKind::SomeoneSleeping,
|
|
EntityKind::PossibleDistress,
|
|
EntityKind::RoomActive,
|
|
EntityKind::ElderlyInactivityAnomaly,
|
|
EntityKind::MeetingInProgress,
|
|
EntityKind::BathroomOccupied,
|
|
EntityKind::FallRiskElevated,
|
|
EntityKind::BedExit,
|
|
EntityKind::NoMovement,
|
|
EntityKind::MultiRoomTransition,
|
|
];
|
|
|
|
all.into_iter()
|
|
.filter(|e| {
|
|
if privacy_mode && e.is_biometric() {
|
|
return false;
|
|
}
|
|
if *e == EntityKind::PoseKeypoints && !publish_pose {
|
|
return false;
|
|
}
|
|
if let Some(slug) = semantic_slug_for(*e) {
|
|
if semantic_disabled.iter().any(|d| d == slug) {
|
|
return false;
|
|
}
|
|
}
|
|
true
|
|
})
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
/// For an entity kind, return the `--no-semantic <PRIMITIVE>` slug it
|
|
/// would be disabled by, or `None` if it's not a semantic primitive.
|
|
fn semantic_slug_for(e: EntityKind) -> Option<&'static str> {
|
|
Some(match e {
|
|
EntityKind::SomeoneSleeping => "sleeping",
|
|
EntityKind::PossibleDistress => "distress",
|
|
EntityKind::RoomActive => "room_active",
|
|
EntityKind::ElderlyInactivityAnomaly => "elderly_anomaly",
|
|
EntityKind::MeetingInProgress => "meeting",
|
|
EntityKind::BathroomOccupied => "bathroom",
|
|
EntityKind::FallRiskElevated => "fall_risk",
|
|
EntityKind::BedExit => "bed_exit",
|
|
EntityKind::NoMovement => "no_movement",
|
|
EntityKind::MultiRoomTransition => "multi_room",
|
|
_ => return None,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::Value;
|
|
|
|
fn builder() -> DiscoveryBuilder<'static> {
|
|
DiscoveryBuilder {
|
|
discovery_prefix: "homeassistant",
|
|
node_id: "aabbccddeeff",
|
|
node_friendly_name: Some("Bedroom"),
|
|
sw_version: "v0.7.0",
|
|
model: "ESP32-S3 CSI node",
|
|
via_device: Some("cognitum_seed_1"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn presence_discovery_payload_shape() {
|
|
let b = builder();
|
|
let cfg = b.build(EntityKind::Presence);
|
|
let j: Value = serde_json::to_value(&cfg).unwrap();
|
|
assert_eq!(j["name"], "Presence");
|
|
assert_eq!(j["unique_id"], "wifi_densepose_aabbccddeeff_presence");
|
|
assert_eq!(j["device_class"], "occupancy");
|
|
assert_eq!(j["payload_on"], "ON");
|
|
assert_eq!(j["payload_off"], "OFF");
|
|
assert_eq!(j["qos"], 1);
|
|
assert_eq!(
|
|
j["state_topic"],
|
|
"homeassistant/binary_sensor/wifi_densepose_aabbccddeeff/presence/state"
|
|
);
|
|
assert_eq!(j["device"]["identifiers"][0], "wifi_densepose_aabbccddeeff");
|
|
assert_eq!(j["device"]["name"], "Bedroom");
|
|
assert_eq!(j["device"]["via_device"], "cognitum_seed_1");
|
|
assert_eq!(j["origin"]["name"], "wifi-densepose-sensing-server");
|
|
}
|
|
|
|
#[test]
|
|
fn heart_rate_discovery_payload_shape() {
|
|
let b = builder();
|
|
let cfg = b.build(EntityKind::HeartRate);
|
|
let j: Value = serde_json::to_value(&cfg).unwrap();
|
|
assert_eq!(j["unit_of_measurement"], "bpm");
|
|
assert_eq!(j["state_class"], "measurement");
|
|
assert_eq!(j["value_template"], "{{ value_json.bpm }}");
|
|
assert_eq!(j["qos"], 0);
|
|
assert!(j["json_attributes_topic"].as_str().unwrap().ends_with("/state"));
|
|
}
|
|
|
|
#[test]
|
|
fn fall_event_payload_uses_event_component_and_types() {
|
|
let b = builder();
|
|
let cfg = b.build(EntityKind::FallDetected);
|
|
let j: Value = serde_json::to_value(&cfg).unwrap();
|
|
assert!(j["state_topic"].as_str().unwrap().contains("/event/"));
|
|
assert_eq!(j["event_types"][0], "fall_detected");
|
|
assert_eq!(j["qos"], 1);
|
|
}
|
|
|
|
#[test]
|
|
fn semantic_primitive_uses_problem_class_for_distress() {
|
|
let b = builder();
|
|
let cfg = b.build(EntityKind::PossibleDistress);
|
|
let j: Value = serde_json::to_value(&cfg).unwrap();
|
|
assert_eq!(j["device_class"], "problem");
|
|
assert_eq!(j["payload_on"], "ON");
|
|
assert_eq!(j["payload_off"], "OFF");
|
|
}
|
|
|
|
#[test]
|
|
fn enabled_entities_default_excludes_pose_and_includes_all_others() {
|
|
let entities = DiscoveryBuilder::enabled_entities(false, false, &[]);
|
|
assert!(!entities.contains(&EntityKind::PoseKeypoints));
|
|
assert!(entities.contains(&EntityKind::Presence));
|
|
assert!(entities.contains(&EntityKind::HeartRate));
|
|
assert!(entities.contains(&EntityKind::SomeoneSleeping));
|
|
}
|
|
|
|
#[test]
|
|
fn privacy_mode_strips_biometrics() {
|
|
let entities = DiscoveryBuilder::enabled_entities(true, true, &[]);
|
|
for e in &entities {
|
|
assert!(!e.is_biometric(), "biometric {:?} leaked with privacy_mode", e);
|
|
}
|
|
// Semantic primitives must remain available (ADR-115 §3.12.3).
|
|
assert!(entities.contains(&EntityKind::SomeoneSleeping));
|
|
assert!(entities.contains(&EntityKind::BathroomOccupied));
|
|
}
|
|
|
|
#[test]
|
|
fn no_semantic_disables_specific_primitive() {
|
|
let disabled = vec!["distress".to_string(), "sleeping".to_string()];
|
|
let entities = DiscoveryBuilder::enabled_entities(false, false, &disabled);
|
|
assert!(!entities.contains(&EntityKind::PossibleDistress));
|
|
assert!(!entities.contains(&EntityKind::SomeoneSleeping));
|
|
// Raw signals untouched.
|
|
assert!(entities.contains(&EntityKind::Presence));
|
|
}
|
|
|
|
#[test]
|
|
fn topic_components_match_entity_kind() {
|
|
// binary_sensor for booleans.
|
|
assert_eq!(EntityKind::Presence.component(), DiscoveryComponent::BinarySensor);
|
|
assert_eq!(EntityKind::SomeoneSleeping.component(), DiscoveryComponent::BinarySensor);
|
|
// event for one-shots.
|
|
assert_eq!(EntityKind::FallDetected.component(), DiscoveryComponent::Event);
|
|
assert_eq!(EntityKind::BedExit.component(), DiscoveryComponent::Event);
|
|
// sensor for measurements.
|
|
assert_eq!(EntityKind::HeartRate.component(), DiscoveryComponent::Sensor);
|
|
assert_eq!(EntityKind::Rssi.component(), DiscoveryComponent::Sensor);
|
|
}
|
|
|
|
#[test]
|
|
fn discovery_config_serialises_without_null_fields() {
|
|
let b = builder();
|
|
let cfg = b.build(EntityKind::Presence);
|
|
let j = serde_json::to_string(&cfg).unwrap();
|
|
// skip_serializing_if = "Option::is_none" must hide unused fields
|
|
// so retained payloads stay compact on small brokers.
|
|
assert!(!j.contains("\"event_types\":null"));
|
|
assert!(!j.contains("\"unit_of_measurement\":null"));
|
|
assert!(!j.contains("\"value_template\":null"));
|
|
}
|
|
|
|
#[test]
|
|
fn availability_topic_matches_state_topic_path() {
|
|
let b = builder();
|
|
let state = format!(
|
|
"homeassistant/binary_sensor/wifi_densepose_aabbccddeeff/presence/state"
|
|
);
|
|
let avail = b.availability_topic(EntityKind::Presence);
|
|
// Must differ only in suffix.
|
|
assert_eq!(
|
|
state.trim_end_matches("/state"),
|
|
avail.trim_end_matches("/availability"),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unique_id_uses_namespaced_node_prefix() {
|
|
let b = builder();
|
|
let cfg = b.build(EntityKind::Rssi);
|
|
assert!(cfg.unique_id.starts_with("wifi_densepose_"));
|
|
// ADR-115 §7 — namespace prevents collision with other HA devices.
|
|
assert!(cfg.unique_id.contains(b.node_id));
|
|
}
|
|
}
|