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

505
v2/Cargo.lock generated
View File

@ -35,6 +35,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"getrandom 0.3.4",
"once_cell",
"version_check",
"zerocopy",
@ -313,6 +314,15 @@ dependencies = [
"system-deps",
]
[[package]]
name = "atoi"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528"
dependencies = [
"num-traits",
]
[[package]]
name = "atomic-polyfill"
version = "1.0.3"
@ -1750,6 +1760,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"const-oid",
"crypto-common",
"subtle",
]
@ -1863,6 +1874,12 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "dotenvy"
version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "dpi"
version = "0.1.2"
@ -1964,6 +1981,9 @@ name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
dependencies = [
"serde",
]
[[package]]
name = "embed-resource"
@ -2074,6 +2094,23 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "etcetera"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943"
dependencies = [
"cfg-if",
"home",
"windows-sys 0.48.0",
]
[[package]]
name = "event-listener"
version = "2.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0"
[[package]]
name = "fallible-iterator"
version = "0.3.0"
@ -2327,6 +2364,17 @@ dependencies = [
"futures-util",
]
[[package]]
name = "futures-intrusive"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f"
dependencies = [
"futures-core",
"lock_api",
"parking_lot",
]
[[package]]
name = "futures-io"
version = "0.3.32"
@ -3205,6 +3253,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
"allocator-api2",
"serde",
]
@ -3232,6 +3281,15 @@ dependencies = [
"serde_core",
]
[[package]]
name = "hashlink"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7"
dependencies = [
"hashbrown 0.14.5",
]
[[package]]
name = "hdrhistogram"
version = "7.5.4"
@ -3282,6 +3340,9 @@ name = "heck"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "heck"
@ -3301,6 +3362,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hkdf"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
dependencies = [
"hmac",
]
[[package]]
name = "hmac"
version = "0.12.1"
@ -3341,6 +3411,15 @@ dependencies = [
"serde",
]
[[package]]
name = "home"
version = "0.5.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "homecore"
version = "0.1.0-alpha.0"
@ -3378,6 +3457,36 @@ dependencies = [
"uuid",
]
[[package]]
name = "homecore-automation"
version = "0.1.0-alpha.0"
dependencies = [
"async-trait",
"chrono",
"homecore",
"minijinja",
"serde",
"serde_json",
"serde_yaml",
"thiserror 1.0.69",
"tokio",
"uuid",
]
[[package]]
name = "homecore-hap"
version = "0.1.0-alpha.0"
dependencies = [
"async-trait",
"homecore",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"tracing",
"uuid",
]
[[package]]
name = "homecore-plugins"
version = "0.1.0-alpha.0"
@ -3392,6 +3501,21 @@ dependencies = [
"wasmtime",
]
[[package]]
name = "homecore-recorder"
version = "0.1.0-alpha.0"
dependencies = [
"async-trait",
"chrono",
"homecore",
"serde",
"serde_json",
"sqlx",
"thiserror 1.0.69",
"tokio",
"tracing",
]
[[package]]
name = "html5ever"
version = "0.29.1"
@ -4076,6 +4200,9 @@ name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
dependencies = [
"spin",
]
[[package]]
name = "leb128"
@ -4167,6 +4294,17 @@ dependencies = [
"redox_syscall 0.7.4",
]
[[package]]
name = "libsqlite3-sys"
version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf4e226dcd58b4be396f7bd3c20da8fdee2911400705297ba7d2d7cc2c30f716"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "libudev"
version = "0.3.0"
@ -4312,6 +4450,16 @@ dependencies = [
"rawpointer",
]
[[package]]
name = "md-5"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [
"cfg-if",
"digest",
]
[[package]]
name = "mdns-sd"
version = "0.11.5"
@ -4350,6 +4498,12 @@ dependencies = [
"stable_deref_trait",
]
[[package]]
name = "memo-map"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b"
[[package]]
name = "memoffset"
version = "0.9.1"
@ -4444,6 +4598,17 @@ dependencies = [
"walkdir",
]
[[package]]
name = "minijinja"
version = "2.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2929e494b2280e1e18959bb2e121da03347ae896896fdfaceaab43c88a02803f"
dependencies = [
"memo-map",
"serde",
"serde_json",
]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
@ -4809,6 +4974,22 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-bigint-dig"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
dependencies = [
"lazy_static",
"libm",
"num-integer",
"num-iter",
"num-traits",
"rand 0.8.5",
"smallvec",
"zeroize",
]
[[package]]
name = "num-complex"
version = "0.4.6"
@ -5587,6 +5768,17 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "pkcs1"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
dependencies = [
"der",
"pkcs8",
"spki",
]
[[package]]
name = "pkcs8"
version = "0.10.2"
@ -6605,6 +6797,26 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e27ee8bb91ca0adcf0ecb116293afa12d393f9c2b9b9cd54d33e8078fe19839"
[[package]]
name = "rsa"
version = "0.9.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
dependencies = [
"const-oid",
"digest",
"num-bigint-dig",
"num-integer",
"num-traits",
"pkcs1",
"pkcs8",
"rand_core 0.6.4",
"signature",
"spki",
"subtle",
"zeroize",
]
[[package]]
name = "rstar"
version = "0.8.4"
@ -7419,6 +7631,19 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap 2.13.0",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]]
name = "serialize-to-javascript"
version = "0.1.2"
@ -7561,6 +7786,7 @@ version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"digest",
"rand_core 0.6.4",
]
@ -7747,6 +7973,219 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b9b39299b249ad65f3b7e96443bad61c02ca5cd3589f46cb6d610a0fd6c0d6a"
[[package]]
name = "sqlformat"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7bba3a93db0cc4f7bdece8bb09e77e2e785c20bfebf79eb8340ed80708048790"
dependencies = [
"nom",
"unicode_categories",
]
[[package]]
name = "sqlx"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9a2ccff1a000a5a59cd33da541d9f2fdcd9e6e8229cc200565942bff36d0aaa"
dependencies = [
"sqlx-core",
"sqlx-macros",
"sqlx-mysql",
"sqlx-postgres",
"sqlx-sqlite",
]
[[package]]
name = "sqlx-core"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24ba59a9342a3d9bab6c56c118be528b27c9b60e490080e9711a04dccac83ef6"
dependencies = [
"ahash",
"atoi",
"byteorder",
"bytes",
"chrono",
"crc",
"crossbeam-queue",
"either",
"event-listener",
"futures-channel",
"futures-core",
"futures-intrusive",
"futures-io",
"futures-util",
"hashlink",
"hex",
"indexmap 2.13.0",
"log",
"memchr",
"native-tls",
"once_cell",
"paste",
"percent-encoding",
"serde",
"serde_json",
"sha2",
"smallvec",
"sqlformat",
"thiserror 1.0.69",
"tokio",
"tokio-stream",
"tracing",
"url",
"uuid",
]
[[package]]
name = "sqlx-macros"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ea40e2345eb2faa9e1e5e326db8c34711317d2b5e08d0d5741619048a803127"
dependencies = [
"proc-macro2",
"quote",
"sqlx-core",
"sqlx-macros-core",
"syn 1.0.109",
]
[[package]]
name = "sqlx-macros-core"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5833ef53aaa16d860e92123292f1f6a3d53c34ba8b1969f152ef1a7bb803f3c8"
dependencies = [
"dotenvy",
"either",
"heck 0.4.1",
"hex",
"once_cell",
"proc-macro2",
"quote",
"serde",
"serde_json",
"sha2",
"sqlx-core",
"sqlx-mysql",
"sqlx-postgres",
"sqlx-sqlite",
"syn 1.0.109",
"tempfile",
"tokio",
"url",
]
[[package]]
name = "sqlx-mysql"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ed31390216d20e538e447a7a9b959e06ed9fc51c37b514b46eb758016ecd418"
dependencies = [
"atoi",
"base64 0.21.7",
"bitflags 2.11.0",
"byteorder",
"bytes",
"chrono",
"crc",
"digest",
"dotenvy",
"either",
"futures-channel",
"futures-core",
"futures-io",
"futures-util",
"generic-array 0.14.7",
"hex",
"hkdf",
"hmac",
"itoa",
"log",
"md-5",
"memchr",
"once_cell",
"percent-encoding",
"rand 0.8.5",
"rsa",
"serde",
"sha1",
"sha2",
"smallvec",
"sqlx-core",
"stringprep",
"thiserror 1.0.69",
"tracing",
"uuid",
"whoami",
]
[[package]]
name = "sqlx-postgres"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c824eb80b894f926f89a0b9da0c7f435d27cdd35b8c655b114e58223918577e"
dependencies = [
"atoi",
"base64 0.21.7",
"bitflags 2.11.0",
"byteorder",
"chrono",
"crc",
"dotenvy",
"etcetera",
"futures-channel",
"futures-core",
"futures-io",
"futures-util",
"hex",
"hkdf",
"hmac",
"home",
"itoa",
"log",
"md-5",
"memchr",
"once_cell",
"rand 0.8.5",
"serde",
"serde_json",
"sha2",
"smallvec",
"sqlx-core",
"stringprep",
"thiserror 1.0.69",
"tracing",
"uuid",
"whoami",
]
[[package]]
name = "sqlx-sqlite"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b244ef0a8414da0bed4bb1910426e890b19e5e9bccc27ada6b797d05c55ae0aa"
dependencies = [
"atoi",
"chrono",
"flume",
"futures-channel",
"futures-core",
"futures-executor",
"futures-intrusive",
"futures-util",
"libsqlite3-sys",
"log",
"percent-encoding",
"serde",
"sqlx-core",
"tracing",
"url",
"urlencoding",
"uuid",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@ -7790,6 +8229,17 @@ dependencies = [
"quote",
]
[[package]]
name = "stringprep"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
dependencies = [
"unicode-bidi",
"unicode-normalization",
"unicode-properties",
]
[[package]]
name = "strsim"
version = "0.11.1"
@ -9103,12 +9553,33 @@ version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-bidi"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-normalization"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
dependencies = [
"tinyvec",
]
[[package]]
name = "unicode-properties"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
[[package]]
name = "unicode-segmentation"
version = "1.12.0"
@ -9127,6 +9598,18 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "unicode_categories"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "untrusted"
version = "0.9.0"
@ -9199,6 +9682,12 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "urlpattern"
version = "0.3.0"
@ -9359,6 +9848,12 @@ dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
[[package]]
name = "wasm-bindgen"
version = "0.2.114"
@ -10003,6 +10498,16 @@ dependencies = [
"windows-core 0.61.2",
]
[[package]]
name = "whoami"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d"
dependencies = [
"libredox",
"wasite",
]
[[package]]
name = "wide"
version = "0.7.33"

View File

@ -31,6 +31,8 @@ members = [
"crates/homecore", # ADR-127 — HOMECORE state machine
"crates/homecore-plugins", # ADR-128 — HOMECORE-PLUGINS WASM runtime (P1 scaffold)
"crates/homecore-api", # ADR-130 — HOMECORE REST + WS API
"crates/homecore-automation", # ADR-129 — HOMECORE automation engine
"crates/homecore-recorder", # ADR-132 — HOMECORE state recorder
# ADR-100/ADR-101: Cognitum Cog packaging — first Cog from this repo.
# Ships the wifi-densepose pose-estimation model as a signed binary +
# JSONL manifest installable by the Cognitum V0 appliance (cognitum-v0,
@ -55,6 +57,7 @@ members = [
# `vendor/rvcsi` and published to crates.io as `rvcsi-*` 0.3.x. Depend on the
# published crates (or the submodule's `crates/rvcsi-*` paths) — not as v2
# workspace members, since `vendor/rvcsi/Cargo.toml` is its own workspace.
"crates/homecore-hap", # ADR-125 — Apple Home HomeKit Accessory Protocol bridge
]
# ADR-040: WASM edge crate targets wasm32-unknown-unknown (no_std),
# excluded from workspace to avoid breaking `cargo test --workspace`.

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"] }

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);
}
}

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();
}
}

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,
}

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;

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());
}
}

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();
}
}

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)
)));
}
}