mirror of
https://github.com/ruvnet/RuView
synced 2026-08-06 19:51:43 +00:00
feat(homecore-hap/p1): ADR-125 HAP bridge scaffold (17 tests pass)
Add `homecore-hap` crate: HapAccessoryType (11 variants), HapCharacteristic, EntityToAccessoryMapper (light/switch/binary_sensor/sensor/cover/lock domains), HapBridge add/remove/running API, NullAdvertiser mDNS stub, and RuViewToHapMapper (presence→OccupancySensor, fall→LeakSensor, motion→MotionSensor). P2 `hap-server` feature gates the real hap = "0.1" server + mdns-sd integration. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
//! HOMECORE entity → HAP accessory type + characteristic value mapping.
|
||||
//!
|
||||
//! Mirrors the HA `homekit` integration's mapping table
|
||||
//! (homeassistant/components/homekit/type_*.py) for the entity domains and
|
||||
//! device classes handled in P1.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use homecore::entity::{EntityId, State};
|
||||
|
||||
use crate::accessory::{HapAccessoryType, HapCharacteristic, HapCharacteristicValue};
|
||||
use crate::error::HapError;
|
||||
|
||||
/// Result of mapping one HOMECORE entity state to the HAP layer.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AccessoryMapping {
|
||||
/// HAP service type to advertise for this entity.
|
||||
pub accessory_type: HapAccessoryType,
|
||||
/// Characteristic key/value pairs to set on the HAP service.
|
||||
pub characteristics: Vec<(HapCharacteristic, HapCharacteristicValue)>,
|
||||
}
|
||||
|
||||
/// Maps a HOMECORE entity `(EntityId, State)` pair to a `HapAccessoryType`
|
||||
/// and its current characteristic values.
|
||||
///
|
||||
/// Rule table (mirrors HA homekit_controller mapping):
|
||||
///
|
||||
/// | Domain | device_class | HAP service |
|
||||
/// |--------|-------------|-------------|
|
||||
/// | `light` | — | Lightbulb |
|
||||
/// | `switch` | — | Switch |
|
||||
/// | `binary_sensor` | `occupancy` | OccupancySensor |
|
||||
/// | `binary_sensor` | `motion` | MotionSensor |
|
||||
/// | `binary_sensor` | `door` / `window` | ContactSensor |
|
||||
/// | `sensor` | — + unit=°C/°F | TemperatureSensor |
|
||||
/// | `sensor` | — + unit=% (humidity) | HumiditySensor |
|
||||
/// | `cover` (door) | — | Door |
|
||||
/// | `lock` | — | Lock |
|
||||
pub struct EntityToAccessoryMapper;
|
||||
|
||||
impl EntityToAccessoryMapper {
|
||||
/// Map a HOMECORE entity to its HAP representation.
|
||||
///
|
||||
/// Returns `HapError::UnmappableEntity` for domains that have no
|
||||
/// defined HAP mapping (e.g. `automation`, `input_boolean`).
|
||||
pub fn map(entity_id: &EntityId, state: &State) -> Result<AccessoryMapping, HapError> {
|
||||
match entity_id.domain() {
|
||||
"light" => Self::map_light(state),
|
||||
"switch" => Self::map_switch(state),
|
||||
"binary_sensor" => Self::map_binary_sensor(entity_id, state),
|
||||
"sensor" => Self::map_sensor(entity_id, state),
|
||||
"cover" => Self::map_cover(state),
|
||||
"lock" => Self::map_lock(state),
|
||||
other => Err(HapError::UnmappableEntity {
|
||||
entity_id: entity_id.as_str().to_owned(),
|
||||
reason: format!("domain '{other}' has no HAP mapping in P1"),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_light(state: &State) -> Result<AccessoryMapping, HapError> {
|
||||
let on = state.state == "on";
|
||||
let mut chars = vec![(HapCharacteristic::On, HapCharacteristicValue::Bool(on))];
|
||||
if let Some(b) = state.attributes.get("brightness").and_then(Value::as_u64) {
|
||||
chars.push((
|
||||
HapCharacteristic::Brightness,
|
||||
HapCharacteristicValue::UInt8(b.min(255) as u8),
|
||||
));
|
||||
}
|
||||
Ok(AccessoryMapping { accessory_type: HapAccessoryType::Lightbulb, characteristics: chars })
|
||||
}
|
||||
|
||||
fn map_switch(state: &State) -> Result<AccessoryMapping, HapError> {
|
||||
let on = state.state == "on";
|
||||
Ok(AccessoryMapping {
|
||||
accessory_type: HapAccessoryType::Switch,
|
||||
characteristics: vec![(HapCharacteristic::On, HapCharacteristicValue::Bool(on))],
|
||||
})
|
||||
}
|
||||
|
||||
fn map_binary_sensor(
|
||||
entity_id: &EntityId,
|
||||
state: &State,
|
||||
) -> Result<AccessoryMapping, HapError> {
|
||||
let detected = state.state == "on";
|
||||
let device_class = state
|
||||
.attributes
|
||||
.get("device_class")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_owned();
|
||||
|
||||
// Also check name heuristics for device_class-less entities.
|
||||
let name = entity_id.name();
|
||||
let is_occupancy = device_class == "occupancy" || name.contains("occupancy") || name.contains("presence");
|
||||
let is_motion = device_class == "motion" || name.contains("motion");
|
||||
let is_door = device_class == "door" || device_class == "window";
|
||||
|
||||
if is_occupancy {
|
||||
return Ok(AccessoryMapping {
|
||||
accessory_type: HapAccessoryType::OccupancySensor,
|
||||
characteristics: vec![(
|
||||
HapCharacteristic::OccupancyDetected,
|
||||
HapCharacteristicValue::UInt8(if detected { 1 } else { 0 }),
|
||||
)],
|
||||
});
|
||||
}
|
||||
if is_motion {
|
||||
return Ok(AccessoryMapping {
|
||||
accessory_type: HapAccessoryType::MotionSensor,
|
||||
characteristics: vec![(
|
||||
HapCharacteristic::MotionDetected,
|
||||
HapCharacteristicValue::Bool(detected),
|
||||
)],
|
||||
});
|
||||
}
|
||||
if is_door {
|
||||
return Ok(AccessoryMapping {
|
||||
accessory_type: HapAccessoryType::ContactSensor,
|
||||
characteristics: vec![(
|
||||
HapCharacteristic::ContactSensorState,
|
||||
HapCharacteristicValue::UInt8(if detected { 1 } else { 0 }),
|
||||
)],
|
||||
});
|
||||
}
|
||||
// Fallback: treat as motion sensor
|
||||
Ok(AccessoryMapping {
|
||||
accessory_type: HapAccessoryType::MotionSensor,
|
||||
characteristics: vec![(
|
||||
HapCharacteristic::MotionDetected,
|
||||
HapCharacteristicValue::Bool(detected),
|
||||
)],
|
||||
})
|
||||
}
|
||||
|
||||
fn map_sensor(entity_id: &EntityId, state: &State) -> Result<AccessoryMapping, HapError> {
|
||||
let unit = state
|
||||
.attributes
|
||||
.get("unit_of_measurement")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_owned();
|
||||
let name = entity_id.name();
|
||||
|
||||
let is_temp = unit == "°C" || unit == "°F" || unit == "C" || unit == "F"
|
||||
|| name.contains("temp") || name.contains("temperature");
|
||||
let is_humidity = unit == "%" && (name.contains("humid") || name.contains("rh"));
|
||||
|
||||
if is_temp {
|
||||
let temp: f64 = state.state.parse().unwrap_or(0.0);
|
||||
return Ok(AccessoryMapping {
|
||||
accessory_type: HapAccessoryType::TemperatureSensor,
|
||||
characteristics: vec![(
|
||||
HapCharacteristic::CurrentTemperature,
|
||||
HapCharacteristicValue::Float(temp),
|
||||
)],
|
||||
});
|
||||
}
|
||||
if is_humidity {
|
||||
let hum: f64 = state.state.parse().unwrap_or(0.0);
|
||||
return Ok(AccessoryMapping {
|
||||
accessory_type: HapAccessoryType::HumiditySensor,
|
||||
characteristics: vec![(
|
||||
HapCharacteristic::CurrentRelativeHumidity,
|
||||
HapCharacteristicValue::Float(hum),
|
||||
)],
|
||||
});
|
||||
}
|
||||
Err(HapError::UnmappableEntity {
|
||||
entity_id: entity_id.as_str().to_owned(),
|
||||
reason: "sensor unit/name not recognised as temperature or humidity".into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn map_cover(state: &State) -> Result<AccessoryMapping, HapError> {
|
||||
let door_state: u8 = match state.state.as_str() {
|
||||
"open" => 0,
|
||||
"opening" => 2,
|
||||
"closing" => 3,
|
||||
_ => 1, // closed
|
||||
};
|
||||
Ok(AccessoryMapping {
|
||||
accessory_type: HapAccessoryType::Door,
|
||||
characteristics: vec![(
|
||||
HapCharacteristic::CurrentDoorState,
|
||||
HapCharacteristicValue::UInt8(door_state),
|
||||
)],
|
||||
})
|
||||
}
|
||||
|
||||
fn map_lock(state: &State) -> Result<AccessoryMapping, HapError> {
|
||||
let lock_state: u8 = match state.state.as_str() {
|
||||
"unlocked" => 0,
|
||||
"locked" => 1,
|
||||
_ => 3, // unknown
|
||||
};
|
||||
Ok(AccessoryMapping {
|
||||
accessory_type: HapAccessoryType::Lock,
|
||||
characteristics: vec![(
|
||||
HapCharacteristic::LockCurrentState,
|
||||
HapCharacteristicValue::UInt8(lock_state),
|
||||
)],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use homecore::entity::{EntityId, State};
|
||||
use homecore::event::Context;
|
||||
|
||||
fn state(id: &str, st: &str, attrs: serde_json::Value) -> (EntityId, State) {
|
||||
let eid = EntityId::parse(id).unwrap();
|
||||
let s = State::new(eid.clone(), st, attrs, Context::default());
|
||||
(eid, s)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_kitchen_on_with_brightness() {
|
||||
let (eid, s) = state(
|
||||
"light.kitchen",
|
||||
"on",
|
||||
serde_json::json!({"brightness": 200}),
|
||||
);
|
||||
let mapping = EntityToAccessoryMapper::map(&eid, &s).unwrap();
|
||||
assert_eq!(mapping.accessory_type, HapAccessoryType::Lightbulb);
|
||||
assert!(mapping.characteristics.contains(&(
|
||||
HapCharacteristic::On,
|
||||
HapCharacteristicValue::Bool(true)
|
||||
)));
|
||||
assert!(mapping.characteristics.contains(&(
|
||||
HapCharacteristic::Brightness,
|
||||
HapCharacteristicValue::UInt8(200)
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_sensor_occupancy_device_class() {
|
||||
let (eid, s) = state(
|
||||
"binary_sensor.kitchen_presence",
|
||||
"on",
|
||||
serde_json::json!({"device_class": "occupancy"}),
|
||||
);
|
||||
let mapping = EntityToAccessoryMapper::map(&eid, &s).unwrap();
|
||||
assert_eq!(mapping.accessory_type, HapAccessoryType::OccupancySensor);
|
||||
assert!(mapping.characteristics.contains(&(
|
||||
HapCharacteristic::OccupancyDetected,
|
||||
HapCharacteristicValue::UInt8(1)
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sensor_outdoor_temp_celsius() {
|
||||
let (eid, s) = state(
|
||||
"sensor.outdoor_temp",
|
||||
"21.5",
|
||||
serde_json::json!({"unit_of_measurement": "°C"}),
|
||||
);
|
||||
let mapping = EntityToAccessoryMapper::map(&eid, &s).unwrap();
|
||||
assert_eq!(mapping.accessory_type, HapAccessoryType::TemperatureSensor);
|
||||
assert!(mapping.characteristics.contains(&(
|
||||
HapCharacteristic::CurrentTemperature,
|
||||
HapCharacteristicValue::Float(21.5)
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unmappable_domain_returns_error() {
|
||||
let (eid, s) = state("automation.morning", "on", serde_json::json!({}));
|
||||
assert!(EntityToAccessoryMapper::map(&eid, &s).is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user