1use serde_json::Value;
8
9use homecore::entity::{EntityId, State};
10
11use crate::accessory::{HapAccessoryType, HapCharacteristic, HapCharacteristicValue};
12use crate::error::HapError;
13
14#[derive(Debug, Clone)]
16pub struct AccessoryMapping {
17 pub accessory_type: HapAccessoryType,
19 pub characteristics: Vec<(HapCharacteristic, HapCharacteristicValue)>,
21}
22
23pub struct EntityToAccessoryMapper;
40
41impl EntityToAccessoryMapper {
42 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 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 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, };
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, };
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}