mirror of
https://github.com/ruvnet/RuView
synced 2026-08-03 19:21:42 +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,36 @@
|
||||
# homecore-hap — Apple Home HomeKit Accessory Protocol bridge (ADR-125 P1 scaffold)
|
||||
#
|
||||
# P1 ships the trait surface, accessory/characteristic types, entity→HAP mapping,
|
||||
# bridge API, and an mDNS-advertise stub. The actual HAP-1.1 server and real
|
||||
# mDNS integration are feature-gated to P2 via the `hap-server` feature flag.
|
||||
|
||||
[package]
|
||||
name = "homecore-hap"
|
||||
version = "0.1.0-alpha.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["rUv <ruv@ruv.net>", "HOMECORE Contributors"]
|
||||
description = "Apple Home HomeKit Accessory Protocol bridge — ADR-125 P1 scaffold"
|
||||
repository = "https://github.com/ruvnet/wifi-densepose"
|
||||
|
||||
[lib]
|
||||
name = "homecore_hap"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# P2: gates the actual hap = "0.1" crate integration + real mDNS via mdns-sd
|
||||
hap-server = []
|
||||
|
||||
[dependencies]
|
||||
homecore = { path = "../homecore" }
|
||||
tokio = { version = "1", features = ["sync", "rt", "rt-multi-thread", "time", "macros"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
tracing = "0.1"
|
||||
async-trait = "0.1"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["sync", "rt", "rt-multi-thread", "time", "macros", "test-util"] }
|
||||
@@ -0,0 +1,124 @@
|
||||
//! HAP service type and characteristic enum catalogues.
|
||||
//!
|
||||
//! Mirrors the HAP-1.1 service/characteristic namespace used by Apple Home
|
||||
//! and the `hap` crate (https://crates.io/crates/hap). Keeping these as
|
||||
//! plain Rust enums in P1 avoids the heavy `hap` dep until P2.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// HAP service types exposed by the RuView bridge.
|
||||
///
|
||||
/// Derived from HomeKit Accessory Protocol Specification §8 (service
|
||||
/// definitions) and cross-checked against HA's `homekit` integration
|
||||
/// service catalog.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum HapAccessoryType {
|
||||
/// HAP `Lightbulb` service — maps `light.*` entities.
|
||||
Lightbulb,
|
||||
/// HAP `Switch` service — maps generic boolean `switch.*` entities.
|
||||
Switch,
|
||||
/// HAP `OccupancySensor` — maps presence / occupancy binary sensors.
|
||||
OccupancySensor,
|
||||
/// HAP `MotionSensor` — maps motion binary sensors + RuView motion.
|
||||
MotionSensor,
|
||||
/// HAP `TemperatureSensor` — maps `sensor.*temperature*` entities.
|
||||
TemperatureSensor,
|
||||
/// HAP `HumiditySensor` — maps `sensor.*humidity*` entities.
|
||||
HumiditySensor,
|
||||
/// HAP `LeakSensor` — maps abnormal event sensors; used for fall detection
|
||||
/// following HA's homekit_controller convention (HAP §11.42).
|
||||
LeakSensor,
|
||||
/// HAP `ContactSensor` — maps door / window binary sensors.
|
||||
ContactSensor,
|
||||
/// HAP `Door` service — maps `cover.*door*` entities.
|
||||
Door,
|
||||
/// HAP `LockMechanism` service — maps `lock.*` entities.
|
||||
Lock,
|
||||
/// HAP `SecuritySystem` service — maps alarm / security panel entities.
|
||||
SecuritySystem,
|
||||
}
|
||||
|
||||
impl HapAccessoryType {
|
||||
/// All defined variants — used in tests and for UI enumeration.
|
||||
pub const ALL: &'static [HapAccessoryType] = &[
|
||||
HapAccessoryType::Lightbulb,
|
||||
HapAccessoryType::Switch,
|
||||
HapAccessoryType::OccupancySensor,
|
||||
HapAccessoryType::MotionSensor,
|
||||
HapAccessoryType::TemperatureSensor,
|
||||
HapAccessoryType::HumiditySensor,
|
||||
HapAccessoryType::LeakSensor,
|
||||
HapAccessoryType::ContactSensor,
|
||||
HapAccessoryType::Door,
|
||||
HapAccessoryType::Lock,
|
||||
HapAccessoryType::SecuritySystem,
|
||||
];
|
||||
}
|
||||
|
||||
/// HAP characteristic identifiers that the bridge reads or writes.
|
||||
///
|
||||
/// Each variant corresponds to one HAP characteristic UUID as specified in
|
||||
/// HomeKit Accessory Protocol Specification §9.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum HapCharacteristic {
|
||||
/// `On` (bool) — Lightbulb / Switch power state.
|
||||
On,
|
||||
/// `Brightness` (uint8, 0–100) — Lightbulb brightness percentage.
|
||||
Brightness,
|
||||
/// `CurrentTemperature` (float, °C) — TemperatureSensor reading.
|
||||
CurrentTemperature,
|
||||
/// `CurrentRelativeHumidity` (float, %) — HumiditySensor reading.
|
||||
CurrentRelativeHumidity,
|
||||
/// `OccupancyDetected` (uint8, 0=not detected, 1=detected).
|
||||
OccupancyDetected,
|
||||
/// `MotionDetected` (bool).
|
||||
MotionDetected,
|
||||
/// `LeakDetected` (uint8, 0=no leak, 1=leak detected). Re-used for falls.
|
||||
LeakDetected,
|
||||
/// `ContactSensorState` (uint8, 0=in contact, 1=not in contact).
|
||||
ContactSensorState,
|
||||
/// `CurrentDoorState` (uint8, HAP §9.30).
|
||||
CurrentDoorState,
|
||||
/// `LockCurrentState` (uint8, HAP §9.56).
|
||||
LockCurrentState,
|
||||
/// `SecuritySystemCurrentState` (uint8, HAP §9.97).
|
||||
SecuritySystemCurrentState,
|
||||
}
|
||||
|
||||
/// Typed value carried by a HAP characteristic update.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum HapCharacteristicValue {
|
||||
Bool(bool),
|
||||
UInt8(u8),
|
||||
Float(f64),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn all_11_accessory_types_defined() {
|
||||
assert_eq!(HapAccessoryType::ALL.len(), 11);
|
||||
// Spot-check each variant is present.
|
||||
assert!(HapAccessoryType::ALL.contains(&HapAccessoryType::Lightbulb));
|
||||
assert!(HapAccessoryType::ALL.contains(&HapAccessoryType::Switch));
|
||||
assert!(HapAccessoryType::ALL.contains(&HapAccessoryType::OccupancySensor));
|
||||
assert!(HapAccessoryType::ALL.contains(&HapAccessoryType::MotionSensor));
|
||||
assert!(HapAccessoryType::ALL.contains(&HapAccessoryType::TemperatureSensor));
|
||||
assert!(HapAccessoryType::ALL.contains(&HapAccessoryType::HumiditySensor));
|
||||
assert!(HapAccessoryType::ALL.contains(&HapAccessoryType::LeakSensor));
|
||||
assert!(HapAccessoryType::ALL.contains(&HapAccessoryType::ContactSensor));
|
||||
assert!(HapAccessoryType::ALL.contains(&HapAccessoryType::Door));
|
||||
assert!(HapAccessoryType::ALL.contains(&HapAccessoryType::Lock));
|
||||
assert!(HapAccessoryType::ALL.contains(&HapAccessoryType::SecuritySystem));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn characteristic_value_roundtrip_serde() {
|
||||
let v = HapCharacteristicValue::Float(22.5);
|
||||
let json = serde_json::to_string(&v).unwrap();
|
||||
let back: HapCharacteristicValue = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(v, back);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
//! `HapBridge` — owns the set of HOMECORE entities exposed as HAP accessories.
|
||||
//!
|
||||
//! P1 does not start a real HAP-1.1 server; it ships the API surface so other
|
||||
//! crates (and P2's `hap-server` feature) can register accessories and query
|
||||
//! their current mapping. The actual mDNS + HAP pairing is gated to P2.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use homecore::entity::EntityId;
|
||||
|
||||
use crate::accessory::HapAccessoryType;
|
||||
use crate::error::HapError;
|
||||
use crate::mapping::{AccessoryMapping, EntityToAccessoryMapper};
|
||||
use crate::mdns::{HapServiceRecord, MdnsAdvertiser, NullAdvertiser};
|
||||
|
||||
/// One registered HAP accessory — an entity + its last-known mapping.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExposedAccessory {
|
||||
pub entity_id: EntityId,
|
||||
pub accessory_type: HapAccessoryType,
|
||||
pub mapping: AccessoryMapping,
|
||||
}
|
||||
|
||||
struct BridgeInner {
|
||||
accessories: HashMap<EntityId, ExposedAccessory>,
|
||||
}
|
||||
|
||||
/// The P1 HAP bridge.
|
||||
///
|
||||
/// Call [`HapBridge::add_accessory`] to register entities and
|
||||
/// [`HapBridge::running_accessories`] to read back what is currently
|
||||
/// registered. In P2, `start()` will spawn the `hap` server task.
|
||||
#[derive(Clone)]
|
||||
pub struct HapBridge {
|
||||
inner: Arc<RwLock<BridgeInner>>,
|
||||
advertiser: Arc<dyn MdnsAdvertiser>,
|
||||
pub service_record: HapServiceRecord,
|
||||
}
|
||||
|
||||
impl HapBridge {
|
||||
/// Create a bridge with the given service record and a `NullAdvertiser`
|
||||
/// (P1 default — real mDNS lands in P2).
|
||||
pub fn new(service_record: HapServiceRecord) -> Self {
|
||||
Self::with_advertiser(service_record, Arc::new(NullAdvertiser))
|
||||
}
|
||||
|
||||
/// Create a bridge with a custom `MdnsAdvertiser` (used in tests and P2).
|
||||
pub fn with_advertiser(
|
||||
service_record: HapServiceRecord,
|
||||
advertiser: Arc<dyn MdnsAdvertiser>,
|
||||
) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(RwLock::new(BridgeInner { accessories: HashMap::new() })),
|
||||
advertiser,
|
||||
service_record,
|
||||
}
|
||||
}
|
||||
|
||||
/// Register an entity as a HAP accessory.
|
||||
///
|
||||
/// The entity's current mapping is computed from `state`; call
|
||||
/// `update_accessory` on each `StateChanged` event to keep it fresh.
|
||||
///
|
||||
/// Returns `HapError::AlreadyRegistered` if the entity is already
|
||||
/// registered. Call `remove_accessory` first to replace it.
|
||||
pub fn add_accessory(
|
||||
&self,
|
||||
entity_id: &EntityId,
|
||||
state: &homecore::entity::State,
|
||||
) -> Result<(), HapError> {
|
||||
let mapping = EntityToAccessoryMapper::map(entity_id, state)?;
|
||||
let accessory_type = mapping.accessory_type;
|
||||
let exposed = ExposedAccessory {
|
||||
entity_id: entity_id.clone(),
|
||||
accessory_type,
|
||||
mapping,
|
||||
};
|
||||
let mut inner = self.inner.write().unwrap();
|
||||
if inner.accessories.contains_key(entity_id) {
|
||||
return Err(HapError::AlreadyRegistered(entity_id.as_str().to_owned()));
|
||||
}
|
||||
inner.accessories.insert(entity_id.clone(), exposed);
|
||||
tracing::debug!(entity = %entity_id, ?accessory_type, "HAP accessory registered");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a registered accessory.
|
||||
///
|
||||
/// Returns `HapError::EntityNotFound` if the entity was not registered.
|
||||
pub fn remove_accessory(&self, entity_id: &EntityId) -> Result<(), HapError> {
|
||||
let mut inner = self.inner.write().unwrap();
|
||||
if inner.accessories.remove(entity_id).is_none() {
|
||||
return Err(HapError::EntityNotFound(entity_id.as_str().to_owned()));
|
||||
}
|
||||
tracing::debug!(entity = %entity_id, "HAP accessory removed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Snapshot all currently registered accessories.
|
||||
pub fn running_accessories(&self) -> Vec<ExposedAccessory> {
|
||||
self.inner.read().unwrap().accessories.values().cloned().collect()
|
||||
}
|
||||
|
||||
/// Number of registered accessories.
|
||||
pub fn len(&self) -> usize {
|
||||
self.inner.read().unwrap().accessories.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
|
||||
/// P2 stub — will start the HAP-1.1 server + mDNS advertisement.
|
||||
/// In P1 this only fires the null advertiser.
|
||||
pub async fn start(&self) -> Result<(), HapError> {
|
||||
self.advertiser.advertise(&self.service_record).await?;
|
||||
tracing::info!(
|
||||
instance = %self.service_record.instance_name,
|
||||
port = self.service_record.port,
|
||||
"HapBridge started (P1 — no real HAP server; mDNS stub only)"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Graceful shutdown — retracts mDNS advertisement.
|
||||
pub async fn stop(&self) -> Result<(), HapError> {
|
||||
self.advertiser.retract(&self.service_record.instance_name).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use homecore::entity::{EntityId, State};
|
||||
use homecore::event::Context;
|
||||
|
||||
fn make_bridge() -> HapBridge {
|
||||
HapBridge::new(HapServiceRecord {
|
||||
instance_name: "RuView Sense".into(),
|
||||
port: 51826,
|
||||
setup_code: "111-22-333".into(),
|
||||
device_id: "AA:BB:CC:DD:EE:FF".into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn light_state(name: &str, on: bool, brightness: u8) -> (EntityId, State) {
|
||||
let eid = EntityId::parse(&format!("light.{name}")).unwrap();
|
||||
let attrs = serde_json::json!({"brightness": brightness});
|
||||
let s = State::new(eid.clone(), if on { "on" } else { "off" }, attrs, Context::default());
|
||||
(eid, s)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_remove_roundtrip() {
|
||||
let bridge = make_bridge();
|
||||
let (eid, s) = light_state("kitchen", true, 200);
|
||||
|
||||
assert!(bridge.is_empty());
|
||||
bridge.add_accessory(&eid, &s).unwrap();
|
||||
assert_eq!(bridge.len(), 1);
|
||||
|
||||
let acc = bridge.running_accessories();
|
||||
assert_eq!(acc.len(), 1);
|
||||
assert_eq!(acc[0].entity_id, eid);
|
||||
assert_eq!(acc[0].accessory_type, HapAccessoryType::Lightbulb);
|
||||
|
||||
bridge.remove_accessory(&eid).unwrap();
|
||||
assert!(bridge.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_duplicate_returns_error() {
|
||||
let bridge = make_bridge();
|
||||
let (eid, s) = light_state("kitchen", true, 200);
|
||||
bridge.add_accessory(&eid, &s).unwrap();
|
||||
let err = bridge.add_accessory(&eid, &s).unwrap_err();
|
||||
assert!(matches!(err, HapError::AlreadyRegistered(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_nonexistent_returns_error() {
|
||||
let bridge = make_bridge();
|
||||
let eid = EntityId::parse("light.ghost").unwrap();
|
||||
let err = bridge.remove_accessory(&eid).unwrap_err();
|
||||
assert!(matches!(err, HapError::EntityNotFound(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_stop_with_null_advertiser() {
|
||||
let bridge = make_bridge();
|
||||
bridge.start().await.unwrap();
|
||||
bridge.stop().await.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//! Unified error type for `homecore-hap`.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors produced by the HAP bridge and its sub-components.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum HapError {
|
||||
#[error("entity not found: {0}")]
|
||||
EntityNotFound(String),
|
||||
|
||||
#[error("entity {entity_id} cannot be mapped to a HAP accessory type: {reason}")]
|
||||
UnmappableEntity { entity_id: String, reason: String },
|
||||
|
||||
#[error("accessory already registered: {0}")]
|
||||
AlreadyRegistered(String),
|
||||
|
||||
#[error("mDNS advertiser error: {0}")]
|
||||
MdnsError(String),
|
||||
|
||||
#[error("bridge not running")]
|
||||
NotRunning,
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//! `homecore-hap` — Apple Home HomeKit Accessory Protocol bridge (ADR-125).
|
||||
//!
|
||||
//! # P1 scope
|
||||
//!
|
||||
//! Ships the trait surface and type definitions needed to map HOMECORE entity
|
||||
//! states onto HAP accessory / characteristic values. The actual HAP-1.1 TLS
|
||||
//! server and real mDNS advertisement are gated behind the `hap-server`
|
||||
//! feature (P2). P1 ships `NullAdvertiser` (no-op) so the bridge compiles and
|
||||
//! all tests pass with `--no-default-features`.
|
||||
//!
|
||||
//! # Module layout
|
||||
//!
|
||||
//! | Module | Purpose |
|
||||
//! |--------|---------|
|
||||
//! | [`accessory`] | HAP service / characteristic enum catalogue |
|
||||
//! | [`mapping`] | `EntityToAccessoryMapper` — HOMECORE entity → HAP |
|
||||
//! | [`bridge`] | `HapBridge` — owns exposed accessories |
|
||||
//! | [`mdns`] | `MdnsAdvertiser` trait + `NullAdvertiser` stub |
|
||||
//! | [`ruview`] | `RuViewToHapMapper` — sensing primitives → HAP |
|
||||
//! | [`error`] | Unified `HapError` type |
|
||||
|
||||
pub mod accessory;
|
||||
pub mod bridge;
|
||||
pub mod error;
|
||||
pub mod mapping;
|
||||
pub mod mdns;
|
||||
pub mod ruview;
|
||||
|
||||
pub use accessory::{HapAccessoryType, HapCharacteristic, HapCharacteristicValue};
|
||||
pub use bridge::{ExposedAccessory, HapBridge};
|
||||
pub use error::HapError;
|
||||
pub use mapping::EntityToAccessoryMapper;
|
||||
pub use mdns::{MdnsAdvertiser, NullAdvertiser};
|
||||
pub use ruview::RuViewToHapMapper;
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//! mDNS advertisement trait and P1 no-op stub.
|
||||
//!
|
||||
//! Real mDNS via the `mdns-sd` crate (https://crates.io/crates/mdns-sd)
|
||||
//! lands in P2 behind the `hap-server` feature flag. P1 ships `NullAdvertiser`
|
||||
//! so the bridge compiles and tests pass without any mDNS infrastructure.
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::error::HapError;
|
||||
|
||||
/// Service record advertised over mDNS for HAP discovery.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HapServiceRecord {
|
||||
/// Service instance name shown in Apple Home ("RuView Sense").
|
||||
pub instance_name: String,
|
||||
/// TCP port the HAP server listens on (default 51826).
|
||||
pub port: u16,
|
||||
/// HAP pairing setup code (8 digits, formatted as XXX-XX-XXX).
|
||||
pub setup_code: String,
|
||||
/// Unique device ID (colon-separated MAC-like hex, required by HAP §5.4).
|
||||
pub device_id: String,
|
||||
}
|
||||
|
||||
/// Advertise (and retract) a HAP accessory over mDNS (`_hap._tcp`).
|
||||
///
|
||||
/// Implementors register the `_hap._tcp` service so HomePod / Apple TV can
|
||||
/// discover the bridge and initiate pairing. P1 provides only `NullAdvertiser`.
|
||||
#[async_trait]
|
||||
pub trait MdnsAdvertiser: Send + Sync {
|
||||
/// Begin advertising the service. Idempotent.
|
||||
async fn advertise(&self, record: &HapServiceRecord) -> Result<(), HapError>;
|
||||
|
||||
/// Stop advertising. Called on bridge shutdown.
|
||||
async fn retract(&self, instance_name: &str) -> Result<(), HapError>;
|
||||
}
|
||||
|
||||
/// No-op advertiser for P1 / test environments.
|
||||
///
|
||||
/// All calls succeed without touching the network.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct NullAdvertiser;
|
||||
|
||||
#[async_trait]
|
||||
impl MdnsAdvertiser for NullAdvertiser {
|
||||
async fn advertise(&self, record: &HapServiceRecord) -> Result<(), HapError> {
|
||||
tracing::debug!(
|
||||
instance = %record.instance_name,
|
||||
port = record.port,
|
||||
"NullAdvertiser: skipping mDNS advertisement (P1 stub)"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn retract(&self, instance_name: &str) -> Result<(), HapError> {
|
||||
tracing::debug!(
|
||||
instance = %instance_name,
|
||||
"NullAdvertiser: skipping mDNS retract (P1 stub)"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn null_advertiser_is_noop() {
|
||||
let adv = NullAdvertiser;
|
||||
let rec = HapServiceRecord {
|
||||
instance_name: "RuView Sense".into(),
|
||||
port: 51826,
|
||||
setup_code: "111-22-333".into(),
|
||||
device_id: "AA:BB:CC:DD:EE:FF".into(),
|
||||
};
|
||||
adv.advertise(&rec).await.unwrap();
|
||||
adv.retract(&rec.instance_name).await.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
//! RuView sensing primitives → HAP characteristic mapping (ADR-125 §2.1.d).
|
||||
//!
|
||||
//! Per ADR-125, RuView's privacy-class-2/3 events map to HomeKit primitives
|
||||
//! as semantic ambient signals, not surveillance events:
|
||||
//!
|
||||
//! | RuView primitive | HAP service | Rationale |
|
||||
//! |-----------------|-------------|-----------|
|
||||
//! | `edge_vitals.presence` | OccupancySensor | Anonymous presence = occupancy |
|
||||
//! | `edge_vitals.motion` | MotionSensor | Motion burst |
|
||||
//! | `edge_vitals.fall_detected` | LeakSensor | HA convention: abnormal events |
|
||||
//! | `edge_vitals.breathing_present` | OccupancySensor | Sleep-room occupancy |
|
||||
//!
|
||||
//! Raw `identity_risk_score`, `rf_signature_hash`, and class-0 BFI data are
|
||||
//! **never** mapped. Structural invariant I1 (ADR-118 §2.2) is enforced here.
|
||||
|
||||
use crate::accessory::{HapAccessoryType, HapCharacteristic, HapCharacteristicValue};
|
||||
use crate::mapping::AccessoryMapping;
|
||||
|
||||
/// Parsed RuView edge vitals event from the sensing-server.
|
||||
///
|
||||
/// All fields are class-2 (Anonymous) or class-3 (Restricted) derived signals.
|
||||
/// Raw BFI / `identity_risk_score` / `rf_signature_hash` are intentionally
|
||||
/// absent — they must not cross the HAP boundary per ADR-125 §2.2.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct EdgeVitals {
|
||||
/// True if at least one person is present in the sensing zone.
|
||||
pub presence: bool,
|
||||
/// True if motion was detected in the last sensing window.
|
||||
pub motion: bool,
|
||||
/// True if a fall event was detected (latched, 5 s cooldown).
|
||||
pub fall_detected: bool,
|
||||
/// True if rhythmic breathing is detected (sleep-room occupancy signal).
|
||||
pub breathing_present: bool,
|
||||
/// Optional ambient temperature reading (°C), forwarded if available
|
||||
/// from a co-located temperature sensor.
|
||||
pub ambient_temp_c: Option<f64>,
|
||||
}
|
||||
|
||||
/// Maps `EdgeVitals` to a `Vec<AccessoryMapping>` — one per RuView primitive
|
||||
/// that should be exposed as a distinct HAP service (child accessory).
|
||||
pub struct RuViewToHapMapper;
|
||||
|
||||
impl RuViewToHapMapper {
|
||||
/// Convert a `EdgeVitals` snapshot to HAP accessory mappings.
|
||||
///
|
||||
/// Always returns mappings for presence, motion, and fall; the ambient
|
||||
/// temperature mapping is only emitted when `ambient_temp_c` is `Some`.
|
||||
pub fn map(vitals: &EdgeVitals) -> Vec<AccessoryMapping> {
|
||||
let mut out = Vec::with_capacity(4);
|
||||
|
||||
// Presence → OccupancySensor
|
||||
out.push(AccessoryMapping {
|
||||
accessory_type: HapAccessoryType::OccupancySensor,
|
||||
characteristics: vec![(
|
||||
HapCharacteristic::OccupancyDetected,
|
||||
HapCharacteristicValue::UInt8(if vitals.presence || vitals.breathing_present { 1 } else { 0 }),
|
||||
)],
|
||||
});
|
||||
|
||||
// Motion → MotionSensor
|
||||
out.push(AccessoryMapping {
|
||||
accessory_type: HapAccessoryType::MotionSensor,
|
||||
characteristics: vec![(
|
||||
HapCharacteristic::MotionDetected,
|
||||
HapCharacteristicValue::Bool(vitals.motion),
|
||||
)],
|
||||
});
|
||||
|
||||
// Fall detected → LeakSensor (HA homekit_controller convention for
|
||||
// "abnormal event" — not a literal water leak, but an automation-
|
||||
// triggerable threshold event, per ADR-125 §2.1.d).
|
||||
out.push(AccessoryMapping {
|
||||
accessory_type: HapAccessoryType::LeakSensor,
|
||||
characteristics: vec![(
|
||||
HapCharacteristic::LeakDetected,
|
||||
HapCharacteristicValue::UInt8(if vitals.fall_detected { 1 } else { 0 }),
|
||||
)],
|
||||
});
|
||||
|
||||
// Optional temperature
|
||||
if let Some(temp) = vitals.ambient_temp_c {
|
||||
out.push(AccessoryMapping {
|
||||
accessory_type: HapAccessoryType::TemperatureSensor,
|
||||
characteristics: vec![(
|
||||
HapCharacteristic::CurrentTemperature,
|
||||
HapCharacteristicValue::Float(temp),
|
||||
)],
|
||||
});
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::accessory::{HapAccessoryType, HapCharacteristic, HapCharacteristicValue};
|
||||
|
||||
#[test]
|
||||
fn presence_true_maps_to_occupancy_detected_1() {
|
||||
let vitals = EdgeVitals { presence: true, ..Default::default() };
|
||||
let mappings = RuViewToHapMapper::map(&vitals);
|
||||
let occ = mappings.iter().find(|m| m.accessory_type == HapAccessoryType::OccupancySensor).unwrap();
|
||||
assert!(occ.characteristics.contains(&(
|
||||
HapCharacteristic::OccupancyDetected,
|
||||
HapCharacteristicValue::UInt8(1)
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fall_detected_maps_to_leak_sensor() {
|
||||
let vitals = EdgeVitals { fall_detected: true, ..Default::default() };
|
||||
let mappings = RuViewToHapMapper::map(&vitals);
|
||||
let leak = mappings.iter().find(|m| m.accessory_type == HapAccessoryType::LeakSensor).unwrap();
|
||||
assert!(leak.characteristics.contains(&(
|
||||
HapCharacteristic::LeakDetected,
|
||||
HapCharacteristicValue::UInt8(1)
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_false_maps_correctly() {
|
||||
let vitals = EdgeVitals { motion: false, ..Default::default() };
|
||||
let mappings = RuViewToHapMapper::map(&vitals);
|
||||
let mot = mappings.iter().find(|m| m.accessory_type == HapAccessoryType::MotionSensor).unwrap();
|
||||
assert!(mot.characteristics.contains(&(
|
||||
HapCharacteristic::MotionDetected,
|
||||
HapCharacteristicValue::Bool(false)
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambient_temp_emits_temperature_mapping() {
|
||||
let vitals = EdgeVitals { ambient_temp_c: Some(22.5), ..Default::default() };
|
||||
let mappings = RuViewToHapMapper::map(&vitals);
|
||||
let temp = mappings.iter().find(|m| m.accessory_type == HapAccessoryType::TemperatureSensor);
|
||||
assert!(temp.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_ambient_temp_omits_temperature_mapping() {
|
||||
let vitals = EdgeVitals { ambient_temp_c: None, ..Default::default() };
|
||||
let mappings = RuViewToHapMapper::map(&vitals);
|
||||
assert!(mappings.iter().all(|m| m.accessory_type != HapAccessoryType::TemperatureSensor));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn breathing_present_triggers_occupancy() {
|
||||
let vitals = EdgeVitals { presence: false, breathing_present: true, ..Default::default() };
|
||||
let mappings = RuViewToHapMapper::map(&vitals);
|
||||
let occ = mappings.iter().find(|m| m.accessory_type == HapAccessoryType::OccupancySensor).unwrap();
|
||||
assert!(occ.characteristics.contains(&(
|
||||
HapCharacteristic::OccupancyDetected,
|
||||
HapCharacteristicValue::UInt8(1)
|
||||
)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user