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:
ruv
2026-05-25 18:25:27 -04:00
parent 02742f2f47
commit cc079febef
10 changed files with 1430 additions and 0 deletions
+36
View File
@@ -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"] }
+124
View File
@@ -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, 0100) — 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);
}
}
+196
View File
@@ -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();
}
}
+22
View File
@@ -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,
}
+34
View File
@@ -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;
+273
View File
@@ -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());
}
}
+79
View File
@@ -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();
}
}
+158
View File
@@ -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)
)));
}
}