Skip to main content

homecore_hap/
mapping.rs

1//! HOMECORE entity → HAP accessory type + characteristic value mapping.
2//!
3//! Mirrors the HA `homekit` integration's mapping table
4//! (homeassistant/components/homekit/type_*.py) for the entity domains and
5//! device classes handled in P1.
6
7use serde_json::Value;
8
9use homecore::entity::{EntityId, State};
10
11use crate::accessory::{HapAccessoryType, HapCharacteristic, HapCharacteristicValue};
12use crate::error::HapError;
13
14/// Result of mapping one HOMECORE entity state to the HAP layer.
15#[derive(Debug, Clone)]
16pub struct AccessoryMapping {
17    /// HAP service type to advertise for this entity.
18    pub accessory_type: HapAccessoryType,
19    /// Characteristic key/value pairs to set on the HAP service.
20    pub characteristics: Vec<(HapCharacteristic, HapCharacteristicValue)>,
21}
22
23/// Maps a HOMECORE entity `(EntityId, State)` pair to a `HapAccessoryType`
24/// and its current characteristic values.
25///
26/// Rule table (mirrors HA homekit_controller mapping):
27///
28/// | Domain | device_class | HAP service |
29/// |--------|-------------|-------------|
30/// | `light` | — | Lightbulb |
31/// | `switch` | — | Switch |
32/// | `binary_sensor` | `occupancy` | OccupancySensor |
33/// | `binary_sensor` | `motion` | MotionSensor |
34/// | `binary_sensor` | `door` / `window` | ContactSensor |
35/// | `sensor` | — + unit=°C/°F | TemperatureSensor |
36/// | `sensor` | — + unit=% (humidity) | HumiditySensor |
37/// | `cover` (door) | — | Door |
38/// | `lock` | — | Lock |
39pub struct EntityToAccessoryMapper;
40
41impl EntityToAccessoryMapper {
42    /// Map a HOMECORE entity to its HAP representation.
43    ///
44    /// Returns `HapError::UnmappableEntity` for domains that have no
45    /// defined HAP mapping (e.g. `automation`, `input_boolean`).
46    pub fn map(entity_id: &EntityId, state: &State) -> Result<AccessoryMapping, HapError> {
47        match entity_id.domain() {
48            "light" => Self::map_light(state),
49            "switch" => Self::map_switch(state),
50            "binary_sensor" => Self::map_binary_sensor(entity_id, state),
51            "sensor" => Self::map_sensor(entity_id, state),
52            "cover" => Self::map_cover(state),
53            "lock" => Self::map_lock(state),
54            other => Err(HapError::UnmappableEntity {
55                entity_id: entity_id.as_str().to_owned(),
56                reason: format!("domain '{other}' has no HAP mapping in P1"),
57            }),
58        }
59    }
60
61    fn map_light(state: &State) -> Result<AccessoryMapping, HapError> {
62        let on = state.state == "on";
63        let mut chars = vec![(HapCharacteristic::On, HapCharacteristicValue::Bool(on))];
64        if let Some(b) = state.attributes.get("brightness").and_then(Value::as_u64) {
65            chars.push((
66                HapCharacteristic::Brightness,
67                HapCharacteristicValue::UInt8(b.min(255) as u8),
68            ));
69        }
70        Ok(AccessoryMapping { accessory_type: HapAccessoryType::Lightbulb, characteristics: chars })
71    }
72
73    fn map_switch(state: &State) -> Result<AccessoryMapping, HapError> {
74        let on = state.state == "on";
75        Ok(AccessoryMapping {
76            accessory_type: HapAccessoryType::Switch,
77            characteristics: vec![(HapCharacteristic::On, HapCharacteristicValue::Bool(on))],
78        })
79    }
80
81    fn map_binary_sensor(
82        entity_id: &EntityId,
83        state: &State,
84    ) -> Result<AccessoryMapping, HapError> {
85        let detected = state.state == "on";
86        let device_class = state
87            .attributes
88            .get("device_class")
89            .and_then(Value::as_str)
90            .unwrap_or("")
91            .to_owned();
92
93        // Also check name heuristics for device_class-less entities.
94        let name = entity_id.name();
95        let is_occupancy = device_class == "occupancy" || name.contains("occupancy") || name.contains("presence");
96        let is_motion = device_class == "motion" || name.contains("motion");
97        let is_door = device_class == "door" || device_class == "window";
98
99        if is_occupancy {
100            return Ok(AccessoryMapping {
101                accessory_type: HapAccessoryType::OccupancySensor,
102                characteristics: vec![(
103                    HapCharacteristic::OccupancyDetected,
104                    HapCharacteristicValue::UInt8(if detected { 1 } else { 0 }),
105                )],
106            });
107        }
108        if is_motion {
109            return Ok(AccessoryMapping {
110                accessory_type: HapAccessoryType::MotionSensor,
111                characteristics: vec![(
112                    HapCharacteristic::MotionDetected,
113                    HapCharacteristicValue::Bool(detected),
114                )],
115            });
116        }
117        if is_door {
118            return Ok(AccessoryMapping {
119                accessory_type: HapAccessoryType::ContactSensor,
120                characteristics: vec![(
121                    HapCharacteristic::ContactSensorState,
122                    HapCharacteristicValue::UInt8(if detected { 1 } else { 0 }),
123                )],
124            });
125        }
126        // Fallback: treat as motion sensor
127        Ok(AccessoryMapping {
128            accessory_type: HapAccessoryType::MotionSensor,
129            characteristics: vec![(
130                HapCharacteristic::MotionDetected,
131                HapCharacteristicValue::Bool(detected),
132            )],
133        })
134    }
135
136    fn map_sensor(entity_id: &EntityId, state: &State) -> Result<AccessoryMapping, HapError> {
137        let unit = state
138            .attributes
139            .get("unit_of_measurement")
140            .and_then(Value::as_str)
141            .unwrap_or("")
142            .to_owned();
143        let name = entity_id.name();
144
145        let is_temp = unit == "°C" || unit == "°F" || unit == "C" || unit == "F"
146            || name.contains("temp") || name.contains("temperature");
147        let is_humidity = unit == "%" && (name.contains("humid") || name.contains("rh"));
148
149        if is_temp {
150            let temp: f64 = state.state.parse().unwrap_or(0.0);
151            return Ok(AccessoryMapping {
152                accessory_type: HapAccessoryType::TemperatureSensor,
153                characteristics: vec![(
154                    HapCharacteristic::CurrentTemperature,
155                    HapCharacteristicValue::Float(temp),
156                )],
157            });
158        }
159        if is_humidity {
160            let hum: f64 = state.state.parse().unwrap_or(0.0);
161            return Ok(AccessoryMapping {
162                accessory_type: HapAccessoryType::HumiditySensor,
163                characteristics: vec![(
164                    HapCharacteristic::CurrentRelativeHumidity,
165                    HapCharacteristicValue::Float(hum),
166                )],
167            });
168        }
169        Err(HapError::UnmappableEntity {
170            entity_id: entity_id.as_str().to_owned(),
171            reason: "sensor unit/name not recognised as temperature or humidity".into(),
172        })
173    }
174
175    fn map_cover(state: &State) -> Result<AccessoryMapping, HapError> {
176        let door_state: u8 = match state.state.as_str() {
177            "open" => 0,
178            "opening" => 2,
179            "closing" => 3,
180            _ => 1, // closed
181        };
182        Ok(AccessoryMapping {
183            accessory_type: HapAccessoryType::Door,
184            characteristics: vec![(
185                HapCharacteristic::CurrentDoorState,
186                HapCharacteristicValue::UInt8(door_state),
187            )],
188        })
189    }
190
191    fn map_lock(state: &State) -> Result<AccessoryMapping, HapError> {
192        let lock_state: u8 = match state.state.as_str() {
193            "unlocked" => 0,
194            "locked" => 1,
195            _ => 3, // unknown
196        };
197        Ok(AccessoryMapping {
198            accessory_type: HapAccessoryType::Lock,
199            characteristics: vec![(
200                HapCharacteristic::LockCurrentState,
201                HapCharacteristicValue::UInt8(lock_state),
202            )],
203        })
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use homecore::entity::{EntityId, State};
211    use homecore::event::Context;
212
213    fn state(id: &str, st: &str, attrs: serde_json::Value) -> (EntityId, State) {
214        let eid = EntityId::parse(id).unwrap();
215        let s = State::new(eid.clone(), st, attrs, Context::default());
216        (eid, s)
217    }
218
219    #[test]
220    fn light_kitchen_on_with_brightness() {
221        let (eid, s) = state(
222            "light.kitchen",
223            "on",
224            serde_json::json!({"brightness": 200}),
225        );
226        let mapping = EntityToAccessoryMapper::map(&eid, &s).unwrap();
227        assert_eq!(mapping.accessory_type, HapAccessoryType::Lightbulb);
228        assert!(mapping.characteristics.contains(&(
229            HapCharacteristic::On,
230            HapCharacteristicValue::Bool(true)
231        )));
232        assert!(mapping.characteristics.contains(&(
233            HapCharacteristic::Brightness,
234            HapCharacteristicValue::UInt8(200)
235        )));
236    }
237
238    #[test]
239    fn binary_sensor_occupancy_device_class() {
240        let (eid, s) = state(
241            "binary_sensor.kitchen_presence",
242            "on",
243            serde_json::json!({"device_class": "occupancy"}),
244        );
245        let mapping = EntityToAccessoryMapper::map(&eid, &s).unwrap();
246        assert_eq!(mapping.accessory_type, HapAccessoryType::OccupancySensor);
247        assert!(mapping.characteristics.contains(&(
248            HapCharacteristic::OccupancyDetected,
249            HapCharacteristicValue::UInt8(1)
250        )));
251    }
252
253    #[test]
254    fn sensor_outdoor_temp_celsius() {
255        let (eid, s) = state(
256            "sensor.outdoor_temp",
257            "21.5",
258            serde_json::json!({"unit_of_measurement": "°C"}),
259        );
260        let mapping = EntityToAccessoryMapper::map(&eid, &s).unwrap();
261        assert_eq!(mapping.accessory_type, HapAccessoryType::TemperatureSensor);
262        assert!(mapping.characteristics.contains(&(
263            HapCharacteristic::CurrentTemperature,
264            HapCharacteristicValue::Float(21.5)
265        )));
266    }
267
268    #[test]
269    fn unmappable_domain_returns_error() {
270        let (eid, s) = state("automation.morning", "on", serde_json::json!({}));
271        assert!(EntityToAccessoryMapper::map(&eid, &s).is_err());
272    }
273}