Skip to main content

homecore_hap/
ruview.rs

1//! RuView sensing primitives → HAP characteristic mapping (ADR-125 §2.1.d).
2//!
3//! Per ADR-125, RuView's privacy-class-2/3 events map to HomeKit primitives
4//! as semantic ambient signals, not surveillance events:
5//!
6//! | RuView primitive | HAP service | Rationale |
7//! |-----------------|-------------|-----------|
8//! | `edge_vitals.presence` | OccupancySensor | Anonymous presence = occupancy |
9//! | `edge_vitals.motion` | MotionSensor | Motion burst |
10//! | `edge_vitals.fall_detected` | LeakSensor | HA convention: abnormal events |
11//! | `edge_vitals.breathing_present` | OccupancySensor | Sleep-room occupancy |
12//!
13//! Raw `identity_risk_score`, `rf_signature_hash`, and class-0 BFI data are
14//! **never** mapped. Structural invariant I1 (ADR-118 §2.2) is enforced here.
15
16use crate::accessory::{HapAccessoryType, HapCharacteristic, HapCharacteristicValue};
17use crate::mapping::AccessoryMapping;
18
19/// Parsed RuView edge vitals event from the sensing-server.
20///
21/// All fields are class-2 (Anonymous) or class-3 (Restricted) derived signals.
22/// Raw BFI / `identity_risk_score` / `rf_signature_hash` are intentionally
23/// absent — they must not cross the HAP boundary per ADR-125 §2.2.
24#[derive(Debug, Clone, Default)]
25pub struct EdgeVitals {
26    /// True if at least one person is present in the sensing zone.
27    pub presence: bool,
28    /// True if motion was detected in the last sensing window.
29    pub motion: bool,
30    /// True if a fall event was detected (latched, 5 s cooldown).
31    pub fall_detected: bool,
32    /// True if rhythmic breathing is detected (sleep-room occupancy signal).
33    pub breathing_present: bool,
34    /// Optional ambient temperature reading (°C), forwarded if available
35    /// from a co-located temperature sensor.
36    pub ambient_temp_c: Option<f64>,
37}
38
39/// Maps `EdgeVitals` to a `Vec<AccessoryMapping>` — one per RuView primitive
40/// that should be exposed as a distinct HAP service (child accessory).
41pub struct RuViewToHapMapper;
42
43impl RuViewToHapMapper {
44    /// Convert a `EdgeVitals` snapshot to HAP accessory mappings.
45    ///
46    /// Always returns mappings for presence, motion, and fall; the ambient
47    /// temperature mapping is only emitted when `ambient_temp_c` is `Some`.
48    pub fn map(vitals: &EdgeVitals) -> Vec<AccessoryMapping> {
49        let mut out = Vec::with_capacity(4);
50
51        // Presence → OccupancySensor
52        out.push(AccessoryMapping {
53            accessory_type: HapAccessoryType::OccupancySensor,
54            characteristics: vec![(
55                HapCharacteristic::OccupancyDetected,
56                HapCharacteristicValue::UInt8(if vitals.presence || vitals.breathing_present { 1 } else { 0 }),
57            )],
58        });
59
60        // Motion → MotionSensor
61        out.push(AccessoryMapping {
62            accessory_type: HapAccessoryType::MotionSensor,
63            characteristics: vec![(
64                HapCharacteristic::MotionDetected,
65                HapCharacteristicValue::Bool(vitals.motion),
66            )],
67        });
68
69        // Fall detected → LeakSensor (HA homekit_controller convention for
70        // "abnormal event" — not a literal water leak, but an automation-
71        // triggerable threshold event, per ADR-125 §2.1.d).
72        out.push(AccessoryMapping {
73            accessory_type: HapAccessoryType::LeakSensor,
74            characteristics: vec![(
75                HapCharacteristic::LeakDetected,
76                HapCharacteristicValue::UInt8(if vitals.fall_detected { 1 } else { 0 }),
77            )],
78        });
79
80        // Optional temperature
81        if let Some(temp) = vitals.ambient_temp_c {
82            out.push(AccessoryMapping {
83                accessory_type: HapAccessoryType::TemperatureSensor,
84                characteristics: vec![(
85                    HapCharacteristic::CurrentTemperature,
86                    HapCharacteristicValue::Float(temp),
87                )],
88            });
89        }
90
91        out
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use crate::accessory::{HapAccessoryType, HapCharacteristic, HapCharacteristicValue};
99
100    #[test]
101    fn presence_true_maps_to_occupancy_detected_1() {
102        let vitals = EdgeVitals { presence: true, ..Default::default() };
103        let mappings = RuViewToHapMapper::map(&vitals);
104        let occ = mappings.iter().find(|m| m.accessory_type == HapAccessoryType::OccupancySensor).unwrap();
105        assert!(occ.characteristics.contains(&(
106            HapCharacteristic::OccupancyDetected,
107            HapCharacteristicValue::UInt8(1)
108        )));
109    }
110
111    #[test]
112    fn fall_detected_maps_to_leak_sensor() {
113        let vitals = EdgeVitals { fall_detected: true, ..Default::default() };
114        let mappings = RuViewToHapMapper::map(&vitals);
115        let leak = mappings.iter().find(|m| m.accessory_type == HapAccessoryType::LeakSensor).unwrap();
116        assert!(leak.characteristics.contains(&(
117            HapCharacteristic::LeakDetected,
118            HapCharacteristicValue::UInt8(1)
119        )));
120    }
121
122    #[test]
123    fn motion_false_maps_correctly() {
124        let vitals = EdgeVitals { motion: false, ..Default::default() };
125        let mappings = RuViewToHapMapper::map(&vitals);
126        let mot = mappings.iter().find(|m| m.accessory_type == HapAccessoryType::MotionSensor).unwrap();
127        assert!(mot.characteristics.contains(&(
128            HapCharacteristic::MotionDetected,
129            HapCharacteristicValue::Bool(false)
130        )));
131    }
132
133    #[test]
134    fn ambient_temp_emits_temperature_mapping() {
135        let vitals = EdgeVitals { ambient_temp_c: Some(22.5), ..Default::default() };
136        let mappings = RuViewToHapMapper::map(&vitals);
137        let temp = mappings.iter().find(|m| m.accessory_type == HapAccessoryType::TemperatureSensor);
138        assert!(temp.is_some());
139    }
140
141    #[test]
142    fn no_ambient_temp_omits_temperature_mapping() {
143        let vitals = EdgeVitals { ambient_temp_c: None, ..Default::default() };
144        let mappings = RuViewToHapMapper::map(&vitals);
145        assert!(mappings.iter().all(|m| m.accessory_type != HapAccessoryType::TemperatureSensor));
146    }
147
148    #[test]
149    fn breathing_present_triggers_occupancy() {
150        let vitals = EdgeVitals { presence: false, breathing_present: true, ..Default::default() };
151        let mappings = RuViewToHapMapper::map(&vitals);
152        let occ = mappings.iter().find(|m| m.accessory_type == HapAccessoryType::OccupancySensor).unwrap();
153        assert!(occ.characteristics.contains(&(
154            HapCharacteristic::OccupancyDetected,
155            HapCharacteristicValue::UInt8(1)
156        )));
157    }
158}