From cc079febef5501b1c53ec55d3c543a7909b18d52 Mon Sep 17 00:00:00 2001 From: ruv Date: Mon, 25 May 2026 18:25:27 -0400 Subject: [PATCH] feat(homecore-hap/p1): ADR-125 HAP bridge scaffold (17 tests pass) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- v2/Cargo.lock | 505 ++++++++++++++++++++++++ v2/Cargo.toml | 3 + v2/crates/homecore-hap/Cargo.toml | 36 ++ v2/crates/homecore-hap/src/accessory.rs | 124 ++++++ v2/crates/homecore-hap/src/bridge.rs | 196 +++++++++ v2/crates/homecore-hap/src/error.rs | 22 ++ v2/crates/homecore-hap/src/lib.rs | 34 ++ v2/crates/homecore-hap/src/mapping.rs | 273 +++++++++++++ v2/crates/homecore-hap/src/mdns.rs | 79 ++++ v2/crates/homecore-hap/src/ruview.rs | 158 ++++++++ 10 files changed, 1430 insertions(+) create mode 100644 v2/crates/homecore-hap/Cargo.toml create mode 100644 v2/crates/homecore-hap/src/accessory.rs create mode 100644 v2/crates/homecore-hap/src/bridge.rs create mode 100644 v2/crates/homecore-hap/src/error.rs create mode 100644 v2/crates/homecore-hap/src/lib.rs create mode 100644 v2/crates/homecore-hap/src/mapping.rs create mode 100644 v2/crates/homecore-hap/src/mdns.rs create mode 100644 v2/crates/homecore-hap/src/ruview.rs diff --git a/v2/Cargo.lock b/v2/Cargo.lock index bbbf885c..7b825927 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -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" diff --git a/v2/Cargo.toml b/v2/Cargo.toml index 5af860d1..23369461 100644 --- a/v2/Cargo.toml +++ b/v2/Cargo.toml @@ -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`. diff --git a/v2/crates/homecore-hap/Cargo.toml b/v2/crates/homecore-hap/Cargo.toml new file mode 100644 index 00000000..a4ebe91a --- /dev/null +++ b/v2/crates/homecore-hap/Cargo.toml @@ -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 ", "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"] } diff --git a/v2/crates/homecore-hap/src/accessory.rs b/v2/crates/homecore-hap/src/accessory.rs new file mode 100644 index 00000000..1e1adfb7 --- /dev/null +++ b/v2/crates/homecore-hap/src/accessory.rs @@ -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); + } +} diff --git a/v2/crates/homecore-hap/src/bridge.rs b/v2/crates/homecore-hap/src/bridge.rs new file mode 100644 index 00000000..faa66185 --- /dev/null +++ b/v2/crates/homecore-hap/src/bridge.rs @@ -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, +} + +/// 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>, + advertiser: Arc, + 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, + ) -> 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 { + 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(); + } +} diff --git a/v2/crates/homecore-hap/src/error.rs b/v2/crates/homecore-hap/src/error.rs new file mode 100644 index 00000000..a7125616 --- /dev/null +++ b/v2/crates/homecore-hap/src/error.rs @@ -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, +} diff --git a/v2/crates/homecore-hap/src/lib.rs b/v2/crates/homecore-hap/src/lib.rs new file mode 100644 index 00000000..931f2f8a --- /dev/null +++ b/v2/crates/homecore-hap/src/lib.rs @@ -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; diff --git a/v2/crates/homecore-hap/src/mapping.rs b/v2/crates/homecore-hap/src/mapping.rs new file mode 100644 index 00000000..770a3a6a --- /dev/null +++ b/v2/crates/homecore-hap/src/mapping.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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()); + } +} diff --git a/v2/crates/homecore-hap/src/mdns.rs b/v2/crates/homecore-hap/src/mdns.rs new file mode 100644 index 00000000..ada1bed3 --- /dev/null +++ b/v2/crates/homecore-hap/src/mdns.rs @@ -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(); + } +} diff --git a/v2/crates/homecore-hap/src/ruview.rs b/v2/crates/homecore-hap/src/ruview.rs new file mode 100644 index 00000000..c0a46d2a --- /dev/null +++ b/v2/crates/homecore-hap/src/ruview.rs @@ -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, +} + +/// Maps `EdgeVitals` to a `Vec` — 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 { + 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) + ))); + } +}