Compare commits

..

24 Commits

Author SHA1 Message Date
rUv 535043731c fix(homecore): review findings from PR #1451 — HAP secret redaction, REST cap, event_type, migration --force (#1452)
HAP accessory signing seed no longer reachable via derived Debug. /api/history/period and /api/logbook no longer break the default (unfiltered) call shape above 32 entities. fire_event's event_type validation relaxed to match real HA's contract. homecore-migrate gained a --force flag for re-running imports. Public v2051 release notes corrected. 110 tests across the 3 touched crates, 0 failed, clippy clean.
2026-07-27 17:01:25 -07:00
rUv 42d56fc1a5 Merge pull request #1451 from ruvnet/feat/homecore-platform-parity
feat(homecore): complete platform runtime capabilities
2026-07-27 12:37:42 -07:00
ruv 5bf820700c fix(homecore-plugins): use patched Wasmtime runtime 2026-07-27 15:23:33 -04:00
ruv 546081e628 docs(homecore): record platform runtime completion 2026-07-27 15:11:03 -04:00
ruv e7c598e64c fix(homecore-server): provision stable HAP identity securely 2026-07-27 15:08:52 -04:00
ruv 42684a7a1e feat(hap): implement authenticated pairing and transport 2026-07-27 15:06:26 -04:00
ruv b41b8c8a82 fix(homecore-api): bound multi-entity history responses 2026-07-27 14:47:04 -04:00
ruv e47d40c5c4 feat(homecore-api): add logbook and integration REST routes 2026-07-27 14:45:35 -04:00
ruv bc690ff309 feat(homecore-api): serve bounded recorder history 2026-07-27 14:43:48 -04:00
ruv fbd5cfa242 feat(homecore-server): wire HAP runtime and registry APIs 2026-07-27 14:41:25 -04:00
ruv 5b5c7f323d fix(homecore): wire plugin lifecycle and preserve MSRV 2026-07-27 14:30:51 -04:00
ruv d8dcccda28 feat(homecore): load signed native and Wasmtime plugins 2026-07-27 14:23:01 -04:00
ruv ec2c64cb62 fix(homecore-server): separate HA and dashboard event routes 2026-07-27 14:19:51 -04:00
ruv c2abe53e92 feat(homecore-hap): add fail-closed network foundation 2026-07-27 14:15:59 -04:00
ruv 0a8e72e762 feat(homecore): complete migration and startup restore 2026-07-27 14:14:56 -04:00
ruv 3136f1305b feat(homecore-api): negotiate modern websocket clients 2026-07-27 14:09:17 -04:00
ruv 273bd449c8 feat(homecore-api): expose loaded components 2026-07-27 14:07:06 -04:00
ruv ac1fdfb725 feat(homecore): add API compatibility and voice protocols 2026-07-27 13:59:36 -04:00
rUv 581af67fbc fix(homecore): harden runtime and publish truthful capabilities (#1450) 2026-07-27 10:41:43 -07:00
rUv 13015c9d36 fix(vitals): HeartRateExtractor weights=[] silent None + BreathingExtractor stale-lock recovery (#1422, #1423) (#1449)
Fixes #1422, Fixes #1423. HeartRateExtractor no longer truncates subcarrier count on empty phases; BreathingExtractor resets immediately on first out-of-band rejection instead of passively draining a stale window (recovery 28.6s -> 6.0s). 64 tests passing, clippy clean, zero regressions workspace-wide.
2026-07-27 08:46:58 -07:00
rUv 931a38abdb fix(calibration): PresenceSpecialist reads empty room as present when occ_var < empty_var (#1440) (#1448)
Fixes the calibration-path bug in #1440. Disables the variance channel when occupied-anchor variance doesn't genuinely exceed the empty-room baseline, mirroring the existing mean-shift channel's fallback. 64 tests passing, zero regressions.
2026-07-27 08:46:47 -07:00
rUv 4e720540d8 fix(sensing-server): classification.presence contradicting motion_level (#1442) (#1447)
Fixes #1442. Extracted classify_vitals() so presence is always derived from the label, matching the convention already used elsewhere in the codebase. 4 new regression tests, 707 tests passing, zero regressions.
2026-07-27 08:46:36 -07:00
rUv 2cc378c12f chore: version-bump and republish 10 of 12 documented crates to crates.io (#1439)
Published: wifi-densepose-core 0.3.2, -vitals 0.3.2, -wifiscan 0.3.2, -hardware 0.3.2, -signal 0.3.6, -nn 0.3.2, -ruvector 0.3.3, -train 0.3.3, -mat 0.3.2, -wasm 0.3.1.

Not published (blocked, needs a decision): wifi-densepose-sensing-server and wifi-densepose-cli both path-depend on ruview-auth, which is publish = false. Version bumps reserved (0.3.5, 0.3.2) but not published.
2026-07-26 15:44:24 -07:00
rUv 2e018f4f19 feat(ruview-unified): Unified RF spatial world model — ADR-273..282 (#1437)
Native frame contract, universal RF encoder, RF-aware Gaussian spatial memory, physics-guided synthetic RF worlds, edge sensing control plane, BLE-CS + factorized pose. All 10 ADRs (273-282) fully implemented and tested (99 tests); ADR-278 (radar inverse rendering) honestly gated with zero code as a future research program.

Deep-reviewed and hardware-tested against a live ESP32-C6 CSI node before merge: fixed a reachable panic, a silent NaN-corruption path, a cross-entity Gaussian conflation bug, and a wrong-center-frequency bug in the WiFi adapter (confirmed live: was misreporting channel 4 as 2437 MHz, now correctly reports 2427 MHz matching the hardware parser exactly). Added a standing hardware-in-the-loop test (examples/esp32_live_hardware_test.rs). Also fixed unrelated pre-existing issues surfaced during validation (wifi-densepose-core clippy warnings, a ruview-auth Windows build break, a sensing-server test flake).

Full review: https://gist.github.com/ruvnet/89795f3c4b8ea166cff5ac35ae4c7651
2026-07-26 14:37:56 -07:00
90 changed files with 10668 additions and 1507 deletions
+3
View File
@@ -8,11 +8,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **HOMECORE platform runtime completion — secure native/Wasmtime plugins, authenticated HAP IP, expanded Home Assistant APIs, durable restoration/migration, and voice protocols.** `homecore-server` now owns deterministic compiled-in native plugin registration plus explicitly configured, path-bounded, Ed25519 publisher-verified Wasm packages executed through Wasmtime with setup/state-change/teardown lifecycle; arbitrary native dynamic libraries remain intentionally unsupported. The optional HAP server implements persisted accessory identity and controller records, SRP-6a Pair-Setup M1M6, X25519/Ed25519 Pair-Verify M1M4, HKDF-SHA512/ChaCha20-Poly1305 record framing, authenticated/admin endpoint gates, replay/tamper closure, live entity synchronization, and paired-state `_hap._tcp` mDNS updates (45 focused tests; external Apple certification is not claimed). Startup restores device/entity registries and deterministic latest recorder states before plugins, and migration now atomically preserves forward-compatible device/config-entry fields. The HA-compatible surface adds events, templates, config checks, components, registries, history/logbook with SQL-enforced global response bounds, calendar/camera provider routes, and modern WebSocket negotiation while retaining a machine-readable limitations matrix for integration-specific behavior. Assist adds bounded PCM16, async STT/TTS contracts, an end-to-end speech pipeline, and an authenticated satellite session protocol; real deployments still provide the speech engines.
- **`ruview-unified` increment 3 — Gaussian update-loop completion, separable delay-Doppler, and property-tested boundary hardening.** (1) `GaussianMap::merge_overlapping` (ADR-275 step 5: mutual-Mahalanobis + semantic-compatibility dedup catching drift the insert-time gate misses) and lifetime-aware decay (`τ_eff = τ·(1+ln(1+lifetime/τ))` — confirmed structures outlive transients at equal nominal τ). (2) `delay_doppler_map` reimplemented separably (`O(B²S+S²B)`), proven equivalent to the direct reference to <1e-10 and **measured 8.3× faster** (520 µs vs 4.34 ms at 56×8). (3) `tests/security_boundaries.rs` — 8 `proptest` properties over the boundary surfaces (arbitrary values incl. NaN/±inf via `f64::from_bits`) that found and fixed three input-controlled defects: a BLE-CS phase-unwrap infinite loop on non-finite phases and an ~1e299-iteration loop on finite-huge phases (now O(1) modular unwrap + plausibility bound), and a subnormal Gaussian scale overflowing `1/σ²` to NaN density (now physical σ/occupancy bounds). (4) New criterion benches for all increment-2 hot paths (`to_canonical` 38 µs, `ble_cs_range` 481 ns, AoI planner 647 ns/200 regions, coherent fusion 1.5 µs/32 members, factorized pose 521 ns). ruview-unified now 98 tests (87 lib + 3 acceptance + 8 security), 0 failed, clippy-clean.
- **`ruview-unified` increment 2 — native frame contract + programmable perception (ADR-279..282).** (1) `RfFrameV2` becomes the authoritative RF record: native complex IQ with explicit validity masks, declared `PhaseState`, TX/RX poses + antenna geometry in one building frame, calibration/quality state, and a provenance rule enforced at construction — `Synthetic ⇒ L0Simulation` and `Measured ⇒ ≥ L1CapturedReplay` can never alias (the public L0L5 evidence ladder is now a type); the 56-bin canonical tensor is demoted to a derived compatibility view (`to_canonical`, mask-aware gap-filling through the same normalization path as every adapter; native samples proven byte-untouched). (2) Active sensing control plane (`control.rs`): ETSI-ISAC-vocabulary `SensingTask` admission (raw export always refused; identity requires consent), `SensingAction`/`InformationGoal`, an age-of-information `ActiveSensingPlanner` (priority = uncertainty × change rate × criticality ÷ cost; **measured 95% sensing-traffic reduction** vs uniform refresh on a 20-region scenario), fail-closed `CoherentSensorGroup` fusion gates (time/phase/geometry bounds; five denial paths tested), policy-authorized RIS/movable-antenna actuation receipts, and purpose-scoped `TaskSufficientRepresentation` leakage validation. (3) New modality surfaces: BLE Channel Sounding adapter + `ble_cs_range` treating phase-slope and RTT as **separate cross-validated evidence** (exact distance recovery on synthetic tones; relay-style divergence flagged, never averaged), delay-Doppler-native `FieldAxis` + `delay_doppler_map` (unit-peak tone test), IEEE P3162 synthetic-aperture import profile. (4) RePos-factorized pose head (relative skeleton on the content representation, root on the geometry-conditioned one, calibrated per-joint uncertainties): held-out-room MPJPE 0.0003 m vs 0.2534 m for the monolithic baseline in the room-shortcut leakage experiment; ≤2% structured-adapter budget (740 params). (5) Age gate input now `log(1+age_ms)` per the age-aware-CSI recipe (gradient check re-proven); Gaussian primitives gained `first_seen_ns`/`doppler_variance`/bounded `source_receipts` lineage; `PartitionKey` gained a `session` dimension and `SplitManifest` certifies disjointness across all seven dimensions. 87 tests, 0 failed; crate clippy-clean. Docker images unaffected (no shipped binary consumes the crate yet); Python proof re-verified PASS.
- **`ruview-unified` — unified RF spatial world model, P1 (ADR-273..278).** New v2 workspace leaf crate implementing the five-pillar architecture: (1) canonical `RfTensor` (`links × 56 bins × 8 snapshots`, complex, validated at the boundary) plus a fail-closed hardware adapter registry with reference adapters for 802.11 CSI (consumes `wifi-densepose-core::CsiFrame`), FMCW radar cubes (fast-time DFT), UWB CIR, and 5G SRS (comb de-interleave); (2) a universal RF foundation encoder — window-median + CFO-aligned tokenizer, masked-reconstruction pretraining with a hand-derived backward pass verified against central finite differences (174 params sampled, max rel err 1.31e-5), the ADR-273 fusion contract `z = Enc(CSI) ⊙ σ(AgeEnc) + GeomEnc(pose)`, and ≤1% task adapters (presence 129 / activity 268 / localization 387 / anomaly 2 vs a 40,856-param backbone); (3) an RF-aware Gaussian spatial memory — anisotropic primitives with per-band×angle reflectivity, confidence-weighted fusion, exponential decay, spatial-hash + semantic queries, closed-form (erf) BeerLambert channel-gain queries that degrade to exact Friis on an empty map, inverse gain updates that learn an unseen 6 dB obstruction to <0.5 dB in 20 link observations, and a JITOMA-style task-gated scene graph; (4) a physics-guided synthetic RF world generator — AllenBerkley image method (order ≤2), complex-permittivity Fresnel materials, bistatic person scattering with *emergent* Doppler (proven against the analytic phase rate), seeded ChaCha20 domain randomization of physics + hardware nuisances (gain/CFO/phase noise/packet loss/interference); (5) an edge sensing control plane — 802.11bf/ETSI-ISAC-aligned purposes and zones, fail-closed authorization, a double-gated identity purpose, retention bounds, and a `BoundedEvent`-only trust boundary that makes raw RF export unrepresentable. Anti-leakage evaluation (`StrictSplit` by room/day/person/chipset/firmware/layout with an independent disjointness verifier, ECE, selective risk, degradation) plus an end-to-end acceptance pipeline: presence F1 1.00 on held-out rooms *and* held-out chipset, degradation 0.0, ECE 0.012, p95 tokenize+encode 2.0 ms debug / 105 µs release — **all SYNTHETIC** (honest labeling propagates from `RfModality::Synthetic` through `Provenance.synthetic`). Criterion benches with an optimization pass: segment-corridor candidate search took `channel_gain` from 139 µs → 27 µs (O(1) in map size; hash/linear crossover at ~4k Gaussians reported honestly), `observe_link` 305 µs → 74 µs, precomputed DFT twiddles 4.9×. 66 unit + 3 acceptance tests, 0 failed.
### Changed
- **crates.io release batch — 10 of the 12 documented crates republished at their next patch version.** `wifi-densepose-core` 0.3.2, `-vitals` 0.3.2, `-wifiscan` 0.3.2, `-hardware` 0.3.2 (picks up the ADR-273..282 review-fix commit's clippy fixes), `-signal` 0.3.6, `-nn` 0.3.2, `-ruvector` 0.3.3, `-train` 0.3.3, `-mat` 0.3.2, `-wasm` 0.3.1 — all published and verified live on crates.io. **`wifi-densepose-sensing-server` and `wifi-densepose-cli` were bumped locally (0.3.5, 0.3.2) but NOT published**: both now path-depend on `ruview-auth`, which is deliberately `publish = false` and not on crates.io — `cargo publish` correctly refuses to publish a crate with an unversioned/unpublishable path dependency. This is a pre-existing gap (the dependency predates this batch); resolving it is a deliberate call for whoever owns whether `ruview-auth` becomes public, not something to route around silently.
- **`wifi-densepose` promoted to `2.0.0` stable; `ruview` `2.0.0` prepared for its first stable publish (ADR-184 P2).** Dropped the `a1` alpha suffix on both sibling packages (`python/pyproject.toml`, `python/ruview-meta/pyproject.toml`) and flipped their trove classifier `Development Status :: 3 - Alpha``5 - Production/Stable`; the `ruview` meta-package's `wifi-densepose==2.0.0a1` dependency pins (base + `[client]`) were repointed to `==2.0.0`. **Version-metadata prep only — nothing is published by this change**: the actual PyPI upload remains gated on the ADR-117 v2 witness hash. Justified as "stable": the default (no-extras) wheel builds at 279 KB (`maturin build --release --strip`) and the base non-SOTA suite is green — `pytest python/tests/` (excluding the `[aether]`/`[meridian]`/`[mat]` extra modules) = **185 passed, 0 failed** (smoke / keypoint / pose / vitals / bfld / security / WS+MQTT client).
- **CI (ADR-184): `pip-release.yml` keeps token-based PyPI authentication until Trusted Publishing is registered.** An OIDC migration was attempted in `cc153e8b5` and reverted in `82d5c7339` so releases would not enter a half-configured state. Production currently uses `PYPI_API_TOKEN`; TestPyPI uses its independent `TESTPYPI_API_TOKEN`. The workflow now builds and publishes `wifi-densepose` and `ruview` together, verifies their versions and dependency pin match, and fails closed before production upload when `expected_features_v2.sha256` is absent.
- **`@ruvnet/rvagent` startup optimization — stdio time-to-first-response ~242 ms → ~189 ms (22%; MEASURED, median of repeated `initialize` round-trips against `dist/index.js`, this container, reproduce with a piped-stdin timer).** Two changes: (1) `./http-transport.js` is now imported **lazily** inside the `RVAGENT_HTTP_PORT` branch — it chain-loads the MCP SDK's `streamableHttp` module (~48 ms MEASURED via per-module `import()` timing), which the default stdio path never uses; (2) the advertised JSON Schemas generated from the Zod sources are memoized per tool instead of re-walking the Zod tree on every `tools/list` (matters under the session-per-server HTTP model where each session lists tools). No behavior change: 99/99 jest tests, HTTP session flow re-smoke-tested through the lazy path. The `@ruvnet/ruview` harness CLI was profiled too and left alone — 50 ms vs the ~29 ms bare `node -e ''` floor on the same box (MEASURED), i.e. already near the interpreter floor with zero dependencies.
@@ -82,6 +82,11 @@ The entity registry is a `RwLock<HashMap<EntityId, EntityEntry>>` backed by an a
`DeviceRegistry` mirrors HA's `core.device_registry` schema (version 13). Devices are identified by a set of `(id_type, id_value)` tuples (the `identifiers` field), which matches HA's pattern of accepting multiple identifier types per device (MAC address, serial number, integration-specific ID).
`DeviceEntry` and the in-memory `DeviceRegistry` are implemented. On server
startup, entity and device registry files are restored in deterministic key
order with a configurable hard row bound; malformed individual entries are
isolated and reported.
---
## 3. HA-side reference table
@@ -148,6 +148,12 @@ correctness, fail-closed write integrity, semantic-store NaN poisoning, and PII
- **Memory-DoS — `get_state_history` was unbounded.** No `LIMIT`, so a wide time window over a
high-frequency entity loaded an unbounded row set into memory. Now capped at
`MAX_HISTORY_ROWS` (1,000,000); sibling search paths were already `k`-bounded.
- **Startup state restoration.** `latest_states(limit)` selects one newest row
per entity with `(last_updated_ts, state_id)` tie-breaking, orders results by
entity ID, and caps requests at 100,000. Malformed rows are skipped with
typed warnings. `restore_latest` preserves recorded timestamps and installs
snapshots with a `homecore.restore` context before the recorder listener and
automation engine start.
- **Disk-DoS / documented-but-missing `purge`.** The README advertised `Recorder::purge`, but
no retention path existed → unbounded disk growth. Added a **transactional** `purge(older_than)`
with an **exclusive** cutoff (idempotent, no off-by-one) that deletes old `states`/`events` and
@@ -224,8 +224,9 @@ touched:
SHA-256-checks the module, Ed25519-verifies the signature against
`publisher_key`, and enforces a `PluginPolicy` trust allowlist
(secure-default rejects unsigned/untrusted/tampered modules).
- **HAP real pairing (P2)** — SRP/HKDF pairing + encrypted sessions; current
bridge is an accessory-mapping surface. **ACCEPTED-FUTURE (honestly stubbed).**
- **HAP real pairing (P2)** — **DONE (2026-07-27 addendum below).** SRP/HKDF
Pair-Setup, transcript-authenticated Pair-Verify, encrypted sessions, and
administrator-only pairing management now land as one fail-closed boundary.
- **`RunMode::Queued`/`Restart`/`max` ordering** — ~~`Single`/`Parallel` are
honored; bounded queueing, restart-kill, and `max` concurrency are not yet
wired (every non-Single mode is parallel).~~ **DONE — ADR-162 §A5.** Restart
@@ -336,3 +337,35 @@ is still delivered (old code: 5s-timeout panic).
+1 api-root accept-guard, +1 WS lag-survival), 0 failed. Workspace green.
Python deterministic proof unchanged (homecore-api is off the signal proof
path).
## Addendum — HAP cryptographic boundary completed (2026-07-27)
The P2 HAP deferral recorded above is closed as a single security boundary in
`homecore-hap`; it was not replaced with a success-shaped partial protocol.
- Pair-Setup M1-M6 uses RustCrypto SRP-6a with the RFC 5054 3072-bit group,
SHA-512 and HAP proof compatibility, followed by the specified
HKDF-SHA512, ChaCha20-Poly1305, and Ed25519 transcript construction.
- Pair-Verify M1-M4 uses ephemeral X25519, strict Ed25519 transcript
verification, and separately derived directional control keys.
- The TCP server changes to authenticated HAP record framing only after the
plaintext M4 response is written. Record lengths are authenticated, plaintext
is capped at 1024 bytes, counters are independent and monotonic, and any
authentication/replay/framing failure closes without an oracle response.
- Accessory identity, signing seed, SRP verifier, and controller pairings share
one versioned, bounded, permission-checked, atomically replaced store. The raw
setup code is disclosed only on first provisioning and is not persisted.
- Protected endpoints require an encrypted Pair-Verify session. Pairing
management rechecks current persisted administrator authority, handles the
last-admin invariant, updates mDNS paired state, and revokes live sessions.
Evidence includes a deterministic HAP SRP vector, complete in-process
Pair-Setup and Pair-Verify ceremonies, malformed/proof/transcript tests, record
tamper/replay/oversize tests, persistence lifecycle tests, and a real TCP test
that verifies Pair-Verify, accesses `/accessories` over encrypted records, then
proves replay closes the connection.
This closes the cryptographic implementation item, not the entire Apple Home
product surface. Current-Apple/MFi interoperability has not been certified;
transient/split Pair-Setup, writable/timed characteristics, resource endpoints,
and persisted AID/IID allocation remain explicitly unsupported.
@@ -2,7 +2,7 @@
| Field | Value |
|-------|-------|
| **Status** | Accepted — P1 scaffold (full conversion deferred to P2) |
| **Status** | Accepted — registry/config persistence implemented |
| **Date** | 2026-05-25 |
| **Deciders** | ruv |
| **Codename** | **HOMECORE-MIGRATE** |
@@ -44,8 +44,8 @@ files are read, how schema versions are validated, and what happens on an unknow
## 2. Decision
Ship `homecore-migrate` as a CLI + library that reads an existing HA filesystem and imports
its configuration into HOMECORE. P1 is a **scaffold**: it parses and inspects everything and
converts the entity registry; full conversion of the remaining artifacts is deferred to P2.
its configuration into HOMECORE. Registry and config-entry conversion are durable; automation
conversion and secret-reference resolution remain deferred.
### 2.1 Storage reader + versioned format gate (P1, shipped)
@@ -57,22 +57,25 @@ converts the entity registry; full conversion of the remaining artifacts is defe
unknown `minor_version` is a **hard error** (`MigrateError::UnsupportedSchemaVersion`),
never a silent best-effort parse. Better to refuse than to corrupt.
### 2.2 Per-artifact parsers (P1, shipped)
### 2.2 Per-artifact conversion (shipped)
- `entity_registry::load()``core.entity_registry``Vec<homecore::EntityEntry>`
(ready for import).
- `device_registry::load()``core.device_registry` `Vec<DeviceImport>` (P1 diagnostic;
full conversion P2).
- `config_entries::load()``core.config_entries` → domain counts + integration names
(the format is undocumented per §6 Q5; treated diagnostically).
- `device_registry::read_device_registry()` converts the supported v13 device fields into
`homecore::DeviceEntry`; `write_device_registry()` emits an HA-compatible v13 envelope.
- `config_entries::convert_config_entries()` emits versioned `homecore.config_entries`
storage. Original rows are retained verbatim, while unsupported domains and fields produce
typed warnings instead of being discarded.
- `secrets::load_secrets()``secrets.yaml``HashMap<String, String>` (resolution P2).
- `automations::load()``automations.yaml` → count + ID/alias list (conversion P2).
### 2.3 CLI (P1, shipped)
### 2.3 CLI
- `homecore-migrate inspect <ha-dir>` previews what will be migrated (entity/device/config
counts, redacted secret/automation lists) (`src/cli.rs`, `src/main.rs`).
- `import-entities` and `export-for-sidecar` are declared but their full behaviour is P2.
- `import-entities`, `import-devices`, and `import-config-entries` write destination files and
emit one-line JSON summaries. Writes use synced same-directory temporary files and atomic
no-clobber publication; an existing destination is never implicitly replaced.
### 2.4 Structured errors (P1, shipped)
@@ -88,27 +91,25 @@ converts the entity registry; full conversion of the remaining artifacts is defe
file path and a coarse location (`serde_yaml::Error::location()`), never the scalar content.
Pinned by `secrets::tests::malformed_secrets_error_never_contains_secret_value` (asserts the
rendered error **and its full `#[source]` chain** never contain the secret value).
**Review dimensions confirmed clean with evidence:** source is never mutated (no
`fs::write`/`remove`/`create` anywhere — P1 reads source, writes nothing); paths are
**Review dimensions confirmed clean with evidence:** source is never mutated; destination
writes are explicit `--to` paths and no-clobber; paths are
user-supplied dirs joined with fixed filenames (no `..`/absolute traversal beyond the
user's own privileges); malformed/typed/truncated `.storage` JSON and YAML **error, never
panic** (every production `unwrap`/`expect` is test-only); unknown schema `minor_version`
hard-errors fail-closed; no SQL/shell/path injection surface (the tool emits diagnostics
only, persists nothing in P1).
hard-errors fail-closed; no SQL/shell injection surface.
### 2.5 Deferred to P2+ (NOT built — honestly labelled)
- Convert `config_entries` HOMECORE plugin manifests.
- Execute imported config entries (a matching HOMECORE plugin must claim the preserved domain).
- Convert `automations.yaml``homecore-automation` YAML.
- Side-by-side runtime mode (requires `homecore-recorder`, ADR-132; behind the `recorder`
Cargo feature, currently a no-op stub).
- `!secret` reference resolution in non-secrets YAML files.
### 2.6 Test evidence (as shipped)
### 2.6 Test evidence
- 21 tests (`cargo test -p homecore-migrate`) — 19 as originally shipped plus 2 added by the
2026-06 security review (`secrets::tests::malformed_secrets_error_never_contains_secret_value`,
`malformed_secrets_error_reports_location`).
- Targeted tests cover registry round trips, unknown versions, lossless unsupported config
fields/domains, malformed input, and crash-safe/no-overwrite destination behaviour.
## 3. Consequences
@@ -118,13 +119,12 @@ converts the entity registry; full conversion of the remaining artifacts is defe
schema drift fails loudly instead of corrupting an imported home.
- Reusing HA's own `.storage` and YAML formats means no intermediate export step; the tool
reads a live HA install directly.
- P1 `inspect` gives users a no-risk dry run before any write.
- `inspect` gives users a no-risk dry run before any write.
**Negative / honest limits.**
- P1 is a **scaffold**: only the entity registry is conversion-ready. Device registry,
config-entry→plugin, automation, and secret-resolution conversions are P2 and **not yet
built** — the Status field and crate docs say so.
- Imported config entries are durable but do not install or execute Python HA integrations.
- Automation conversion and secret-reference resolution are not built.
- The side-by-side recorder export depends on ADR-132 and is currently a feature-gated
no-op.
- Performance figures in the README (envelope parse < 5 ms, 1 000-entity load < 50 ms) are
+8 -5
View File
@@ -252,8 +252,9 @@ impl PyBreathingExtractor {
/// hr = HeartRateExtractor.esp32_default() # 56 subcarriers, 100 Hz, 15s window
///
/// # Feed residuals and matching unwrapped phases from your preprocessor.
/// # Unlike BreathingExtractor weights, phases=[] is invalid for heart-rate
/// # extraction because the Rust core requires phase data for each subcarrier.
/// # Like BreathingExtractor's weights, phases=[] means "no per-subcarrier
/// # coherence information available" and falls back to equal weighting
/// # across all subcarriers -- it does NOT silently drop every frame.
/// est = hr.extract(residuals=[0.01, -0.02, …], phases=[0.0, 0.01, …])
/// if est is not None:
/// print(est.value_bpm, est.confidence)
@@ -281,9 +282,11 @@ impl PyHeartRateExtractor {
}
/// Extract heart rate from per-subcarrier residuals and matching
/// per-subcarrier unwrapped phases (radians). Empty phases are invalid
/// and return `None` because the Rust extractor requires phase data.
/// GIL released during DSP.
/// per-subcarrier unwrapped phases (radians). A short or empty `phases`
/// slice falls back to equal weighting for any subcarrier missing phase
/// data (issue #1423) -- it does not truncate the number of subcarriers
/// fused, and does not silently return `None` for every frame. GIL
/// released during DSP.
fn extract(
&mut self,
py: Python<'_>,
+32
View File
@@ -223,6 +223,38 @@ def test_heart_rate_extract_with_synthetic_signal_and_phases() -> None:
)
def test_heart_rate_extract_with_empty_phases_produces_estimates() -> None:
"""Issue #1423 regression: `phases=[]` must fall back to equal
weighting (mirroring `BreathingExtractor`'s `weights=[]`), not silently
return `None` for every frame.
Reproduces the GH-issue repro: a noiseless 1.2 Hz (72 BPM) sine
identical across all 56 subcarriers, fed frame-by-frame with an empty
`phases` list. Before the fix this produced 0/4000 estimates; the same
signal with `phases=[1.0] * 56` already produced thousands.
"""
hr = HeartRateExtractor.esp32_default()
sample_rate = 100.0
target_freq = 1.2 # 72 BPM
n_samples = 4000
produced = 0
for i in range(n_samples):
t = i / sample_rate
base = math.sin(2.0 * math.pi * target_freq * t)
residuals = [base] * 56
est = hr.extract(residuals=residuals, phases=[])
if est is not None:
produced += 1
assert math.isfinite(est.value_bpm)
assert 0.0 <= est.confidence <= 1.0
assert produced > 0, (
"HeartRateExtractor.extract(residuals=..., phases=[]) must not silently "
"return None for every frame of a clean 72 BPM signal (issue #1423)"
)
# ─── Build feature flag ──────────────────────────────────────────────
Generated
+487 -294
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -103,7 +103,7 @@ exclude = [
]
[workspace.package]
version = "0.3.0"
version = "0.3.1"
edition = "2021"
authors = ["rUv <ruv@ruv.net>", "WiFi-DensePose Contributors"]
license = "MIT OR Apache-2.0"
+2
View File
@@ -17,6 +17,8 @@ path = "src/bin/server.rs"
[dependencies]
homecore = { path = "../homecore", version = "0.1.0-alpha.0" }
homecore-automation = { path = "../homecore-automation", version = "0.1.0-alpha.0" }
homecore-recorder = { path = "../homecore-recorder", version = "0.1.0-alpha.0" }
axum = { version = "0.7", features = ["ws", "json", "macros"] }
tokio = { version = "1", features = ["full"] }
+22 -6
View File
@@ -27,6 +27,7 @@ pub fn router(state: SharedState) -> Router {
Router::new()
.route("/api/", get(rest::api_root))
.route("/api/config", get(rest::get_config))
.route("/api/components", get(rest::get_components))
.route("/api/states", get(rest::get_states))
.route(
"/api/states/:entity_id",
@@ -36,6 +37,22 @@ pub fn router(state: SharedState) -> Router {
)
.route("/api/services", get(rest::get_services))
.route("/api/services/:domain/:service", post(rest::call_service))
.route("/api/events", get(rest::get_events))
.route("/api/events/:event_type", post(rest::fire_event))
.route("/api/template", post(rest::render_template))
.route("/api/config/core/check_config", post(rest::check_config))
.route("/api/error_log", get(rest::error_log))
.route("/api/history/period", get(rest::get_history))
.route(
"/api/history/period/:start_time",
get(rest::get_history_period),
)
.route("/api/logbook", get(rest::get_logbook))
.route("/api/logbook/:start_time", get(rest::get_logbook_period))
.route("/api/calendars", get(rest::get_calendars))
.route("/api/calendars/:entity_id", get(rest::get_calendar_events))
.route("/api/camera_proxy/:entity_id", get(rest::get_camera_proxy))
.route("/api/homecore/compatibility", get(rest::compatibility))
.route("/api/websocket", get(ws::websocket_handler))
.layer(cors)
.layer(TraceLayer::new_for_http())
@@ -58,11 +75,7 @@ pub fn build_cors_layer() -> CorsLayer {
CorsLayer::new()
.allow_origin(AllowOrigin::list(origins))
.allow_methods([Method::GET, Method::POST, Method::OPTIONS, Method::DELETE])
.allow_headers([
header::AUTHORIZATION,
header::CONTENT_TYPE,
header::ACCEPT,
])
.allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE, header::ACCEPT])
.allow_credentials(false)
}
@@ -108,7 +121,10 @@ mod tests {
#[test]
fn env_override_via_homecore_cors_origins() {
let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
std::env::set_var("HOMECORE_CORS_ORIGINS", "https://example.com,https://other.example.com");
std::env::set_var(
"HOMECORE_CORS_ORIGINS",
"https://example.com,https://other.example.com",
);
// build_cors_layer() returns a CorsLayer which doesn't expose
// its origin list; we test the parse path indirectly by
// confirming no panic + at least one origin would parse.
+13 -4
View File
@@ -40,13 +40,22 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Token provisioning (HC-WS-08). Prefer the HOMECORE_TOKENS env
// whitelist; fall back to DEV mode (warn-logged) only when unset.
let tokens = if std::env::var("HOMECORE_TOKENS")
let has_tokens = std::env::var("HOMECORE_TOKENS")
.map(|v| !v.trim().is_empty())
.unwrap_or(false)
{
.unwrap_or(false);
let insecure_dev_auth = std::env::var("HOMECORE_INSECURE_DEV_AUTH").as_deref() == Ok("1");
if !has_tokens && !insecure_dev_auth {
return Err(
"HOMECORE_TOKENS is required (or set HOMECORE_INSECURE_DEV_AUTH=1 for loopback-only development)"
.into(),
);
}
let tokens = if has_tokens {
let s = LongLivedTokenStore::from_env();
let n = s.len().await;
tracing::info!("LongLivedTokenStore provisioned with {n} bearer token(s) from HOMECORE_TOKENS");
tracing::info!(
"LongLivedTokenStore provisioned with {n} bearer token(s) from HOMECORE_TOKENS"
);
s
} else {
tracing::warn!(
+6 -1
View File
@@ -14,6 +14,8 @@ pub enum ApiError {
Unauthorized,
#[error("service not registered: {domain}.{service}")]
ServiceNotRegistered { domain: String, service: String },
#[error("service unavailable: {0}")]
Unavailable(String),
#[error("internal error: {0}")]
Internal(String),
}
@@ -21,7 +23,9 @@ pub enum ApiError {
pub type ApiResult<T> = Result<T, ApiError>;
#[derive(Serialize)]
struct ErrorPayload { message: String }
struct ErrorPayload {
message: String,
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
@@ -30,6 +34,7 @@ impl IntoResponse for ApiError {
Self::BadRequest(_) => (StatusCode::BAD_REQUEST, self.to_string()),
Self::Unauthorized => (StatusCode::UNAUTHORIZED, self.to_string()),
Self::ServiceNotRegistered { .. } => (StatusCode::BAD_REQUEST, self.to_string()),
Self::Unavailable(_) => (StatusCode::SERVICE_UNAVAILABLE, self.to_string()),
Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()),
};
(status, Json(ErrorPayload { message })).into_response()
+605 -23
View File
@@ -1,5 +1,6 @@
use axum::extract::{Path, State};
use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::IntoResponse;
use axum::Json;
use serde::{Deserialize, Serialize};
@@ -10,7 +11,9 @@ use crate::error::{ApiError, ApiResult};
use crate::state::SharedState;
#[derive(Serialize)]
pub struct ApiRunning { message: &'static str }
pub struct ApiRunning {
message: &'static str,
}
/// `GET /api/` — the HA `APIStatusView` ("API running." ping).
///
@@ -23,9 +26,14 @@ pub struct ApiRunning { message: &'static str }
/// HOMECORE-API endpoint. The P2 handler skipped the bearer gate that
/// every sibling route applies; this restores wire-compat by validating
/// the bearer like `get_config`/`get_states` before replying.
pub async fn api_root(headers: HeaderMap, State(s): State<SharedState>) -> ApiResult<Json<ApiRunning>> {
pub async fn api_root(
headers: HeaderMap,
State(s): State<SharedState>,
) -> ApiResult<Json<ApiRunning>> {
let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
Ok(Json(ApiRunning { message: "API running." }))
Ok(Json(ApiRunning {
message: "API running.",
}))
}
#[derive(Serialize)]
@@ -36,16 +44,44 @@ pub struct ApiConfig {
components: Vec<String>,
}
pub async fn get_config(headers: HeaderMap, State(s): State<SharedState>) -> ApiResult<Json<ApiConfig>> {
const LOADED_COMPONENTS: &[&str] = &[
"api",
"automation",
"config",
"homecore",
"recorder",
"websocket_api",
];
pub async fn get_config(
headers: HeaderMap,
State(s): State<SharedState>,
) -> ApiResult<Json<ApiConfig>> {
let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
Ok(Json(ApiConfig {
location_name: s.location_name().to_string(),
version: s.version().to_string(),
state: "RUNNING",
components: vec![],
components: LOADED_COMPONENTS
.iter()
.map(|component| (*component).to_owned())
.collect(),
}))
}
pub async fn get_components(
headers: HeaderMap,
State(s): State<SharedState>,
) -> ApiResult<Json<Vec<String>>> {
let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
Ok(Json(
LOADED_COMPONENTS
.iter()
.map(|component| (*component).to_owned())
.collect(),
))
}
#[derive(Serialize)]
pub struct StateView {
pub entity_id: String,
@@ -56,6 +92,328 @@ pub struct StateView {
pub context: ContextView,
}
#[derive(Debug, Deserialize)]
pub struct HistoryQuery {
filter_entity_id: Option<String>,
end_time: Option<String>,
#[serde(default)]
minimal_response: bool,
#[serde(default)]
no_attributes: bool,
#[serde(default)]
significant_changes_only: bool,
}
const MAX_HISTORY_ENTITIES: usize = 32;
const MAX_API_HISTORY_ROWS: usize = 100_000;
pub async fn get_history(
headers: HeaderMap,
State(s): State<SharedState>,
Query(query): Query<HistoryQuery>,
) -> ApiResult<Json<Vec<Vec<StateView>>>> {
history_response(headers, s, None, query).await
}
pub async fn get_history_period(
headers: HeaderMap,
State(s): State<SharedState>,
Path(start_time): Path<String>,
Query(query): Query<HistoryQuery>,
) -> ApiResult<Json<Vec<Vec<StateView>>>> {
history_response(headers, s, Some(start_time), query).await
}
async fn history_response(
headers: HeaderMap,
state: SharedState,
start_time: Option<String>,
query: HistoryQuery,
) -> ApiResult<Json<Vec<Vec<StateView>>>> {
let _ = BearerAuth::from_headers(&headers, state.tokens()).await?;
let recorder = state
.recorder()
.ok_or_else(|| ApiError::Unavailable("recorder is disabled".into()))?;
let now = chrono::Utc::now();
let start = match start_time {
Some(value) => parse_history_time(&value)?,
None => now - chrono::Duration::days(1),
};
let end = match query.end_time.as_deref() {
Some(value) => parse_history_time(value)?,
None => now,
};
if end < start {
return Err(ApiError::BadRequest(
"end_time must not precede start_time".into(),
));
}
let explicit_filter = query.filter_entity_id.is_some();
let entity_ids = match query.filter_entity_id.as_deref() {
Some(raw) => raw
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| {
EntityId::parse(value)
.map_err(|error| ApiError::BadRequest(format!("invalid entity_id: {error}")))
})
.collect::<ApiResult<Vec<_>>>()?,
None => state
.homecore()
.states()
.all()
.into_iter()
.map(|snapshot| snapshot.entity_id.clone())
.collect(),
};
// Only reject an explicit, unusually-large `filter_entity_id` list. The
// real HA frontend's history page calls this endpoint with NO filter by
// design (meaning "all entities") — a real install routinely has 50-500+
// entities, so applying this cap there rejected the single most common
// call shape outright. The `MAX_API_HISTORY_ROWS` total-row budget below
// already bounds the actual work regardless of entity count.
if explicit_filter && entity_ids.len() > MAX_HISTORY_ENTITIES {
return Err(ApiError::BadRequest(format!(
"history queries are limited to {MAX_HISTORY_ENTITIES} explicitly filtered entities"
)));
}
let mut result = Vec::with_capacity(entity_ids.len());
let mut remaining = MAX_API_HISTORY_ROWS;
for entity_id in entity_ids {
let rows = recorder
.get_state_history_limited(&entity_id, start, end, remaining)
.await
.map_err(|error| ApiError::Internal(format!("history query failed: {error}")))?;
remaining = remaining.saturating_sub(rows.len());
let mut previous_state: Option<String> = None;
let states = rows
.into_iter()
.filter_map(|row| {
if query.significant_changes_only
&& previous_state.as_deref() == Some(row.state.as_str())
{
return None;
}
previous_state = Some(row.state.clone());
let changed = history_timestamp(row.last_changed_ts);
let updated = history_timestamp(row.last_updated_ts);
Some(StateView {
entity_id: row.entity_id.as_str().to_owned(),
state: row.state,
attributes: if query.no_attributes || query.minimal_response {
serde_json::json!({})
} else {
row.attributes
},
last_changed: changed,
last_updated: updated,
context: ContextView {
id: row.context_id.unwrap_or_default(),
user_id: None,
parent_id: None,
},
})
})
.collect();
result.push(states);
}
Ok(Json(result))
}
fn parse_history_time(value: &str) -> ApiResult<chrono::DateTime<chrono::Utc>> {
chrono::DateTime::parse_from_rfc3339(value)
.map(|value| value.with_timezone(&chrono::Utc))
.map_err(|_| ApiError::BadRequest("history timestamps must be RFC 3339".into()))
}
fn history_timestamp(seconds: f64) -> String {
let whole = seconds.floor() as i64;
let nanos = ((seconds - seconds.floor()) * 1_000_000_000.0).round() as u32;
chrono::DateTime::<chrono::Utc>::from_timestamp(whole, nanos.min(999_999_999))
.unwrap_or(chrono::DateTime::<chrono::Utc>::UNIX_EPOCH)
.to_rfc3339()
}
#[derive(Debug, Deserialize)]
pub struct LogbookQuery {
end_time: Option<String>,
entity: Option<String>,
}
pub async fn get_logbook(
headers: HeaderMap,
State(s): State<SharedState>,
Query(query): Query<LogbookQuery>,
) -> ApiResult<Json<Vec<serde_json::Value>>> {
logbook_response(headers, s, None, query).await
}
pub async fn get_logbook_period(
headers: HeaderMap,
State(s): State<SharedState>,
Path(start_time): Path<String>,
Query(query): Query<LogbookQuery>,
) -> ApiResult<Json<Vec<serde_json::Value>>> {
logbook_response(headers, s, Some(start_time), query).await
}
async fn logbook_response(
headers: HeaderMap,
state: SharedState,
start_time: Option<String>,
query: LogbookQuery,
) -> ApiResult<Json<Vec<serde_json::Value>>> {
let _ = BearerAuth::from_headers(&headers, state.tokens()).await?;
let recorder = state
.recorder()
.ok_or_else(|| ApiError::Unavailable("recorder is disabled".into()))?;
let now = chrono::Utc::now();
let start = match start_time {
Some(value) => parse_history_time(&value)?,
None => now - chrono::Duration::days(1),
};
let end = match query.end_time.as_deref() {
Some(value) => parse_history_time(value)?,
None => now,
};
if end < start {
return Err(ApiError::BadRequest(
"end_time must not precede start_time".into(),
));
}
let explicit_filter = query.entity.is_some();
let entity_ids = match query.entity.as_deref() {
Some(raw) => raw
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| {
EntityId::parse(value)
.map_err(|error| ApiError::BadRequest(format!("invalid entity_id: {error}")))
})
.collect::<ApiResult<Vec<_>>>()?,
None => state
.homecore()
.states()
.all()
.into_iter()
.map(|snapshot| snapshot.entity_id.clone())
.collect(),
};
// See the matching comment in `history_response`: only reject an
// explicit, unusually-large filter — the default (no filter, "all
// entities") is the real HA frontend's normal call shape, and the
// `MAX_API_HISTORY_ROWS` row budget below already bounds the work.
if explicit_filter && entity_ids.len() > MAX_HISTORY_ENTITIES {
return Err(ApiError::BadRequest(format!(
"logbook queries are limited to {MAX_HISTORY_ENTITIES} explicitly filtered entities"
)));
}
let mut entries = Vec::new();
let mut remaining = MAX_API_HISTORY_ROWS;
for entity_id in entity_ids {
let rows = recorder
.get_state_history_limited(&entity_id, start, end, remaining)
.await
.map_err(|error| ApiError::Internal(format!("logbook query failed: {error}")))?;
remaining = remaining.saturating_sub(rows.len());
for row in rows {
entries.push(serde_json::json!({
"when": history_timestamp(row.last_updated_ts),
"name": row.entity_id.as_str(),
"state": row.state,
"entity_id": row.entity_id.as_str(),
"context_id": row.context_id
}));
}
}
entries.sort_by(|left, right| {
left["when"]
.as_str()
.cmp(&right["when"].as_str())
.then_with(|| left["entity_id"].as_str().cmp(&right["entity_id"].as_str()))
});
Ok(Json(entries))
}
#[derive(Serialize)]
pub struct CalendarView {
entity_id: String,
name: String,
}
pub async fn get_calendars(
headers: HeaderMap,
State(s): State<SharedState>,
) -> ApiResult<Json<Vec<CalendarView>>> {
let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
let calendars = s
.homecore()
.states()
.all_by_domain("calendar")
.into_iter()
.map(|state| CalendarView {
entity_id: state.entity_id.as_str().to_owned(),
name: state
.attributes
.get("friendly_name")
.and_then(serde_json::Value::as_str)
.unwrap_or(state.entity_id.as_str())
.to_owned(),
})
.collect();
Ok(Json(calendars))
}
#[derive(Debug, Deserialize)]
pub struct CalendarQuery {
start: String,
end: String,
}
pub async fn get_calendar_events(
headers: HeaderMap,
State(s): State<SharedState>,
Path(entity_id): Path<String>,
Query(query): Query<CalendarQuery>,
) -> ApiResult<Json<Vec<serde_json::Value>>> {
let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
let id =
EntityId::parse(&entity_id).map_err(|error| ApiError::BadRequest(error.to_string()))?;
if id.domain() != "calendar" || s.homecore().states().get(&id).is_none() {
return Err(ApiError::NotFound(entity_id));
}
let start = parse_history_time(&query.start)?;
let end = parse_history_time(&query.end)?;
if end < start {
return Err(ApiError::BadRequest(
"end must not precede start".to_owned(),
));
}
// Calendar integrations may expose their current entity without an event
// provider. An empty list is the valid response for that interval.
Ok(Json(Vec::new()))
}
pub async fn get_camera_proxy(
headers: HeaderMap,
State(s): State<SharedState>,
Path(entity_id): Path<String>,
) -> ApiResult<StatusCode> {
let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
let id =
EntityId::parse(&entity_id).map_err(|error| ApiError::BadRequest(error.to_string()))?;
if id.domain() != "camera" || s.homecore().states().get(&id).is_none() {
return Err(ApiError::NotFound(entity_id));
}
Err(ApiError::Unavailable(
"camera integration has no image provider".into(),
))
}
#[derive(Serialize)]
pub struct ContextView {
pub id: String,
@@ -80,10 +438,15 @@ impl StateView {
}
}
pub async fn get_states(headers: HeaderMap, State(s): State<SharedState>) -> ApiResult<Json<Vec<StateView>>> {
pub async fn get_states(
headers: HeaderMap,
State(s): State<SharedState>,
) -> ApiResult<Json<Vec<StateView>>> {
let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
let snapshots = s.homecore().states().all();
Ok(Json(snapshots.iter().map(|x| StateView::from_state(x)).collect()))
Ok(Json(
snapshots.iter().map(|x| StateView::from_state(x)).collect(),
))
}
pub async fn get_state(
@@ -93,7 +456,11 @@ pub async fn get_state(
) -> ApiResult<Json<StateView>> {
let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
let id = EntityId::parse(entity_id.clone()).map_err(|e| ApiError::BadRequest(e.to_string()))?;
let st = s.homecore().states().get(&id).ok_or_else(|| ApiError::NotFound(entity_id))?;
let st = s
.homecore()
.states()
.get(&id)
.ok_or(ApiError::NotFound(entity_id))?;
Ok(Json(StateView::from_state(&st)))
}
@@ -128,9 +495,20 @@ pub async fn set_state(
let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
let id = EntityId::parse(entity_id).map_err(|e| ApiError::BadRequest(e.to_string()))?;
let existed = s.homecore().states().get(&id).is_some();
let attrs = if body.attributes.is_null() { serde_json::json!({}) } else { body.attributes };
let snap = s.homecore().states().set(id, body.state, attrs, Context::new());
let status = if existed { StatusCode::OK } else { StatusCode::CREATED };
let attrs = if body.attributes.is_null() {
serde_json::json!({})
} else {
body.attributes
};
let snap = s
.homecore()
.states()
.set(id, body.state, attrs, Context::new());
let status = if existed {
StatusCode::OK
} else {
StatusCode::CREATED
};
Ok((status, Json(StateView::from_state(&snap))))
}
@@ -140,17 +518,31 @@ pub struct ServiceDomainView {
pub services: serde_json::Value,
}
pub async fn get_services(headers: HeaderMap, State(s): State<SharedState>) -> ApiResult<Json<Vec<ServiceDomainView>>> {
pub async fn get_services(
headers: HeaderMap,
State(s): State<SharedState>,
) -> ApiResult<Json<Vec<ServiceDomainView>>> {
let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
let services = s.homecore().services().registered_services().await;
let mut by_domain: std::collections::HashMap<String, serde_json::Map<String, serde_json::Value>> =
std::collections::HashMap::new();
let mut by_domain: std::collections::HashMap<
String,
serde_json::Map<String, serde_json::Value>,
> = std::collections::HashMap::new();
for sv in services {
by_domain.entry(sv.domain.clone()).or_default().insert(sv.service.clone(), serde_json::json!({}));
by_domain
.entry(sv.domain.clone())
.or_default()
.insert(sv.service.clone(), serde_json::json!({}));
}
Ok(Json(by_domain.into_iter().map(|(domain, services)| ServiceDomainView {
domain, services: serde_json::Value::Object(services),
}).collect()))
Ok(Json(
by_domain
.into_iter()
.map(|(domain, services)| ServiceDomainView {
domain,
services: serde_json::Value::Object(services),
})
.collect(),
))
}
pub async fn call_service(
@@ -166,9 +558,199 @@ pub async fn call_service(
data: body,
context: Context::new(),
};
let resp = s.homecore().services().call(call).await.map_err(|e| match e {
homecore::ServiceError::NotRegistered { .. } => ApiError::ServiceNotRegistered { domain, service },
other => ApiError::Internal(other.to_string()),
})?;
let resp = s
.homecore()
.services()
.call(call)
.await
.map_err(|e| match e {
homecore::ServiceError::NotRegistered { .. } => {
ApiError::ServiceNotRegistered { domain, service }
}
other => ApiError::Internal(other.to_string()),
})?;
Ok(Json(resp))
}
#[derive(Serialize)]
pub struct EventView {
pub event: String,
pub listener_count: usize,
}
/// Event types whose wire shape is implemented by the core event bridge.
const CORE_EVENT_TYPES: &[&str] = &[
"state_changed",
"call_service",
"homeassistant_start",
"homeassistant_stop",
];
pub async fn get_events(
headers: HeaderMap,
State(s): State<SharedState>,
) -> ApiResult<Json<Vec<EventView>>> {
let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
Ok(Json(
CORE_EVENT_TYPES
.iter()
.map(|event| EventView {
event: (*event).to_owned(),
// Tokio broadcast intentionally does not expose a stable
// per-filter count. Zero is HA-compatible and honest.
listener_count: 0,
})
.collect(),
))
}
/// Whether `event_type` is acceptable to fire on the domain bus.
///
/// Real Home Assistant places essentially no format restriction on event
/// types beyond "non-empty string" — integrations commonly fire types with
/// mixed case, dots, or hyphens (e.g. `mobile_app.notification_action`,
/// `ios.action_fired`). The original check here only accepted
/// `[a-z0-9_]+`, silently rejecting any of those — a real behavioral gap
/// versus the documented contract, not a security boundary (this endpoint is
/// already bearer-authenticated). We keep only the bounds that protect the
/// server itself: non-empty, a sane length cap, and no control characters
/// (which could otherwise corrupt log lines or downstream storage).
pub(crate) fn is_valid_event_type(event_type: &str) -> bool {
!event_type.is_empty()
&& event_type.len() <= 255
&& event_type.chars().all(|ch| !ch.is_control())
}
pub async fn fire_event(
headers: HeaderMap,
State(s): State<SharedState>,
Path(event_type): Path<String>,
Json(body): Json<serde_json::Value>,
) -> ApiResult<Json<serde_json::Value>> {
let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
if !is_valid_event_type(&event_type) {
return Err(ApiError::BadRequest("invalid event_type".into()));
}
if !body.is_object() && !body.is_null() {
return Err(ApiError::BadRequest("event data must be an object".into()));
}
let data = if body.is_null() {
serde_json::json!({})
} else {
body
};
s.homecore().bus().fire_domain(homecore::DomainEvent::new(
event_type.clone(),
data,
Context::new(),
));
Ok(Json(
serde_json::json!({"message": format!("Event {event_type} fired.")}),
))
}
#[derive(Deserialize)]
pub struct TemplateRequest {
pub template: String,
}
pub async fn render_template(
headers: HeaderMap,
State(s): State<SharedState>,
Json(body): Json<TemplateRequest>,
) -> ApiResult<String> {
let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
let environment = homecore_automation::TemplateEnvironment::new(std::sync::Arc::new(
s.homecore().states().clone(),
));
environment
.render(&body.template)
.map_err(|error| ApiError::BadRequest(error.to_string()))
}
pub async fn check_config(
headers: HeaderMap,
State(s): State<SharedState>,
) -> ApiResult<Json<serde_json::Value>> {
let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
// Runtime configuration has already passed HOMECORE's typed loaders.
Ok(Json(serde_json::json!({
"result": "valid",
"errors": null,
"warnings": null
})))
}
pub async fn error_log(
headers: HeaderMap,
State(s): State<SharedState>,
) -> ApiResult<impl IntoResponse> {
let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
Ok((
[("content-type", "text/plain; charset=utf-8")],
String::new(),
))
}
/// Machine-readable support matrix. This prevents clients from confusing
/// core protocol compatibility with every optional HA integration.
pub async fn compatibility(
headers: HeaderMap,
State(s): State<SharedState>,
) -> ApiResult<Json<serde_json::Value>> {
let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
Ok(Json(serde_json::json!({
"baseline": "Home Assistant Core 2025.1",
"rest": {
"core": "implemented",
"events": "implemented",
"template": "implemented",
"check_config": "implemented",
"error_log": "implemented",
"history": "implemented_when_recorder_enabled",
"logbook": "implemented_when_recorder_enabled",
"calendar": "implemented_with_integration_supplied_events",
"camera": "implemented_with_integration_supplied_images",
"media": "integration_dependent"
},
"websocket": {
"auth": "implemented",
"states_services_config": "implemented",
"events": "implemented",
"render_template": "implemented",
"feature_negotiation_and_panels": "implemented",
"registry_lists": {
"entity": "implemented",
"device": "implemented",
"area": "implemented_empty",
"mutations": "requires_persistent_registry_backend"
},
"lovelace_media": "integration_dependent"
}
})))
}
#[cfg(test)]
mod tests {
use super::is_valid_event_type;
/// Real HA integrations commonly fire event types with mixed case, dots,
/// or hyphens (e.g. `mobile_app.notification_action`). The original
/// `[a-z0-9_]+`-only check rejected all of these; only non-empty,
/// length, and control-character bounds should remain.
#[test]
fn realistic_ha_event_types_are_accepted() {
assert!(is_valid_event_type("mobile_app.notification_action"));
assert!(is_valid_event_type("ios.action_fired"));
assert!(is_valid_event_type("Custom-Event.2"));
assert!(is_valid_event_type("state_changed"));
}
#[test]
fn empty_oversized_or_control_char_event_types_are_rejected() {
assert!(!is_valid_event_type(""));
assert!(!is_valid_event_type(&"a".repeat(256)));
assert!(!is_valid_event_type("bad\nevent"));
assert!(!is_valid_event_type("bad\tevent"));
}
}
+34 -10
View File
@@ -1,5 +1,6 @@
use std::sync::Arc;
use homecore::HomeCore;
use homecore_recorder::Recorder;
use std::sync::Arc;
use crate::tokens::LongLivedTokenStore;
@@ -13,6 +14,7 @@ struct SharedStateInner {
pub homecore_version: String,
pub location_name: String,
pub tokens: LongLivedTokenStore,
pub recorder: Option<Recorder>,
}
impl SharedState {
@@ -28,15 +30,13 @@ impl SharedState {
location_name: impl Into<String>,
homecore_version: impl Into<String>,
) -> Self {
// P2 default: dev-mode token store (accepts any non-empty
// bearer) so existing smoke tests still work; the
// `homecore-server` binary uses with_tokens() to provision a
// real store at boot.
// Fail closed by default. Tests and explicitly insecure local
// development must opt into `allow_any_non_empty()` themselves.
Self::with_tokens(
homecore,
location_name,
homecore_version,
LongLivedTokenStore::allow_any_non_empty(),
LongLivedTokenStore::empty(),
)
}
@@ -52,12 +52,36 @@ impl SharedState {
homecore_version: homecore_version.into(),
location_name: location_name.into(),
tokens,
recorder: None,
}),
}
}
pub fn homecore(&self) -> &HomeCore { &self.inner.homecore }
pub fn version(&self) -> &str { &self.inner.homecore_version }
pub fn location_name(&self) -> &str { &self.inner.location_name }
pub fn tokens(&self) -> &LongLivedTokenStore { &self.inner.tokens }
pub fn with_recorder(self, recorder: Option<Recorder>) -> Self {
Self {
inner: Arc::new(SharedStateInner {
homecore: self.inner.homecore.clone(),
homecore_version: self.inner.homecore_version.clone(),
location_name: self.inner.location_name.clone(),
tokens: self.inner.tokens.clone(),
recorder,
}),
}
}
pub fn homecore(&self) -> &HomeCore {
&self.inner.homecore
}
pub fn version(&self) -> &str {
&self.inner.homecore_version
}
pub fn location_name(&self) -> &str {
&self.inner.location_name
}
pub fn tokens(&self) -> &LongLivedTokenStore {
&self.inner.tokens
}
pub fn recorder(&self) -> Option<&Recorder> {
self.inner.recorder.as_ref()
}
}
+4
View File
@@ -127,6 +127,10 @@ impl LongLivedTokenStore {
self.inner.read().await.tokens.len()
}
pub async fn is_empty(&self) -> bool {
self.inner.read().await.tokens.is_empty()
}
/// Is the store accepting any non-empty bearer (DEV mode)?
pub async fn is_dev_mode(&self) -> bool {
self.inner.read().await.allow_any
+162 -25
View File
@@ -20,7 +20,6 @@
//! drains the response channel onto the socket (HC-WS-02 closed the prior
//! reply-theater where responses were logged and discarded).
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
@@ -28,6 +27,10 @@ use axum::extract::State;
use axum::response::IntoResponse;
use serde::{Deserialize, Serialize};
use tokio::sync::broadcast;
/// Per-connection outbound queue. A bounded queue prevents a client that
/// stops reading from turning event fan-out into unbounded process memory.
const OUTBOUND_QUEUE_CAPACITY: usize = 256;
use tracing::warn;
use homecore::{Context, ServiceCall, ServiceName, SystemEvent};
@@ -49,7 +52,11 @@ async fn handle_socket(mut socket: WebSocket, state: SharedState) {
"type": "auth_required",
"ha_version": state.version(),
});
if socket.send(Message::Text(auth_req.to_string())).await.is_err() {
if socket
.send(Message::Text(auth_req.to_string()))
.await
.is_err()
{
return;
}
@@ -59,7 +66,8 @@ async fn handle_socket(mut socket: WebSocket, state: SharedState) {
_ => {
let _ = socket
.send(Message::Text(
serde_json::json!({"type":"auth_invalid","message":"expected auth"}).to_string(),
serde_json::json!({"type":"auth_invalid","message":"expected auth"})
.to_string(),
))
.await;
return;
@@ -85,7 +93,11 @@ async fn handle_socket(mut socket: WebSocket, state: SharedState) {
return;
}
let auth_ok = serde_json::json!({"type":"auth_ok","ha_version": state.version()});
if socket.send(Message::Text(auth_ok.to_string())).await.is_err() {
if socket
.send(Message::Text(auth_ok.to_string()))
.await
.is_err()
{
return;
}
@@ -118,6 +130,10 @@ struct WsCommand {
service: Option<String>,
#[serde(default)]
service_data: Option<serde_json::Value>,
#[serde(default)]
event_data: Option<serde_json::Value>,
#[serde(default)]
template: Option<String>,
}
#[derive(Serialize)]
@@ -140,7 +156,6 @@ struct ErrorView<'a> {
struct Connection {
state: SharedState,
next_sub_id: AtomicU64,
subs: Arc<dashmap::DashMap<u64, SubscriptionHandle>>,
}
@@ -152,7 +167,6 @@ impl Connection {
fn new(state: SharedState) -> Self {
Self {
state,
next_sub_id: AtomicU64::new(1),
subs: Arc::new(dashmap::DashMap::new()),
}
}
@@ -168,7 +182,7 @@ impl Connection {
// DISCARDED every message — so no `result`/`pong`/`event` ever
// reached the client. Now `rx` feeds `socket.send`.
let (mut sink, mut stream) = socket.split();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let (tx, mut rx) = tokio::sync::mpsc::channel::<String>(OUTBOUND_QUEUE_CAPACITY);
// Writer task: drain replies onto the socket. A `__pong:<n>`
// sentinel maps to a binary Pong control frame; everything else
@@ -205,7 +219,7 @@ impl Connection {
conn.handle_cmd(cmd, &reader_tx).await;
}
Ok(Message::Ping(p)) => {
let _ = reader_tx.send(format!("__pong:{}", p.len()));
let _ = reader_tx.try_send(format!("__pong:{}", p.len()));
}
Ok(Message::Close(_)) | Err(_) => break,
_ => {}
@@ -224,15 +238,22 @@ impl Connection {
let _ = writer_task.await;
}
async fn handle_cmd(&self, cmd: WsCommand, tx: &tokio::sync::mpsc::UnboundedSender<String>) {
async fn handle_cmd(&self, cmd: WsCommand, tx: &tokio::sync::mpsc::Sender<String>) {
match cmd.kind.as_str() {
"supported_features" => {
// HOMECORE currently emits individual messages. Accepting the
// negotiation command keeps modern HA clients compatible while
// deliberately declining optional coalescing.
self.ack(tx, cmd.id, true, None);
}
"ping" => {
let msg = serde_json::json!({"id": cmd.id, "type": "pong"});
let _ = tx.send(msg.to_string());
let _ = tx.try_send(msg.to_string());
}
"get_states" => {
let snapshots = self.state.homecore().states().all();
let views: Vec<StateView> = snapshots.iter().map(|s| StateView::from_state(s)).collect();
let views: Vec<StateView> =
snapshots.iter().map(|s| StateView::from_state(s)).collect();
self.ack(tx, cmd.id, true, Some(serde_json::to_value(views).unwrap()));
}
"get_config" => {
@@ -243,19 +264,53 @@ impl Connection {
});
self.ack(tx, cmd.id, true, Some(payload));
}
"get_panels" => {
// Panels are frontend integration resources. An empty map is
// the valid shape for a headless server.
self.ack(tx, cmd.id, true, Some(serde_json::json!({})));
}
"get_services" => {
let services = self.state.homecore().services().registered_services().await;
let mut by_domain: std::collections::HashMap<String, serde_json::Map<String, serde_json::Value>> =
std::collections::HashMap::new();
let mut by_domain: std::collections::HashMap<
String,
serde_json::Map<String, serde_json::Value>,
> = std::collections::HashMap::new();
for s in services {
by_domain.entry(s.domain).or_default().insert(s.service, serde_json::json!({}));
by_domain
.entry(s.domain)
.or_default()
.insert(s.service, serde_json::json!({}));
}
let payload = serde_json::to_value(by_domain).unwrap();
self.ack(tx, cmd.id, true, Some(payload));
}
"config/entity_registry/list" | "get_entity_registry" => {
let entries = self.state.homecore().entities().all().await;
let payload =
serde_json::to_value(entries).unwrap_or_else(|_| serde_json::json!([]));
self.ack(tx, cmd.id, true, Some(payload));
}
"config/device_registry/list" | "get_device_registry" => {
let entries = self.state.homecore().devices().all().await;
let payload =
serde_json::to_value(entries).unwrap_or_else(|_| serde_json::json!([]));
self.ack(tx, cmd.id, true, Some(payload));
}
"config/area_registry/list" | "get_area_registry" => {
// HOMECORE does not yet model named areas. Returning the valid
// empty-list shape lets clients distinguish that from an
// unsupported command.
self.ack(tx, cmd.id, true, Some(serde_json::json!([])));
}
"call_service" => {
let (Some(domain), Some(service)) = (cmd.domain.clone(), cmd.service.clone()) else {
self.err(tx, cmd.id, "missing_domain_service", "domain and service are required");
let (Some(domain), Some(service)) = (cmd.domain.clone(), cmd.service.clone())
else {
self.err(
tx,
cmd.id,
"missing_domain_service",
"domain and service are required",
);
return;
};
let call = ServiceCall {
@@ -268,8 +323,53 @@ impl Connection {
Err(e) => self.err(tx, cmd.id, "service_error", &e.to_string()),
}
}
"fire_event" => {
let Some(event_type) = cmd.event_type.clone() else {
self.err(tx, cmd.id, "invalid_format", "event_type is required");
return;
};
if !crate::rest::is_valid_event_type(&event_type) {
self.err(tx, cmd.id, "invalid_format", "invalid event_type");
return;
}
let event_data = cmd.event_data.unwrap_or_else(|| serde_json::json!({}));
if !event_data.is_object() {
self.err(tx, cmd.id, "invalid_format", "event_data must be an object");
return;
}
self.state
.homecore()
.bus()
.fire_domain(homecore::DomainEvent::new(
event_type,
event_data,
Context::new(),
));
self.ack(tx, cmd.id, true, None);
}
"render_template" => {
let Some(template) = cmd.template.as_deref() else {
self.err(tx, cmd.id, "invalid_format", "template is required");
return;
};
let environment = homecore_automation::TemplateEnvironment::new(Arc::new(
self.state.homecore().states().clone(),
));
match environment.render(template) {
Ok(rendered) => {
self.ack(tx, cmd.id, true, Some(serde_json::Value::String(rendered)))
}
Err(error) => self.err(tx, cmd.id, "template_error", &error.to_string()),
}
}
"subscribe_events" => {
let sub_id = self.next_sub_id.fetch_add(1, Ordering::Relaxed);
// HA uses the subscribing command ID as the subscription ID
// in every emitted event and in `unsubscribe_events`.
let sub_id = cmd.id;
if self.subs.contains_key(&sub_id) {
self.err(tx, cmd.id, "id_reused", "subscription id is already active");
return;
}
let filter = cmd.event_type.clone();
let tx_clone = tx.clone();
let mut domain_rx = self.state.homecore().bus().subscribe_domain();
@@ -294,7 +394,27 @@ impl Connection {
"time_fired": sc.fired_at.to_rfc3339(),
}
});
if tx_clone.send(payload.to_string()).is_err() { break; }
if tx_clone.try_send(payload.to_string()).is_err() { break; }
}
}
Ok(SystemEvent::ServiceCalled { domain, service, data, context }) => {
if filter.as_deref() == Some("call_service") || filter.is_none() {
let payload = serde_json::json!({
"id": sub_id,
"type": "event",
"event": {
"event_type": "call_service",
"data": {
"domain": domain,
"service": service,
"service_data": data,
},
"origin": "LOCAL",
"time_fired": chrono::Utc::now().to_rfc3339(),
"context": context,
}
});
if tx_clone.try_send(payload.to_string()).is_err() { break; }
}
}
Ok(_) => {}
@@ -321,9 +441,10 @@ impl Connection {
"data": de.event_data,
"origin": format!("{:?}", de.origin).to_uppercase(),
"time_fired": de.fired_at.to_rfc3339(),
"context": de.context,
}
});
if tx_clone.send(payload.to_string()).is_err() { break; }
if tx_clone.try_send(payload.to_string()).is_err() { break; }
}
}
// Same recoverable-lag handling as the system arm
@@ -353,11 +474,21 @@ impl Connection {
self.err(tx, cmd.id, "not_found", "subscription_id not found");
}
} else {
self.err(tx, cmd.id, "missing_subscription", "subscription is required");
self.err(
tx,
cmd.id,
"missing_subscription",
"subscription is required",
);
}
}
other => {
self.err(tx, cmd.id, "unknown_command", &format!("unknown ws command: {other}"));
self.err(
tx,
cmd.id,
"unknown_command",
&format!("unknown ws command: {other}"),
);
}
}
// entity_id is reserved for future per-entity subscribes
@@ -366,7 +497,7 @@ impl Connection {
fn ack(
&self,
tx: &tokio::sync::mpsc::UnboundedSender<String>,
tx: &tokio::sync::mpsc::Sender<String>,
id: u64,
success: bool,
result: Option<serde_json::Value>,
@@ -378,10 +509,16 @@ impl Connection {
result,
error: None,
};
let _ = tx.send(serde_json::to_string(&msg).unwrap());
let _ = tx.try_send(serde_json::to_string(&msg).unwrap());
}
fn err(&self, tx: &tokio::sync::mpsc::UnboundedSender<String>, id: u64, code: &'static str, message: &str) {
fn err(
&self,
tx: &tokio::sync::mpsc::Sender<String>,
id: u64,
code: &'static str,
message: &str,
) {
let msg = ResultMessage {
id,
kind: "result",
@@ -389,7 +526,7 @@ impl Connection {
result: None,
error: Some(ErrorView { code, message }),
};
let _ = tx.send(serde_json::to_string(&msg).unwrap());
let _ = tx.try_send(serde_json::to_string(&msg).unwrap());
}
}
@@ -0,0 +1,193 @@
use axum::body::Body;
use axum::http::{Request, StatusCode};
use homecore::HomeCore;
use homecore_api::{router, LongLivedTokenStore, SharedState};
use http_body_util::BodyExt;
use tower::ServiceExt;
async fn app() -> (axum::Router, HomeCore) {
let homecore = HomeCore::new();
let tokens = LongLivedTokenStore::empty();
tokens.register("test-token").await;
let state = SharedState::with_tokens(homecore.clone(), "Test", "test", tokens);
(router(state), homecore)
}
fn post(uri: &str, body: &str) -> Request<Body> {
Request::builder()
.method("POST")
.uri(uri)
.header("authorization", "Bearer test-token")
.header("content-type", "application/json")
.body(Body::from(body.to_owned()))
.unwrap()
}
#[tokio::test]
async fn rest_event_is_delivered_to_domain_bus() {
let (app, homecore) = app().await;
let mut receiver = homecore.bus().subscribe_domain();
let response = app
.oneshot(post("/api/events/test_event", r#"{"answer":42}"#))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let event = receiver.recv().await.unwrap();
assert_eq!(event.event_type, "test_event");
assert_eq!(event.event_data["answer"], 42);
}
#[tokio::test]
async fn rest_template_uses_live_state_environment() {
let (app, _) = app().await;
let response = app
.oneshot(post("/api/template", r#"{"template":"{{ 6 * 7 }}"}"#))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let bytes = response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&bytes[..], b"42");
}
#[tokio::test]
async fn compatibility_matrix_is_authenticated() {
let (app, _) = app().await;
let response = app
.oneshot(
Request::builder()
.uri("/api/homecore/compatibility")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn history_reads_real_recorder_rows() {
use homecore::{Context, EntityId};
use homecore_recorder::Recorder;
let homecore = HomeCore::new();
let recorder = Recorder::open("sqlite::memory:").await.unwrap();
let mut changes = homecore.states().subscribe();
homecore.states().set(
EntityId::parse("light.history_probe").unwrap(),
"on",
serde_json::json!({"brightness": 123}),
Context::new(),
);
let change = changes.recv().await.unwrap();
recorder.record_state(&change).await.unwrap();
let tokens = LongLivedTokenStore::empty();
tokens.register("test-token").await;
let state =
SharedState::with_tokens(homecore, "Test", "test", tokens).with_recorder(Some(recorder));
let response = router(state)
.oneshot(
Request::builder()
.uri("/api/history/period?filter_entity_id=light.history_probe")
.header("authorization", "Bearer test-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let bytes = response.into_body().collect().await.unwrap().to_bytes();
let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(body[0][0]["entity_id"], "light.history_probe");
assert_eq!(body[0][0]["state"], "on");
assert_eq!(body[0][0]["attributes"]["brightness"], 123);
}
/// The real HA frontend's history/logbook pages call these endpoints with NO
/// entity filter by design (meaning "all entities") — a real install
/// routinely has 50-500+ entities. The 32-entity cap must only reject an
/// explicit, unusually-large `filter_entity_id`/`entity` list, never the
/// default unfiltered "all entities" shape.
#[tokio::test]
async fn history_and_logbook_unfiltered_are_not_capped_by_entity_count() {
use homecore::{Context, EntityId};
use homecore_recorder::Recorder;
let homecore = HomeCore::new();
let recorder = Recorder::open("sqlite::memory:").await.unwrap();
for i in 0..40 {
homecore.states().set(
EntityId::parse(&format!("sensor.probe_{i}")).unwrap(),
"on",
serde_json::json!({}),
Context::new(),
);
}
assert_eq!(homecore.states().all().len(), 40, "sanity: more than MAX_HISTORY_ENTITIES");
let tokens = LongLivedTokenStore::empty();
tokens.register("test-token").await;
let state =
SharedState::with_tokens(homecore, "Test", "test", tokens).with_recorder(Some(recorder));
let app = router(state);
let history_response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/history/period")
.header("authorization", "Bearer test-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
history_response.status(),
StatusCode::OK,
"unfiltered history with >32 known entities must not be rejected"
);
let logbook_response = app
.oneshot(
Request::builder()
.uri("/api/logbook")
.header("authorization", "Bearer test-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
logbook_response.status(),
StatusCode::OK,
"unfiltered logbook with >32 known entities must not be rejected"
);
}
/// An explicit, unusually-large `filter_entity_id` list is still rejected —
/// only the default "no filter" shape is exempt from the cap.
#[tokio::test]
async fn history_explicit_oversized_filter_is_still_rejected() {
use homecore_recorder::Recorder;
let homecore = HomeCore::new();
let recorder = Recorder::open("sqlite::memory:").await.unwrap();
let tokens = LongLivedTokenStore::empty();
tokens.register("test-token").await;
let state =
SharedState::with_tokens(homecore, "Test", "test", tokens).with_recorder(Some(recorder));
let filter: String = (0..40).map(|i| format!("sensor.probe_{i}")).collect::<Vec<_>>().join(",");
let response = router(state)
.oneshot(
Request::builder()
.uri(format!("/api/history/period?filter_entity_id={filter}"))
.header("authorization", "Bearer test-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
+127 -3
View File
@@ -80,7 +80,10 @@ async fn wrong_token_is_rejected() {
resp["type"], "auth_invalid",
"wrong token must be rejected with auth_invalid, got: {resp}"
);
assert_ne!(resp["type"], "auth_ok", "wrong token must NOT receive auth_ok");
assert_ne!(
resp["type"], "auth_ok",
"wrong token must NOT receive auth_ok"
);
}
#[tokio::test]
@@ -99,7 +102,10 @@ async fn correct_token_is_accepted() {
.unwrap();
let resp = next_json(&mut ws).await;
assert_eq!(resp["type"], "auth_ok", "correct token should be accepted, got: {resp}");
assert_eq!(
resp["type"], "auth_ok",
"correct token should be accepted, got: {resp}"
);
}
#[tokio::test]
@@ -133,7 +139,10 @@ async fn result_reply_is_received() {
let reply = tokio::time::timeout(std::time::Duration::from_secs(5), next_json(&mut ws))
.await
.expect("did not receive a reply within 5s — reply theater (HC-WS-02)");
assert_eq!(reply["type"], "result", "expected a result reply, got: {reply}");
assert_eq!(
reply["type"], "result",
"expected a result reply, got: {reply}"
);
assert_eq!(reply["id"], 1);
assert_eq!(reply["success"], true);
}
@@ -263,3 +272,118 @@ async fn subscription_survives_broadcast_lag() {
);
assert_eq!(got["event"]["data"]["marker"], "post-lag");
}
#[tokio::test]
async fn real_state_change_uses_client_subscription_id() {
use homecore::{Context, EntityId};
let (addr, hc) = spawn_server_returning_homecore("good_token_abc").await;
let url = format!("ws://{addr}/api/websocket");
let (mut ws, _resp) = connect_async(&url).await.unwrap();
let _ = next_json(&mut ws).await;
ws.send(Message::Text(
serde_json::json!({"type":"auth","access_token":"good_token_abc"}).to_string(),
))
.await
.unwrap();
let _ = next_json(&mut ws).await;
ws.send(Message::Text(
serde_json::json!({
"id": 41,
"type": "subscribe_events",
"event_type": "state_changed"
})
.to_string(),
))
.await
.unwrap();
let ack = next_json(&mut ws).await;
assert_eq!(ack["id"], 41);
assert_eq!(ack["success"], true);
hc.states().set(
EntityId::parse("light.integration_probe").unwrap(),
"on",
serde_json::json!({"source":"test"}),
Context::new(),
);
let event = tokio::time::timeout(std::time::Duration::from_secs(5), next_json(&mut ws))
.await
.expect("state change was not bridged to the WebSocket system-event subscription");
assert_eq!(
event["id"], 41,
"HA events must use the subscribe command id"
);
assert_eq!(event["type"], "event");
assert_eq!(event["event"]["event_type"], "state_changed");
assert_eq!(
event["event"]["data"]["entity_id"],
"light.integration_probe"
);
ws.send(Message::Text(
serde_json::json!({
"id": 42,
"type": "unsubscribe_events",
"subscription": 41
})
.to_string(),
))
.await
.unwrap();
let unsub = next_json(&mut ws).await;
assert_eq!(unsub["id"], 42);
assert_eq!(unsub["success"], true);
}
#[tokio::test]
async fn registry_list_commands_return_ha_result_shapes() {
use homecore::{EntityEntry, EntityId};
let (addr, hc) = spawn_server_returning_homecore("good_token_abc").await;
hc.entities()
.register(EntityEntry {
entity_id: EntityId::parse("light.registry_probe").unwrap(),
unique_id: Some("probe-1".into()),
platform: "test".into(),
name: Some("Registry probe".into()),
disabled_by: None,
area_id: None,
device_id: None,
entity_category: None,
config_entry_id: None,
})
.await;
let url = format!("ws://{addr}/api/websocket");
let (mut ws, _response) = connect_async(&url).await.unwrap();
let _ = next_json(&mut ws).await;
ws.send(Message::Text(
serde_json::json!({"type":"auth","access_token":"good_token_abc"}).to_string(),
))
.await
.unwrap();
let _ = next_json(&mut ws).await;
for (id, command) in [
(71, "config/entity_registry/list"),
(72, "config/device_registry/list"),
(73, "config/area_registry/list"),
] {
ws.send(Message::Text(
serde_json::json!({"id": id, "type": command}).to_string(),
))
.await
.unwrap();
let reply = next_json(&mut ws).await;
assert_eq!(reply["id"], id);
assert_eq!(reply["success"], true);
assert!(reply["result"].is_array());
if command == "config/entity_registry/list" {
assert_eq!(reply["result"][0]["entity_id"], "light.registry_probe");
}
}
}
+82
View File
@@ -0,0 +1,82 @@
//! Bounded audio types shared by STT, TTS, and satellite transports.
use serde::{Deserialize, Serialize};
use thiserror::Error;
/// Maximum audio accepted in one chunk (256 KiB).
pub const MAX_AUDIO_CHUNK_BYTES: usize = 256 * 1024;
/// Maximum audio accepted in a single utterance (16 MiB).
pub const MAX_UTTERANCE_AUDIO_BYTES: usize = 16 * 1024 * 1024;
/// Audio encodings supported by the native voice pipeline.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AudioCodec {
/// Signed, little-endian, 16-bit PCM.
PcmS16Le,
}
/// A validated audio stream format.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct AudioFormat {
pub codec: AudioCodec,
pub sample_rate: u32,
pub channels: u8,
}
impl AudioFormat {
pub fn validate(self) -> Result<Self, AudioError> {
if !(8_000..=48_000).contains(&self.sample_rate) {
return Err(AudioError::InvalidSampleRate(self.sample_rate));
}
if !(1..=2).contains(&self.channels) {
return Err(AudioError::InvalidChannels(self.channels));
}
Ok(self)
}
}
/// One bounded audio packet.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AudioChunk {
bytes: Vec<u8>,
}
impl AudioChunk {
pub fn new(bytes: Vec<u8>) -> Result<Self, AudioError> {
if bytes.is_empty() {
return Err(AudioError::Empty);
}
if bytes.len() > MAX_AUDIO_CHUNK_BYTES {
return Err(AudioError::ChunkTooLarge(bytes.len()));
}
if bytes.len() % 2 != 0 {
return Err(AudioError::UnalignedPcm);
}
Ok(Self { bytes })
}
pub fn as_bytes(&self) -> &[u8] {
&self.bytes
}
pub fn into_bytes(self) -> Vec<u8> {
self.bytes
}
}
#[derive(Debug, Error, Eq, PartialEq)]
pub enum AudioError {
#[error("audio chunk is empty")]
Empty,
#[error("audio chunk is {0} bytes; maximum is {MAX_AUDIO_CHUNK_BYTES}")]
ChunkTooLarge(usize),
#[error("16-bit PCM data must contain an even number of bytes")]
UnalignedPcm,
#[error("sample rate {0} Hz is outside 8000..=48000")]
InvalidSampleRate(u32),
#[error("channel count {0} is outside 1..=2")]
InvalidChannels(u8),
#[error("utterance audio exceeds {MAX_UTTERANCE_AUDIO_BYTES} bytes")]
UtteranceTooLarge,
}
+24 -10
View File
@@ -35,27 +35,41 @@
//! honest path until it ships.
//! - STT/TTS bridge and satellite protocol (P3).
pub mod intent;
pub mod recognizer;
pub mod semantic_recognizer;
pub mod audio;
pub mod handler;
pub mod runner;
pub mod intent;
pub mod pipeline;
pub mod recognizer;
pub mod runner;
pub mod satellite;
pub mod semantic_recognizer;
pub mod speech;
pub mod voice;
/// Deterministic text embedding used by [`semantic_recognizer::SemanticIntentRecognizer`].
#[cfg(feature = "semantic")]
pub mod embedding;
pub use intent::{Card, Intent, IntentName, IntentResponse};
pub use recognizer::{
IntentRecognizer, RecognizerError, RegexIntentRecognizer, MAX_UTTERANCE_BYTES,
};
pub use semantic_recognizer::{SemanticIntentRecognizer, DEFAULT_SIMILARITY_THRESHOLD};
pub use audio::{AudioChunk, AudioCodec, AudioError, AudioFormat};
pub use handler::{
HandlerError, HassCancelAll, HassLightSet, HassNevermind, HassTurnOff, HassTurnOn,
IntentHandler,
};
pub use intent::{Card, Intent, IntentName, IntentResponse};
pub use pipeline::AssistPipeline;
pub use recognizer::{
IntentRecognizer, RecognizerError, RegexIntentRecognizer, MAX_UTTERANCE_BYTES,
};
pub use runner::{
AssistError, LocalRunner, NoopRunner, RufloResponse, RufloRunner, RufloRunnerOpts,
};
pub use pipeline::AssistPipeline;
pub use satellite::{
SatelliteClientMessage, SatelliteError, SatelliteServerMessage, SatelliteSession,
SatelliteState, SATELLITE_PROTOCOL_VERSION,
};
pub use semantic_recognizer::{SemanticIntentRecognizer, DEFAULT_SIMILARITY_THRESHOLD};
pub use speech::{
DisabledStt, DisabledTts, SpeechError, SpeechToText, SynthesizedSpeech, TextToSpeech,
Transcript,
};
pub use voice::{VoiceError, VoicePipeline, VoiceResponse, DEFAULT_VOICE_TIMEOUT};
+250
View File
@@ -0,0 +1,250 @@
//! Transport-independent satellite voice session protocol.
//!
//! Text frames use [`SatelliteClientMessage`] / [`SatelliteServerMessage`].
//! Binary frames are accepted only while a stream is active.
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::audio::{AudioChunk, AudioError, AudioFormat, MAX_UTTERANCE_AUDIO_BYTES};
pub const SATELLITE_PROTOCOL_VERSION: u16 = 1;
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum SatelliteClientMessage {
Hello {
version: u16,
token: String,
},
Start {
language: String,
format: AudioFormat,
},
End,
Cancel,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum SatelliteServerMessage {
Ready { version: u16 },
Started,
Transcript { text: String, language: String },
Intent { response: crate::IntentResponse },
Audio { format: AudioFormat, bytes: usize },
Finished,
Error { code: String, message: String },
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SatelliteState {
AwaitingHello,
Idle,
Streaming,
Closed,
}
/// Strict state machine used by WebSocket or native satellite transports.
pub struct SatelliteSession {
state: SatelliteState,
authenticated: bool,
audio: Vec<u8>,
format: Option<AudioFormat>,
language: Option<String>,
}
impl SatelliteSession {
pub fn new() -> Self {
Self {
state: SatelliteState::AwaitingHello,
authenticated: false,
audio: Vec::new(),
format: None,
language: None,
}
}
pub fn state(&self) -> SatelliteState {
self.state
}
/// Handle a control message. `authenticate` must perform constant-time
/// credential comparison; credentials are never retained by this session.
pub fn control(
&mut self,
message: SatelliteClientMessage,
authenticate: impl FnOnce(&str) -> bool,
) -> Result<SatelliteServerMessage, SatelliteError> {
match (self.state, message) {
(SatelliteState::AwaitingHello, SatelliteClientMessage::Hello { version, token }) => {
if version != SATELLITE_PROTOCOL_VERSION {
self.state = SatelliteState::Closed;
return Err(SatelliteError::UnsupportedVersion(version));
}
if !authenticate(&token) {
self.state = SatelliteState::Closed;
return Err(SatelliteError::Unauthorized);
}
self.authenticated = true;
self.state = SatelliteState::Idle;
Ok(SatelliteServerMessage::Ready { version })
}
(SatelliteState::Idle, SatelliteClientMessage::Start { language, format })
if self.authenticated =>
{
if language.is_empty() || language.len() > 35 {
return Err(SatelliteError::InvalidLanguage);
}
self.format = Some(format.validate()?);
self.language = Some(language);
self.audio.clear();
self.state = SatelliteState::Streaming;
Ok(SatelliteServerMessage::Started)
}
(SatelliteState::Streaming, SatelliteClientMessage::End) => {
if self.audio.is_empty() {
return Err(SatelliteError::EmptyStream);
}
self.state = SatelliteState::Idle;
Ok(SatelliteServerMessage::Finished)
}
(SatelliteState::Streaming, SatelliteClientMessage::Cancel) => {
self.reset_stream();
Ok(SatelliteServerMessage::Finished)
}
(_, SatelliteClientMessage::Cancel) => {
self.reset_stream();
Ok(SatelliteServerMessage::Finished)
}
_ => Err(SatelliteError::InvalidSequence),
}
}
pub fn audio(&mut self, chunk: AudioChunk) -> Result<(), SatelliteError> {
if self.state != SatelliteState::Streaming {
return Err(SatelliteError::InvalidSequence);
}
if self.audio.len().saturating_add(chunk.as_bytes().len()) > MAX_UTTERANCE_AUDIO_BYTES {
self.reset_stream();
return Err(SatelliteError::AudioLimit);
}
self.audio.extend_from_slice(chunk.as_bytes());
Ok(())
}
pub fn take_utterance(&mut self) -> Option<(Vec<u8>, AudioFormat, String)> {
if self.state != SatelliteState::Idle || self.audio.is_empty() {
return None;
}
let audio = std::mem::take(&mut self.audio);
Some((audio, self.format.take()?, self.language.take()?))
}
fn reset_stream(&mut self) {
self.audio.clear();
self.format = None;
self.language = None;
self.state = if self.authenticated {
SatelliteState::Idle
} else {
SatelliteState::AwaitingHello
};
}
}
impl Default for SatelliteSession {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Error)]
pub enum SatelliteError {
#[error("satellite message is invalid in the current session state")]
InvalidSequence,
#[error("satellite protocol version {0} is unsupported")]
UnsupportedVersion(u16),
#[error("satellite authentication failed")]
Unauthorized,
#[error("invalid language tag")]
InvalidLanguage,
#[error("audio stream is empty")]
EmptyStream,
#[error("audio stream exceeded its size limit")]
AudioLimit,
#[error(transparent)]
Audio(#[from] AudioError),
}
#[cfg(test)]
mod tests {
use super::*;
use crate::audio::AudioCodec;
fn format() -> AudioFormat {
AudioFormat {
codec: AudioCodec::PcmS16Le,
sample_rate: 16_000,
channels: 1,
}
}
#[test]
fn happy_path_preserves_audio_and_metadata() {
let mut session = SatelliteSession::new();
session
.control(
SatelliteClientMessage::Hello {
version: 1,
token: "secret".into(),
},
|token| token == "secret",
)
.unwrap();
session
.control(
SatelliteClientMessage::Start {
language: "en-CA".into(),
format: format(),
},
|_| false,
)
.unwrap();
session
.audio(AudioChunk::new(vec![1, 0, 2, 0]).unwrap())
.unwrap();
session
.control(SatelliteClientMessage::End, |_| false)
.unwrap();
let (audio, stored_format, language) = session.take_utterance().unwrap();
assert_eq!(audio, vec![1, 0, 2, 0]);
assert_eq!(stored_format, format());
assert_eq!(language, "en-CA");
}
#[test]
fn unauthenticated_stream_is_rejected_and_closed() {
let mut session = SatelliteSession::new();
let error = session
.control(
SatelliteClientMessage::Hello {
version: 1,
token: "wrong".into(),
},
|_| false,
)
.unwrap_err();
assert!(matches!(error, SatelliteError::Unauthorized));
assert_eq!(session.state(), SatelliteState::Closed);
}
#[test]
fn binary_before_start_is_rejected() {
let mut session = SatelliteSession::new();
let error = session
.audio(AudioChunk::new(vec![0, 0]).unwrap())
.unwrap_err();
assert!(matches!(error, SatelliteError::InvalidSequence));
}
}
+87
View File
@@ -0,0 +1,87 @@
//! Provider-neutral speech-to-text and text-to-speech contracts.
use async_trait::async_trait;
use thiserror::Error;
use crate::audio::{AudioFormat, MAX_UTTERANCE_AUDIO_BYTES};
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Transcript {
pub text: String,
pub language: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SynthesizedSpeech {
pub audio: Vec<u8>,
pub format: AudioFormat,
}
#[derive(Debug, Error)]
pub enum SpeechError {
#[error("{0} provider is not configured")]
NotConfigured(&'static str),
#[error("speech provider rejected the request: {0}")]
Provider(String),
#[error("speech provider returned invalid data: {0}")]
InvalidOutput(String),
#[error("speech operation timed out")]
Timeout,
}
#[async_trait]
pub trait SpeechToText: Send + Sync {
async fn transcribe(
&self,
audio: &[u8],
format: AudioFormat,
language: &str,
) -> Result<Transcript, SpeechError>;
}
#[async_trait]
pub trait TextToSpeech: Send + Sync {
async fn synthesize(
&self,
text: &str,
language: &str,
) -> Result<SynthesizedSpeech, SpeechError>;
}
/// Fail-closed provider used until an STT integration is configured.
pub struct DisabledStt;
#[async_trait]
impl SpeechToText for DisabledStt {
async fn transcribe(
&self,
_audio: &[u8],
_format: AudioFormat,
_language: &str,
) -> Result<Transcript, SpeechError> {
Err(SpeechError::NotConfigured("STT"))
}
}
/// Fail-closed provider used until a TTS integration is configured.
pub struct DisabledTts;
#[async_trait]
impl TextToSpeech for DisabledTts {
async fn synthesize(
&self,
_text: &str,
_language: &str,
) -> Result<SynthesizedSpeech, SpeechError> {
Err(SpeechError::NotConfigured("TTS"))
}
}
pub(crate) fn validate_provider_audio(audio: &[u8]) -> Result<(), SpeechError> {
if audio.is_empty() || audio.len() > MAX_UTTERANCE_AUDIO_BYTES || audio.len() % 2 != 0 {
return Err(SpeechError::InvalidOutput(
"audio must be non-empty, bounded, aligned PCM".into(),
));
}
Ok(())
}
+183
View File
@@ -0,0 +1,183 @@
//! End-to-end STT → intent → TTS pipeline.
use std::time::Duration;
use homecore::HomeCore;
use thiserror::Error;
use tokio::time::timeout;
use crate::audio::{AudioFormat, MAX_UTTERANCE_AUDIO_BYTES};
use crate::pipeline::AssistPipeline;
use crate::recognizer::IntentRecognizer;
use crate::speech::{
validate_provider_audio, SpeechError, SpeechToText, SynthesizedSpeech, TextToSpeech, Transcript,
};
use crate::{AssistError, IntentResponse};
pub const DEFAULT_VOICE_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Clone, Debug)]
pub struct VoiceResponse {
pub transcript: Transcript,
pub intent: IntentResponse,
pub speech: SynthesizedSpeech,
}
#[derive(Debug, Error)]
pub enum VoiceError {
#[error("input audio is empty or exceeds the utterance limit")]
InvalidAudio,
#[error(transparent)]
Speech(#[from] SpeechError),
#[error(transparent)]
Assist(#[from] AssistError),
#[error("voice pipeline timed out")]
Timeout,
}
pub struct VoicePipeline<S, T, R: IntentRecognizer> {
stt: S,
tts: T,
assist: AssistPipeline<R>,
timeout: Duration,
}
impl<S, T, R> VoicePipeline<S, T, R>
where
S: SpeechToText,
T: TextToSpeech,
R: IntentRecognizer,
{
pub fn new(stt: S, tts: T, assist: AssistPipeline<R>) -> Self {
Self {
stt,
tts,
assist,
timeout: DEFAULT_VOICE_TIMEOUT,
}
}
pub fn with_timeout(mut self, value: Duration) -> Self {
self.timeout = value;
self
}
pub async fn process(
&self,
audio: &[u8],
format: AudioFormat,
language: &str,
hc: &HomeCore,
) -> Result<VoiceResponse, VoiceError> {
if audio.is_empty() || audio.len() > MAX_UTTERANCE_AUDIO_BYTES || audio.len() % 2 != 0 {
return Err(VoiceError::InvalidAudio);
}
let format = format.validate().map_err(|_| VoiceError::InvalidAudio)?;
timeout(self.timeout, async {
let transcript = self.stt.transcribe(audio, format, language).await?;
let intent = self
.assist
.process(&transcript.text, &transcript.language, hc)
.await?;
let speech = self
.tts
.synthesize(&intent.speech, &transcript.language)
.await?;
speech
.format
.validate()
.map_err(|error| SpeechError::InvalidOutput(error.to_string()))?;
validate_provider_audio(&speech.audio)?;
Ok(VoiceResponse {
transcript,
intent,
speech,
})
})
.await
.map_err(|_| VoiceError::Timeout)?
}
}
#[cfg(test)]
mod tests {
use async_trait::async_trait;
use super::*;
use crate::audio::AudioCodec;
use crate::recognizer::RegexIntentRecognizer;
use crate::speech::{SpeechToText, TextToSpeech};
struct FixedStt;
#[async_trait]
impl SpeechToText for FixedStt {
async fn transcribe(
&self,
_audio: &[u8],
_format: AudioFormat,
language: &str,
) -> Result<Transcript, SpeechError> {
Ok(Transcript {
text: "never mind".into(),
language: language.into(),
})
}
}
struct FixedTts;
#[async_trait]
impl TextToSpeech for FixedTts {
async fn synthesize(
&self,
text: &str,
_language: &str,
) -> Result<SynthesizedSpeech, SpeechError> {
assert!(!text.is_empty());
Ok(SynthesizedSpeech {
audio: vec![0, 0, 1, 0],
format: format(),
})
}
}
fn format() -> AudioFormat {
AudioFormat {
codec: AudioCodec::PcmS16Le,
sample_rate: 16_000,
channels: 1,
}
}
#[tokio::test]
async fn runs_stt_intent_and_tts() {
let recognizer = RegexIntentRecognizer::new();
recognizer
.register("HassNevermind", r"never ?mind", "*")
.await
.unwrap();
let pipeline = crate::pipeline::default_pipeline(recognizer);
let voice = VoicePipeline::new(FixedStt, FixedTts, pipeline);
let result = voice
.process(&[0, 0, 1, 0], format(), "en-CA", &HomeCore::new())
.await
.unwrap();
assert_eq!(result.transcript.text, "never mind");
assert_eq!(result.speech.audio.len(), 4);
}
#[tokio::test]
async fn rejects_unaligned_audio_before_provider_call() {
let voice = VoicePipeline::new(
FixedStt,
FixedTts,
crate::pipeline::default_pipeline(RegexIntentRecognizer::new()),
);
let error = voice
.process(&[0], format(), "en", &HomeCore::new())
.await
.unwrap_err();
assert!(matches!(error, VoiceError::InvalidAudio));
}
}
@@ -74,7 +74,7 @@ impl Condition {
Condition::State { entity_id, state } => {
ctx.states
.get(entity_id)
.map_or(false, |s| s.state == *state)
.is_some_and(|s| s.state == *state)
}
Condition::NumericState { entity_id, above, below } => {
let value: Option<f64> = ctx
@@ -84,16 +84,13 @@ impl Condition {
match value {
None => false,
Some(v) => {
above.map_or(true, |a| v > a) && below.map_or(true, |b| v < b)
above.is_none_or(|a| v > a) && below.is_none_or(|b| v < b)
}
}
}
Condition::Template { value_template } => {
if let Some(env) = &ctx.template_env {
match env.render_bool(value_template) {
Ok(v) => v,
Err(_) => false,
}
env.render_bool(value_template).unwrap_or_default()
} else {
false
}
+2 -2
View File
@@ -106,7 +106,7 @@ impl Trigger {
pub fn matches_sync(&self, ctx: &TriggerContext) -> bool {
match self {
Trigger::State { entity_id, from, to } => {
let eid_match = ctx.entity_id.as_ref().map_or(false, |e| e == entity_id);
let eid_match = ctx.entity_id.as_ref() == Some(entity_id);
if !eid_match {
return false;
}
@@ -125,7 +125,7 @@ impl Trigger {
true
}
Trigger::NumericState { entity_id, above, below } => {
let eid_match = ctx.entity_id.as_ref().map_or(false, |e| e == entity_id);
let eid_match = ctx.entity_id.as_ref() == Some(entity_id);
if !eid_match {
return false;
}
+17 -5
View File
@@ -10,7 +10,7 @@ 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"
description = "Fail-closed HomeKit Accessory Protocol network foundation for HOMECORE"
repository = "https://github.com/ruvnet/wifi-densepose"
[lib]
@@ -19,18 +19,30 @@ path = "src/lib.rs"
[features]
default = []
# P2: gates the actual hap = "0.1" crate integration + real mDNS via mdns-sd
hap-server = []
# Enables the bounded TCP/HTTP listener and real `_hap._tcp` mDNS advertiser.
hap-server = ["dep:httparse", "dep:mdns-sd"]
[dependencies]
homecore = { path = "../homecore" }
tokio = { version = "1", features = ["sync", "rt", "rt-multi-thread", "time", "macros"] }
tokio = { version = "1", features = ["fs", "io-util", "macros", "net", "rt", "rt-multi-thread", "sync", "time"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
tracing = "0.1"
async-trait = "0.1"
uuid = { version = "1", features = ["v4", "serde"] }
ed25519-dalek = "2.1"
tempfile = "3"
chacha20poly1305 = "0.10"
getrandom = "0.2"
hkdf = "0.12"
sha2 = "0.10"
sha2_11 = { package = "sha2", version = "0.11" }
srp = "=0.7.0-rc.3"
x25519-dalek = { version = "2", features = ["static_secrets"] }
zeroize = { version = "1", features = ["derive"] }
httparse = { version = "1", optional = true }
mdns-sd = { version = "0.11", optional = true }
[dev-dependencies]
tokio = { version = "1", features = ["sync", "rt", "rt-multi-thread", "time", "macros", "test-util"] }
tokio = { version = "1", features = ["fs", "io-util", "macros", "net", "rt", "rt-multi-thread", "sync", "time", "test-util"] }
+98 -105
View File
@@ -1,121 +1,114 @@
# homecore-hap
Apple Home HomeKit Accessory Protocol bridge for HOMECORE with HAP-1.1 trait surface and mDNS advertisement (P2).
`homecore-hap` is HOMECORE's bounded, fail-closed HAP IP accessory server
(ADR-125). It implements the HAP R2 cryptographic pairing and transport
boundary without relying on the broken `hap` 0.1 pre-release crate.
[![Crates.io](https://img.shields.io/crates/v/homecore-hap.svg)](https://crates.io/crates/homecore-hap)
![License](https://img.shields.io/badge/license-MIT-blue.svg)
![MSRV: 1.89+](https://img.shields.io/badge/MSRV-1.89%2B-purple.svg)
[![Tests](https://img.shields.io/badge/tests-17%20passing-brightgreen.svg)](https://github.com/ruvnet/RuView)
[![ADR-125](https://img.shields.io/badge/ADR-125-orange.svg)](../../docs/adr/ADR-125-homecore-apple-home-homekit-bridge.md)
## Security and protocol coverage
**P1 scaffold**: trait surface for HAP accessories + characteristics, entity→HAP mapping rules, and bridge ownership. The actual HAP-1.1 TLS server and real mDNS integration are gated behind `--features hap-server` (P2).
- Pair-Setup M1-M6 uses the RFC 5054 3072-bit group with SHA-512, the HAP
compatibility proof construction, HKDF-SHA512, ChaCha20-Poly1305, and
Ed25519 long-term keys.
- Pair-Verify M1-M4 uses ephemeral X25519, strict Ed25519 transcript
verification, HKDF-SHA512, and authenticated encrypted sub-TLVs.
- After successful M4, all HTTP and `EVENT/1.0` traffic uses HAP records:
two-byte little-endian authenticated lengths, at most 1024 plaintext bytes,
independent directional keys, and monotonic 64-bit nonces. Authentication,
replay, truncation, and oversize failures close the connection without an
oracle response.
- `/accessories`, `/characteristics`, and `/pairings` are inaccessible until
Pair-Verify succeeds on that TCP connection. Pairings add/remove/list is
restricted to a currently persisted administrator.
- Accessory identity, Ed25519 seed, SRP salt/verifier, and controller records
are stored in a versioned file using same-directory atomic replacement.
Created Unix directories use mode `0700`, files use `0600`, and permissive,
oversized, symlinked, legacy, or malformed stores fail closed.
- The raw setup code is returned only during first provisioning. Only its SRP
verifier is persisted; `SetupCode` redacts `Debug` output and zeroizes on
drop.
- Removing the last administrator atomically clears every pairing. Live
sessions observe pairing revisions and are revoked, while the removal
response is delivered before the requesting session closes.
## What this crate does
The server also bounds connections, headers, bodies, request time, shutdown,
TLV sizes, controller counts, setup attempts, and concurrent Pair-Setup.
mDNS advertises the persisted accessory identifier and updates `sf` after
pairing or unpairing.
`homecore-hap` bridges HOMECORE entity state to Apple HomeKit Accessory Protocol (HAP-1.1), allowing HomeKit-native apps (Home, Control Center, Siri) to control HOMECORE devices. It provides:
## Provisioning and server integration
- **HapAccessoryType enum** — 11 accessory types matching HA's HomeKit integration (`Light`, `Switch`, `Thermostat`, `Lock`, `Door`, etc.)
- **HapCharacteristic enum** — HAP characteristic types (`On`, `Brightness`, `Temperature`, `TargetLockState`, etc.)
- **EntityToAccessoryMapper** — bidirectional rules for mapping HOMECORE entities to HAP accessories (e.g., `light.kitchen``Light` accessory + `On` + `Brightness` characteristics)
- **HapBridge** — owns and exposes a collection of mapped accessories over HAP
- **MdnsAdvertiser trait** — abstraction over mDNS advertisement; P1 ships `NullAdvertiser` (no-op), P2 adds real mDNS via `mdns-sd`
- **RuViewToHapMapper** — bridges RuView sensing data (temperature, humidity, occupancy) to HAP characteristics
```rust,no_run
use std::{net::IpAddr, sync::Arc};
use homecore_hap::{
start_server, HapBridge, HapServerConfig, HapServiceRecord,
MdnsSdAdvertiser, PairingStore,
};
The bridge itself is a HAP Accessory Bridge (HAP-1.1 spec §8.3), advertising a single service with characteristic slots for each exposed accessory.
## Features
- **11 accessory types** — Light, Switch, Thermostat, Door, Lock, Window, Blind, Outlet, Fan, Sensor, SecuritySystem
- **Bi-directional mapping** — HOMECORE entity state ↔ HAP characteristic values with type-safe enums
- **HAP-1.1 spec compliance** — characteristic types and permissions match HomeKit's published spec
- **Trait-based advertisement**`MdnsAdvertiser` abstraction; swappable implementations (null, real mDNS, etc.)
- **RuView integration** — maps WiFi sensing data (occupancy, temperature, vital signs) to HomeKit sensor accessories
- **No TLS server in P1** — bridge compiles and tests pass with `--no-default-features`; real server lands in P2 with `--features hap-server`
- **Home.app compatible** — exposed accessories appear in Home app on any HomeKit hub (Apple TV, HomePod, HomePod mini)
## Capabilities
| Capability | Type | Method | Notes |
|------------|------|--------|-------|
| Define accessory type | Trait | `HapAccessoryType::Light` etc. (11 variants) | Enum; no instantiation yet (P1) |
| Define characteristic | Trait | `HapCharacteristic::On`, `Brightness`, etc. | Enum; values encoded as HAP TLV |
| Map entity to accessory | Mapping | `EntityToAccessoryMapper::map_light()` | Takes `EntityId` + `State`; returns `HapAccessory` |
| Expose accessory | Bridge | `HapBridge::expose(accessory)` | Adds to the bridge's characteristic list |
| Advertise bridge | mDNS | `NullAdvertiser::advertise()` (P1) | No-op stub; real mDNS in P2 |
| Advertise bridge (P2) | mDNS | `mdns_sd::ServiceInstanceBuilder` | Real mDNS via `--features hap-server` |
| Bridge state query | Bridge | `HapBridge::list_accessories()` | Returns exposed accessories + their characteristics |
| Characteristic write | Characteristic | HAP `WriteRequest` TLV (P2) | Home.app button press → service call |
| Characteristic read | Characteristic | HAP `ReadResponse` TLV (P2) | Home.app query → current entity state |
## Comparison to Home Assistant
| Aspect | Home Assistant | homecore-hap |
|--------|----------------|--------------|
| Framework | HA's `hap-python` (pure Python) | Rust 1.89+ with HAP trait abstraction |
| Server type | Python asyncio HAP-1.1 server | TLS server trait (P2); stub in P1 |
| Accessory types | 30+ (Light, Switch, Thermostat, etc.) | 11 (Light, Switch, Thermostat, Door, Lock, Window, Blind, Outlet, Fan, Sensor, SecuritySystem) |
| mDNS | mdns-py broadcast via asyncio | Abstraction + real mDNS (P2) or no-op stub (P1) |
| Entity filtering | YAML `include_domains` + `exclude_entities` | Mapper rules (planned P2) |
| HomeKit hub requirement | Yes (for remote access) | Yes (same as HomeKit) |
| Pairing code generation | Automatic (HA web UI) | Manual setup code (P2) |
| Characteristic persistence | HomeKit cloud only | Paired with homecore state machine |
## Performance
- **Entity→HAP mapping** — < 100 μs per entity (enum lookups + type conversions)
- **HAP write latency** — ~10 ms (TLS decrypt + characteristic parse + entity state set); bounded by homecore state machine lock contention
- **mDNS advertisement** (P2) — ~50 ms multicast broadcast; periodic rediscovery on network change
- **Memory overhead per accessory** — ~500 bytes (enum + characteristic slots + metadata)
- **No per-crate benchmarks yet** — a follow-up issue tracks baseline measurements
## Usage
Mapping an entity (P1):
```rust
use homecore_hap::{EntityToAccessoryMapper, HapBridge, HapAccessoryType};
use homecore::{EntityId, State};
use std::collections::HashMap;
#[tokio::main]
async fn main() {
let light_id = EntityId::parse("light.kitchen").unwrap();
let state = State::new("on", HashMap::new());
// Map the entity to a HAP Light accessory
let mut mapper = EntityToAccessoryMapper::new();
if let Ok(accessory) = mapper.map_light(&light_id, &state) {
println!("Mapped to HAP: {:?}", accessory.accessory_type);
// Expose it via the bridge
let mut bridge = HapBridge::new();
bridge.expose(accessory);
println!("Exposed {} accessories", bridge.list_accessories().len());
}
# async fn run() -> Result<(), Box<dyn std::error::Error>> {
let provisioned = PairingStore::load_or_create(
"/var/lib/homecore-hap/security.json",
)?;
if let Some(setup_code) = provisioned.setup_code.as_ref() {
// Send this once to a trusted local display or provisioning boundary.
println!("HAP setup code: {}", setup_code.expose());
}
let pairings = Arc::new(provisioned.store);
let record = HapServiceRecord::bridge(
"HOMECORE Bridge",
51826,
pairings.accessory_id()?,
);
let bridge = HapBridge::new(record);
let advertiser = Arc::new(MdnsSdAdvertiser::new(
"homecore",
"192.168.1.50".parse::<IpAddr>()?,
)?);
let server = start_server(
HapServerConfig::default(),
bridge.clone(),
pairings,
advertiser,
).await?;
// Feed HOMECORE StateChanged events through bridge.update_accessory(...).
server.shutdown().await?;
# Ok(())
# }
```
Real HAP server (P2, via `--features hap-server`):
The mDNS device ID must equal the persisted accessory ID; startup rejects a
mismatch. Real mDNS also requires a LAN-routable advertised address and
multicast access.
## Validation and remaining interoperability limits
The deterministic suite covers the HAP SRP session-key vector, complete
Pair-Setup and Pair-Verify ceremonies, transcript tampering, wrong proofs,
malformed/replayed/oversized records, atomic restart, last-admin removal, and
a real TCP lifecycle from Pair-Verify through encrypted `/accessories`.
This is protocol-level HAP R2 coverage, not a claim of Apple certification:
- It has not yet been exercised against a current Apple Home controller or
the current commercial MFi specification.
- Transient and split Pair-Setup flags are rejected as `Unavailable`.
- Writable characteristic service calls, timed writes, resource endpoints,
and stable persisted AID/IID allocation are not implemented. The present
characteristic surface is read and event subscription only.
- Operational hardening still depends on protecting the host and the
`0600` security file; no hardware-backed key store is integrated.
Build and test:
```bash
cargo build -p homecore-hap --features hap-server
# The server will advertise over mDNS and accept HomeKit pairing requests
cargo test -p homecore-hap --no-default-features
cargo test -p homecore-hap --features hap-server
cargo clippy -p homecore-hap --all-targets --features hap-server -- -D warnings
```
## Relation to other HOMECORE crates
## Decisions
```
homecore-hap (HomeKit bridge)
├─ homecore (state machine; bridge reads entity states)
├─ homecore-api (exposes HAP state via REST /api for remote debugging)
├─ homecore-server (starts the bridge on homecore init)
└─ homecore-automation (can trigger state changes via service calls)
```
## References
- [ADR-125: HOMECORE Apple Home / HomeKit Bridge](../../docs/adr/ADR-125-homecore-apple-home-homekit-bridge.md)
- [ADR-126: HOMECORE Home Assistant Port (master)](../../docs/adr/ADR-126-homecore-home-assistant-port.md)
- [HomeKit Accessory Protocol Specification (HAP-1.1)](https://developer.apple.com/homekit/)
- [user-guide-apple-homepod.md](../../docs/user-guide-apple-homepod.md)
- [README — wifi-densepose](../../../README.md)
- [ADR-125 — native Apple Home HAP bridge](../../../docs/adr/ADR-125-ruview-apple-home-native-hap-bridge.md)
- [ADR-130 — bounded async REST/WebSocket server patterns](../../../docs/adr/ADR-130-homecore-rest-websocket-api.md)
- [ADR-161 — server-layer security and honest labeling](../../../docs/adr/ADR-161-homecore-server-layer-security.md)
+96 -23
View File
@@ -1,15 +1,15 @@
//! `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.
//! The bridge owns mappings and their event stream. The feature-gated network
//! lifecycle is started separately with `start_server`.
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use homecore::entity::EntityId;
use tokio::sync::broadcast;
use crate::accessory::HapAccessoryType;
use crate::accessory::{HapAccessoryType, HapCharacteristic, HapCharacteristicValue};
use crate::error::HapError;
use crate::mapping::{AccessoryMapping, EntityToAccessoryMapper};
use crate::mdns::{HapServiceRecord, MdnsAdvertiser, NullAdvertiser};
@@ -22,37 +22,49 @@ pub struct ExposedAccessory {
pub mapping: AccessoryMapping,
}
/// A characteristic snapshot emitted after a registered entity changes.
#[derive(Debug, Clone)]
pub struct CharacteristicEvent {
pub entity_id: EntityId,
pub accessory_type: HapAccessoryType,
pub characteristics: Vec<(HapCharacteristic, HapCharacteristicValue)>,
}
struct BridgeInner {
accessories: HashMap<EntityId, ExposedAccessory>,
}
/// The P1 HAP bridge.
/// HOMECORE-to-HAP accessory bridge state.
///
/// 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.
/// registered. Use `start_server` for the bounded TCP lifecycle.
#[derive(Clone)]
pub struct HapBridge {
inner: Arc<RwLock<BridgeInner>>,
advertiser: Arc<dyn MdnsAdvertiser>,
events: broadcast::Sender<CharacteristicEvent>,
pub service_record: HapServiceRecord,
}
impl HapBridge {
/// Create a bridge with the given service record and a `NullAdvertiser`
/// (P1 default — real mDNS lands in P2).
/// Create a bridge with the given service record and a `NullAdvertiser`.
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).
/// Create a bridge with a custom `MdnsAdvertiser`.
pub fn with_advertiser(
service_record: HapServiceRecord,
advertiser: Arc<dyn MdnsAdvertiser>,
) -> Self {
let (events, _) = broadcast::channel(128);
Self {
inner: Arc::new(RwLock::new(BridgeInner { accessories: HashMap::new() })),
inner: Arc::new(RwLock::new(BridgeInner {
accessories: HashMap::new(),
})),
advertiser,
events,
service_record,
}
}
@@ -97,9 +109,46 @@ impl HapBridge {
Ok(())
}
/// Refresh a registered accessory and notify event subscribers.
pub fn update_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 mut inner = self.inner.write().unwrap();
let accessory = inner
.accessories
.get_mut(entity_id)
.ok_or_else(|| HapError::EntityNotFound(entity_id.as_str().to_owned()))?;
accessory.accessory_type = accessory_type;
accessory.mapping = mapping.clone();
}
let _ = self.events.send(CharacteristicEvent {
entity_id: entity_id.clone(),
accessory_type,
characteristics: mapping.characteristics,
});
Ok(())
}
/// Subscribe to bounded characteristic updates. Lagging receivers receive
/// Tokio's explicit `Lagged` error and must resynchronize from a snapshot.
pub fn subscribe_events(&self) -> broadcast::Receiver<CharacteristicEvent> {
self.events.subscribe()
}
/// Snapshot all currently registered accessories.
pub fn running_accessories(&self) -> Vec<ExposedAccessory> {
self.inner.read().unwrap().accessories.values().cloned().collect()
self.inner
.read()
.unwrap()
.accessories
.values()
.cloned()
.collect()
}
/// Number of registered accessories.
@@ -111,21 +160,25 @@ impl HapBridge {
self.len() == 0
}
/// P2 stub — will start the HAP-1.1 server + mDNS advertisement.
/// In P1 this only fires the null advertiser.
/// Start advertisement only.
///
/// This legacy lifecycle does not bind a TCP listener. New integrations
/// should call `start_server`, which advertises only after binding.
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)"
"HAP advertisement started without a TCP server"
);
Ok(())
}
/// Graceful shutdown — retracts mDNS advertisement.
pub async fn stop(&self) -> Result<(), HapError> {
self.advertiser.retract(&self.service_record.instance_name).await?;
self.advertiser
.retract(&self.service_record.instance_name)
.await?;
Ok(())
}
}
@@ -137,18 +190,22 @@ mod tests {
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(),
})
HapBridge::new(HapServiceRecord::bridge(
"RuView Sense",
51826,
"AA:BB:CC:DD:EE:FF",
))
}
fn light_state(name: &str, on: bool, brightness: u8) -> (EntityId, State) {
let eid = EntityId::parse(&format!("light.{name}")).unwrap();
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());
let s = State::new(
eid.clone(),
if on { "on" } else { "off" },
attrs,
Context::default(),
);
(eid, s)
}
@@ -187,6 +244,22 @@ mod tests {
assert!(matches!(err, HapError::EntityNotFound(_)));
}
#[tokio::test]
async fn update_emits_characteristic_event() {
let bridge = make_bridge();
let (eid, initial) = light_state("kitchen", false, 10);
bridge.add_accessory(&eid, &initial).unwrap();
let mut events = bridge.subscribe_events();
let (_, updated) = light_state("kitchen", true, 200);
bridge.update_accessory(&eid, &updated).unwrap();
let event = events.recv().await.unwrap();
assert_eq!(event.entity_id, eid);
assert!(event
.characteristics
.contains(&(HapCharacteristic::On, HapCharacteristicValue::Bool(true))));
}
#[tokio::test]
async fn start_stop_with_null_advertiser() {
let bridge = make_bridge();
+253
View File
@@ -0,0 +1,253 @@
//! HAP cryptographic composition over RustCrypto primitives.
#![cfg_attr(not(feature = "hap-server"), allow(dead_code))]
use chacha20poly1305::aead::{Aead, Payload};
use chacha20poly1305::{ChaCha20Poly1305, KeyInit, Nonce};
use hkdf::Hkdf;
use sha2::Sha512;
use zeroize::{Zeroize, ZeroizeOnDrop};
use crate::error::HapError;
pub(crate) const MAX_RECORD_PLAINTEXT: usize = 1024;
pub(crate) const RECORD_TAG_BYTES: usize = 16;
pub(crate) fn hkdf_sha512(
salt: &[u8],
input_key: &[u8],
info: &[u8],
) -> Result<[u8; 32], HapError> {
let mut output = [0u8; 32];
Hkdf::<Sha512>::new(Some(salt), input_key)
.expand(info, &mut output)
.map_err(|_| HapError::Protocol("HKDF output length is invalid".into()))?;
Ok(output)
}
fn label_nonce(label: &[u8; 8]) -> [u8; 12] {
let mut nonce = [0u8; 12];
nonce[4..].copy_from_slice(label);
nonce
}
pub(crate) fn seal_labeled(
key: &[u8; 32],
label: &[u8; 8],
plaintext: &[u8],
) -> Result<Vec<u8>, HapError> {
seal(key, &label_nonce(label), plaintext, &[])
}
pub(crate) fn open_labeled(
key: &[u8; 32],
label: &[u8; 8],
ciphertext_and_tag: &[u8],
) -> Result<Vec<u8>, HapError> {
open(key, &label_nonce(label), ciphertext_and_tag, &[])
}
fn seal(
key: &[u8; 32],
nonce: &[u8; 12],
plaintext: &[u8],
aad: &[u8],
) -> Result<Vec<u8>, HapError> {
ChaCha20Poly1305::new(key.into())
.encrypt(
Nonce::from_slice(nonce),
Payload {
msg: plaintext,
aad,
},
)
.map_err(|_| HapError::Protocol("ChaCha20-Poly1305 encryption failed".into()))
}
fn open(
key: &[u8; 32],
nonce: &[u8; 12],
ciphertext_and_tag: &[u8],
aad: &[u8],
) -> Result<Vec<u8>, HapError> {
ChaCha20Poly1305::new(key.into())
.decrypt(
Nonce::from_slice(nonce),
Payload {
msg: ciphertext_and_tag,
aad,
},
)
.map_err(|_| HapError::Protocol("ChaCha20-Poly1305 authentication failed".into()))
}
#[derive(Zeroize, ZeroizeOnDrop)]
pub(crate) struct SessionKeys {
accessory_to_controller: [u8; 32],
controller_to_accessory: [u8; 32],
}
impl SessionKeys {
pub(crate) fn derive(shared_secret: &[u8; 32]) -> Result<Self, HapError> {
Ok(Self {
accessory_to_controller: hkdf_sha512(
b"Control-Salt",
shared_secret,
b"Control-Read-Encryption-Key",
)?,
controller_to_accessory: hkdf_sha512(
b"Control-Salt",
shared_secret,
b"Control-Write-Encryption-Key",
)?,
})
}
#[cfg(test)]
pub(crate) fn controller_view(&self) -> Self {
Self {
accessory_to_controller: self.controller_to_accessory,
controller_to_accessory: self.accessory_to_controller,
}
}
}
/// Stateful HAP IP record protection. A failed decryption is terminal: callers
/// must close the connection and must never retry with the same counter.
#[derive(Zeroize, ZeroizeOnDrop)]
pub(crate) struct RecordLayer {
read_key: [u8; 32],
write_key: [u8; 32],
read_counter: u64,
write_counter: u64,
}
impl RecordLayer {
pub(crate) fn accessory(keys: SessionKeys) -> Self {
Self {
read_key: keys.controller_to_accessory,
write_key: keys.accessory_to_controller,
read_counter: 0,
write_counter: 0,
}
}
#[cfg(test)]
pub(crate) fn controller(keys: SessionKeys) -> Self {
Self {
read_key: keys.controller_to_accessory,
write_key: keys.accessory_to_controller,
read_counter: 0,
write_counter: 0,
}
}
pub(crate) fn encrypt(&mut self, plaintext: &[u8]) -> Result<Vec<u8>, HapError> {
let mut output = Vec::with_capacity(
plaintext.len() + plaintext.len().div_ceil(MAX_RECORD_PLAINTEXT) * 18,
);
for chunk in plaintext.chunks(MAX_RECORD_PLAINTEXT) {
let length = u16::try_from(chunk.len())
.expect("HAP record chunks never exceed the u16 range")
.to_le_bytes();
let nonce = record_nonce(self.write_counter);
let encrypted = seal(&self.write_key, &nonce, chunk, &length)?;
self.write_counter = self
.write_counter
.checked_add(1)
.ok_or_else(|| HapError::Protocol("HAP write nonce exhausted".into()))?;
output.extend_from_slice(&length);
output.extend_from_slice(&encrypted);
}
Ok(output)
}
pub(crate) fn decrypt(
&mut self,
length_bytes: [u8; 2],
ciphertext_and_tag: &[u8],
) -> Result<Vec<u8>, HapError> {
let length = u16::from_le_bytes(length_bytes) as usize;
if length > MAX_RECORD_PLAINTEXT {
return Err(HapError::Protocol(
"encrypted HAP record exceeds 1024 bytes".into(),
));
}
if ciphertext_and_tag.len() != length + RECORD_TAG_BYTES {
return Err(HapError::Protocol(
"encrypted HAP record length does not match framing".into(),
));
}
let nonce = record_nonce(self.read_counter);
let plaintext = open(&self.read_key, &nonce, ciphertext_and_tag, &length_bytes)?;
self.read_counter = self
.read_counter
.checked_add(1)
.ok_or_else(|| HapError::Protocol("HAP read nonce exhausted".into()))?;
Ok(plaintext)
}
}
fn record_nonce(counter: u64) -> [u8; 12] {
let mut nonce = [0u8; 12];
nonce[4..].copy_from_slice(&counter.to_le_bytes());
nonce
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deterministic_record_vector_and_multiframe_roundtrip() {
let shared = [0x42; 32];
let keys = SessionKeys::derive(&shared).unwrap();
let mut vector_accessory = RecordLayer::accessory(keys);
let vector = vector_accessory.encrypt(b"HAP").unwrap();
assert_eq!(
vector,
[
0x03, 0x00, 0xa2, 0x37, 0x30, 0x29, 0xba, 0xe2, 0xa9, 0xa6, 0xbb, 0x5b, 0xff, 0xed,
0x6a, 0x29, 0x74, 0x12, 0xd1, 0x6d, 0x7a,
]
);
let mut accessory = RecordLayer::accessory(SessionKeys::derive(&shared).unwrap());
let controller_keys = SessionKeys::derive(&shared).unwrap().controller_view();
let mut controller = RecordLayer::controller(controller_keys);
let plaintext = vec![0x5a; 2050];
let encrypted = accessory.encrypt(&plaintext).unwrap();
assert_eq!(&encrypted[..2], &[0, 4]);
let mut offset = 0;
let mut decrypted = Vec::new();
while offset < encrypted.len() {
let length_bytes: [u8; 2] = encrypted[offset..offset + 2].try_into().unwrap();
let length = u16::from_le_bytes(length_bytes) as usize;
let end = offset + 2 + length + RECORD_TAG_BYTES;
decrypted.extend(
controller
.decrypt(length_bytes, &encrypted[offset + 2..end])
.unwrap(),
);
offset = end;
}
assert_eq!(decrypted, plaintext);
}
#[test]
fn replay_tamper_and_oversize_fail_closed() {
let shared = [7; 32];
let mut sender = RecordLayer::accessory(SessionKeys::derive(&shared).unwrap());
let frame = sender.encrypt(b"authenticated").unwrap();
let length: [u8; 2] = frame[..2].try_into().unwrap();
let mut receiver =
RecordLayer::controller(SessionKeys::derive(&shared).unwrap().controller_view());
assert_eq!(
receiver.decrypt(length, &frame[2..]).unwrap(),
b"authenticated"
);
assert!(receiver.decrypt(length, &frame[2..]).is_err());
assert!(receiver
.decrypt(1025u16.to_le_bytes(), &vec![0; 1025 + RECORD_TAG_BYTES])
.is_err());
}
}
+31
View File
@@ -1,5 +1,6 @@
//! Unified error type for `homecore-hap`.
use std::path::PathBuf;
use thiserror::Error;
/// Errors produced by the HAP bridge and its sub-components.
@@ -19,4 +20,34 @@ pub enum HapError {
#[error("bridge not running")]
NotRunning,
#[error("pairing store error: {0}")]
PairingStore(String),
#[error("invalid pairing record: {0}")]
InvalidPairingRecord(String),
#[error("controller pairing already exists: {0}")]
PairingAlreadyExists(String),
#[error("controller pairing not found: {0}")]
PairingNotFound(String),
#[error("maximum controller pairings reached")]
PairingCapacity,
#[error("insecure permissions on {path}: mode {mode:o}; expected no group/other access")]
InsecurePermissions { path: PathBuf, mode: u32 },
#[error("invalid HAP session transition from {from} to {to}")]
InvalidSessionTransition {
from: &'static str,
to: &'static str,
},
#[error("HAP protocol error: {0}")]
Protocol(String),
#[error("HAP server error: {0}")]
Server(String),
}
+25 -8
View File
@@ -1,12 +1,11 @@
//! `homecore-hap` — Apple Home HomeKit Accessory Protocol bridge (ADR-125).
//!
//! # P1 scope
//! # Network foundation 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`.
//! The crate provides persisted accessory/controller identity, SRP-6a
//! Pair-Setup, X25519/Ed25519 Pair-Verify, encrypted HAP IP framing, bounded
//! TLV8/HTTP parsing, characteristic event flow, and (with `hap-server`) a
//! bounded TCP listener plus real mDNS.
//!
//! # Module layout
//!
@@ -16,19 +15,37 @@
//! | [`mapping`] | `EntityToAccessoryMapper` — HOMECORE entity → HAP |
//! | [`bridge`] | `HapBridge` — owns exposed accessories |
//! | [`mdns`] | `MdnsAdvertiser` trait + `NullAdvertiser` stub |
//! | [`pairing`] | Atomic accessory identity, setup, and pairing persistence |
//! | [`protocol`] | Bounded TLV8 protocol primitives |
//! | [`ruview`] | `RuViewToHapMapper` — sensing primitives → HAP |
//! | [`session`] | Authenticated request-gating state machine |
//! | `server` | Feature-gated bounded TCP/HTTP lifecycle |
//! | [`error`] | Unified `HapError` type |
pub mod accessory;
pub mod bridge;
mod crypto;
pub mod error;
pub mod mapping;
pub mod mdns;
mod pair_setup;
mod pair_verify;
pub mod pairing;
pub mod protocol;
pub mod ruview;
#[cfg(feature = "hap-server")]
pub mod server;
pub mod session;
pub use accessory::{HapAccessoryType, HapCharacteristic, HapCharacteristicValue};
pub use bridge::{ExposedAccessory, HapBridge};
pub use bridge::{CharacteristicEvent, ExposedAccessory, HapBridge};
pub use error::HapError;
pub use mapping::EntityToAccessoryMapper;
pub use mdns::{MdnsAdvertiser, NullAdvertiser};
#[cfg(feature = "hap-server")]
pub use mdns::MdnsSdAdvertiser;
pub use mdns::{HapServiceRecord, MdnsAdvertiser, NullAdvertiser};
pub use pairing::{ControllerPairing, PairingStore, PairingStoreProvisioning, SetupCode};
pub use ruview::RuViewToHapMapper;
#[cfg(feature = "hap-server")]
pub use server::{start_server, HapServerConfig, HapServerHandle};
pub use session::{Session, SessionState};
+194 -37
View File
@@ -1,61 +1,206 @@
//! 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.
//! HAP `_hap._tcp` advertisement.
use async_trait::async_trait;
use crate::error::HapError;
/// Service record advertised over mDNS for HAP discovery.
#[derive(Debug, Clone)]
/// HAP service record advertised over mDNS.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HapServiceRecord {
/// Service instance name shown in Apple Home ("RuView Sense").
/// Service instance shown in discovery UI.
pub instance_name: String,
/// TCP port the HAP server listens on (default 51826).
/// Bound HAP TCP port.
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).
/// Stable colon-separated accessory device identifier.
pub device_id: String,
/// Accessory model (`md` TXT key).
pub model: String,
/// Configuration number (`c#`), incremented when accessory layout changes.
pub configuration_number: u32,
/// Current state number (`s#`).
pub state_number: u32,
/// HAP accessory category identifier. Bridges use `2`.
pub category: u16,
/// Whether a controller pairing already exists.
pub paired: bool,
}
/// 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`.
impl HapServiceRecord {
pub fn bridge(
instance_name: impl Into<String>,
port: u16,
device_id: impl Into<String>,
) -> Self {
Self {
instance_name: instance_name.into(),
port,
device_id: device_id.into(),
model: "HOMECORE Bridge".into(),
configuration_number: 1,
state_number: 1,
category: 2,
paired: false,
}
}
fn validate(&self) -> Result<(), HapError> {
if self.instance_name.is_empty() || self.instance_name.len() > 63 {
return Err(HapError::MdnsError(
"instance_name must contain 1..=63 bytes".into(),
));
}
if self.port == 0 {
return Err(HapError::MdnsError("advertised port cannot be zero".into()));
}
let parts: Vec<&str> = self.device_id.split(':').collect();
if parts.len() != 6
|| parts
.iter()
.any(|part| part.len() != 2 || !part.bytes().all(|byte| byte.is_ascii_hexdigit()))
{
return Err(HapError::MdnsError(
"device_id must be six colon-separated hexadecimal octets".into(),
));
}
if self.model.is_empty() || self.model.len() > 64 {
return Err(HapError::MdnsError(
"model must contain 1..=64 bytes".into(),
));
}
if self.configuration_number == 0 || self.state_number == 0 {
return Err(HapError::MdnsError(
"configuration and state numbers must be non-zero".into(),
));
}
Ok(())
}
/// Standard HAP Bonjour TXT keys. Setup codes and controller data are
/// intentionally never included because TXT records are plaintext.
pub fn txt_records(&self) -> Vec<(String, String)> {
vec![
("c#".into(), self.configuration_number.to_string()),
("ci".into(), self.category.to_string()),
("ff".into(), "0".into()),
("id".into(), self.device_id.to_ascii_uppercase()),
("md".into(), self.model.clone()),
("pv".into(), "1.1".into()),
("s#".into(), self.state_number.to_string()),
("sf".into(), if self.paired { "0" } else { "1" }.into()),
]
}
}
/// Advertise and retract a HAP service.
#[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.
/// Deterministic no-network advertiser for tests and disabled deployments.
#[derive(Debug, Default, Clone)]
pub struct NullAdvertiser;
#[async_trait]
impl MdnsAdvertiser for NullAdvertiser {
async fn advertise(&self, record: &HapServiceRecord) -> Result<(), HapError> {
record.validate()?;
tracing::debug!(
instance = %record.instance_name,
port = record.port,
"NullAdvertiser: skipping mDNS advertisement (P1 stub)"
"HAP mDNS advertisement disabled"
);
Ok(())
}
async fn retract(&self, instance_name: &str) -> Result<(), HapError> {
tracing::debug!(
instance = %instance_name,
"NullAdvertiser: skipping mDNS retract (P1 stub)"
);
tracing::debug!(instance = %instance_name, "HAP mDNS retraction disabled");
Ok(())
}
}
/// Network-backed Bonjour advertiser.
#[cfg(feature = "hap-server")]
pub struct MdnsSdAdvertiser {
daemon: mdns_sd::ServiceDaemon,
hostname: String,
address: String,
registrations: std::sync::Mutex<std::collections::HashMap<String, String>>,
}
#[cfg(feature = "hap-server")]
impl std::fmt::Debug for MdnsSdAdvertiser {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("MdnsSdAdvertiser")
.field("hostname", &self.hostname)
.field("address", &self.address)
.finish_non_exhaustive()
}
}
#[cfg(feature = "hap-server")]
impl MdnsSdAdvertiser {
/// Bind the mDNS daemon for a LAN-routable host/address.
pub fn new(hostname: impl Into<String>, address: std::net::IpAddr) -> Result<Self, HapError> {
let mut hostname = hostname.into();
if !hostname.ends_with(".local.") {
hostname = format!("{}.local.", hostname.trim_end_matches('.'));
}
let daemon = mdns_sd::ServiceDaemon::new()
.map_err(|error| HapError::MdnsError(error.to_string()))?;
Ok(Self {
daemon,
hostname,
address: address.to_string(),
registrations: std::sync::Mutex::new(std::collections::HashMap::new()),
})
}
fn service_info(&self, record: &HapServiceRecord) -> Result<mdns_sd::ServiceInfo, HapError> {
record.validate()?;
let properties: std::collections::HashMap<String, String> =
record.txt_records().into_iter().collect();
mdns_sd::ServiceInfo::new(
"_hap._tcp.local.",
&record.instance_name,
&self.hostname,
self.address.as_str(),
record.port,
Some(properties),
)
.map_err(|error| HapError::MdnsError(error.to_string()))
}
}
#[cfg(feature = "hap-server")]
#[async_trait]
impl MdnsAdvertiser for MdnsSdAdvertiser {
async fn advertise(&self, record: &HapServiceRecord) -> Result<(), HapError> {
let info = self.service_info(record)?;
let fullname = info.get_fullname().to_owned();
self.daemon
.register(info)
.map_err(|error| HapError::MdnsError(error.to_string()))?;
self.registrations
.lock()
.map_err(|_| HapError::MdnsError("registration lock poisoned".into()))?
.insert(record.instance_name.clone(), fullname);
Ok(())
}
async fn retract(&self, instance_name: &str) -> Result<(), HapError> {
let fullname = self
.registrations
.lock()
.map_err(|_| HapError::MdnsError("registration lock poisoned".into()))?
.remove(instance_name);
if let Some(fullname) = fullname {
self.daemon
.unregister(&fullname)
.map_err(|error| HapError::MdnsError(error.to_string()))?;
}
Ok(())
}
}
@@ -64,16 +209,28 @@ impl MdnsAdvertiser for NullAdvertiser {
mod tests {
use super::*;
#[test]
fn txt_record_is_hap_shaped_and_contains_no_setup_secret() {
let record = HapServiceRecord::bridge("RuView Sense", 51826, "AA:BB:CC:DD:EE:FF");
let txt: std::collections::HashMap<_, _> = record.txt_records().into_iter().collect();
assert_eq!(txt.get("pv").map(String::as_str), Some("1.1"));
assert_eq!(txt.get("sf").map(String::as_str), Some("1"));
assert_eq!(txt.get("ci").map(String::as_str), Some("2"));
assert!(!txt
.keys()
.any(|key| key.contains("pin") || key.contains("code")));
}
#[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();
async fn null_advertiser_validates_without_network_io() {
let record = HapServiceRecord::bridge("RuView Sense", 51826, "AA:BB:CC:DD:EE:FF");
NullAdvertiser.advertise(&record).await.unwrap();
NullAdvertiser.retract(&record.instance_name).await.unwrap();
}
#[tokio::test]
async fn malformed_record_is_rejected() {
let record = HapServiceRecord::bridge("RuView Sense", 0, "not-an-id");
assert!(NullAdvertiser.advertise(&record).await.is_err());
}
}
+478
View File
@@ -0,0 +1,478 @@
//! Server-side HAP Pair-Setup M1-M6.
#![cfg_attr(not(feature = "hap-server"), allow(dead_code))]
use std::sync::Arc;
use ed25519_dalek::{Signature, Signer, VerifyingKey};
use sha2_11::Sha512;
use srp::ServerG3072;
use zeroize::{Zeroize, Zeroizing};
use crate::crypto::{hkdf_sha512, open_labeled, seal_labeled};
use crate::error::HapError;
use crate::pairing::{ControllerPairing, PairingStore};
use crate::protocol::{
encode_items, error_response, Tlv8, TLV_ENCRYPTED_DATA, TLV_ERROR_AUTHENTICATION,
TLV_ERROR_BUSY, TLV_ERROR_MAX_TRIES, TLV_ERROR_UNAVAILABLE, TLV_FLAGS, TLV_IDENTIFIER,
TLV_METHOD, TLV_PROOF, TLV_PUBLIC_KEY, TLV_SALT, TLV_SIGNATURE, TLV_STATE,
};
const SRP_USERNAME: &[u8] = b"Pair-Setup";
const PAIR_SETUP_METHOD: u8 = 0;
enum Phase {
Idle,
AwaitM3 {
salt: [u8; 16],
verifier: Vec<u8>,
server_secret: Zeroizing<Vec<u8>>,
},
AwaitM5 {
session_key: Zeroizing<Vec<u8>>,
},
}
pub(crate) struct PairSetup {
store: Arc<PairingStore>,
phase: Phase,
owns_global_slot: bool,
}
pub(crate) struct PairSetupResponse {
pub(crate) body: Vec<u8>,
pub(crate) paired: bool,
pub(crate) terminal: bool,
}
impl PairSetup {
pub(crate) fn new(store: Arc<PairingStore>) -> Self {
Self {
store,
phase: Phase::Idle,
owns_global_slot: false,
}
}
pub(crate) fn handle(&mut self, request: &[u8]) -> Result<PairSetupResponse, HapError> {
let tlv = Tlv8::parse(request)?;
let state = tlv
.byte(TLV_STATE)
.ok_or_else(|| HapError::Protocol("Pair-Setup requires one-byte State".into()))?;
match state {
1 => self.m1(&tlv),
3 => self.m3(&tlv),
5 => self.m5(&tlv),
_ => Err(HapError::Protocol(
"Pair-Setup state is out of sequence".into(),
)),
}
}
fn m1(&mut self, tlv: &Tlv8) -> Result<PairSetupResponse, HapError> {
if !matches!(self.phase, Phase::Idle) {
return Err(HapError::Protocol("Pair-Setup M1 was replayed".into()));
}
if tlv.byte(TLV_METHOD) != Some(PAIR_SETUP_METHOD) {
return Ok(response(
error_response(2, TLV_ERROR_UNAVAILABLE),
false,
true,
));
}
if tlv.get(TLV_FLAGS).is_some() {
// Transient/split setup changes the session-key lifecycle and is
// deliberately rejected instead of being partially implemented.
return Ok(response(
error_response(2, TLV_ERROR_UNAVAILABLE),
false,
true,
));
}
if self.store.is_paired()? {
return Ok(response(
error_response(2, TLV_ERROR_UNAVAILABLE),
false,
true,
));
}
if self.store.pair_setup_locked_out() {
return Ok(response(
error_response(2, TLV_ERROR_MAX_TRIES),
false,
true,
));
}
if !self.store.try_begin_pair_setup() {
return Ok(response(error_response(2, TLV_ERROR_BUSY), false, true));
}
self.owns_global_slot = true;
let (salt, verifier) = self.store.setup_record()?;
let mut server_secret = Zeroizing::new(vec![0u8; 64]);
getrandom::getrandom(server_secret.as_mut_slice())
.map_err(|error| HapError::Protocol(format!("generate SRP secret: {error}")))?;
let server = ServerG3072::<Sha512>::new_with_options(false);
let public_key = server.compute_public_ephemeral(&server_secret, &verifier);
self.phase = Phase::AwaitM3 {
salt,
verifier,
server_secret,
};
Ok(response(
encode_items([
(TLV_STATE, [2].as_slice()),
(TLV_PUBLIC_KEY, public_key.as_slice()),
(TLV_SALT, salt.as_slice()),
]),
false,
false,
))
}
fn m3(&mut self, tlv: &Tlv8) -> Result<PairSetupResponse, HapError> {
let Phase::AwaitM3 {
salt,
verifier,
server_secret,
} = std::mem::replace(&mut self.phase, Phase::Idle)
else {
return Err(HapError::Protocol(
"Pair-Setup M3 arrived without M1".into(),
));
};
let result = (|| {
let client_public = required_bounded(tlv, TLV_PUBLIC_KEY, 384, 384, "SRP public key")?;
let client_proof = required_bounded(tlv, TLV_PROOF, 64, 64, "SRP proof")?;
let server = ServerG3072::<Sha512>::new_with_options(false);
let verifier_state = server
.process_reply(
SRP_USERNAME,
&salt,
&server_secret,
&verifier,
client_public,
)
.map_err(|_| HapError::Protocol("invalid SRP public key".into()))?;
let session_key = verifier_state
.verify_client(client_proof)
.map_err(|_| HapError::Protocol("SRP proof rejected".into()))?
.to_vec();
let proof = verifier_state.proof().to_vec();
Ok::<_, HapError>((session_key, proof))
})();
match result {
Ok((session_key, proof)) => {
self.phase = Phase::AwaitM5 {
session_key: Zeroizing::new(session_key),
};
Ok(response(
encode_items([(TLV_STATE, [4].as_slice()), (TLV_PROOF, proof.as_slice())]),
false,
false,
))
}
Err(_) => {
self.store.record_pair_setup_failure();
self.release_slot();
Ok(response(
error_response(4, TLV_ERROR_AUTHENTICATION),
false,
true,
))
}
}
}
fn m5(&mut self, tlv: &Tlv8) -> Result<PairSetupResponse, HapError> {
let Phase::AwaitM5 { session_key } = std::mem::replace(&mut self.phase, Phase::Idle) else {
return Err(HapError::Protocol(
"Pair-Setup M5 arrived without authenticated M3".into(),
));
};
let result = self.finish_m5(tlv, &session_key);
if result.is_err() {
self.store.record_pair_setup_failure();
}
self.release_slot();
match result {
Ok(body) => Ok(response(body, true, true)),
Err(_) => Ok(response(
error_response(6, TLV_ERROR_AUTHENTICATION),
false,
true,
)),
}
}
fn finish_m5(&self, tlv: &Tlv8, session_key: &[u8]) -> Result<Vec<u8>, HapError> {
let encrypted = required_bounded(
tlv,
TLV_ENCRYPTED_DATA,
17,
4096,
"Pair-Setup encrypted data",
)?;
let encryption_key = hkdf_sha512(
b"Pair-Setup-Encrypt-Salt",
session_key,
b"Pair-Setup-Encrypt-Info",
)?;
let mut plaintext = Zeroizing::new(open_labeled(&encryption_key, b"PS-Msg05", encrypted)?);
let sub_tlv = Tlv8::parse(&plaintext)?;
let controller_id_bytes =
required_bounded(&sub_tlv, TLV_IDENTIFIER, 1, 64, "controller identifier")?;
let controller_id = std::str::from_utf8(controller_id_bytes)
.map_err(|_| HapError::Protocol("controller identifier is not UTF-8".into()))?
.to_owned();
let controller_key: [u8; 32] =
required_bounded(&sub_tlv, TLV_PUBLIC_KEY, 32, 32, "controller LTPK")?
.try_into()
.expect("length checked");
let signature_bytes: [u8; 64] =
required_bounded(&sub_tlv, TLV_SIGNATURE, 64, 64, "controller signature")?
.try_into()
.expect("length checked");
let verifying_key = VerifyingKey::from_bytes(&controller_key)
.map_err(|_| HapError::Protocol("controller LTPK is invalid".into()))?;
let controller_x = hkdf_sha512(
b"Pair-Setup-Controller-Sign-Salt",
session_key,
b"Pair-Setup-Controller-Sign-Info",
)?;
let mut controller_info =
Zeroizing::new(Vec::with_capacity(32 + controller_id_bytes.len() + 32));
controller_info.extend_from_slice(&controller_x);
controller_info.extend_from_slice(controller_id_bytes);
controller_info.extend_from_slice(&controller_key);
verifying_key
.verify_strict(&controller_info, &Signature::from_bytes(&signature_bytes))
.map_err(|_| HapError::Protocol("controller signature rejected".into()))?;
let signing_key = self.store.signing_key()?;
let accessory_id = self.store.accessory_id()?;
let accessory_public = signing_key.verifying_key().to_bytes();
let accessory_x = hkdf_sha512(
b"Pair-Setup-Accessory-Sign-Salt",
session_key,
b"Pair-Setup-Accessory-Sign-Info",
)?;
let mut accessory_info = Zeroizing::new(Vec::with_capacity(32 + accessory_id.len() + 32));
accessory_info.extend_from_slice(&accessory_x);
accessory_info.extend_from_slice(accessory_id.as_bytes());
accessory_info.extend_from_slice(&accessory_public);
let accessory_signature = signing_key.sign(&accessory_info).to_bytes();
let mut response_plaintext = Zeroizing::new(encode_items([
(TLV_IDENTIFIER, accessory_id.as_bytes()),
(TLV_PUBLIC_KEY, accessory_public.as_slice()),
(TLV_SIGNATURE, accessory_signature.as_slice()),
]));
let response_encrypted = seal_labeled(&encryption_key, b"PS-Msg06", &response_plaintext)?;
// Commit only after every authentication and response construction
// step has succeeded, and before emitting success-shaped M6.
self.store.add_initial(ControllerPairing {
controller_id,
public_key: controller_key,
admin: true,
})?;
plaintext.zeroize();
response_plaintext.zeroize();
Ok(encode_items([
(TLV_STATE, [6].as_slice()),
(TLV_ENCRYPTED_DATA, response_encrypted.as_slice()),
]))
}
fn release_slot(&mut self) {
if self.owns_global_slot {
self.store.end_pair_setup();
self.owns_global_slot = false;
}
}
}
impl Drop for PairSetup {
fn drop(&mut self) {
self.release_slot();
}
}
fn response(body: Vec<u8>, paired: bool, terminal: bool) -> PairSetupResponse {
PairSetupResponse {
body,
paired,
terminal,
}
}
fn required_bounded<'a>(
tlv: &'a Tlv8,
kind: u8,
min: usize,
max: usize,
name: &str,
) -> Result<&'a [u8], HapError> {
let value = tlv
.get(kind)
.ok_or_else(|| HapError::Protocol(format!("missing {name}")))?;
if !(min..=max).contains(&value.len()) {
return Err(HapError::Protocol(format!(
"{name} must contain {min}..={max} bytes"
)));
}
Ok(value)
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::SigningKey;
use srp::ClientG3072;
fn setup() -> (tempfile::TempDir, Arc<PairingStore>) {
let directory = tempfile::tempdir().unwrap();
let store = PairingStore::create(
directory.path().join("pairings.json"),
crate::pairing::SetupCode::parse("518-26-003").unwrap(),
Some("AA:BB:CC:DD:EE:FF".into()),
)
.unwrap();
(directory, Arc::new(store))
}
#[test]
fn apple_hap_srp_3072_sha512_session_key_vector() {
let decode = |value: &str| {
value
.as_bytes()
.chunks_exact(2)
.map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap())
.collect::<Vec<_>>()
};
let salt = decode("BEB25379D1A8581EB5A727673A2441EE");
let a = decode("60975527035CF2AD1989806F0407210BC81EDC04E2762A56AFD529DDDA2D4393");
let b = decode("E487CB59D31AC550471E81F00F6928E01DDA08E974A004F49E61F5D105284D20");
let expected_key = decode(
"5CBC219DB052138EE1148C71CD4498963D682549CE91CA24F098468F06015BEB\
6AF245C2093F98C3651BCA83AB8CAB2B580BBF02184FEFDF26142F73DF95AC50",
);
let client = ClientG3072::<Sha512>::new();
let server = ServerG3072::<Sha512>::new_with_options(false);
let verifier = client.compute_verifier(b"alice", b"password123", &salt);
let a_public = client.compute_public_ephemeral(&a);
let b_public = server.compute_public_ephemeral(&b, &verifier);
let client_state = client
.process_reply(&a, b"alice", b"password123", &salt, &b_public)
.unwrap();
let server_state = server
.process_reply(b"alice", &salt, &b, &verifier, &a_public)
.unwrap();
assert_eq!(
server_state.verify_client(client_state.proof()).unwrap(),
expected_key
);
assert_eq!(
client_state.verify_server(server_state.proof()).unwrap(),
expected_key
);
}
#[test]
fn full_m1_through_m6_persists_only_authenticated_controller() {
let (_directory, store) = setup();
let mut server = PairSetup::new(store.clone());
let m1 = encode_items([(TLV_STATE, [1].as_slice()), (TLV_METHOD, [0].as_slice())]);
let m2 = Tlv8::parse(&server.handle(&m1).unwrap().body).unwrap();
let salt = m2.get(TLV_SALT).unwrap();
let server_public = m2.get(TLV_PUBLIC_KEY).unwrap();
let client = ClientG3072::<Sha512>::new();
let client_secret = [0x31; 48];
let client_state = client
.process_reply(
&client_secret,
SRP_USERNAME,
b"518-26-003",
salt,
server_public,
)
.unwrap();
let client_public = client.compute_public_ephemeral(&client_secret);
let m3 = encode_items([
(TLV_STATE, [3].as_slice()),
(TLV_PUBLIC_KEY, client_public.as_slice()),
(TLV_PROOF, client_state.proof()),
]);
let m4 = Tlv8::parse(&server.handle(&m3).unwrap().body).unwrap();
let session_key = client_state
.verify_server(m4.get(TLV_PROOF).unwrap())
.unwrap();
let controller_signing = SigningKey::from_bytes(&[0x22; 32]);
let controller_public = controller_signing.verifying_key().to_bytes();
let controller_id = b"deterministic-controller";
let controller_x = hkdf_sha512(
b"Pair-Setup-Controller-Sign-Salt",
session_key,
b"Pair-Setup-Controller-Sign-Info",
)
.unwrap();
let mut info = Vec::new();
info.extend_from_slice(&controller_x);
info.extend_from_slice(controller_id);
info.extend_from_slice(&controller_public);
let signature = controller_signing.sign(&info).to_bytes();
let sub_tlv = encode_items([
(TLV_IDENTIFIER, controller_id.as_slice()),
(TLV_PUBLIC_KEY, controller_public.as_slice()),
(TLV_SIGNATURE, signature.as_slice()),
]);
let encryption_key = hkdf_sha512(
b"Pair-Setup-Encrypt-Salt",
session_key,
b"Pair-Setup-Encrypt-Info",
)
.unwrap();
let encrypted = seal_labeled(&encryption_key, b"PS-Msg05", &sub_tlv).unwrap();
let m5 = encode_items([
(TLV_STATE, [5].as_slice()),
(TLV_ENCRYPTED_DATA, encrypted.as_slice()),
]);
let result = server.handle(&m5).unwrap();
assert!(result.paired);
let m6 = Tlv8::parse(&result.body).unwrap();
assert!(open_labeled(
&encryption_key,
b"PS-Msg06",
m6.get(TLV_ENCRYPTED_DATA).unwrap()
)
.is_ok());
assert_eq!(
store
.get("deterministic-controller")
.unwrap()
.unwrap()
.public_key,
controller_public
);
}
#[test]
fn malformed_replayed_and_wrong_proof_requests_fail_closed() {
let (_directory, store) = setup();
let mut server = PairSetup::new(store.clone());
let m1 = encode_items([(TLV_STATE, [1].as_slice()), (TLV_METHOD, [0].as_slice())]);
assert!(!server.handle(&m1).unwrap().paired);
assert!(server.handle(&m1).is_err());
let bad_m3 = encode_items([
(TLV_STATE, [3].as_slice()),
(TLV_PUBLIC_KEY, [1].as_slice()),
(TLV_PROOF, [0u8; 64].as_slice()),
]);
let response = Tlv8::parse(&server.handle(&bad_m3).unwrap().body).unwrap();
assert_eq!(
response.byte(crate::protocol::TLV_ERROR),
Some(TLV_ERROR_AUTHENTICATION)
);
assert!(!store.is_paired().unwrap());
}
}
+332
View File
@@ -0,0 +1,332 @@
//! Server-side HAP Pair-Verify M1-M4.
#![cfg_attr(not(feature = "hap-server"), allow(dead_code))]
use std::sync::Arc;
use ed25519_dalek::{Signature, Signer, VerifyingKey};
use x25519_dalek::{PublicKey, StaticSecret};
use zeroize::{Zeroize, Zeroizing};
use crate::crypto::{hkdf_sha512, open_labeled, seal_labeled, SessionKeys};
use crate::error::HapError;
use crate::pairing::PairingStore;
use crate::protocol::{
encode_items, error_response, Tlv8, TLV_ENCRYPTED_DATA, TLV_ERROR_AUTHENTICATION,
TLV_IDENTIFIER, TLV_PUBLIC_KEY, TLV_SIGNATURE, TLV_STATE,
};
struct AwaitM3 {
controller_public: [u8; 32],
accessory_public: [u8; 32],
shared_secret: Zeroizing<[u8; 32]>,
session_key: Zeroizing<[u8; 32]>,
}
enum Phase {
Idle,
AwaitM3(AwaitM3),
}
pub(crate) struct AuthenticatedSession {
pub(crate) controller_id: String,
pub(crate) admin: bool,
pub(crate) keys: SessionKeys,
}
pub(crate) struct PairVerifyResponse {
pub(crate) body: Vec<u8>,
pub(crate) authenticated: Option<AuthenticatedSession>,
pub(crate) terminal: bool,
}
pub(crate) struct PairVerify {
store: Arc<PairingStore>,
phase: Phase,
}
impl PairVerify {
pub(crate) fn new(store: Arc<PairingStore>) -> Self {
Self {
store,
phase: Phase::Idle,
}
}
pub(crate) fn handle(&mut self, request: &[u8]) -> Result<PairVerifyResponse, HapError> {
let tlv = Tlv8::parse(request)?;
let state = tlv
.byte(TLV_STATE)
.ok_or_else(|| HapError::Protocol("Pair-Verify requires one-byte State".into()))?;
match state {
1 => self.m1(&tlv),
3 => self.m3(&tlv),
_ => Err(HapError::Protocol(
"Pair-Verify state is out of sequence".into(),
)),
}
}
fn m1(&mut self, tlv: &Tlv8) -> Result<PairVerifyResponse, HapError> {
if !matches!(self.phase, Phase::Idle) {
return Err(HapError::Protocol("Pair-Verify M1 was replayed".into()));
}
if !self.store.is_paired()? {
return Ok(PairVerifyResponse {
body: error_response(2, TLV_ERROR_AUTHENTICATION),
authenticated: None,
terminal: true,
});
}
let controller_public: [u8; 32] =
required_exact(tlv, TLV_PUBLIC_KEY, 32, "controller Curve25519 public key")?
.try_into()
.expect("length checked");
let mut secret_bytes = Zeroizing::new([0u8; 32]);
getrandom::getrandom(secret_bytes.as_mut())
.map_err(|error| HapError::Protocol(format!("generate X25519 secret: {error}")))?;
let secret = StaticSecret::from(*secret_bytes);
let accessory_public = PublicKey::from(&secret).to_bytes();
let shared = secret.diffie_hellman(&PublicKey::from(controller_public));
if !shared.was_contributory() {
return Ok(PairVerifyResponse {
body: error_response(2, TLV_ERROR_AUTHENTICATION),
authenticated: None,
terminal: true,
});
}
let shared_secret = Zeroizing::new(shared.to_bytes());
let accessory_id = self.store.accessory_id()?;
let signing_key = self.store.signing_key()?;
let mut accessory_info = Zeroizing::new(Vec::with_capacity(32 + accessory_id.len() + 32));
accessory_info.extend_from_slice(&accessory_public);
accessory_info.extend_from_slice(accessory_id.as_bytes());
accessory_info.extend_from_slice(&controller_public);
let signature = signing_key.sign(&accessory_info).to_bytes();
let plaintext = Zeroizing::new(encode_items([
(TLV_IDENTIFIER, accessory_id.as_bytes()),
(TLV_SIGNATURE, signature.as_slice()),
]));
let session_key = Zeroizing::new(hkdf_sha512(
b"Pair-Verify-Encrypt-Salt",
shared_secret.as_ref(),
b"Pair-Verify-Encrypt-Info",
)?);
let encrypted = seal_labeled(&session_key, b"PV-Msg02", &plaintext)?;
self.phase = Phase::AwaitM3(AwaitM3 {
controller_public,
accessory_public,
shared_secret,
session_key,
});
Ok(PairVerifyResponse {
body: encode_items([
(TLV_STATE, [2].as_slice()),
(TLV_PUBLIC_KEY, accessory_public.as_slice()),
(TLV_ENCRYPTED_DATA, encrypted.as_slice()),
]),
authenticated: None,
terminal: false,
})
}
fn m3(&mut self, tlv: &Tlv8) -> Result<PairVerifyResponse, HapError> {
let Phase::AwaitM3(state) = std::mem::replace(&mut self.phase, Phase::Idle) else {
return Err(HapError::Protocol(
"Pair-Verify M3 arrived without M1".into(),
));
};
match self.finish_m3(tlv, state) {
Ok(authenticated) => Ok(PairVerifyResponse {
body: encode_items([(TLV_STATE, [4].as_slice())]),
authenticated: Some(authenticated),
terminal: true,
}),
Err(_) => Ok(PairVerifyResponse {
body: error_response(4, TLV_ERROR_AUTHENTICATION),
authenticated: None,
terminal: true,
}),
}
}
fn finish_m3(&self, tlv: &Tlv8, state: AwaitM3) -> Result<AuthenticatedSession, HapError> {
let encrypted = tlv
.get(TLV_ENCRYPTED_DATA)
.filter(|value| (17..=4096).contains(&value.len()))
.ok_or_else(|| HapError::Protocol("invalid Pair-Verify encrypted data".into()))?;
let mut plaintext =
Zeroizing::new(open_labeled(&state.session_key, b"PV-Msg03", encrypted)?);
let sub_tlv = Tlv8::parse(&plaintext)?;
let controller_id_bytes = sub_tlv
.get(TLV_IDENTIFIER)
.filter(|value| !value.is_empty() && value.len() <= 64)
.ok_or_else(|| HapError::Protocol("invalid controller identifier".into()))?;
let controller_id = std::str::from_utf8(controller_id_bytes)
.map_err(|_| HapError::Protocol("controller identifier is not UTF-8".into()))?
.to_owned();
let signature_bytes: [u8; 64] =
required_exact(&sub_tlv, TLV_SIGNATURE, 64, "controller signature")?
.try_into()
.expect("length checked");
let pairing = self
.store
.get(&controller_id)?
.ok_or_else(|| HapError::Protocol("unknown controller pairing".into()))?;
let key = VerifyingKey::from_bytes(&pairing.public_key)
.map_err(|_| HapError::Protocol("persisted controller key is invalid".into()))?;
let mut controller_info =
Zeroizing::new(Vec::with_capacity(32 + controller_id_bytes.len() + 32));
controller_info.extend_from_slice(&state.controller_public);
controller_info.extend_from_slice(controller_id_bytes);
controller_info.extend_from_slice(&state.accessory_public);
key.verify_strict(&controller_info, &Signature::from_bytes(&signature_bytes))
.map_err(|_| HapError::Protocol("controller transcript signature rejected".into()))?;
let keys = SessionKeys::derive(&state.shared_secret)?;
plaintext.zeroize();
Ok(AuthenticatedSession {
controller_id,
admin: pairing.admin,
keys,
})
}
}
fn required_exact<'a>(
tlv: &'a Tlv8,
kind: u8,
length: usize,
name: &str,
) -> Result<&'a [u8], HapError> {
tlv.get(kind)
.filter(|value| value.len() == length)
.ok_or_else(|| HapError::Protocol(format!("{name} must contain {length} bytes")))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::crypto::RecordLayer;
use crate::pairing::{ControllerPairing, SetupCode};
use ed25519_dalek::SigningKey;
fn setup() -> (tempfile::TempDir, Arc<PairingStore>, SigningKey) {
let directory = tempfile::tempdir().unwrap();
let store = PairingStore::create(
directory.path().join("pairings.json"),
SetupCode::parse("518-26-003").unwrap(),
Some("AA:BB:CC:DD:EE:FF".into()),
)
.unwrap();
let controller = SigningKey::from_bytes(&[0x44; 32]);
store
.add_initial(ControllerPairing {
controller_id: "controller-1".into(),
public_key: controller.verifying_key().to_bytes(),
admin: true,
})
.unwrap();
(directory, Arc::new(store), controller)
}
#[test]
fn full_m1_through_m4_authenticates_transcript_and_derives_record_keys() {
let (_directory, store, controller_signing) = setup();
let mut server = PairVerify::new(store.clone());
let controller_secret = StaticSecret::from([0x33; 32]);
let controller_public = PublicKey::from(&controller_secret).to_bytes();
let m1 = encode_items([
(TLV_STATE, [1].as_slice()),
(TLV_PUBLIC_KEY, controller_public.as_slice()),
]);
let m2 = Tlv8::parse(&server.handle(&m1).unwrap().body).unwrap();
let accessory_public: [u8; 32] = m2.get(TLV_PUBLIC_KEY).unwrap().try_into().unwrap();
let shared = controller_secret.diffie_hellman(&PublicKey::from(accessory_public));
let session_key = hkdf_sha512(
b"Pair-Verify-Encrypt-Salt",
shared.as_bytes(),
b"Pair-Verify-Encrypt-Info",
)
.unwrap();
let accessory_sub_tlv = Tlv8::parse(
&open_labeled(
&session_key,
b"PV-Msg02",
m2.get(TLV_ENCRYPTED_DATA).unwrap(),
)
.unwrap(),
)
.unwrap();
let accessory_id = accessory_sub_tlv.get(TLV_IDENTIFIER).unwrap();
let accessory_signature: [u8; 64] = accessory_sub_tlv
.get(TLV_SIGNATURE)
.unwrap()
.try_into()
.unwrap();
let mut accessory_info = Vec::new();
accessory_info.extend_from_slice(&accessory_public);
accessory_info.extend_from_slice(accessory_id);
accessory_info.extend_from_slice(&controller_public);
VerifyingKey::from_bytes(&store.accessory_public_key().unwrap())
.unwrap()
.verify_strict(
&accessory_info,
&Signature::from_bytes(&accessory_signature),
)
.unwrap();
let controller_id = b"controller-1";
let mut controller_info = Vec::new();
controller_info.extend_from_slice(&controller_public);
controller_info.extend_from_slice(controller_id);
controller_info.extend_from_slice(&accessory_public);
let signature = controller_signing.sign(&controller_info).to_bytes();
let sub_tlv = encode_items([
(TLV_IDENTIFIER, controller_id.as_slice()),
(TLV_SIGNATURE, signature.as_slice()),
]);
let encrypted = seal_labeled(&session_key, b"PV-Msg03", &sub_tlv).unwrap();
let m3 = encode_items([
(TLV_STATE, [3].as_slice()),
(TLV_ENCRYPTED_DATA, encrypted.as_slice()),
]);
let result = server.handle(&m3).unwrap();
assert_eq!(Tlv8::parse(&result.body).unwrap().byte(TLV_STATE), Some(4));
let authenticated = result.authenticated.unwrap();
assert_eq!(authenticated.controller_id, "controller-1");
let mut accessory_records = RecordLayer::accessory(authenticated.keys);
let encrypted_record = accessory_records.encrypt(b"response").unwrap();
assert!(!encrypted_record
.windows(8)
.any(|window| window == b"response"));
}
#[test]
fn all_zero_key_replay_and_tamper_fail_closed() {
let (_directory, store, _) = setup();
let mut server = PairVerify::new(store);
let zero_key = encode_items([
(TLV_STATE, [1].as_slice()),
(TLV_PUBLIC_KEY, [0u8; 32].as_slice()),
]);
let response = Tlv8::parse(&server.handle(&zero_key).unwrap().body).unwrap();
assert_eq!(
response.byte(crate::protocol::TLV_ERROR),
Some(TLV_ERROR_AUTHENTICATION)
);
let controller_secret = StaticSecret::from([9; 32]);
let controller_public = PublicKey::from(&controller_secret).to_bytes();
let m1 = encode_items([
(TLV_STATE, [1].as_slice()),
(TLV_PUBLIC_KEY, controller_public.as_slice()),
]);
let _ = server.handle(&m1).unwrap();
assert!(server.handle(&m1).is_err());
let tampered = encode_items([
(TLV_STATE, [3].as_slice()),
(TLV_ENCRYPTED_DATA, [0u8; 17].as_slice()),
]);
let response = server.handle(&tampered).unwrap();
assert!(response.authenticated.is_none());
}
}
+776
View File
@@ -0,0 +1,776 @@
//! Durable HAP accessory identity, setup verifier, and controller pairings.
#![cfg_attr(not(feature = "hap-server"), allow(dead_code))]
use std::collections::BTreeMap;
use std::fs::{self, File};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU16, Ordering};
use std::sync::RwLock;
use ed25519_dalek::{SigningKey, VerifyingKey};
use serde::{Deserialize, Serialize};
use sha2_11::Sha512;
use srp::ClientG3072;
use tempfile::Builder;
use tokio::sync::watch;
use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
use crate::error::HapError;
const STORE_VERSION: u32 = 2;
const MAX_STORE_BYTES: u64 = 1024 * 1024;
const MAX_CONTROLLER_ID_BYTES: usize = 64;
const MAX_CONTROLLERS: usize = 16;
const MAX_PAIR_SETUP_FAILURES: u16 = 100;
const SRP_USERNAME: &[u8] = b"Pair-Setup";
/// A controller authorized by a completed HAP pairing ceremony.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ControllerPairing {
/// Case-sensitive HAP controller pairing identifier.
pub controller_id: String,
/// Controller Ed25519 long-term public key.
pub public_key: [u8; 32],
/// Whether this controller may manage other pairings.
pub admin: bool,
}
impl ControllerPairing {
/// Validate bounded identifiers and the encoded Ed25519 point.
pub fn validate(&self) -> Result<(), HapError> {
let len = self.controller_id.len();
if len == 0
|| len > MAX_CONTROLLER_ID_BYTES
|| self.controller_id.chars().any(char::is_control)
{
return Err(HapError::InvalidPairingRecord(
"controller_id must contain 1..=64 bytes and no control characters".into(),
));
}
VerifyingKey::from_bytes(&self.public_key).map_err(|_| {
HapError::InvalidPairingRecord("controller public key is not valid Ed25519".into())
})?;
Ok(())
}
}
/// A newly provisioned HAP setup code.
///
/// The code is returned only when a store is created. The persisted file holds
/// an SRP verifier instead of the raw code. Call [`SetupCode::expose`] only at
/// the operator-facing provisioning boundary.
#[derive(Zeroize, ZeroizeOnDrop)]
pub struct SetupCode {
bytes: [u8; 10],
}
impl SetupCode {
pub fn parse(value: &str) -> Result<Self, HapError> {
let bytes: [u8; 10] = value.as_bytes().try_into().map_err(|_| {
HapError::InvalidPairingRecord("setup code must use the XXX-XX-XXX format".into())
})?;
if bytes[3] != b'-'
|| bytes[6] != b'-'
|| bytes
.iter()
.enumerate()
.any(|(index, byte)| index != 3 && index != 6 && !byte.is_ascii_digit())
{
return Err(HapError::InvalidPairingRecord(
"setup code must use the XXX-XX-XXX format".into(),
));
}
let digits: Vec<u8> = bytes.iter().copied().filter(u8::is_ascii_digit).collect();
if is_trivial_setup_code(&digits) {
return Err(HapError::InvalidPairingRecord(
"setup code is prohibited because it is trivial".into(),
));
}
Ok(Self { bytes })
}
/// Explicitly expose the code for a display, label, or provisioning UI.
pub fn expose(&self) -> &str {
std::str::from_utf8(&self.bytes).expect("validated setup code is ASCII")
}
pub(crate) fn as_bytes(&self) -> &[u8] {
&self.bytes
}
fn generate() -> Result<Self, HapError> {
const RANGE: u32 = 100_000_000;
const ACCEPT_BELOW: u32 = (u32::MAX / RANGE) * RANGE;
loop {
let mut random = [0u8; 4];
getrandom::getrandom(&mut random)
.map_err(|error| HapError::PairingStore(format!("generate setup code: {error}")))?;
let sample = u32::from_le_bytes(random);
if sample >= ACCEPT_BELOW {
continue;
}
let digits = format!("{:08}", sample % RANGE);
if is_trivial_setup_code(digits.as_bytes()) {
continue;
}
return Self::parse(&format!(
"{}-{}-{}",
&digits[..3],
&digits[3..5],
&digits[5..]
));
}
}
}
impl std::fmt::Debug for SetupCode {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("SetupCode([REDACTED])")
}
}
fn is_trivial_setup_code(digits: &[u8]) -> bool {
digits == b"12345678"
|| digits == b"87654321"
|| (digits.len() == 8 && digits.iter().all(|digit| *digit == digits[0]))
}
/// Result of opening an existing store or provisioning a new one.
pub struct PairingStoreProvisioning {
pub store: PairingStore,
/// Present only on first creation. It is never recoverable from the store.
pub setup_code: Option<SetupCode>,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct StoredAccessory {
device_id: String,
signing_seed: [u8; 32],
}
// Manual, redacted impl: `signing_seed` is the accessory's permanent Ed25519
// identity key, used to sign every Pair-Setup/Pair-Verify transcript for the
// device's whole lifetime with no rotation mechanism. A derived `Debug` would
// print it in plaintext the first time anything formats this struct (a log
// line, a panic message) — same rationale as `SetupCode`'s manual impl below.
impl std::fmt::Debug for StoredAccessory {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("StoredAccessory")
.field("device_id", &self.device_id)
.field("signing_seed", &"[REDACTED]")
.finish()
}
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct StoredSetup {
salt: [u8; 16],
verifier: Vec<u8>,
}
// Manual, redacted impl: `salt`/`verifier` are the SRP-6a material derived
// from the setup code. Printing them would hand an attacker exactly what an
// offline dictionary attack against the (8-digit) setup code needs.
impl std::fmt::Debug for StoredSetup {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("StoredSetup([REDACTED])")
}
}
impl Drop for StoredAccessory {
fn drop(&mut self) {
self.signing_seed.zeroize();
}
}
impl Drop for StoredSetup {
fn drop(&mut self) {
self.salt.zeroize();
self.verifier.zeroize();
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct PairingFile {
version: u32,
accessory: StoredAccessory,
setup: StoredSetup,
controllers: Vec<ControllerPairing>,
}
#[derive(Debug, Clone)]
struct StoreState {
accessory: StoredAccessory,
setup: StoredSetup,
controllers: BTreeMap<String, ControllerPairing>,
}
/// Thread-safe, atomically persisted HAP security store.
#[derive(Debug)]
pub struct PairingStore {
path: PathBuf,
state: RwLock<StoreState>,
pair_setup_active: AtomicBool,
pair_setup_failures: AtomicU16,
changes: watch::Sender<u64>,
}
impl PairingStore {
/// Open an existing v2 store.
pub fn open(path: impl Into<PathBuf>) -> Result<Self, HapError> {
let path = path.into();
if !path.exists() {
return Err(HapError::PairingStore(format!(
"{} does not exist; use PairingStore::load_or_create for first provisioning",
path.display()
)));
}
Self::from_state(path.clone(), load_file(&path)?)
}
/// Open a store, or securely create identity/setup material and return the
/// one-time setup code.
pub fn load_or_create(path: impl Into<PathBuf>) -> Result<PairingStoreProvisioning, HapError> {
let path = path.into();
if path.exists() {
return Ok(PairingStoreProvisioning {
store: Self::open(path)?,
setup_code: None,
});
}
let setup_code = SetupCode::generate()?;
let state = new_state(&setup_code, None)?;
persist_file(&path, &state, false)?;
Ok(PairingStoreProvisioning {
store: Self::from_state(path, state)?,
setup_code: Some(setup_code),
})
}
/// Deterministic provisioning entry point for an externally generated
/// per-accessory setup code and optional device ID.
pub fn create(
path: impl Into<PathBuf>,
setup_code: SetupCode,
device_id: Option<String>,
) -> Result<Self, HapError> {
let path = path.into();
if path.exists() {
return Err(HapError::PairingStore(format!(
"{} already exists",
path.display()
)));
}
let state = new_state(&setup_code, device_id)?;
persist_file(&path, &state, false)?;
Self::from_state(path, state)
}
fn from_state(path: PathBuf, state: StoreState) -> Result<Self, HapError> {
validate_state(&state)?;
let (changes, _) = watch::channel(0);
Ok(Self {
path,
state: RwLock::new(state),
pair_setup_active: AtomicBool::new(false),
pair_setup_failures: AtomicU16::new(0),
changes,
})
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn accessory_id(&self) -> Result<String, HapError> {
Ok(self.read_state()?.accessory.device_id.clone())
}
pub fn accessory_public_key(&self) -> Result<[u8; 32], HapError> {
Ok(self.signing_key()?.verifying_key().to_bytes())
}
pub fn list(&self) -> Result<Vec<ControllerPairing>, HapError> {
Ok(self.read_state()?.controllers.values().cloned().collect())
}
pub fn get(&self, controller_id: &str) -> Result<Option<ControllerPairing>, HapError> {
Ok(self.read_state()?.controllers.get(controller_id).cloned())
}
pub fn is_paired(&self) -> Result<bool, HapError> {
Ok(!self.read_state()?.controllers.is_empty())
}
/// Add the first administrator after a fully authenticated Pair-Setup M5.
pub(crate) fn add_initial(&self, pairing: ControllerPairing) -> Result<(), HapError> {
pairing.validate()?;
if !pairing.admin {
return Err(HapError::InvalidPairingRecord(
"the first controller must be an administrator".into(),
));
}
self.update(|state| {
if !state.controllers.is_empty() {
return Err(HapError::PairingAlreadyExists(
pairing.controller_id.clone(),
));
}
state
.controllers
.insert(pairing.controller_id.clone(), pairing);
Ok(())
})?;
self.pair_setup_failures.store(0, Ordering::Release);
Ok(())
}
/// Add or update a pairing according to HAP Add Pairing semantics.
pub(crate) fn upsert(&self, pairing: ControllerPairing) -> Result<(), HapError> {
pairing.validate()?;
self.update(|state| {
if let Some(existing) = state.controllers.get(&pairing.controller_id) {
if existing.public_key != pairing.public_key {
return Err(HapError::InvalidPairingRecord(
"existing pairing public key cannot be replaced".into(),
));
}
} else if state.controllers.len() >= MAX_CONTROLLERS {
return Err(HapError::PairingCapacity);
}
state
.controllers
.insert(pairing.controller_id.clone(), pairing);
Ok(())
})
}
/// Remove a pairing idempotently. Removing the final administrator clears
/// every pairing as required by HAP.
pub(crate) fn remove_hap(&self, controller_id: &str) -> Result<bool, HapError> {
let mut removed = false;
self.update(|state| {
removed = state.controllers.remove(controller_id).is_some();
if removed
&& !state.controllers.is_empty()
&& !state.controllers.values().any(|pairing| pairing.admin)
{
state.controllers.clear();
}
Ok(())
})?;
Ok(removed)
}
pub(crate) fn signing_key(&self) -> Result<SigningKey, HapError> {
Ok(SigningKey::from_bytes(
&self.read_state()?.accessory.signing_seed,
))
}
pub(crate) fn setup_record(&self) -> Result<([u8; 16], Vec<u8>), HapError> {
let state = self.read_state()?;
Ok((state.setup.salt, state.setup.verifier.clone()))
}
pub(crate) fn try_begin_pair_setup(&self) -> bool {
self.pair_setup_active
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
}
pub(crate) fn end_pair_setup(&self) {
self.pair_setup_active.store(false, Ordering::Release);
}
pub(crate) fn pair_setup_locked_out(&self) -> bool {
self.pair_setup_failures.load(Ordering::Acquire) >= MAX_PAIR_SETUP_FAILURES
}
pub(crate) fn record_pair_setup_failure(&self) {
let _ = self.pair_setup_failures.fetch_update(
Ordering::AcqRel,
Ordering::Acquire,
|attempts| Some(attempts.saturating_add(1)),
);
}
pub(crate) fn subscribe_changes(&self) -> watch::Receiver<u64> {
self.changes.subscribe()
}
fn read_state(&self) -> Result<std::sync::RwLockReadGuard<'_, StoreState>, HapError> {
self.state
.read()
.map_err(|_| HapError::PairingStore("pairing store lock poisoned".into()))
}
fn update(
&self,
mutate: impl FnOnce(&mut StoreState) -> Result<(), HapError>,
) -> Result<(), HapError> {
let mut guard = self
.state
.write()
.map_err(|_| HapError::PairingStore("pairing store lock poisoned".into()))?;
let mut next = guard.clone();
mutate(&mut next)?;
validate_state(&next)?;
persist_file(&self.path, &next, true)?;
*guard = next;
self.changes.send_modify(|revision| *revision += 1);
Ok(())
}
}
fn new_state(setup_code: &SetupCode, device_id: Option<String>) -> Result<StoreState, HapError> {
let mut signing_seed = [0u8; 32];
let mut salt = [0u8; 16];
getrandom::getrandom(&mut signing_seed)
.and_then(|_| getrandom::getrandom(&mut salt))
.map_err(|error| HapError::PairingStore(format!("generate accessory identity: {error}")))?;
let device_id = match device_id {
Some(device_id) => device_id,
None => generate_device_id()?,
};
let verifier =
ClientG3072::<Sha512>::new().compute_verifier(SRP_USERNAME, setup_code.as_bytes(), &salt);
let state = StoreState {
accessory: StoredAccessory {
device_id,
signing_seed,
},
setup: StoredSetup { salt, verifier },
controllers: BTreeMap::new(),
};
validate_state(&state)?;
Ok(state)
}
fn generate_device_id() -> Result<String, HapError> {
let mut bytes = [0u8; 6];
getrandom::getrandom(&mut bytes)
.map_err(|error| HapError::PairingStore(format!("generate device ID: {error}")))?;
Ok(bytes
.iter()
.map(|byte| format!("{byte:02X}"))
.collect::<Vec<_>>()
.join(":"))
}
fn validate_device_id(device_id: &str) -> Result<(), HapError> {
let parts: Vec<&str> = device_id.split(':').collect();
if parts.len() != 6
|| parts
.iter()
.any(|part| part.len() != 2 || !part.bytes().all(|byte| byte.is_ascii_hexdigit()))
{
return Err(HapError::InvalidPairingRecord(
"accessory device ID must be six colon-separated hexadecimal octets".into(),
));
}
Ok(())
}
fn validate_state(state: &StoreState) -> Result<(), HapError> {
validate_device_id(&state.accessory.device_id)?;
SigningKey::from_bytes(&state.accessory.signing_seed);
if state.setup.verifier.len() != 384 {
return Err(HapError::InvalidPairingRecord(
"SRP verifier must contain exactly 384 bytes".into(),
));
}
if state.controllers.len() > MAX_CONTROLLERS {
return Err(HapError::InvalidPairingRecord(
"too many persisted controller pairings".into(),
));
}
for (id, pairing) in &state.controllers {
pairing.validate()?;
if id != &pairing.controller_id {
return Err(HapError::InvalidPairingRecord(
"controller map key does not match identifier".into(),
));
}
}
if !state.controllers.is_empty() && !state.controllers.values().any(|pairing| pairing.admin) {
return Err(HapError::InvalidPairingRecord(
"persisted pairings have no administrator".into(),
));
}
Ok(())
}
fn load_file(path: &Path) -> Result<StoreState, HapError> {
let metadata = fs::symlink_metadata(path)
.map_err(|error| HapError::PairingStore(format!("metadata {}: {error}", path.display())))?;
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err(HapError::PairingStore(format!(
"{} must be a regular, non-symlink file",
path.display()
)));
}
if metadata.len() > MAX_STORE_BYTES {
return Err(HapError::PairingStore(format!(
"{} exceeds the {MAX_STORE_BYTES}-byte limit",
path.display()
)));
}
validate_permissions(path, &metadata)?;
let mut bytes = Zeroizing::new(Vec::with_capacity(metadata.len() as usize));
File::open(path)
.and_then(|file| file.take(MAX_STORE_BYTES + 1).read_to_end(bytes.as_mut()))
.map_err(|error| HapError::PairingStore(format!("read {}: {error}", path.display())))?;
if bytes.len() as u64 > MAX_STORE_BYTES {
return Err(HapError::PairingStore(
"pairing store exceeds size limit".into(),
));
}
let file: PairingFile = serde_json::from_slice(bytes.as_slice())
.map_err(|error| HapError::PairingStore(format!("parse {}: {error}", path.display())))?;
if file.version != STORE_VERSION {
return Err(HapError::PairingStore(format!(
"unsupported pairing store version {}; v1 controller-only stores cannot be used because they lack accessory identity and setup material",
file.version
)));
}
let mut controllers = BTreeMap::new();
for pairing in file.controllers {
pairing.validate()?;
let id = pairing.controller_id.clone();
if controllers.insert(id.clone(), pairing).is_some() {
return Err(HapError::InvalidPairingRecord(format!(
"duplicate controller_id {id}"
)));
}
}
let state = StoreState {
accessory: file.accessory,
setup: file.setup,
controllers,
};
validate_state(&state)?;
Ok(state)
}
fn persist_file(path: &Path, state: &StoreState, overwrite: bool) -> Result<(), HapError> {
let parent = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or(Path::new("."));
create_private_dir(parent)?;
let payload = Zeroizing::new(
serde_json::to_vec_pretty(&PairingFile {
version: STORE_VERSION,
accessory: state.accessory.clone(),
setup: state.setup.clone(),
controllers: state.controllers.values().cloned().collect(),
})
.map_err(|error| HapError::PairingStore(format!("serialize pairings: {error}")))?,
);
let mut temp = Builder::new()
.prefix(".homecore-hap-security-")
.tempfile_in(parent)
.map_err(|error| HapError::PairingStore(format!("create temporary store: {error}")))?;
set_private_file_permissions(temp.as_file())?;
temp.write_all(&payload)
.and_then(|_| temp.flush())
.and_then(|_| temp.as_file().sync_all())
.map_err(|error| HapError::PairingStore(format!("write temporary store: {error}")))?;
if overwrite {
temp.persist(path).map_err(|error| {
HapError::PairingStore(format!("replace {}: {}", path.display(), error.error))
})?;
} else {
temp.persist_noclobber(path).map_err(|error| {
HapError::PairingStore(format!("create {}: {}", path.display(), error.error))
})?;
}
#[cfg(unix)]
File::open(parent)
.and_then(|directory| directory.sync_all())
.map_err(|error| HapError::PairingStore(format!("sync {}: {error}", parent.display())))?;
Ok(())
}
fn create_private_dir(path: &Path) -> Result<(), HapError> {
let created = !path.exists();
if created {
fs::create_dir_all(path).map_err(|error| {
HapError::PairingStore(format!("create {}: {error}", path.display()))
})?;
}
#[cfg(unix)]
if created {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o700)).map_err(|error| {
HapError::PairingStore(format!("chmod {}: {error}", path.display()))
})?;
}
Ok(())
}
fn set_private_file_permissions(_file: &File) -> Result<(), HapError> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
_file
.set_permissions(fs::Permissions::from_mode(0o600))
.map_err(|error| HapError::PairingStore(format!("chmod temporary store: {error}")))?;
}
Ok(())
}
fn validate_permissions(path: &Path, metadata: &fs::Metadata) -> Result<(), HapError> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = metadata.permissions().mode();
if mode & 0o077 != 0 {
return Err(HapError::InsecurePermissions {
path: path.to_path_buf(),
mode: mode & 0o777,
});
}
}
let _ = (path, metadata);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn store(directory: &tempfile::TempDir) -> PairingStore {
PairingStore::create(
directory.path().join("pairings.json"),
SetupCode::parse("518-26-003").unwrap(),
Some("AA:BB:CC:DD:EE:FF".into()),
)
.unwrap()
}
fn pairing(id: &str, byte: u8, admin: bool) -> ControllerPairing {
ControllerPairing {
controller_id: id.into(),
public_key: SigningKey::from_bytes(&[byte; 32])
.verifying_key()
.to_bytes(),
admin,
}
}
#[test]
fn first_provisioning_returns_code_but_restart_does_not() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("pairings.json");
let provisioned = PairingStore::load_or_create(&path).unwrap();
let id = provisioned.store.accessory_id().unwrap();
assert!(provisioned.setup_code.is_some());
drop(provisioned);
let reopened = PairingStore::load_or_create(path).unwrap();
assert!(reopened.setup_code.is_none());
assert_eq!(reopened.store.accessory_id().unwrap(), id);
}
#[test]
fn identity_and_pairings_survive_atomic_restart() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("pairings.json");
let store = store(&directory);
let public_key = store.accessory_public_key().unwrap();
store.add_initial(pairing("controller-1", 7, true)).unwrap();
drop(store);
let reopened = PairingStore::open(path).unwrap();
assert_eq!(reopened.accessory_public_key().unwrap(), public_key);
assert_eq!(
reopened.list().unwrap(),
vec![pairing("controller-1", 7, true)]
);
}
#[test]
fn prohibited_setup_codes_are_rejected_and_debug_is_redacted() {
assert!(SetupCode::parse("111-11-111").is_err());
assert!(SetupCode::parse("123-45-678").is_err());
let code = SetupCode::parse("518-26-003").unwrap();
assert_eq!(format!("{code:?}"), "SetupCode([REDACTED])");
}
/// A stray `format!("{store:?}")` (a future debug log line, a panic
/// message) must never print the accessory's permanent Ed25519 signing
/// seed or the SRP salt/verifier — both are compromise-forever secrets
/// with no rotation mechanism.
#[test]
fn stored_accessory_and_setup_debug_never_print_secret_material() {
let accessory = StoredAccessory {
device_id: "AA:BB:CC:DD:EE:FF".into(),
signing_seed: [0x42; 32],
};
let rendered = format!("{accessory:?}");
assert!(rendered.contains("device_id"));
assert!(rendered.contains("AA:BB:CC:DD:EE:FF"));
assert!(!rendered.contains("66"), "hex of 0x42 must not leak: {rendered}");
assert_eq!(
rendered,
"StoredAccessory { device_id: \"AA:BB:CC:DD:EE:FF\", signing_seed: \"[REDACTED]\" }"
);
let setup = StoredSetup { salt: [0x7a; 16], verifier: vec![0x13; 8] };
assert_eq!(format!("{setup:?}"), "StoredSetup([REDACTED])");
// The redaction must propagate through every derived-Debug container
// that embeds these structs, with no further code changes needed.
let state = StoreState {
accessory,
setup,
controllers: std::collections::BTreeMap::new(),
};
let rendered_state = format!("{state:?}");
assert!(!rendered_state.contains("0x42"));
assert!(rendered_state.contains("[REDACTED]"));
}
#[test]
fn removing_last_admin_clears_all_pairings() {
let directory = tempfile::tempdir().unwrap();
let store = store(&directory);
store.add_initial(pairing("admin", 1, true)).unwrap();
store.upsert(pairing("member", 2, false)).unwrap();
assert!(store.remove_hap("admin").unwrap());
assert!(store.list().unwrap().is_empty());
}
#[test]
fn malformed_or_legacy_records_fail_closed() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("pairings.json");
fs::write(&path, br#"{"version":1,"controllers":[]}"#).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
}
assert!(PairingStore::open(path).is_err());
}
#[cfg(unix)]
#[test]
fn permissive_existing_file_is_rejected() {
use std::os::unix::fs::PermissionsExt;
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("pairings.json");
let _ = store(&directory);
fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
assert!(matches!(
PairingStore::open(path),
Err(HapError::InsecurePermissions { .. })
));
}
}
+141
View File
@@ -0,0 +1,141 @@
//! Bounded HAP TLV8 primitives.
use std::collections::BTreeMap;
use crate::error::HapError;
pub const TLV_METHOD: u8 = 0x00;
pub const TLV_IDENTIFIER: u8 = 0x01;
pub const TLV_SALT: u8 = 0x02;
pub const TLV_PUBLIC_KEY: u8 = 0x03;
pub const TLV_PROOF: u8 = 0x04;
pub const TLV_ENCRYPTED_DATA: u8 = 0x05;
pub const TLV_STATE: u8 = 0x06;
pub const TLV_ERROR: u8 = 0x07;
pub const TLV_SIGNATURE: u8 = 0x0a;
pub const TLV_PERMISSIONS: u8 = 0x0b;
pub const TLV_FLAGS: u8 = 0x13;
pub const TLV_SEPARATOR: u8 = 0xff;
pub const TLV_ERROR_UNKNOWN: u8 = 0x01;
pub const TLV_ERROR_AUTHENTICATION: u8 = 0x02;
pub const TLV_ERROR_MAX_PEERS: u8 = 0x04;
pub const TLV_ERROR_MAX_TRIES: u8 = 0x05;
pub const TLV_ERROR_UNAVAILABLE: u8 = 0x06;
pub const TLV_ERROR_BUSY: u8 = 0x07;
const MAX_TLV_BYTES: usize = 4096;
const MAX_TLV_TYPES: usize = 32;
/// Parsed TLV8 values. Repeated fragments of a type are concatenated.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Tlv8 {
values: BTreeMap<u8, Vec<u8>>,
}
impl Tlv8 {
pub fn parse(input: &[u8]) -> Result<Self, HapError> {
if input.len() > MAX_TLV_BYTES {
return Err(HapError::Protocol("TLV8 body exceeds 4096 bytes".into()));
}
let mut values: BTreeMap<u8, Vec<u8>> = BTreeMap::new();
let mut offset = 0usize;
while offset < input.len() {
if input.len() - offset < 2 {
return Err(HapError::Protocol("truncated TLV8 header".into()));
}
let kind = input[offset];
let len = input[offset + 1] as usize;
offset += 2;
let end = offset
.checked_add(len)
.filter(|end| *end <= input.len())
.ok_or_else(|| HapError::Protocol("truncated TLV8 value".into()))?;
if !values.contains_key(&kind) && values.len() == MAX_TLV_TYPES {
return Err(HapError::Protocol("too many TLV8 types".into()));
}
values
.entry(kind)
.or_default()
.extend_from_slice(&input[offset..end]);
offset = end;
}
Ok(Self { values })
}
pub fn get(&self, kind: u8) -> Option<&[u8]> {
self.values.get(&kind).map(Vec::as_slice)
}
pub fn byte(&self, kind: u8) -> Option<u8> {
let value = self.get(kind)?;
(value.len() == 1).then_some(value[0])
}
pub fn insert(&mut self, kind: u8, value: impl Into<Vec<u8>>) {
self.values.insert(kind, value.into());
}
pub fn encode(&self) -> Vec<u8> {
encode_items(
self.values
.iter()
.map(|(&kind, value)| (kind, value.as_slice())),
)
}
}
/// Encode ordered TLV8 items, including repeated items separated by a
/// zero-length `Separator` for `/pairings` list responses.
pub fn encode_items<'a>(items: impl IntoIterator<Item = (u8, &'a [u8])>) -> Vec<u8> {
let mut encoded = Vec::new();
for (kind, value) in items {
if value.is_empty() {
encoded.extend_from_slice(&[kind, 0]);
continue;
}
for chunk in value.chunks(u8::MAX as usize) {
encoded.push(kind);
encoded.push(chunk.len() as u8);
encoded.extend_from_slice(chunk);
}
}
encoded
}
pub fn error_response(state: u8, error: u8) -> Vec<u8> {
encode_items([
(TLV_STATE, [state].as_slice()),
(TLV_ERROR, [error].as_slice()),
])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tlv8_roundtrip_supports_fragmented_values() {
let mut tlv = Tlv8::default();
tlv.insert(TLV_PUBLIC_KEY, vec![3; 300]);
tlv.insert(TLV_STATE, vec![1]);
let encoded = tlv.encode();
let decoded = Tlv8::parse(&encoded).unwrap();
assert_eq!(decoded, tlv);
}
#[test]
fn malformed_or_oversized_tlv_is_rejected() {
assert!(Tlv8::parse(&[TLV_STATE]).is_err());
assert!(Tlv8::parse(&[TLV_STATE, 2, 1]).is_err());
assert!(Tlv8::parse(&vec![0; MAX_TLV_BYTES + 1]).is_err());
}
#[test]
fn error_response_is_not_success_shaped() {
let response = error_response(2, TLV_ERROR_UNAVAILABLE);
let decoded = Tlv8::parse(&response).unwrap();
assert_eq!(decoded.byte(TLV_STATE), Some(2));
assert_eq!(decoded.byte(TLV_ERROR), Some(TLV_ERROR_UNAVAILABLE));
}
}
File diff suppressed because it is too large Load Diff
+171
View File
@@ -0,0 +1,171 @@
//! Per-connection HAP authentication state.
#![cfg_attr(not(feature = "hap-server"), allow(dead_code))]
use crate::crypto::SessionKeys;
use crate::error::HapError;
/// Authentication phase of one TCP connection.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SessionState {
Connected,
PairSetup,
PairVerify,
Authenticated { controller_id: String, admin: bool },
Closing,
}
impl SessionState {
fn name(&self) -> &'static str {
match self {
Self::Connected => "connected",
Self::PairSetup => "pair-setup",
Self::PairVerify => "pair-verify",
Self::Authenticated { .. } => "authenticated",
Self::Closing => "closing",
}
}
pub fn is_authenticated(&self) -> bool {
matches!(self, Self::Authenticated { .. })
}
}
/// Fail-closed state machine for one HAP connection.
pub struct Session {
state: SessionState,
pending_keys: Option<SessionKeys>,
}
impl Default for Session {
fn default() -> Self {
Self::new()
}
}
impl Session {
pub fn new() -> Self {
Self {
state: SessionState::Connected,
pending_keys: None,
}
}
pub fn state(&self) -> &SessionState {
&self.state
}
pub fn begin_pair_setup(&mut self) -> Result<(), HapError> {
self.transition(SessionState::PairSetup)
}
pub fn begin_pair_verify(&mut self) -> Result<(), HapError> {
self.transition(SessionState::PairVerify)
}
pub(crate) fn authenticate(
&mut self,
controller_id: String,
admin: bool,
keys: SessionKeys,
) -> Result<(), HapError> {
if !matches!(self.state, SessionState::PairVerify) {
return Err(HapError::InvalidSessionTransition {
from: self.state.name(),
to: "authenticated",
});
}
self.state = SessionState::Authenticated {
controller_id,
admin,
};
self.pending_keys = Some(keys);
Ok(())
}
pub(crate) fn take_session_keys(&mut self) -> Option<SessionKeys> {
self.pending_keys.take()
}
pub fn reset_pairing(&mut self) -> Result<(), HapError> {
match self.state {
SessionState::PairSetup | SessionState::PairVerify => {
self.state = SessionState::Connected;
self.pending_keys = None;
Ok(())
}
_ => Err(HapError::InvalidSessionTransition {
from: self.state.name(),
to: "connected",
}),
}
}
pub fn close(&mut self) {
self.pending_keys = None;
self.state = SessionState::Closing;
}
pub(crate) fn controller_id(&self) -> Option<&str> {
match &self.state {
SessionState::Authenticated { controller_id, .. } => Some(controller_id),
_ => None,
}
}
#[cfg(all(test, feature = "hap-server"))]
pub(crate) fn authenticated_for_test(admin: bool) -> Self {
Self {
state: SessionState::Authenticated {
controller_id: "test-controller".into(),
admin,
},
pending_keys: None,
}
}
fn transition(&mut self, next: SessionState) -> Result<(), HapError> {
if matches!(self.state, SessionState::Connected) {
self.state = next;
Ok(())
} else {
Err(HapError::InvalidSessionTransition {
from: self.state.name(),
to: next.name(),
})
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn authentication_requires_pair_verify_and_yields_keys_once() {
let mut session = Session::new();
let keys = SessionKeys::derive(&[7; 32]).unwrap();
assert!(session
.authenticate("controller".into(), true, keys)
.is_err());
session.begin_pair_verify().unwrap();
session
.authenticate(
"controller".into(),
true,
SessionKeys::derive(&[7; 32]).unwrap(),
)
.unwrap();
assert!(session.state().is_authenticated());
assert!(session.take_session_keys().is_some());
assert!(session.take_session_keys().is_none());
}
#[test]
fn pairing_phases_cannot_overlap() {
let mut session = Session::new();
session.begin_pair_setup().unwrap();
assert!(session.begin_pair_verify().is_err());
session.reset_pairing().unwrap();
session.begin_pair_verify().unwrap();
}
}
+60 -126
View File
@@ -1,143 +1,77 @@
# homecore-migrate
Migration tooling for importing Home Assistant configuration, entities, and secrets into HOMECORE.
Migration tooling for importing Home Assistant filesystem storage into
HOMECORE. The implementation follows
[ADR-165](../../docs/adr/ADR-165-homecore-migrate-from-home-assistant.md).
[![Crates.io](https://img.shields.io/crates/v/homecore-migrate.svg)](https://crates.io/crates/homecore-migrate)
![License](https://img.shields.io/badge/license-MIT-blue.svg)
![MSRV: 1.89+](https://img.shields.io/badge/MSRV-1.89%2B-purple.svg)
[![Tests](https://img.shields.io/badge/tests-19%20passing-brightgreen.svg)](https://github.com/ruvnet/RuView)
[![ADR-165](https://img.shields.io/badge/ADR-165-orange.svg)](../../docs/adr/ADR-165-homecore-migrate-from-home-assistant.md)
## Implemented
Parse and inspect Home Assistant's `.storage/` directory, entity registry, device registry, secrets, and automations. Convert existing HA configurations for import into HOMECORE (full conversion in P2).
- Reads versioned HA `.storage` JSON and rejects unknown schema versions.
- Converts entity registry metadata to `homecore::EntityEntry`.
- Converts the supported HA v13 device-registry fields to
`homecore::DeviceEntry`, including identifiers, connections, versions,
serial number, labels, topology, and config-entry links.
- Converts `core.config_entries` to a versioned
`homecore.config_entries` file. Each original row is retained verbatim.
Unsupported domains and non-portable fields produce typed warnings.
- Publishes destination JSON through a synced same-directory temporary file
and an atomic no-clobber link. Existing destination files are never replaced.
- Emits one-line JSON summaries from every import command.
- Parses secrets with redacted errors and inspects automations.
## What this crate does
## CLI
`homecore-migrate` reads Home Assistant's filesystem state and provides tooling to analyze and migrate it to HOMECORE. It includes:
- **HaStorageDir** — reads HA's `.homeassistant/.storage/` directory and parses versioned JSON envelopes
- **Entity registry parser** — converts `core.entity_registry` JSON to HOMECORE `EntityEntry` types
- **Device registry parser** — reads `core.device_registry` (P1 diagnostic only; full conversion in P2)
- **Config entries parser** — reads `core.config_entries` to list active integrations
- **Secrets parser** — reads `secrets.yaml` as `HashMap<String, String>` for reference resolution (P2)
- **Automations parser** — reads `automations.yaml` and counts/lists automations (full conversion in P2)
- **CLI binary**`homecore-migrate inspect` to preview what will be migrated
The tool enforces version schema compatibility: unknown HA schema versions are rejected (hard error per ADR-165 §6 Q5) rather than silently corrupting data.
## Features
- **Entity registry import**`core.entity_registry` → HOMECORE entity definitions (ready for import)
- **Device registry inspection** — read HA device metadata; full conversion deferred to P2
- **Config entries analysis** — list active integrations by domain (enables gap analysis)
- **Secrets extraction** — read `secrets.yaml` references for annotation (resolution in P2)
- **Automations counting** — list automation IDs and aliases without conversion (conversion in P2)
- **Schema version validation** — explicit rejection of unknown HA versions (no silent corruption)
- **Structured error reporting**`MigrateError` enum with context (file path, line number)
- **CLI subcommands**`inspect` to preview, `import-entities` to load (P2), `export-for-sidecar` (P2)
## Capabilities
| Capability | Type | Method | Notes |
|------------|------|--------|-------|
| Read storage envelope | Parser | `storage::read_envelope(path)` | Deserialize `.storage/*.json` |
| Parse entity registry | Parser | `entity_registry::load(storage_dir)` | → `Vec<homecore::EntityEntry>` |
| Inspect device registry | Parser | `device_registry::load(storage_dir)` | → `Vec<DeviceImport>` (P1 diagnostic) |
| List config entries | Parser | `config_entries::load(storage_dir)` | → domain counts + names |
| Load secrets | Parser | `secrets::load_secrets(path)` | → `HashMap<String, String>` |
| Count automations | Parser | `automations::load(path)` | → count + ID list |
| Validate schema version | Validator | `storage_format::validate_version(major, minor)` | Hard error if unknown |
| Convert to HOMECORE | Converter | `entity_registry::to_homecore_entries()` (P2) | → `homecore::EntityRegistry` |
| Export side-by-side DB | Exporter | `recorder::export_states()` (P2, `--features recorder`) | → `.homecore/home.db` |
## Comparison to Home Assistant
| Aspect | Home Assistant | homecore-migrate |
|--------|----------------|-----------------|
| State source | Python `.homeassistant/` directory | Same HA filesystem format |
| Entity registry format | JSON envelope in `.storage/core.entity_registry` | Identical format, schema v13 |
| Schema versioning | `version` + optional `minor_version` | Explicit version struct validation |
| Secrets resolution | `!secret` YAML references via loader | Planned P2 (reads `secrets.yaml`) |
| Automation conversion | Python → HA YAML (internal) | P2: convert to `homecore-automation` format |
| Device registry import | Python device types | P1 diagnostic; full conversion P2 |
| Side-by-side runtime | N/A (HA doesn't side-by-side migrate) | P2 feature: run old + new in parallel |
| CLI tooling | HA doesn't export | `homecore-migrate` binary with subcommands |
## Performance
- **Storage envelope parse** — < 5 ms per file (serde_json)
- **Entity registry load** — < 50 ms for 1,000 entities
- **Storage directory scan** — < 100 ms for full `.storage/` directory
- **Secrets file parse** — < 10 ms (YAML)
- **No per-crate benchmarks yet** — a follow-up issue tracks baseline measurements
## Usage
CLI inspection (P1):
The import commands take the HA `.storage` directory and the HOMECORE storage
destination:
```bash
# Inspect what will be migrated from an existing HA installation
homecore-migrate inspect ~/.homeassistant
homecore-migrate import-entities \
--storage ~/.homeassistant/.storage \
--to ~/.homecore/storage
# Output:
# Entity Registry: 47 entities
# light: 12
# sensor: 20
# binary_sensor: 10
# switch: 5
# Device Registry: 8 devices
# Config Entries: 6 integrations (mqtt, rest, zeroconf, ...)
# Secrets: 3 defined (redacted)
# Automations: 5 automations (redacted)
homecore-migrate import-devices \
--storage ~/.homeassistant/.storage \
--to ~/.homecore/storage
homecore-migrate import-config-entries \
--storage ~/.homeassistant/.storage \
--to ~/.homecore/storage
```
Programmatic entity import (P1):
Successful imports print a machine-readable JSON object:
```rust
use homecore_migrate::entity_registry;
use homecore::HomeCore;
#[tokio::main]
async fn main() {
let storage_dir = std::path::Path::new("/home/user/.homeassistant/.storage");
// Load HA entities
let entries = entity_registry::load(storage_dir)
.expect("load entity registry");
println!("Loaded {} entities", entries.len());
// Import into HOMECORE (P2 when EntityRegistry::import() lands)
let homecore = HomeCore::new();
for entry in entries {
println!("Entity: {} ({})", entry.entity_id, entry.name);
}
}
```json
{"kind":"device_registry","imported":8,"warning_count":0,"warnings":[],"destination":"/home/user/.homecore/storage/core.device_registry"}
```
Full migration (P2 onwards, via `--features recorder`):
`inspect`, `inspect-config-entries`, `inspect-secrets`, and
`inspect-automations` are read-only.
## Destination files
| Source | Destination | Format |
|---|---|---|
| `core.entity_registry` | `core.entity_registry` | HA-compatible v1/minor 13 envelope |
| `core.device_registry` | `core.device_registry` | HA-compatible v1/minor 13 envelope |
| `core.config_entries` | `homecore.config_entries` | HOMECORE v1/minor 0 envelope |
Config entries are storage-compatible, not runtime-compatible: importing an
entry does not install or execute its HA integration. A HOMECORE plugin must
explicitly claim the domain and consume the preserved source payload.
## Remaining limitations
- Automation conversion is not implemented; the tool only inspects
`automations.yaml`.
- `!secret` reference resolution in other YAML files is not implemented.
- Deleted entity/device tombstones are not imported.
- Device fields newer than HA registry minor version 13 require an explicit
parser update; unknown versions fail closed.
- No side-by-side HA recorder database exporter is provided.
## Validation
```bash
# Side-by-side: old HA continues running while HOMECORE reads the DB
homecore-migrate export-for-sidecar \
--ha-dir ~/.homeassistant \
--homecore-db ~/.homecore/home.db \
--keep-automations true # Don't stop HA automations during test period
cargo test -p homecore-migrate
cargo clippy -p homecore-migrate --all-targets -- -D warnings
```
## Relation to other HOMECORE crates
```
homecore-migrate (import from HA)
├─ homecore (EntityEntry → EntityRegistry; config entry imports)
├─ homecore-automation (automations.yaml → automation rules, P2)
├─ homecore-recorder (side-by-side state export, P2, `--features recorder`)
├─ homecore-plugins (config_entries → plugin manifests, P2)
└─ homecore-server (can auto-import at startup with --import-ha flag, P2)
```
## References
- [ADR-165: HOMECORE Migration from Python Home Assistant](../../docs/adr/ADR-165-homecore-migrate-from-home-assistant.md)
- [ADR-126: HOMECORE Home Assistant Port (master)](../../docs/adr/ADR-126-homecore-home-assistant-port.md)
- [Home Assistant .storage/ format](https://developers.home-assistant.io/docs/storage/)
- [homecore-migrate CLI source](src/main.rs)
- [README — wifi-densepose](../../../README.md)
+32 -2
View File
@@ -21,10 +21,12 @@ pub enum Command {
Inspect(InspectArgs),
/// Import entity registry from HA into a HOMECORE storage directory.
ImportEntities(ImportEntitiesArgs),
/// Import device registry (P1: parses and reports; wiring to HOMECORE P2).
/// Import the device registry into HOMECORE storage.
ImportDevices(ImportDevicesArgs),
/// Inspect config entries (P1: count + domain list; conversion is P2).
/// Inspect config entries without writing.
InspectConfigEntries(InspectConfigEntriesArgs),
/// Import config entries losslessly into versioned HOMECORE storage.
ImportConfigEntries(ImportConfigEntriesArgs),
/// Parse secrets.yaml and report secret names (values redacted).
InspectSecrets(InspectSecretsArgs),
/// Count and list automations from automations.yaml (conversion is P2).
@@ -46,6 +48,11 @@ pub struct ImportEntitiesArgs {
/// Path to the HOMECORE storage directory (destination).
#[arg(long)]
pub to: PathBuf,
/// Overwrite an existing destination file instead of refusing. Use this
/// to re-run an import after fixing a bad source row, or to re-import
/// after further changes on the HA side.
#[arg(long)]
pub force: bool,
}
#[derive(Debug, clap::Args)]
@@ -53,6 +60,29 @@ pub struct ImportDevicesArgs {
/// Path to the HA `.storage/` directory.
#[arg(long)]
pub storage: PathBuf,
/// Path to the HOMECORE storage directory (destination).
#[arg(long)]
pub to: PathBuf,
/// Overwrite an existing destination file instead of refusing. Use this
/// to re-run an import after fixing a bad source row, or to re-import
/// after further changes on the HA side.
#[arg(long)]
pub force: bool,
}
#[derive(Debug, clap::Args)]
pub struct ImportConfigEntriesArgs {
/// Path to the HA `.storage/` directory.
#[arg(long)]
pub storage: PathBuf,
/// Path to the HOMECORE storage directory (destination).
#[arg(long)]
pub to: PathBuf,
/// Overwrite an existing destination file instead of refusing. Use this
/// to re-run an import after fixing a bad source row, or to re-import
/// after further changes on the HA side.
#[arg(long)]
pub force: bool,
}
#[derive(Debug, clap::Args)]
+278 -85
View File
@@ -1,84 +1,247 @@
//! Parser for `core.config_entries` (HA storage schema v1, minor_version varies).
//! Lossless conversion of HA `core.config_entries` into HOMECORE storage.
//!
//! Per ADR-165 §6 Q5, `.storage/core.config_entries` format is undocumented
//! and version-gated. P1 reads the envelope and emits:
//! - count of config entries
//! - list of integration domains represented
//!
//! Conversion to HOMECORE plugin manifests is P2.
//!
//! Note: `config_entries` uses a different `minor_version` track from
//! `entity_registry`. As of HA 2025.1 it is typically minor_version=1 or 2.
//! We accept any minor_version ≤ MAX_SUPPORTED_MINOR and hard-error above it.
//! HOMECORE cannot yet execute arbitrary HA integrations. Every source row is
//! therefore retained verbatim while portable identity/config fields are
//! projected for future plugin setup. Typed warnings make the unsupported
//! surface machine-readable instead of silently dropping it.
use std::path::Path;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::{storage::read_envelope, MigrateError};
use crate::{
storage::{read_envelope, write_json_atomic, HaStorageEnvelope},
MigrateError,
};
/// Maximum `minor_version` we claim to understand for config_entries.
const SOURCE_KEY: &str = "core.config_entries";
pub const DESTINATION_KEY: &str = "homecore.config_entries";
const MAX_SUPPORTED_MINOR: u32 = 4;
const HOMECORE_CONFIG_VERSION: u32 = 1;
/// Diagnostic summary produced by P1 inspection.
#[derive(Clone, Debug, Serialize)]
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct HomeCoreConfigEntry {
pub entry_id: String,
pub domain: String,
pub title: String,
#[serde(default)]
pub data: serde_json::Value,
/// Exact HA row, including unsupported and future fields.
pub source: serde_json::Value,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "code", rename_all = "snake_case")]
pub enum MigrationWarning {
UnsupportedDomain { entry_id: String, domain: String },
UnsupportedField { entry_id: String, field: String },
UnsupportedRootField { field: String },
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct HomeCoreConfigData {
pub source_version: u32,
pub source_minor_version: u32,
/// Exact non-entry fields from the HA `data` object.
#[serde(default)]
pub source_extra: BTreeMap<String, serde_json::Value>,
pub entries: Vec<HomeCoreConfigEntry>,
pub warnings: Vec<MigrationWarning>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct HomeCoreConfigEnvelope {
pub version: u32,
pub minor_version: u32,
pub key: String,
pub data: HomeCoreConfigData,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ConfigEntriesSummary {
pub count: usize,
pub domains: Vec<String>,
pub warning_count: usize,
pub destination: Option<PathBuf>,
}
/// Minimal fields we read from each config-entry row.
#[derive(Debug, Deserialize)]
struct HaConfigEntryRow {
domain: String,
#[allow(dead_code)]
entry_id: String,
/// Title shown in HA UI (informational only in P1).
#[serde(default)]
#[allow(dead_code)]
title: Option<String>,
/// Source of the entry: "user" | "discovery" | "import" etc.
#[serde(default)]
#[allow(dead_code)]
source: Option<String>,
/// State: "loaded" | "setup_error" etc.
#[serde(default)]
#[allow(dead_code)]
state: Option<String>,
}
#[derive(Debug, Deserialize)]
struct HaConfigEntriesData {
entries: Vec<HaConfigEntryRow>,
}
/// Read `core.config_entries` from `path` and return a diagnostic summary.
pub fn inspect_config_entries(path: &Path) -> Result<ConfigEntriesSummary, MigrateError> {
let env = read_envelope(path)?;
let file_str = path.display().to_string();
// config_entries has version=1 and minor_version in 1..MAX_SUPPORTED_MINOR.
fn validate_source(env: &HaStorageEnvelope, path: &Path) -> Result<(), MigrateError> {
if env.version != 1 || env.minor_version > MAX_SUPPORTED_MINOR {
return Err(MigrateError::UnsupportedSchemaVersion {
file: file_str.clone(),
file: path.display().to_string(),
version: env.version,
minor_version: env.minor_version,
});
}
if env.key != SOURCE_KEY {
return Err(MigrateError::UnexpectedStorageKey {
path: path.display().to_string(),
expected: SOURCE_KEY.to_owned(),
actual: env.key.clone(),
});
}
Ok(())
}
let data: HaConfigEntriesData =
serde_json::from_value(env.data).map_err(|e| MigrateError::JsonParse {
path: file_str,
source: e,
pub fn convert_config_entries(path: &Path) -> Result<HomeCoreConfigEnvelope, MigrateError> {
let env = read_envelope(path)?;
validate_source(&env, path)?;
let entries = env
.data
.get("entries")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| MigrateError::MissingField {
field: "entries".to_owned(),
context: path.display().to_string(),
})?;
let source_extra: BTreeMap<String, serde_json::Value> = env
.data
.as_object()
.into_iter()
.flat_map(|object| object.iter())
.filter(|(field, _)| field.as_str() != "entries")
.map(|(field, value)| (field.clone(), value.clone()))
.collect();
let mut domains: Vec<String> = data.entries.iter().map(|e| e.domain.clone()).collect();
domains.sort();
domains.dedup();
let portable = BTreeSet::from(["entry_id", "domain", "title", "data"]);
let mut converted = Vec::with_capacity(entries.len());
let mut warnings = source_extra
.keys()
.map(|field| MigrationWarning::UnsupportedRootField {
field: field.clone(),
})
.collect::<Vec<_>>();
for (index, source) in entries.iter().enumerate() {
let object = source
.as_object()
.ok_or_else(|| MigrateError::MissingField {
field: "object row".to_owned(),
context: format!("{} data.entries[{index}]", path.display()),
})?;
let string_field = |field: &str| -> Result<String, MigrateError> {
object
.get(field)
.and_then(serde_json::Value::as_str)
.map(str::to_owned)
.ok_or_else(|| MigrateError::MissingField {
field: field.to_owned(),
context: format!("{} data.entries[{index}]", path.display()),
})
};
let entry_id = string_field("entry_id")?;
let domain = string_field("domain")?;
let title = object
.get("title")
.and_then(serde_json::Value::as_str)
.unwrap_or(&domain)
.to_owned();
let data = object
.get("data")
.cloned()
.unwrap_or_else(|| serde_json::json!({}));
// No HA domain is implicitly claimed executable by HOMECORE. The
// source is retained so a matching plugin can consume it later.
warnings.push(MigrationWarning::UnsupportedDomain {
entry_id: entry_id.clone(),
domain: domain.clone(),
});
for field in object
.keys()
.filter(|field| !portable.contains(field.as_str()))
{
warnings.push(MigrationWarning::UnsupportedField {
entry_id: entry_id.clone(),
field: field.clone(),
});
}
converted.push(HomeCoreConfigEntry {
entry_id,
domain,
title,
data,
source: source.clone(),
});
}
warnings.sort_by_key(|warning| serde_json::to_string(warning).unwrap_or_default());
Ok(HomeCoreConfigEnvelope {
version: HOMECORE_CONFIG_VERSION,
minor_version: 0,
key: DESTINATION_KEY.to_owned(),
data: HomeCoreConfigData {
source_version: env.version,
source_minor_version: env.minor_version,
source_extra,
entries: converted,
warnings,
},
})
}
pub fn write_config_entries(
storage_dir: &Path,
envelope: &HomeCoreConfigEnvelope,
) -> Result<PathBuf, MigrateError> {
write_config_entries_with(storage_dir, envelope, false)
}
/// As [`write_config_entries`], but `force = true` atomically replaces an
/// existing destination instead of refusing — the escape hatch for
/// re-running an import after fixing a bad source row.
pub fn write_config_entries_with(
storage_dir: &Path,
envelope: &HomeCoreConfigEnvelope,
force: bool,
) -> Result<PathBuf, MigrateError> {
let target = storage_dir.join(DESTINATION_KEY);
write_json_atomic(&target, envelope, force)
}
pub fn read_homecore_config_entries(path: &Path) -> Result<HomeCoreConfigEnvelope, MigrateError> {
let raw = std::fs::read_to_string(path).map_err(|source| MigrateError::Io {
path: path.display().to_string(),
source,
})?;
let envelope: HomeCoreConfigEnvelope =
serde_json::from_str(&raw).map_err(|source| MigrateError::JsonParse {
path: path.display().to_string(),
source,
})?;
if envelope.version != HOMECORE_CONFIG_VERSION || envelope.minor_version != 0 {
return Err(MigrateError::UnsupportedSchemaVersion {
file: path.display().to_string(),
version: envelope.version,
minor_version: envelope.minor_version,
});
}
if envelope.key != DESTINATION_KEY {
return Err(MigrateError::UnexpectedStorageKey {
path: path.display().to_string(),
expected: DESTINATION_KEY.to_owned(),
actual: envelope.key,
});
}
Ok(envelope)
}
pub fn inspect_config_entries(path: &Path) -> Result<ConfigEntriesSummary, MigrateError> {
let converted = convert_config_entries(path)?;
let domains: BTreeMap<&str, usize> =
converted
.data
.entries
.iter()
.fold(BTreeMap::new(), |mut map, entry| {
*map.entry(entry.domain.as_str()).or_default() += 1;
map
});
Ok(ConfigEntriesSummary {
count: data.entries.len(),
domains,
count: converted.data.entries.len(),
domains: domains.keys().map(|value| (*value).to_owned()).collect(),
warning_count: converted.data.warnings.len(),
destination: None,
})
}
@@ -89,40 +252,70 @@ mod tests {
use tempfile::NamedTempFile;
const FIXTURE: &str = r#"{
"version": 1,
"minor_version": 1,
"key": "core.config_entries",
"data": {
"entries": [
{"domain": "hue", "entry_id": "ce_001", "title": "Philips Hue", "source": "user", "state": "loaded"},
{"domain": "zha", "entry_id": "ce_002", "title": "ZHA", "source": "user", "state": "loaded"},
{"domain": "hue", "entry_id": "ce_003", "title": "Hue 2", "source": "user", "state": "setup_error"}
]
}
"version":1,"minor_version":2,"key":"core.config_entries",
"data":{"future_root":{"kept":true},"entries":[{
"domain":"future_hub","entry_id":"ce_001","title":"Future Hub",
"source":"user","state":"loaded","data":{"host":"10.0.0.2"},
"options":{"scan":true},"future_field":{"nested":[1,2,3]}
}]}
}"#;
#[test]
fn inspect_emits_count_and_domains() {
let mut f = NamedTempFile::new().unwrap();
f.write_all(FIXTURE.as_bytes()).unwrap();
let summary = inspect_config_entries(f.path()).unwrap();
assert_eq!(summary.count, 3);
assert_eq!(summary.domains, vec!["hue", "zha"]);
fn fixture() -> NamedTempFile {
let mut file = NamedTempFile::new().unwrap();
file.write_all(FIXTURE.as_bytes()).unwrap();
file
}
#[test]
fn unknown_minor_version_hard_errors() {
let json = r#"{
"version": 1, "minor_version": 99,
"key": "core.config_entries",
"data": {"entries": []}
}"#;
let mut f = NamedTempFile::new().unwrap();
f.write_all(json.as_bytes()).unwrap();
let err = inspect_config_entries(f.path()).unwrap_err();
fn unknown_domain_and_fields_are_lossless_with_typed_warnings() {
let source = fixture();
let converted = convert_config_entries(source.path()).unwrap();
assert_eq!(
converted.data.entries[0].source["future_field"]["nested"][2],
3
);
assert_eq!(converted.data.source_extra["future_root"]["kept"], true);
assert!(converted.data.warnings.iter().any(|warning| matches!(
warning,
MigrationWarning::UnsupportedDomain { domain, .. } if domain == "future_hub"
)));
assert!(converted.data.warnings.iter().any(|warning| matches!(
warning,
MigrationWarning::UnsupportedField { field, .. } if field == "future_field"
)));
let dir = tempfile::tempdir().unwrap();
let path = write_config_entries(dir.path(), &converted).unwrap();
let restored = read_homecore_config_entries(&path).unwrap();
assert_eq!(restored, converted);
}
#[test]
fn unknown_destination_version_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(DESTINATION_KEY);
std::fs::write(
&path,
r#"{"version":99,"minor_version":0,"key":"homecore.config_entries","data":{"source_version":1,"source_minor_version":1,"entries":[],"warnings":[]}}"#,
)
.unwrap();
assert!(matches!(
err,
MigrateError::UnsupportedSchemaVersion { minor_version: 99, .. }
read_homecore_config_entries(&path),
Err(MigrateError::UnsupportedSchemaVersion { version: 99, .. })
));
}
#[test]
fn malformed_entry_is_an_error_not_a_partial_write() {
let mut source = NamedTempFile::new().unwrap();
source
.write_all(
br#"{"version":1,"minor_version":1,"key":"core.config_entries","data":{"entries":[{"domain":"hue"}]}}"#,
)
.unwrap();
assert!(matches!(
convert_config_entries(source.path()),
Err(MigrateError::MissingField { .. })
));
}
}
+185 -71
View File
@@ -1,60 +1,142 @@
//! Parser for `core.device_registry` (HA storage schema v1, minor_version 113).
//!
//! P1: deserializes the envelope and returns `Vec<DeviceImport>`.
//! HOMECORE's device registry isn't fully wired yet (ADR-127 §2.5 deferred
//! to P2), so `DeviceImport` is a staging type for the future hand-off.
//! Conversion for HA `core.device_registry` schema v1/minor 1-13.
use std::path::Path;
use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use homecore::DeviceEntry;
use serde::Deserialize;
use crate::{storage::read_envelope, storage_format::v13, MigrateError};
use crate::{
storage::{read_envelope, write_json_atomic},
storage_format::v13,
MigrateError,
};
/// Staging type for a device imported from HA. Not yet wired to HOMECORE's
/// device registry (ADR-127 §2.5 — deferred to P2).
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DeviceImport {
pub id: String,
pub config_entries: Vec<String>,
#[serde(default)]
pub manufacturer: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub name: Option<String>,
/// `identifiers` — list of `[integration, id]` pairs. Preserved as raw
/// JSON for P2 consumption; not yet mapped to HOMECORE DeviceEntry.
#[serde(default)]
pub identifiers: Vec<Vec<String>>,
#[serde(default)]
pub connections: Vec<Vec<String>>,
#[serde(default)]
pub via_device_id: Option<String>,
#[serde(default)]
pub area_id: Option<String>,
}
const FILE_KEY: &str = "core.device_registry";
#[derive(Debug, Deserialize)]
struct HaDeviceRegistryData {
devices: Vec<DeviceImport>,
/// Deleted device tombstones — ignored in P1.
devices: Vec<HaDeviceRow>,
#[serde(default)]
#[allow(dead_code)]
deleted_devices: Vec<serde_json::Value>,
}
/// Read `core.device_registry` from `path` and return the raw import list.
pub fn read_device_registry(path: &Path) -> Result<Vec<DeviceImport>, MigrateError> {
let env = read_envelope(path)?;
let file_str = path.display().to_string();
v13::require_supported(&file_str, env.version, env.minor_version)?;
#[derive(Debug, Deserialize)]
struct HaDeviceRow {
id: String,
#[serde(default)]
config_entries: HashSet<String>,
#[serde(default)]
identifiers: HashSet<(String, String)>,
#[serde(default)]
connections: HashSet<(String, String)>,
#[serde(default)]
manufacturer: Option<String>,
#[serde(default)]
model: Option<String>,
#[serde(default)]
model_id: Option<String>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
name_by_user: Option<String>,
#[serde(default)]
sw_version: Option<String>,
#[serde(default)]
hw_version: Option<String>,
#[serde(default)]
serial_number: Option<String>,
#[serde(default)]
via_device_id: Option<String>,
#[serde(default)]
area_id: Option<String>,
#[serde(default)]
entry_type: Option<String>,
#[serde(default)]
disabled_by: Option<String>,
#[serde(default)]
configuration_url: Option<String>,
#[serde(default)]
labels: HashSet<String>,
#[serde(default)]
primary_config_entry: Option<String>,
#[serde(default, flatten)]
extra: BTreeMap<String, serde_json::Value>,
}
impl From<HaDeviceRow> for DeviceEntry {
fn from(row: HaDeviceRow) -> Self {
Self {
id: row.id,
config_entries: row.config_entries,
identifiers: row.identifiers,
connections: row.connections,
manufacturer: row.manufacturer,
model: row.model,
model_id: row.model_id,
name: row.name,
name_by_user: row.name_by_user,
sw_version: row.sw_version,
hw_version: row.hw_version,
serial_number: row.serial_number,
via_device_id: row.via_device_id,
area_id: row.area_id,
entry_type: row.entry_type,
disabled_by: row.disabled_by,
configuration_url: row.configuration_url,
labels: row.labels,
primary_config_entry: row.primary_config_entry,
extra: row.extra,
}
}
}
pub fn read_device_registry(path: &Path) -> Result<Vec<DeviceEntry>, MigrateError> {
let env = read_envelope(path)?;
let file = path.display().to_string();
v13::require_supported(&file, env.version, env.minor_version)?;
if env.key != FILE_KEY {
return Err(MigrateError::UnexpectedStorageKey {
path: file,
expected: FILE_KEY.to_owned(),
actual: env.key,
});
}
let data: HaDeviceRegistryData =
serde_json::from_value(env.data).map_err(|e| MigrateError::JsonParse {
path: file_str,
source: e,
serde_json::from_value(env.data).map_err(|source| MigrateError::JsonParse {
path: path.display().to_string(),
source,
})?;
Ok(data.devices)
let _preserved_tombstone_count = data.deleted_devices.len();
Ok(data.devices.into_iter().map(DeviceEntry::from).collect())
}
pub fn write_device_registry(
storage_dir: &Path,
devices: &[DeviceEntry],
) -> Result<PathBuf, MigrateError> {
write_device_registry_with(storage_dir, devices, false)
}
/// As [`write_device_registry`], but `force = true` atomically replaces an
/// existing destination instead of refusing — the escape hatch for
/// re-running an import after fixing a bad source row.
pub fn write_device_registry_with(
storage_dir: &Path,
devices: &[DeviceEntry],
force: bool,
) -> Result<PathBuf, MigrateError> {
let target = storage_dir.join(FILE_KEY);
let payload = serde_json::json!({
"version": 1,
"minor_version": 13,
"key": FILE_KEY,
"data": {
"devices": devices,
"deleted_devices": []
}
});
write_json_atomic(&target, &payload, force)
}
#[cfg(test)]
@@ -64,36 +146,68 @@ mod tests {
use tempfile::NamedTempFile;
const FIXTURE: &str = r#"{
"version": 1,
"minor_version": 13,
"key": "core.device_registry",
"data": {
"devices": [
{
"id": "dev_abc",
"config_entries": ["ce_001"],
"manufacturer": "Philips",
"model": "Hue Bridge",
"name": "Philips Hue Bridge",
"identifiers": [["hue", "001788FFFE3D4B13"]],
"connections": [["mac", "00:17:88:ff:fe:3d:4b:13"]],
"via_device_id": null,
"area_id": null
}
],
"deleted_devices": []
}
"version":1,"minor_version":13,"key":"core.device_registry",
"data":{"devices":[{
"id":"dev_abc","config_entries":["ce_001"],
"manufacturer":"Philips","model":"Hue Bridge","model_id":"BSB002",
"name":"Hue","name_by_user":"Downstairs Hue",
"sw_version":"1.2","hw_version":"3","serial_number":"SN42",
"identifiers":[["hue","001788FFFE3D4B13"]],
"connections":[["mac","00:17:88:ff:fe:3d:4b:13"]],
"via_device_id":"gateway","area_id":"living_room",
"entry_type":"service","disabled_by":"user",
"configuration_url":"http://hue.local","labels":["lighting"],
"primary_config_entry":"ce_001","created_at":1735689600.0
}],"deleted_devices":[]}
}"#;
#[test]
fn parses_device_registry() {
let mut f = NamedTempFile::new().unwrap();
f.write_all(FIXTURE.as_bytes()).unwrap();
let devices = read_device_registry(f.path()).unwrap();
assert_eq!(devices.len(), 1);
let d = &devices[0];
assert_eq!(d.id, "dev_abc");
assert_eq!(d.manufacturer.as_deref(), Some("Philips"));
assert_eq!(d.identifiers, vec![vec!["hue", "001788FFFE3D4B13"]]);
fn all_supported_fields_round_trip() {
let mut source = NamedTempFile::new().unwrap();
source.write_all(FIXTURE.as_bytes()).unwrap();
let devices = read_device_registry(source.path()).unwrap();
let destination = tempfile::tempdir().unwrap();
let path = write_device_registry(destination.path(), &devices).unwrap();
let imported = read_device_registry(&path).unwrap();
assert_eq!(imported, devices);
assert_eq!(imported[0].serial_number.as_deref(), Some("SN42"));
assert!(imported[0].labels.contains("lighting"));
assert_eq!(imported[0].extra["created_at"], 1735689600.0);
}
#[test]
fn destination_is_never_overwritten() {
let mut source = NamedTempFile::new().unwrap();
source.write_all(FIXTURE.as_bytes()).unwrap();
let devices = read_device_registry(source.path()).unwrap();
let destination = tempfile::tempdir().unwrap();
write_device_registry(destination.path(), &devices).unwrap();
let error = write_device_registry(destination.path(), &[]).unwrap_err();
assert!(error.to_string().contains("refusing to overwrite"));
assert_eq!(
read_device_registry(&destination.path().join(FILE_KEY))
.unwrap()
.len(),
1
);
}
/// `force = true` is the operator escape hatch: re-running an import
/// after fixing a bad source row (or re-importing after further HA-side
/// changes) must not require manually deleting prior output first.
#[test]
fn force_overwrites_existing_destination() {
let mut source = NamedTempFile::new().unwrap();
source.write_all(FIXTURE.as_bytes()).unwrap();
let devices = read_device_registry(source.path()).unwrap();
let destination = tempfile::tempdir().unwrap();
write_device_registry(destination.path(), &devices).unwrap();
write_device_registry_with(destination.path(), &[], true).unwrap();
assert_eq!(
read_device_registry(&destination.path().join(FILE_KEY))
.unwrap()
.len(),
0
);
}
}
@@ -26,14 +26,14 @@
//! }
//! ```
use std::path::Path;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use homecore::{registry::DisabledBy, EntityCategory, EntityEntry, EntityId};
use crate::{
storage::read_envelope,
storage::{read_envelope, write_json_atomic},
storage_format::v13,
MigrateError,
};
@@ -75,21 +75,21 @@ struct HaEntityRow {
// Fields present in v13 that we capture but do not yet map to HOMECORE.
// Forwarded as Q5 items.
#[serde(default)]
hidden_by: Option<String>, // v13: "user" | "integration"
hidden_by: Option<String>, // v13: "user" | "integration"
#[serde(default)]
has_entity_name: Option<bool>, // v13: HA naming convention flag
has_entity_name: Option<bool>, // v13: HA naming convention flag
#[serde(default)]
original_name: Option<String>, // v13: integration-provided default name
original_name: Option<String>, // v13: integration-provided default name
#[serde(default)]
icon: Option<String>, // v13: mdi:xxx icon override
icon: Option<String>, // v13: mdi:xxx icon override
#[serde(default)]
original_icon: Option<String>, // v13: integration-provided icon
original_icon: Option<String>, // v13: integration-provided icon
#[serde(default)]
aliases: Option<Vec<String>>, // v13: user-set aliases for voice assist
aliases: Option<Vec<String>>, // v13: user-set aliases for voice assist
#[serde(default)]
capabilities: Option<serde_json::Value>, // v13: integration-specific caps
#[serde(default)]
supported_features: Option<u64>, // v13: bitmask
supported_features: Option<u64>, // v13: bitmask
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
@@ -166,6 +166,39 @@ pub fn read_entity_registry(path: &Path) -> Result<Vec<EntityEntry>, MigrateErro
Ok(entries)
}
/// Persist imported entries using the HA-compatible v13 storage envelope.
///
/// The destination is created if needed and the file is written through a
/// same-directory temporary file followed by an atomic rename. Existing
/// registries are never overwritten implicitly.
pub fn write_entity_registry(
storage_dir: &Path,
entries: &[EntityEntry],
) -> Result<PathBuf, MigrateError> {
write_entity_registry_with(storage_dir, entries, false)
}
/// As [`write_entity_registry`], but `force = true` atomically replaces an
/// existing destination instead of refusing — the escape hatch for
/// re-running an import after fixing a bad source row.
pub fn write_entity_registry_with(
storage_dir: &Path,
entries: &[EntityEntry],
force: bool,
) -> Result<PathBuf, MigrateError> {
let target = storage_dir.join(FILE_KEY);
let payload = serde_json::json!({
"version": 1,
"minor_version": 13,
"key": FILE_KEY,
"data": {
"entities": entries,
"deleted_entities": []
}
});
write_json_atomic(&target, &payload, force)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -227,7 +260,10 @@ mod tests {
fn entity_fields_round_trip_correctly() {
let f = write_fixture(FIXTURE_V13);
let entries = read_entity_registry(f.path()).unwrap();
let light = entries.iter().find(|e| e.entity_id.as_str() == "light.kitchen").unwrap();
let light = entries
.iter()
.find(|e| e.entity_id.as_str() == "light.kitchen")
.unwrap();
assert_eq!(light.unique_id.as_deref(), Some("hue_lamp_42"));
assert_eq!(light.platform, "hue");
assert_eq!(light.name.as_deref(), Some("Kitchen lamp"));
@@ -238,6 +274,21 @@ mod tests {
assert_eq!(light.config_entry_id.as_deref(), Some("ce_001"));
}
#[test]
fn writes_atomic_compatible_registry_without_overwrite() {
let source = write_fixture(FIXTURE_V13);
let entries = read_entity_registry(source.path()).unwrap();
let destination = tempfile::tempdir().unwrap();
let path = write_entity_registry(destination.path(), &entries).unwrap();
let imported = read_entity_registry(&path).unwrap();
assert_eq!(imported.len(), entries.len());
assert_eq!(imported[0].entity_id, entries[0].entity_id);
let second = write_entity_registry(destination.path(), &entries).unwrap_err();
assert!(second.to_string().contains("refusing to overwrite"));
}
#[test]
fn disabled_by_maps_to_homecore() {
let f = write_fixture(FIXTURE_V13);
@@ -260,7 +311,13 @@ mod tests {
let f = write_fixture(json);
let err = read_entity_registry(f.path()).unwrap_err();
assert!(
matches!(err, MigrateError::UnsupportedSchemaVersion { minor_version: 99, .. }),
matches!(
err,
MigrateError::UnsupportedSchemaVersion {
minor_version: 99,
..
}
),
"got: {err}"
);
let msg = err.to_string();
+13 -5
View File
@@ -4,20 +4,21 @@
//! (HOMECORE-MIGRATE; ADR-126 §4 series map labels the role "ADR-134 HOMECORE-MIGRATE",
//! but on-disk ADR-134 is CIR — the migrate decision was renumbered to ADR-165. See ADR-164).
//!
//! ## P1 scope
//! ## Implemented scope
//!
//! - [`storage`] — `HaStorageDir`, `HaStorageEnvelope`; `read_envelope(path)`
//! - [`storage_format`] — versioned format parsers (`v13`); unknown minor_version → hard error
//! - [`entity_registry`] — `core.entity_registry` → `Vec<homecore::EntityEntry>`
//! - [`device_registry`] — `core.device_registry` → `Vec<DeviceImport>` (P1 stub)
//! - [`config_entries`] — `core.config_entries` diagnostic (count + domain list; P2 converts)
//! - [`device_registry`] — full supported HA v13 device fields → `homecore::DeviceEntry`
//! - [`config_entries`] — lossless, versioned HOMECORE representation + typed warnings
//! - [`secrets`] — `secrets.yaml` → `HashMap<String, String>`
//! - [`automations`] — `automations.yaml` count + ID list (P2 converts)
//! - [`cli`] — `clap`-derived subcommand types shared between `src/main.rs` and tests
//!
//! ## What is NOT here yet (deferred to P2+)
//! ## Remaining limitations
//!
//! - Conversion of `config_entries` to HOMECORE plugin manifests
//! - Imported config entries are durable but do not make an HA integration executable;
//! a matching HOMECORE plugin must consume the preserved source payload.
//! - Conversion of `automations.yaml` to `homecore-automation` YAML
//! - Side-by-side runtime mode (requires `homecore-recorder`, ADR-132)
//! - `!secret` reference resolution in non-secrets YAML files
@@ -88,6 +89,13 @@ pub enum MigrateError {
minor_version: u32,
},
#[error("unexpected storage key in {path}: expected {expected}, got {actual}")]
UnexpectedStorageKey {
path: String,
expected: String,
actual: String,
},
#[error("missing required field '{field}' in {context}")]
MissingField { field: String, context: String },
+71 -18
View File
@@ -2,6 +2,21 @@
use clap::Parser;
use homecore_migrate::cli::{Cli, Command};
use serde::Serialize;
#[derive(Serialize)]
struct ImportSummary {
kind: &'static str,
imported: usize,
warning_count: usize,
warnings: Vec<serde_json::Value>,
destination: std::path::PathBuf,
}
fn print_summary(summary: &ImportSummary) -> anyhow::Result<()> {
println!("{}", serde_json::to_string(summary)?);
Ok(())
}
fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt::init();
@@ -9,7 +24,10 @@ fn main() -> anyhow::Result<()> {
match cli.command {
Command::Inspect(args) => {
println!("Inspecting HA .storage directory: {}", args.storage.display());
println!(
"Inspecting HA .storage directory: {}",
args.storage.display()
);
// Probe entity_registry
let entity_path = args.storage.join("core.entity_registry");
if entity_path.exists() {
@@ -42,31 +60,41 @@ fn main() -> anyhow::Result<()> {
Command::ImportEntities(args) => {
let entity_path = args.storage.join("core.entity_registry");
let entries =
homecore_migrate::entity_registry::read_entity_registry(&entity_path)?;
println!("Imported {} entity entries (P1: in-memory only)", entries.len());
println!(" Destination: {} (P2 persistence)", args.to.display());
for e in &entries {
println!(
" {} ({}{})",
e.entity_id.as_str(),
e.platform,
if e.disabled_by.is_some() { " DISABLED" } else { "" }
);
}
let entries = homecore_migrate::entity_registry::read_entity_registry(&entity_path)?;
let destination = homecore_migrate::entity_registry::write_entity_registry_with(
&args.to,
&entries,
args.force,
)?;
print_summary(&ImportSummary {
kind: "entity_registry",
imported: entries.len(),
warning_count: 0,
warnings: vec![],
destination,
})?;
}
Command::ImportDevices(args) => {
let device_path = args.storage.join("core.device_registry");
let devices =
homecore_migrate::device_registry::read_device_registry(&device_path)?;
println!("Parsed {} device entries (P1: staging only, wiring to HOMECORE is P2)", devices.len());
let devices = homecore_migrate::device_registry::read_device_registry(&device_path)?;
let destination = homecore_migrate::device_registry::write_device_registry_with(
&args.to,
&devices,
args.force,
)?;
print_summary(&ImportSummary {
kind: "device_registry",
imported: devices.len(),
warning_count: 0,
warnings: vec![],
destination,
})?;
}
Command::InspectConfigEntries(args) => {
let ce_path = args.storage.join("core.config_entries");
let summary =
homecore_migrate::config_entries::inspect_config_entries(&ce_path)?;
let summary = homecore_migrate::config_entries::inspect_config_entries(&ce_path)?;
println!(
"config_entries: {} total, domains: {}",
summary.count,
@@ -74,6 +102,31 @@ fn main() -> anyhow::Result<()> {
);
}
Command::ImportConfigEntries(args) => {
let source = args.storage.join("core.config_entries");
let converted = homecore_migrate::config_entries::convert_config_entries(&source)?;
let imported = converted.data.entries.len();
let warning_count = converted.data.warnings.len();
let warnings = converted
.data
.warnings
.iter()
.map(serde_json::to_value)
.collect::<Result<Vec<_>, _>>()?;
let destination = homecore_migrate::config_entries::write_config_entries_with(
&args.to,
&converted,
args.force,
)?;
print_summary(&ImportSummary {
kind: "config_entries",
imported,
warning_count,
warnings,
destination,
})?;
}
Command::InspectSecrets(args) => {
let secrets_path = args.config_dir.join("secrets.yaml");
let secrets = homecore_migrate::secrets::read_secrets(&secrets_path)?;
+100
View File
@@ -15,12 +15,17 @@
//! left as `serde_json::Value` — version-specific parsers in `storage_format`
//! are responsible for further deserialization.
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use serde::{Deserialize, Serialize};
use crate::MigrateError;
static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
/// Points to a HA `.storage/` directory.
#[derive(Clone, Debug)]
pub struct HaStorageDir {
@@ -66,6 +71,101 @@ pub fn read_envelope(path: &Path) -> Result<HaStorageEnvelope, MigrateError> {
})
}
/// Durably publish JSON at `target` without ever replacing an existing file.
///
/// Bytes are synced in a same-directory temporary file, then exposed with an
/// atomic hard-link create. `hard_link` fails with `AlreadyExists` if another
/// process won the destination race, unlike a POSIX rename which would replace
/// the destination after a check-then-rename sequence.
pub fn write_json_atomic_noclobber<T: Serialize>(
target: &Path,
value: &T,
) -> Result<PathBuf, MigrateError> {
write_json_atomic(target, value, false)
}
/// Same atomic write (temp file → `sync_all` → publish), but when `force` is
/// true an existing destination is atomically replaced via `rename` instead
/// of refusing via `hard_link`'s `AlreadyExists`. Without `force`, an
/// operator who re-runs an import after fixing a bad source row (or wants a
/// fresh pass) had no way to overwrite prior output short of deleting it by
/// hand first — this is the escape hatch for that, opt-in so the default
/// no-clobber safety is unchanged.
pub fn write_json_atomic<T: Serialize>(
target: &Path,
value: &T,
force: bool,
) -> Result<PathBuf, MigrateError> {
let parent = target.parent().ok_or_else(|| MigrateError::Io {
path: target.display().to_string(),
source: std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"destination has no parent directory",
),
})?;
fs::create_dir_all(parent).map_err(|source| MigrateError::Io {
path: parent.display().to_string(),
source,
})?;
let bytes = serde_json::to_vec_pretty(value).map_err(|source| MigrateError::JsonParse {
path: target.display().to_string(),
source,
})?;
let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
let name = target
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("storage");
let temp = parent.join(format!(".{name}.{}.{}.tmp", std::process::id(), sequence));
let result = (|| -> std::io::Result<()> {
let mut file = OpenOptions::new()
.create_new(true)
.write(true)
.open(&temp)?;
file.write_all(&bytes)?;
file.write_all(b"\n")?;
file.sync_all()?;
if force {
// `rename`-over-existing hits real sharing-violation flakiness
// on Windows (ERROR_ACCESS_DENIED even with no other open
// handle in this process). Pre-clear the destination instead,
// then publish through the same hard_link step the no-clobber
// path uses. This briefly widens the crash window (a crash
// between remove and hard_link leaves no destination file
// rather than the old one), which is the accepted, opt-in
// tradeoff of explicitly requesting an overwrite — the default
// (non-force) path keeps its full no-clobber atomicity.
match fs::remove_file(target) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error),
}
}
fs::hard_link(&temp, target)?;
fs::remove_file(&temp)?;
Ok(())
})();
if let Err(source) = result {
let _ = fs::remove_file(&temp);
let source = if source.kind() == std::io::ErrorKind::AlreadyExists {
std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"destination exists; refusing to overwrite",
)
} else {
source
};
return Err(MigrateError::Io {
path: target.display().to_string(),
source,
});
}
Ok(target.to_path_buf())
}
#[cfg(test)]
mod tests {
use super::*;
+5 -10
View File
@@ -5,9 +5,7 @@
# - PluginRuntime trait + InProcessRuntime (native Rust, first-party plugins)
# - PluginRegistry (load / unload / list)
#
# P2 will add the `wasmtime` feature (gated below, default-off) for the real
# Wasmtime JIT sandbox. wasm3 interpretation mode lands behind `--features wasm3`
# in P3 for constrained-hardware targets.
# The `wasmtime` feature gates the audited server-side WebAssembly sandbox.
[package]
name = "homecore-plugins"
@@ -27,8 +25,6 @@ default = []
# P2: real Wasmtime JIT sandbox (Cranelift; ~15 MB binary delta on Pi 5).
# Do not enable in production until the host ABI is frozen (ADR-128 §8 risk).
wasmtime = ["dep:wasmtime"]
# P3: wasm3 interpretation mode for constrained hardware (~50 kB).
wasm3 = ["dep:wasm3"]
[dependencies]
# HOMECORE state machine — local path (ADR-127).
@@ -60,12 +56,11 @@ hex = "0.4"
base64 = "0.22"
# Optional Wasmtime runtime (P2, default-off — 30 MB dep).
# Bumped from 25.0.3 → 42 to remediate RUSTSEC-2026-0095 and RUSTSEC-2026-0096
# (Cranelift/Winch sandbox-escape CVEs, CVSS 9.0 — iter-11 security sprint HC-03/04).
wasmtime = { version = "42", optional = true }
# Upgraded from 25.0.3 to remediate the Cranelift/Winch sandbox advisories;
# 40.0.4 was subsequently replaced because of RUSTSEC-2026-0114.
# Patched 36.x retains HOMECORE's Rust 1.89 MSRV.
wasmtime = { version = "36.0.8", optional = true }
# Optional wasm3 interpretation runtime (P3, default-off).
wasm3 = { version = "0.3", optional = true }
[dev-dependencies]
tokio = { version = "1", features = ["sync", "rt", "rt-multi-thread", "time", "macros", "test-util"] }
+272
View File
@@ -0,0 +1,272 @@
//! Secure filesystem discovery for packaged WASM plugins.
//!
//! A plugin directory has exactly this shape:
//!
//! ```text
//! <configured-root>/<package>/manifest.json
//! <module named by wasm_module>
//! ```
//!
//! Only immediate child directories of explicitly configured roots are
//! inspected. Symlinks are rejected and the module must be a plain filename
//! in the same package directory. This deliberately avoids recursive search,
//! implicit current-directory loading, and path traversal.
use std::collections::BTreeSet;
use std::fs;
use std::path::{Component, Path, PathBuf};
use crate::{PluginError, PluginManifest};
/// Conservative input limits applied before allocating file contents.
#[derive(Debug, Clone, Copy)]
pub struct DiscoveryLimits {
pub max_plugins: usize,
pub max_manifest_bytes: u64,
pub max_module_bytes: u64,
}
impl Default for DiscoveryLimits {
fn default() -> Self {
Self {
max_plugins: 128,
max_manifest_bytes: 256 * 1024,
max_module_bytes: 16 * 1024 * 1024,
}
}
}
/// A validated package whose files are safe to read.
#[derive(Debug, Clone)]
pub struct DiscoveredPlugin {
pub manifest: PluginManifest,
pub package_dir: PathBuf,
pub manifest_path: PathBuf,
pub module_path: PathBuf,
}
impl DiscoveredPlugin {
/// Read the module after re-checking its type and size. Re-checking closes
/// the common accidental replacement window between discovery and load;
/// signature verification remains the authoritative tamper gate.
pub fn read_module(&self, limits: DiscoveryLimits) -> Result<Vec<u8>, PluginError> {
bounded_regular_file(&self.module_path, limits.max_module_bytes, "WASM module")
}
}
/// Discover packages in deterministic root/path order.
pub fn discover_plugins(
roots: &[PathBuf],
limits: DiscoveryLimits,
) -> Result<Vec<DiscoveredPlugin>, PluginError> {
if roots.is_empty() {
return Ok(Vec::new());
}
if limits.max_plugins == 0 || limits.max_manifest_bytes == 0 || limits.max_module_bytes == 0 {
return Err(PluginError::ResourceLimit(
"discovery limits must all be greater than zero".into(),
));
}
let mut packages = BTreeSet::new();
for configured_root in roots {
let metadata = fs::symlink_metadata(configured_root).map_err(|e| {
PluginError::Discovery(format!(
"cannot inspect configured plugin root {}: {e}",
configured_root.display()
))
})?;
if metadata.file_type().is_symlink() || !metadata.is_dir() {
return Err(PluginError::Discovery(format!(
"configured plugin root must be a real directory, not a symlink: {}",
configured_root.display()
)));
}
let root = fs::canonicalize(configured_root)?;
let entries = fs::read_dir(&root)?;
for entry in entries {
let entry = entry?;
let ty = entry.file_type()?;
if ty.is_symlink() {
return Err(PluginError::Discovery(format!(
"symlink found in plugin root: {}",
entry.path().display()
)));
}
if ty.is_dir() && entry.path().join("manifest.json").exists() {
packages.insert(entry.path());
}
}
}
if packages.len() > limits.max_plugins {
return Err(PluginError::ResourceLimit(format!(
"discovered {} plugins, maximum is {}",
packages.len(),
limits.max_plugins
)));
}
let mut result = Vec::with_capacity(packages.len());
let mut domains = BTreeSet::new();
for package_dir in packages {
let package_dir = fs::canonicalize(package_dir)?;
let manifest_path = package_dir.join("manifest.json");
let manifest_bytes =
bounded_regular_file(&manifest_path, limits.max_manifest_bytes, "manifest")?;
let manifest_text = std::str::from_utf8(&manifest_bytes).map_err(|e| {
PluginError::InvalidManifest(format!("{} is not UTF-8: {e}", manifest_path.display()))
})?;
let manifest = PluginManifest::parse_json(manifest_text)?;
if !domains.insert(manifest.domain.clone()) {
return Err(PluginError::Discovery(format!(
"duplicate plugin domain `{}`",
manifest.domain
)));
}
let module_name = manifest.wasm_module.as_deref().ok_or_else(|| {
PluginError::InvalidManifest(format!(
"WASM package `{}` has no wasm_module",
manifest.domain
))
})?;
let relative = Path::new(module_name);
if relative.components().count() != 1
|| !matches!(relative.components().next(), Some(Component::Normal(_)))
|| relative.extension().and_then(|v| v.to_str()) != Some("wasm")
{
return Err(PluginError::InvalidManifest(format!(
"plugin `{}` wasm_module must be a .wasm filename in its package directory",
manifest.domain
)));
}
let module_path = package_dir.join(relative);
let metadata = fs::symlink_metadata(&module_path).map_err(|e| {
PluginError::Discovery(format!("cannot inspect {}: {e}", module_path.display()))
})?;
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err(PluginError::Discovery(format!(
"plugin module must be a regular non-symlink file: {}",
module_path.display()
)));
}
if metadata.len() > limits.max_module_bytes {
return Err(PluginError::ResourceLimit(format!(
"{} is {} bytes; maximum is {}",
module_path.display(),
metadata.len(),
limits.max_module_bytes
)));
}
result.push(DiscoveredPlugin {
manifest,
package_dir,
manifest_path,
module_path,
});
}
Ok(result)
}
fn bounded_regular_file(path: &Path, maximum: u64, kind: &str) -> Result<Vec<u8>, PluginError> {
let metadata = fs::symlink_metadata(path)?;
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err(PluginError::Discovery(format!(
"{kind} must be a regular non-symlink file: {}",
path.display()
)));
}
if metadata.len() > maximum {
return Err(PluginError::ResourceLimit(format!(
"{} is {} bytes; maximum is {}",
path.display(),
metadata.len(),
maximum
)));
}
let bytes = fs::read(path)?;
if bytes.len() as u64 > maximum {
return Err(PluginError::ResourceLimit(format!(
"{} grew beyond the {maximum}-byte limit while being read",
path.display()
)));
}
Ok(bytes)
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
fn root() -> PathBuf {
let path = std::env::temp_dir().join(format!(
"homecore-plugin-discovery-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir(&path).unwrap();
path
}
fn package(root: &Path, directory: &str, domain: &str, module: &str) {
let dir = root.join(directory);
fs::create_dir(&dir).unwrap();
fs::write(
dir.join("manifest.json"),
format!(
r#"{{"domain":"{domain}","name":"Test","version":"1","wasm_module":"{module}"}}"#
),
)
.unwrap();
if !module.contains('/') && !module.contains('\\') && module.ends_with(".wasm") {
fs::write(dir.join(module), b"\0asm\x01\0\0\0").unwrap();
}
}
#[test]
fn discovery_is_sorted_and_only_reads_explicit_roots() {
let root = root();
package(&root, "z-last", "zeta", "z.wasm");
package(&root, "a-first", "alpha", "a.wasm");
let found =
discover_plugins(std::slice::from_ref(&root), DiscoveryLimits::default()).unwrap();
assert_eq!(
found
.iter()
.map(|p| p.manifest.domain.as_str())
.collect::<Vec<_>>(),
["alpha", "zeta"]
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn traversal_module_path_is_rejected() {
let root = root();
package(&root, "bad", "bad", "../bad.wasm");
let error =
discover_plugins(std::slice::from_ref(&root), DiscoveryLimits::default()).unwrap_err();
assert!(matches!(error, PluginError::InvalidManifest(_)));
fs::remove_dir_all(root).unwrap();
}
#[test]
fn size_limits_are_enforced_before_read() {
let root = root();
package(&root, "large", "large", "large.wasm");
let error = discover_plugins(
std::slice::from_ref(&root),
DiscoveryLimits {
max_module_bytes: 4,
..DiscoveryLimits::default()
},
)
.unwrap_err();
assert!(matches!(error, PluginError::ResourceLimit(_)));
fs::remove_dir_all(root).unwrap();
}
}
+8
View File
@@ -21,6 +21,14 @@ pub enum PluginError {
#[error("runtime error: {0}")]
RuntimeError(String),
/// Plugin discovery or filesystem layout is invalid.
#[error("plugin discovery failed: {0}")]
Discovery(String),
/// A configured manifest, module, or ABI payload exceeds its limit.
#[error("plugin resource limit exceeded: {0}")]
ResourceLimit(String),
/// The plugin's `setup` hook returned an error.
#[error("plugin setup failed: {0}")]
SetupFailed(String),
+7 -8
View File
@@ -14,14 +14,12 @@
//! - [`registry`] — `PluginRegistry<R>`: load / unload / list plugins.
//! - [`error`] — `PluginError` typed error enum.
//!
//! ## What's NOT here yet (deferred)
//! ## Runtime scope
//!
//! - `WasmtimeRuntime` (P2, `--features wasmtime`): Cranelift JIT sandbox on
//! Pi 5 / x86_64. The runtime-selection question (Wasmtime vs wasm3) is still
//! open (ADR-128 §8) and will be resolved in Q2 before P2 begins.
//! - Host ABI wiring: `hc_state_get`, `hc_state_set`, `hc_event_fire`, etc.
//! (P2 — requires ADR-127 state machine API freeze first).
//! - Config entry lifecycle + hot-load (P3).
//! - `WasmtimeRuntime` (`--features wasmtime`) provides the Cranelift sandbox.
//! - Native Rust plugins are compiled into the server; arbitrary native
//! dynamic libraries are not loaded.
//! - The host ABI is deliberately narrow and resource-bounded.
//!
//! ## Now enforced (ADR-162)
//!
@@ -38,9 +36,9 @@
//! | Feature | Default | Description |
//! |---------|---------|-------------|
//! | `wasmtime` | off | Wasmtime Cranelift JIT runtime (P2) |
//! | `wasm3` | off | wasm3 interpreter runtime for constrained hardware (P3) |
pub mod error;
pub mod discovery;
pub mod host_abi;
pub mod manifest;
pub mod permissions;
@@ -53,6 +51,7 @@ pub mod verify;
pub mod wasmtime_runtime;
pub use error::PluginError;
pub use discovery::{discover_plugins, DiscoveredPlugin, DiscoveryLimits};
pub use host_abi::{ConfigEntryJson, StateChangedEventJson};
pub use manifest::{IotClass, IntegrationType, PluginManifest};
pub use permissions::PermissionSet;
+8 -1
View File
@@ -11,10 +11,11 @@ use async_trait::async_trait;
use homecore::HomeCore;
use crate::error::PluginError;
use crate::StateChangedEventJson;
/// Unique identifier for a loaded plugin — mirrors the `domain` field of
/// the plugin's `PluginManifest` (e.g. `"mqtt"`, `"homecore_lights"`).
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct PluginId(pub String);
impl PluginId {
@@ -52,6 +53,12 @@ pub trait HomeCorePlugin: Send + Sync + 'static {
/// register its entities, services, and event subscriptions here.
async fn setup(&self, hc: HomeCore) -> Result<(), PluginError>;
/// Receive a committed state change. The default is a no-op so built-in
/// plugins only opt into event work they need.
async fn state_changed(&self, _event: &StateChangedEventJson) -> Result<(), PluginError> {
Ok(())
}
/// Called when the plugin is being removed from the registry.
///
/// The plugin should clean up subscriptions and deregister its entities.
+54 -16
View File
@@ -5,7 +5,7 @@
//! the `InProcessRuntime` (P1) for a `WasmtimeRuntime` (P2) without
//! changing registry code.
use std::collections::HashMap;
use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;
use homecore::HomeCore;
@@ -15,6 +15,7 @@ use crate::error::PluginError;
use crate::manifest::PluginManifest;
use crate::plugin::{HomeCorePlugin, PluginId};
use crate::runtime::{LoadedPlugin, PluginRuntime};
use crate::StateChangedEventJson;
/// Holds all loaded plugins keyed by `PluginId`.
///
@@ -22,7 +23,8 @@ use crate::runtime::{LoadedPlugin, PluginRuntime};
/// unload) take an exclusive lock only while mutating the map.
pub struct PluginRegistry<R: PluginRuntime> {
runtime: R,
plugins: RwLock<HashMap<PluginId, LoadedPlugin>>,
plugins: RwLock<BTreeMap<PluginId, LoadedPlugin>>,
loading: RwLock<BTreeSet<PluginId>>,
}
impl<R: PluginRuntime> PluginRegistry<R> {
@@ -30,7 +32,8 @@ impl<R: PluginRuntime> PluginRegistry<R> {
pub fn new(runtime: R) -> Self {
Self {
runtime,
plugins: RwLock::new(HashMap::new()),
plugins: RwLock::new(BTreeMap::new()),
loading: RwLock::new(BTreeSet::new()),
}
}
@@ -48,22 +51,31 @@ impl<R: PluginRuntime> PluginRegistry<R> {
{
let guard = self.plugins.read().await;
if guard.contains_key(&id) {
let mut loading = self.loading.write().await;
if guard.contains_key(&id) || !loading.insert(id.clone()) {
return Err(PluginError::AlreadyLoaded(id.to_string()));
}
}
let loaded = self
let result = self
.runtime
.load(id.clone(), manifest, plugin)
.await?;
loaded
.setup(hc)
.await
.map_err(|e| PluginError::SetupFailed(e.to_string()))?;
.await;
let loaded = match result {
Ok(loaded) => loaded,
Err(error) => {
self.loading.write().await.remove(&id);
return Err(error);
}
};
if let Err(error) = loaded.setup(hc).await {
// Best-effort rollback for partially initialized plugins.
let _ = loaded.unload().await;
self.loading.write().await.remove(&id);
return Err(PluginError::SetupFailed(error.to_string()));
}
self.plugins.write().await.insert(id.clone(), loaded);
self.loading.write().await.remove(&id);
Ok(id)
}
@@ -78,10 +90,11 @@ impl<R: PluginRuntime> PluginRegistry<R> {
.ok_or_else(|| PluginError::NotFound(id.to_string()))?
};
loaded
.unload()
.await
.map_err(|e| PluginError::UnloadFailed(e.to_string()))?;
if let Err(error) = loaded.unload().await {
// Keep the handle reachable so teardown can be retried.
self.plugins.write().await.insert(id.clone(), loaded);
return Err(PluginError::UnloadFailed(error.to_string()));
}
Ok(())
}
@@ -99,4 +112,29 @@ impl<R: PluginRuntime> PluginRegistry<R> {
pub async fn contains(&self, id: &PluginId) -> bool {
self.plugins.read().await.contains_key(id)
}
/// Dispatch a state change in stable plugin-domain order.
pub async fn state_changed(&self, event: &StateChangedEventJson) -> Vec<(PluginId, PluginError)> {
let guard = self.plugins.read().await;
let mut errors = Vec::new();
for (id, plugin) in guard.iter() {
if let Err(error) = plugin.state_changed(event).await {
errors.push((id.clone(), error));
}
}
errors
}
/// Tear down all plugins in reverse domain order. Failures are collected
/// while remaining plugins still receive teardown.
pub async fn shutdown(&self) -> Vec<(PluginId, PluginError)> {
let ids: Vec<_> = self.plugins.read().await.keys().cloned().rev().collect();
let mut errors = Vec::new();
for id in ids {
if let Err(error) = self.unload(&id).await {
errors.push((id, error));
}
}
errors
}
}
+8 -3
View File
@@ -1,8 +1,8 @@
//! `PluginRuntime` trait + `InProcessRuntime` (P1).
//!
//! Abstracts over Wasmtime (P2, `--features wasmtime`) and native in-process
//! Rust plugins (P1, always-on). A third backend, wasm3 (P3), will provide
//! interpretation mode for constrained hardware.
//! Rust plugins (P1, always-on). Constrained builds use the compiled-in native
//! registry rather than an unaudited interpreter backend.
//!
//! # Architecture
//!
@@ -12,7 +12,6 @@
//! ▼
//! PluginRuntime ◄─── InProcessRuntime (P1, native Rust, <1 µs call)
//! ◄─── WasmtimeRuntime (P2, Cranelift JIT, ~5 ms cold start)
//! ◄─── Wasm3Runtime (P3, interpreter, ~50 kB, Pi Zero)
//! ```
use std::sync::Arc;
@@ -23,6 +22,7 @@ use homecore::HomeCore;
use crate::error::PluginError;
use crate::manifest::PluginManifest;
use crate::plugin::{HomeCorePlugin, PluginId};
use crate::StateChangedEventJson;
/// A loaded plugin handle — returned by [`PluginRuntime::load`].
pub struct LoadedPlugin {
@@ -42,6 +42,11 @@ impl LoadedPlugin {
pub async fn unload(&self) -> Result<(), PluginError> {
self.instance.unload().await
}
/// Dispatch a committed state change to this plugin.
pub async fn state_changed(&self, event: &StateChangedEventJson) -> Result<(), PluginError> {
self.instance.state_changed(event).await
}
}
/// Abstraction over the WASM (and native) plugin execution environment.
+9 -2
View File
@@ -5,6 +5,7 @@
//! and PluginError variants.
#[cfg(test)]
#[allow(clippy::module_inception)]
mod tests {
use std::sync::Arc;
@@ -144,7 +145,10 @@ mod tests {
.expect("load should succeed");
assert_eq!(id.as_str(), "lights");
assert!(*plugin.setup_called.lock().await, "setup should have been called");
assert!(
*plugin.setup_called.lock().await,
"setup should have been called"
);
let listing = registry.list().await;
assert_eq!(listing.len(), 1);
@@ -163,7 +167,10 @@ mod tests {
.expect("load should succeed");
registry.unload(&id).await.expect("unload should succeed");
assert!(*plugin.unload_called.lock().await, "unload should have been called");
assert!(
*plugin.unload_called.lock().await,
"unload should have been called"
);
assert_eq!(registry.list().await.len(), 0);
}
@@ -26,7 +26,7 @@
use std::sync::{Arc, Mutex};
use homecore::HomeCore;
use wasmtime::{Engine, Linker, Module, Store};
use wasmtime::{Config, Engine, Linker, Module, Store, StoreLimits, StoreLimitsBuilder};
use crate::error::PluginError;
use crate::host_abi::{LogLevel, StateChangedEventJson, MAX_ABI_BUFFER_BYTES};
@@ -34,6 +34,13 @@ use crate::manifest::PluginManifest;
use crate::permissions::PermissionSet;
use crate::verify::{verify_module, PluginPolicy};
/// Hard ceiling for every module accepted by the runtime, including callers
/// that bypass filesystem discovery.
pub const MAX_WASM_MODULE_BYTES: usize = 16 * 1024 * 1024;
const MAX_SUBSCRIPTIONS: usize = 4096;
const MAX_LINEAR_MEMORY_BYTES: usize = 16 * 1024 * 1024;
const FUEL_PER_CALL: u64 = 10_000_000;
// ── Store data ─────────────────────────────────────────────────────────────
/// Per-plugin state stored inside the Wasmtime [`Store`].
@@ -51,6 +58,7 @@ pub struct PluginStoreData {
/// [`WasmtimeRuntime::load_plugin`] path installs the manifest's
/// declared set.
pub permissions: PermissionSet,
limits: StoreLimits,
}
// ── WasmtimeRuntime ────────────────────────────────────────────────────────
@@ -66,7 +74,10 @@ pub struct WasmtimeRuntime {
impl WasmtimeRuntime {
/// Create a new runtime with default Cranelift config.
pub fn new() -> Result<Self, PluginError> {
let engine = Engine::default();
let mut config = Config::new();
config.consume_fuel(true);
let engine = Engine::new(&config)
.map_err(|e| PluginError::RuntimeError(format!("Wasmtime engine: {e}")))?;
Ok(Self { engine })
}
@@ -83,6 +94,7 @@ impl WasmtimeRuntime {
wasm_bytes: &[u8],
hc: HomeCore,
) -> Result<WasmPlugin, PluginError> {
check_module_size(wasm_bytes)?;
self.instantiate(wasm_bytes, hc, PermissionSet::allow_all())
}
@@ -104,6 +116,7 @@ impl WasmtimeRuntime {
hc: HomeCore,
policy: &PluginPolicy,
) -> Result<WasmPlugin, PluginError> {
check_module_size(wasm_bytes)?;
// P4: verify before instantiation.
verify_module(manifest, wasm_bytes, policy)?;
// P5: scope write authority to the manifest's declared permissions.
@@ -128,8 +141,17 @@ impl WasmtimeRuntime {
hc,
subscriptions: Vec::new(),
permissions,
limits: StoreLimitsBuilder::new()
.memory_size(MAX_LINEAR_MEMORY_BYTES)
.instances(1)
.memories(1)
.build(),
};
let mut store = Store::new(&self.engine, store_data);
store.limiter(|data| &mut data.limits);
store
.set_fuel(FUEL_PER_CALL)
.map_err(|e| PluginError::RuntimeError(format!("set instantiation fuel: {e}")))?;
let instance = linker
.instantiate(&mut store, &module)
@@ -179,6 +201,12 @@ fn register_hc_state_get(
out_ptr: i32,
out_cap: i32|
-> i32 {
if out_ptr < 0
|| out_cap < 0
|| out_cap as usize > MAX_ABI_BUFFER_BYTES
{
return -1;
}
// Phase 1: read the entity key from guest memory.
let key: String = {
let mem = match caller.get_export("memory") {
@@ -216,7 +244,9 @@ fn register_hc_state_get(
Some(wasmtime::Extern::Memory(m)) => m,
_ => return -1,
};
let end = out_ptr as usize + json_bytes.len();
let Some(end) = (out_ptr as usize).checked_add(json_bytes.len()) else {
return -1;
};
let out = match mem.data_mut(&mut caller).get_mut(out_ptr as usize..end) {
Some(s) => s,
None => return -1,
@@ -331,7 +361,15 @@ fn register_hc_state_subscribe(
None => return -1,
}
};
caller.data_mut().subscriptions.push(eid);
if homecore::EntityId::parse(&eid).is_err() {
return -1;
}
if caller.data().subscriptions.len() >= MAX_SUBSCRIPTIONS {
return -2;
}
if !caller.data().subscriptions.contains(&eid) {
caller.data_mut().subscriptions.push(eid);
}
0
},
)
@@ -374,6 +412,7 @@ fn register_hc_log(
///
/// The `Arc<Mutex<_>>` allows the handle to be `Clone` + `Send` while
/// maintaining exclusive access for calls into the WASM module.
#[derive(Clone)]
pub struct WasmPlugin {
pub inner: Arc<Mutex<(Store<PluginStoreData>, wasmtime::Instance)>>,
}
@@ -394,6 +433,9 @@ impl WasmPlugin {
.lock()
.map_err(|e| PluginError::RuntimeError(format!("lock: {e}")))?;
let (store, instance) = &mut *guard;
store
.set_fuel(FUEL_PER_CALL)
.map_err(|e| PluginError::RuntimeError(format!("set call fuel: {e}")))?;
call_export_str(store, instance, "plugin_setup", config_entry_json)
}
@@ -409,20 +451,46 @@ impl WasmPlugin {
.lock()
.map_err(|e| PluginError::RuntimeError(format!("lock: {e}")))?;
let (store, instance) = &mut *guard;
store
.set_fuel(FUEL_PER_CALL)
.map_err(|e| PluginError::RuntimeError(format!("set call fuel: {e}")))?;
call_export_str(store, instance, "plugin_handle_state_changed", &json)
}
/// Call an optional `plugin_teardown() -> i32` export. Older modules that
/// do not export teardown remain compatible; new modules can release
/// guest-owned resources deterministically.
pub fn call_teardown(&self) -> Result<i32, PluginError> {
let mut guard = self
.inner
.lock()
.map_err(|e| PluginError::RuntimeError(format!("lock: {e}")))?;
let (store, instance) = &mut *guard;
store
.set_fuel(FUEL_PER_CALL)
.map_err(|e| PluginError::RuntimeError(format!("set call fuel: {e}")))?;
let Some(func) = instance.get_func(&mut *store, "plugin_teardown") else {
return Ok(0);
};
let func = func.typed::<(), i32>(&*store).map_err(|e| {
PluginError::RuntimeError(format!("plugin_teardown has invalid signature: {e}"))
})?;
func.call(&mut *store, ())
.map_err(|e| PluginError::RuntimeError(format!("call plugin_teardown: {e}")))
}
}
// ── Memory helpers ─────────────────────────────────────────────────────────
/// Read a UTF-8 string from guest linear memory.
fn read_str(mem: &[u8], ptr: i32, len: i32) -> Option<&str> {
if len < 0 || len as usize > MAX_ABI_BUFFER_BYTES {
if ptr < 0 || len < 0 || len as usize > MAX_ABI_BUFFER_BYTES {
return None;
}
let ptr = ptr as usize;
let len = len as usize;
let slice = mem.get(ptr..ptr + len)?;
let end = ptr.checked_add(len)?;
let slice = mem.get(ptr..end)?;
std::str::from_utf8(slice).ok()
}
@@ -435,6 +503,13 @@ fn call_export_str(
payload: &str,
) -> Result<i32, PluginError> {
let payload_bytes = payload.as_bytes().to_vec(); // owned copy avoids reborrow issues
if payload_bytes.len() > MAX_ABI_BUFFER_BYTES {
return Err(PluginError::ResourceLimit(format!(
"ABI payload is {} bytes; maximum is {}",
payload_bytes.len(),
MAX_ABI_BUFFER_BYTES
)));
}
let payload_len = payload_bytes.len() as i32;
// 1. Allocate guest buffer.
@@ -444,15 +519,23 @@ fn call_export_str(
let ptr = alloc
.call(&mut *store, payload_len)
.map_err(|e| PluginError::RuntimeError(format!("call alloc: {e}")))?;
if ptr < 0 {
return Err(PluginError::RuntimeError(
"guest alloc returned a negative pointer".into(),
));
}
// 2. Write payload into guest memory.
{
let mem = instance
.get_memory(&mut *store, "memory")
.ok_or_else(|| PluginError::RuntimeError("no memory export".into()))?;
let end = (ptr as usize)
.checked_add(payload_bytes.len())
.ok_or_else(|| PluginError::RuntimeError("guest allocation overflow".into()))?;
let guest_slice = mem
.data_mut(&mut *store)
.get_mut(ptr as usize..ptr as usize + payload_bytes.len())
.get_mut(ptr as usize..end)
.ok_or_else(|| PluginError::RuntimeError("guest memory OOB".into()))?;
guest_slice.copy_from_slice(&payload_bytes);
}
@@ -476,6 +559,17 @@ fn call_export_str(
Ok(result)
}
fn check_module_size(wasm_bytes: &[u8]) -> Result<(), PluginError> {
if wasm_bytes.len() > MAX_WASM_MODULE_BYTES {
return Err(PluginError::ResourceLimit(format!(
"WASM module is {} bytes; maximum is {}",
wasm_bytes.len(),
MAX_WASM_MODULE_BYTES
)));
}
Ok(())
}
// ── Unit tests (using inline WAT) ──────────────────────────────────────────
#[cfg(test)]
+3
View File
@@ -30,6 +30,8 @@ Data persists in `.homecore/home.db` (by default; configurable). Queries work vi
- **Attribute persistence** — JSON attributes for entities stored in separate table (HA pattern)
- **Automatic deduplication** — skip writes when state hasn't changed (detect via hash)
- **Recorder runs table** — track purge cycles and migration events (HA `recorder_runs` equivalent)
- **Startup restoration** — deterministic newest row per entity, bounded at
100,000 rows, with malformed rows isolated as typed warnings
- **Semantic search** (P2, `--features ruvector`) — embed state attributes + query by meaning
- **HNSW index** (P2) — k-NN search for "all warm rooms" via ruvector
- **No data export overhead** — SQLite is queryable directly; no proprietary format
@@ -41,6 +43,7 @@ Data persists in `.homecore/home.db` (by default; configurable). Queries work vi
| Record state change | Listener | `RecorderListener::on_state_changed(event)` | Fires on homecore event bus; writes to SQLite |
| Query state history | SQL | `SELECT * FROM states WHERE entity_id = ? ORDER BY last_changed DESC` | Standard SQLite; can be queried from anywhere |
| Purge old states | Maintenance | `Recorder::purge(older_than)` | Deletes states older than specified timestamp |
| Restore latest states | Startup | `Recorder::restore_latest(states, limit)` | Entity-id ordered, bounded, malformed-row isolation |
| Deduplicate write | Dedup | `DedupEngine::should_record(old_state, new_state)` | Skip if state hash unchanged |
| Create semantic index | Index | `SemanticIndex::index_state(entity_id, state)` (P2, opt-in) | Hash-based embeddings; real embeddings in P3 |
| Search by meaning | Search | `SemanticIndex::search(query, k)` (P2, opt-in) | "warm rooms" → k-NN search in ruvector HNSW |
+525 -87
View File
@@ -20,11 +20,24 @@ use tokio::sync::RwLock;
use tracing::debug;
use homecore::entity::{EntityId, State};
use homecore::event::{DomainEvent, StateChangedEvent};
use homecore::event::{Context, DomainEvent, StateChangedEvent};
use homecore::StateMachine;
use crate::dedup::fnv64a_hash;
use crate::schema::ALL_DDL;
type SearchStateRecord = (
i64,
String,
String,
Option<String>,
f64,
f64,
Option<String>,
);
type StateRecord = (String, String, Option<String>, f64, f64, Option<String>);
type HistoryStateRecord = (i64, String, Option<String>, f64, f64, Option<String>);
/// Hard upper bound on rows returned by [`Recorder::get_state_history`].
///
/// Without this cap a wide `[since, until]` window over a high-frequency entity
@@ -33,6 +46,8 @@ use crate::schema::ALL_DDL;
/// history-graph query, small enough to bound the worst case. Callers needing a
/// wider span page by narrowing the window.
pub const MAX_HISTORY_ROWS: i64 = 1_000_000;
/// Absolute cap for one startup restore query.
pub const MAX_RESTORE_STATES: usize = 100_000;
/// Errors returned by `Recorder` operations.
#[derive(Error, Debug)]
@@ -289,9 +304,8 @@ impl Recorder {
.replace('_', "\\_");
let pattern = format!("%{escaped}%");
let rows: Vec<(i64, String, String, Option<String>, f64, f64, Option<String>)> =
sqlx::query_as(
"SELECT s.state_id, s.entity_id, s.state, sa.shared_attrs, \
let rows: Vec<SearchStateRecord> = sqlx::query_as(
"SELECT s.state_id, s.entity_id, s.state, sa.shared_attrs, \
s.last_changed_ts, s.last_updated_ts, s.context_id \
FROM states s \
LEFT JOIN state_attributes sa ON s.attributes_id = sa.attributes_id \
@@ -301,48 +315,57 @@ impl Recorder {
OR sa.shared_attrs LIKE ?2 ESCAPE '\\' \
ORDER BY s.last_updated_ts DESC \
LIMIT ?3",
)
.bind(query)
.bind(&pattern)
.bind(k as i64)
.fetch_all(&self.pool)
.await?;
)
.bind(query)
.bind(&pattern)
.bind(k as i64)
.fetch_all(&self.pool)
.await?;
rows.into_iter()
.map(|(state_id, entity_id, state, shared_attrs, last_changed_ts, last_updated_ts, context_id)| {
let eid = EntityId::parse(&entity_id)
.unwrap_or_else(|_| EntityId::parse("unknown.unknown").unwrap());
let attributes = shared_attrs
.as_deref()
.map(serde_json::from_str)
.transpose()?
.unwrap_or(serde_json::Value::Object(Default::default()));
Ok(StateRow {
.map(
|(
state_id,
entity_id: eid,
entity_id,
state,
attributes,
shared_attrs,
last_changed_ts,
last_updated_ts,
context_id,
})
})
)| {
let eid = EntityId::parse(&entity_id)
.unwrap_or_else(|_| EntityId::parse("unknown.unknown").unwrap());
let attributes = shared_attrs
.as_deref()
.map(serde_json::from_str)
.transpose()?
.unwrap_or(serde_json::Value::Object(Default::default()));
Ok(StateRow {
state_id,
entity_id: eid,
state,
attributes,
last_changed_ts,
last_updated_ts,
context_id,
})
},
)
.collect()
}
/// Fetch a single `StateRow` by its `state_id`, joining attributes.
async fn fetch_state_row(&self, state_id: i64) -> Result<Option<StateRow>, RecorderError> {
let row: Option<(String, String, Option<String>, f64, f64, Option<String>)> =
sqlx::query_as(
"SELECT s.entity_id, s.state, sa.shared_attrs, \
let row: Option<StateRecord> = sqlx::query_as(
"SELECT s.entity_id, s.state, sa.shared_attrs, \
s.last_changed_ts, s.last_updated_ts, s.context_id \
FROM states s \
LEFT JOIN state_attributes sa ON s.attributes_id = sa.attributes_id \
WHERE s.state_id = ?",
)
.bind(state_id)
.fetch_optional(&self.pool)
.await?;
)
.bind(state_id)
.fetch_optional(&self.pool)
.await?;
let Some((entity_id, state, shared_attrs, last_changed_ts, last_updated_ts, context_id)) =
row
@@ -406,10 +429,28 @@ impl Recorder {
since: DateTime<Utc>,
until: DateTime<Utc>,
) -> Result<Vec<StateRow>, RecorderError> {
self.get_state_history_limited(entity_id, since, until, MAX_HISTORY_ROWS as usize)
.await
}
/// Query state history with a caller-selected limit capped by
/// [`MAX_HISTORY_ROWS`]. The bound is applied in SQL, before rows are
/// materialized.
pub async fn get_state_history_limited(
&self,
entity_id: &EntityId,
since: DateTime<Utc>,
until: DateTime<Utc>,
requested_limit: usize,
) -> Result<Vec<StateRow>, RecorderError> {
let limit = requested_limit.min(MAX_HISTORY_ROWS as usize);
if limit == 0 {
return Ok(Vec::new());
}
let since_ts = since.timestamp_micros() as f64 / 1_000_000.0;
let until_ts = until.timestamp_micros() as f64 / 1_000_000.0;
let rows: Vec<(i64, String, Option<String>, f64, f64, Option<String>)> = sqlx::query_as(
let rows: Vec<HistoryStateRecord> = sqlx::query_as(
"SELECT s.state_id, s.state, sa.shared_attrs, \
s.last_changed_ts, s.last_updated_ts, s.context_id \
FROM states s \
@@ -423,31 +464,189 @@ impl Recorder {
.bind(entity_id.as_str())
.bind(since_ts)
.bind(until_ts)
.bind(MAX_HISTORY_ROWS)
.bind(limit as i64)
.fetch_all(&self.pool)
.await?;
rows.into_iter()
.map(|(state_id, state, shared_attrs, last_changed_ts, last_updated_ts, context_id)| {
let attributes = shared_attrs
.as_deref()
.map(serde_json::from_str)
.transpose()?
.unwrap_or(serde_json::Value::Object(Default::default()));
.map(
|(state_id, state, shared_attrs, last_changed_ts, last_updated_ts, context_id)| {
let attributes = shared_attrs
.as_deref()
.map(serde_json::from_str)
.transpose()?
.unwrap_or(serde_json::Value::Object(Default::default()));
Ok(StateRow {
state_id,
entity_id: entity_id.clone(),
state,
attributes,
last_changed_ts,
last_updated_ts,
context_id,
})
})
Ok(StateRow {
state_id,
entity_id: entity_id.clone(),
state,
attributes,
last_changed_ts,
last_updated_ts,
context_id,
})
},
)
.collect()
}
/// Read the newest row for each entity in deterministic entity-id order.
///
/// The query is bounded and uses `(last_updated_ts, state_id)` as a stable
/// newest-row tie-break. Malformed rows are reported and skipped
/// individually so one corrupt entity cannot prevent the rest from
/// starting.
pub async fn latest_states(
&self,
requested_limit: usize,
) -> Result<LatestStates, RecorderError> {
let limit = requested_limit.min(MAX_RESTORE_STATES);
if limit == 0 {
return Ok(LatestStates::default());
}
type RawRestoreRow = (
i64,
String,
Option<String>,
Option<String>,
Option<f64>,
Option<f64>,
Option<String>,
);
let rows: Vec<RawRestoreRow> = sqlx::query_as(
"SELECT state_id, entity_id, state, shared_attrs, \
last_changed_ts, last_updated_ts, context_id \
FROM ( \
SELECT s.state_id, s.entity_id, s.state, sa.shared_attrs, \
s.last_changed_ts, s.last_updated_ts, s.context_id, \
ROW_NUMBER() OVER ( \
PARTITION BY s.entity_id \
ORDER BY s.last_updated_ts DESC, s.state_id DESC \
) AS newest \
FROM states s \
LEFT JOIN state_attributes sa ON s.attributes_id = sa.attributes_id \
) \
WHERE newest = 1 \
ORDER BY entity_id ASC \
LIMIT ?",
)
.bind((limit + 1) as i64)
.fetch_all(&self.pool)
.await?;
let truncated = rows.len() > limit;
let mut states = Vec::with_capacity(rows.len().min(limit));
let mut warnings = Vec::new();
for (
state_id,
raw_entity_id,
raw_state,
raw_attributes,
last_changed_ts,
last_updated_ts,
context_id,
) in rows.into_iter().take(limit)
{
let entity_id = match EntityId::parse(&raw_entity_id) {
Ok(value) => value,
Err(error) => {
warnings.push(RestoreWarning::MalformedEntityId {
state_id,
entity_id: raw_entity_id,
reason: error.to_string(),
});
continue;
}
};
let Some(state) = raw_state else {
warnings.push(RestoreWarning::MissingState {
state_id,
entity_id: raw_entity_id,
});
continue;
};
let attributes = match raw_attributes {
Some(value) => match serde_json::from_str(&value) {
Ok(value) => value,
Err(error) => {
warnings.push(RestoreWarning::MalformedAttributes {
state_id,
entity_id: raw_entity_id,
reason: error.to_string(),
});
continue;
}
},
None => serde_json::json!({}),
};
let Some(last_changed) = last_changed_ts.and_then(timestamp_from_seconds) else {
warnings.push(RestoreWarning::MalformedTimestamp {
state_id,
entity_id: raw_entity_id,
field: "last_changed_ts",
});
continue;
};
let Some(last_updated) = last_updated_ts.and_then(timestamp_from_seconds) else {
warnings.push(RestoreWarning::MalformedTimestamp {
state_id,
entity_id: raw_entity_id,
field: "last_updated_ts",
});
continue;
};
let parent_id = match context_id {
Some(value) => match value.parse() {
Ok(value) => Some(value),
Err(_) => {
warnings.push(RestoreWarning::MalformedContext {
state_id,
entity_id: raw_entity_id.clone(),
});
None
}
},
None => None,
};
states.push(State {
entity_id,
state,
attributes,
last_changed,
last_updated,
context: Context::restoration(parent_id),
});
}
Ok(LatestStates {
states,
warnings,
truncated,
})
}
/// Load latest durable snapshots into a state machine without producing
/// fresh recorder events.
pub async fn restore_latest(
&self,
states: &StateMachine,
limit: usize,
) -> Result<RestoreReport, RecorderError> {
let batch = self.latest_states(limit).await?;
let mut restored = 0;
for state in batch.states {
// latest_states constructs the required restoration marker.
if states.restore(state).is_ok() {
restored += 1;
}
}
Ok(RestoreReport {
restored,
warnings: batch.warnings,
truncated: batch.truncated,
})
}
/// Purge history older than `older_than`, returning a [`PurgeStats`] summary.
///
/// Deletes:
@@ -511,6 +710,58 @@ impl Recorder {
}
}
fn timestamp_from_seconds(value: f64) -> Option<DateTime<Utc>> {
if !value.is_finite() {
return None;
}
let micros = (value * 1_000_000.0).round();
if micros < i64::MIN as f64 || micros > i64::MAX as f64 {
return None;
}
DateTime::from_timestamp_micros(micros as i64)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RestoreWarning {
MalformedEntityId {
state_id: i64,
entity_id: String,
reason: String,
},
MissingState {
state_id: i64,
entity_id: String,
},
MalformedAttributes {
state_id: i64,
entity_id: String,
reason: String,
},
MalformedTimestamp {
state_id: i64,
entity_id: String,
field: &'static str,
},
MalformedContext {
state_id: i64,
entity_id: String,
},
}
#[derive(Debug, Default)]
pub struct LatestStates {
pub states: Vec<State>,
pub warnings: Vec<RestoreWarning>,
pub truncated: bool,
}
#[derive(Debug)]
pub struct RestoreReport {
pub restored: usize,
pub warnings: Vec<RestoreWarning>,
pub truncated: bool,
}
/// Summary of a [`Recorder::purge`] run.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PurgeStats {
@@ -554,14 +805,20 @@ mod tests {
use super::*;
async fn open_memory() -> Recorder {
Recorder::open("sqlite::memory:").await.expect("open in-memory DB")
Recorder::open("sqlite::memory:")
.await
.expect("open in-memory DB")
}
fn entity(s: &str) -> EntityId {
EntityId::parse(s).unwrap()
}
fn make_state_event(entity_id: &str, state_val: &str, attrs: serde_json::Value) -> StateChangedEvent {
fn make_state_event(
entity_id: &str,
state_val: &str,
attrs: serde_json::Value,
) -> StateChangedEvent {
let eid = entity(entity_id);
let ctx = Context::new();
let s = Arc::new(State::new(eid.clone(), state_val, attrs, ctx));
@@ -585,7 +842,10 @@ mod tests {
.await
.unwrap();
let names: Vec<&str> = tables.iter().map(|(n,)| n.as_str()).collect();
assert!(names.contains(&"state_attributes"), "missing state_attributes");
assert!(
names.contains(&"state_attributes"),
"missing state_attributes"
);
assert!(names.contains(&"states"), "missing states");
assert!(names.contains(&"events"), "missing events");
assert!(names.contains(&"recorder_runs"), "missing recorder_runs");
@@ -595,7 +855,10 @@ mod tests {
async fn schema_idempotent_double_open() {
// Applying schema twice (on the same pool) must not panic or error.
let recorder = open_memory().await;
recorder.apply_schema().await.expect("second apply_schema must be a no-op");
recorder
.apply_schema()
.await
.expect("second apply_schema must be a no-op");
}
// ── record_state ──────────────────────────────────────────────────────────
@@ -603,7 +866,11 @@ mod tests {
#[tokio::test]
async fn record_state_inserts_row() {
let recorder = open_memory().await;
let event = make_state_event("light.kitchen", "on", serde_json::json!({"brightness": 200}));
let event = make_state_event(
"light.kitchen",
"on",
serde_json::json!({"brightness": 200}),
);
let state_id = recorder.record_state(&event).await.unwrap();
assert!(state_id.is_some(), "expected a state_id");
@@ -642,19 +909,20 @@ mod tests {
recorder.record_state(&e1).await.unwrap();
recorder.record_state(&e2).await.unwrap();
let attr_count: (i64,) =
sqlx::query_as("SELECT COUNT(*) FROM state_attributes")
.fetch_one(&recorder.pool)
.await
.unwrap();
let attr_count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM state_attributes")
.fetch_one(&recorder.pool)
.await
.unwrap();
// Both events share identical attrs → only one state_attributes row.
assert_eq!(attr_count.0, 1, "identical attrs must share one state_attributes row");
assert_eq!(
attr_count.0, 1,
"identical attrs must share one state_attributes row"
);
let state_count: (i64,) =
sqlx::query_as("SELECT COUNT(*) FROM states")
.fetch_one(&recorder.pool)
.await
.unwrap();
let state_count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM states")
.fetch_one(&recorder.pool)
.await
.unwrap();
assert_eq!(state_count.0, 2, "two states rows expected");
}
@@ -667,11 +935,10 @@ mod tests {
recorder.record_state(&e1).await.unwrap();
recorder.record_state(&e2).await.unwrap();
let attr_count: (i64,) =
sqlx::query_as("SELECT COUNT(*) FROM state_attributes")
.fetch_one(&recorder.pool)
.await
.unwrap();
let attr_count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM state_attributes")
.fetch_one(&recorder.pool)
.await
.unwrap();
assert_eq!(attr_count.0, 2);
}
@@ -691,7 +958,10 @@ mod tests {
let since = Utc::now() - chrono::Duration::seconds(10);
let until = Utc::now() + chrono::Duration::seconds(10);
let rows = recorder.get_state_history(&eid, since, until).await.unwrap();
let rows = recorder
.get_state_history(&eid, since, until)
.await
.unwrap();
assert_eq!(rows.len(), 3, "expected 3 history rows");
// Verify ascending order by last_updated_ts.
@@ -705,6 +975,36 @@ mod tests {
assert_eq!(rows[2].state, "22.0");
}
#[tokio::test]
async fn history_caller_limit_is_applied_before_materialization() {
let recorder = open_memory().await;
let eid = entity("sensor.bounded");
for value in ["1", "2", "3"] {
recorder
.record_state(&make_state_event(
"sensor.bounded",
value,
serde_json::json!({}),
))
.await
.unwrap();
}
let since = Utc::now() - chrono::Duration::seconds(10);
let until = Utc::now() + chrono::Duration::seconds(10);
let rows = recorder
.get_state_history_limited(&eid, since, until, 2)
.await
.unwrap();
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].state, "1");
assert_eq!(rows[1].state, "2");
assert!(recorder
.get_state_history_limited(&eid, since, until, 0)
.await
.unwrap()
.is_empty());
}
// ── record_event ──────────────────────────────────────────────────────────
#[tokio::test]
@@ -739,11 +1039,19 @@ mod tests {
// FAILS against the old always-empty path: asserts real rows come back.
let recorder = open_memory().await;
recorder
.record_state(&make_state_event("light.kitchen", "on", serde_json::json!({})))
.record_state(&make_state_event(
"light.kitchen",
"on",
serde_json::json!({}),
))
.await
.unwrap();
recorder
.record_state(&make_state_event("light.bedroom", "off", serde_json::json!({})))
.record_state(&make_state_event(
"light.bedroom",
"off",
serde_json::json!({}),
))
.await
.unwrap();
recorder
@@ -779,7 +1087,10 @@ mod tests {
))
.await
.unwrap();
let rows = recorder.search_states_by_text("portland", 10).await.unwrap();
let rows = recorder
.search_states_by_text("portland", 10)
.await
.unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].entity_id.as_str(), "sensor.weather");
assert_eq!(rows[0].attributes["location"], "portland");
@@ -806,7 +1117,11 @@ mod tests {
async fn text_search_no_match_returns_empty() {
let recorder = open_memory().await;
recorder
.record_state(&make_state_event("light.kitchen", "on", serde_json::json!({})))
.record_state(&make_state_event(
"light.kitchen",
"on",
serde_json::json!({}),
))
.await
.unwrap();
let rows = recorder
@@ -829,7 +1144,11 @@ mod tests {
// EntityId::parse permits this, so it reaches the bind path as data.
let evil = "light.x_drop_table_states_select";
recorder
.record_state(&make_state_event(evil, "'; DROP TABLE states; --", serde_json::json!({})))
.record_state(&make_state_event(
evil,
"'; DROP TABLE states; --",
serde_json::json!({}),
))
.await
.unwrap();
@@ -884,11 +1203,14 @@ mod tests {
// public method (whose cap is MAX_HISTORY_ROWS) and run the *same* SQL
// shape with a small bind to demonstrate the LIMIT term is effective —
// and separately assert the constant is a sane positive bound.
assert!(MAX_HISTORY_ROWS > 0, "history cap must be positive");
let recorder = open_memory().await;
for v in &["1", "2", "3", "4", "5"] {
recorder
.record_state(&make_state_event("sensor.bounded", v, serde_json::json!({})))
.record_state(&make_state_event(
"sensor.bounded",
v,
serde_json::json!({}),
))
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(2)).await;
@@ -905,12 +1227,20 @@ mod tests {
.fetch_all(&recorder.pool)
.await
.unwrap();
assert_eq!(capped.len(), 2, "LIMIT term effectively bounds the result set");
assert_eq!(
capped.len(),
2,
"LIMIT term effectively bounds the result set"
);
// And the real method returns all rows when under the cap.
let eid = entity("sensor.bounded");
let rows = recorder
.get_state_history(&eid, Utc::now() - chrono::Duration::seconds(10), Utc::now() + chrono::Duration::seconds(10))
.get_state_history(
&eid,
Utc::now() - chrono::Duration::seconds(10),
Utc::now() + chrono::Duration::seconds(10),
)
.await
.unwrap();
assert_eq!(rows.len(), 5, "all rows under the cap return");
@@ -938,7 +1268,10 @@ mod tests {
// Read back the actual timestamps so the cutoff is exact.
let since = Utc::now() - chrono::Duration::seconds(60);
let until = Utc::now() + chrono::Duration::seconds(60);
let all = recorder.get_state_history(&eid, since, until).await.unwrap();
let all = recorder
.get_state_history(&eid, since, until)
.await
.unwrap();
assert_eq!(all.len(), 3);
// Cut off exactly at the middle row's timestamp.
let mid_ts = all[1].last_updated_ts;
@@ -947,8 +1280,15 @@ mod tests {
let stats = recorder.purge(cutoff).await.unwrap();
assert_eq!(stats.states_deleted, 1, "only the strictly-older 'old' row");
let remaining = recorder.get_state_history(&eid, since, until).await.unwrap();
assert_eq!(remaining.len(), 2, "boundary 'mid' row is KEPT (exclusive cutoff)");
let remaining = recorder
.get_state_history(&eid, since, until)
.await
.unwrap();
assert_eq!(
remaining.len(),
2,
"boundary 'mid' row is KEPT (exclusive cutoff)"
);
assert_eq!(remaining[0].state, "mid");
assert_eq!(remaining[1].state, "new");
}
@@ -986,18 +1326,28 @@ mod tests {
// referenced by sensor.b, so it must survive.
let eid_b = entity("sensor.b");
let rows_b = recorder
.get_state_history(&eid_b, Utc::now() - chrono::Duration::seconds(60), Utc::now() + chrono::Duration::seconds(60))
.get_state_history(
&eid_b,
Utc::now() - chrono::Duration::seconds(60),
Utc::now() + chrono::Duration::seconds(60),
)
.await
.unwrap();
let b_ts = rows_b[0].last_updated_ts;
let cutoff = DateTime::<Utc>::from_timestamp_micros((b_ts * 1_000_000.0) as i64).unwrap();
let stats = recorder.purge(cutoff).await.unwrap();
assert_eq!(stats.states_deleted, 1, "sensor.a purged");
assert_eq!(stats.attributes_deleted, 0, "shared blob still referenced — kept");
assert_eq!(
stats.attributes_deleted, 0,
"shared blob still referenced — kept"
);
assert_eq!(attr_count(&recorder).await, 1, "blob survives");
// Now purge everything → sensor.b gone, blob orphaned → GC'd.
let stats2 = recorder.purge(Utc::now() + chrono::Duration::seconds(120)).await.unwrap();
let stats2 = recorder
.purge(Utc::now() + chrono::Duration::seconds(120))
.await
.unwrap();
assert_eq!(stats2.states_deleted, 1, "sensor.b purged");
assert_eq!(stats2.attributes_deleted, 1, "now-orphaned blob GC'd");
assert_eq!(attr_count(&recorder).await, 0, "no blobs remain");
@@ -1008,7 +1358,11 @@ mod tests {
let recorder = open_memory().await;
let ctx = Context::new();
recorder
.record_event(&DomainEvent::new("call_service", serde_json::json!({}), ctx))
.record_event(&DomainEvent::new(
"call_service",
serde_json::json!({}),
ctx,
))
.await
.unwrap();
// Purge with a far-future cutoff removes the event.
@@ -1030,11 +1384,95 @@ mod tests {
// real rows via the text fallback — proving it's no longer always-empty.
let recorder = open_memory().await;
recorder
.record_state(&make_state_event("light.kitchen", "on", serde_json::json!({})))
.record_state(&make_state_event(
"light.kitchen",
"on",
serde_json::json!({}),
))
.await
.unwrap();
let rows = recorder.search_semantic("kitchen", 5).await.unwrap();
assert_eq!(rows.len(), 1, "fallback must surface the kitchen row");
assert_eq!(rows[0].entity_id.as_str(), "light.kitchen");
}
#[tokio::test]
async fn latest_states_is_deterministic_newest_per_entity() {
let recorder = open_memory().await;
recorder
.record_state(&make_state_event("sensor.z", "old", serde_json::json!({})))
.await
.unwrap();
recorder
.record_state(&make_state_event("sensor.a", "only", serde_json::json!({})))
.await
.unwrap();
recorder
.record_state(&make_state_event("sensor.z", "new", serde_json::json!({})))
.await
.unwrap();
let batch = recorder.latest_states(10).await.unwrap();
assert_eq!(batch.states.len(), 2);
assert_eq!(batch.states[0].entity_id.as_str(), "sensor.a");
assert_eq!(batch.states[1].entity_id.as_str(), "sensor.z");
assert_eq!(batch.states[1].state, "new");
assert!(batch
.states
.iter()
.all(|state| state.context.is_restoration()));
}
#[tokio::test]
async fn latest_states_isolates_malformed_rows_and_honours_bound() {
let recorder = open_memory().await;
recorder
.record_state(&make_state_event(
"sensor.good",
"ok",
serde_json::json!({"x": 1}),
))
.await
.unwrap();
sqlx::query(
"INSERT INTO states \
(entity_id, state, attributes_id, last_changed_ts, last_updated_ts, context_id) \
VALUES ('INVALID', 'bad', NULL, 1.0, 2.0, NULL)",
)
.execute(&recorder.pool)
.await
.unwrap();
let attrs_id = sqlx::query(
"INSERT INTO state_attributes (shared_attrs, hash) VALUES ('not-json', 424242)",
)
.execute(&recorder.pool)
.await
.unwrap()
.last_insert_rowid();
sqlx::query(
"INSERT INTO states \
(entity_id, state, attributes_id, last_changed_ts, last_updated_ts, context_id) \
VALUES ('sensor.badattrs', 'bad', ?, 1.0, 2.0, NULL)",
)
.bind(attrs_id)
.execute(&recorder.pool)
.await
.unwrap();
let batch = recorder.latest_states(10).await.unwrap();
assert_eq!(batch.states.len(), 1);
assert_eq!(batch.warnings.len(), 2);
assert!(batch
.warnings
.iter()
.any(|warning| matches!(warning, RestoreWarning::MalformedEntityId { .. })));
assert!(batch
.warnings
.iter()
.any(|warning| matches!(warning, RestoreWarning::MalformedAttributes { .. })));
let bounded = recorder.latest_states(1).await.unwrap();
assert!(bounded.truncated);
assert!(bounded.states.len() <= 1);
}
}
+7 -1
View File
@@ -30,9 +30,15 @@ pub mod schema;
pub mod semantic;
// Re-export the primary public API surface.
pub use db::{PurgeStats, Recorder, RecorderError, StateRow, MAX_HISTORY_ROWS};
pub use db::{
LatestStates, PurgeStats, Recorder, RecorderError, RestoreReport, RestoreWarning,
SemanticIndex, StateRow, MAX_HISTORY_ROWS, MAX_RESTORE_STATES,
};
pub use listener::RecorderListener;
/// Null semantic index used when the `ruvector` feature is off.
/// Satisfies the [`db::SemanticIndex`] trait bound without any allocation.
pub use db::NullSemanticIndex;
#[cfg(feature = "ruvector")]
pub use semantic::RuvectorSemanticIndex;
+8 -3
View File
@@ -23,12 +23,13 @@ path = "src/main.rs"
# The 8 HOMECORE crates this binary integrates
homecore = { path = "../homecore", version = "0.1.0-alpha.0" }
homecore-api = { path = "../homecore-api", version = "0.1.0-alpha.0" }
homecore-plugins = { path = "../homecore-plugins", version = "0.1.0-alpha.0" }
homecore-hap = { path = "../homecore-hap", version = "0.1.0-alpha.0" }
homecore-plugins = { path = "../homecore-plugins", version = "0.1.0-alpha.0" }
homecore-recorder = { path = "../homecore-recorder", version = "0.1.0-alpha.0" }
# Reuse version-gated registry envelope parsing in the isolated restore module.
homecore-migrate = { path = "../homecore-migrate", version = "0.1.0-alpha.0" }
homecore-automation = { path = "../homecore-automation", version = "0.1.0-alpha.0" }
homecore-assist = { path = "../homecore-assist", version = "0.1.0-alpha.0" }
# Migration crate is CLI-only; not linked here.
homecore-hap = { path = "../homecore-hap", version = "0.1.0-alpha.0", optional = true }
tokio = { version = "1", features = ["full"] }
tracing = "0.1"
@@ -50,6 +51,7 @@ tower-http = { version = "0.6", features = ["fs", "trace", "cors"] }
# CHANGELOG / ADR-131 security note).
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_yaml = "0.9"
# Concurrent fan-out of per-bank RoomState fetches in the gateway (§11 perf).
futures = "0.3"
@@ -57,6 +59,7 @@ futures = "0.3"
# Drive the assembled router in integration tests via ServiceExt::oneshot.
tower = { version = "0.5", features = ["util"] }
http-body-util = "0.1"
tempfile = "3"
[features]
default = []
@@ -64,3 +67,5 @@ default = []
ruvector = ["homecore-recorder/ruvector"]
# Pull in real Wasmtime plugin runtime (vs InProcessRuntime).
wasmtime = ["homecore-plugins/wasmtime"]
# Bind the HAP TCP listener and publish `_hap._tcp` over mDNS.
hap-server = ["dep:homecore-hap", "homecore-hap/hap-server"]
+64 -180
View File
@@ -1,204 +1,88 @@
# homecore-server
Integrated HOMECORE server binary that wires state machine, API, recorder, plugins, automations, intent assistant, and HomeKit bridge into one process.
`homecore-server` is the alpha integration binary for HOMECORE. It runs one
shared state machine, event bus, service registry, REST/WebSocket API, optional
SQLite recorder, automation engine, intent endpoint, BFF gateway, and static
dashboard.
[![Crates.io](https://img.shields.io/badge/crates.io-workspace%20binary-inactive)](.)
![License](https://img.shields.io/badge/license-MIT-blue.svg)
![MSRV: 1.89+](https://img.shields.io/badge/MSRV-1.89%2B-purple.svg)
[![ADR-126](https://img.shields.io/badge/ADR-126-orange.svg)](../../docs/adr/ADR-126-homecore-home-assistant-port.md)
It is not a drop-in replacement for the complete Home Assistant API. The exact
implemented and deferred surfaces are listed in
[`docs/homecore-capabilities.md`](../../docs/homecore-capabilities.md).
The production-ready HOMECORE binary — boots all 7 subsystems (core, API, recorder, plugins, automation, assist, HAP bridge) in a single process listening on `:8123`.
## Security defaults
## What this crate does
`homecore-server` is the integration point for the entire HOMECORE ecosystem. It orchestrates:
1. **HomeCore runtime** — state machine, event bus, service registry
2. **REST + WebSocket API** — Axum server on `:8123` (HA-compatible)
3. **SQLite Recorder** — persists all state changes to disk
4. **Plugin Registry** — loads and manages integrations (InProcessRuntime by default)
5. **Automation Engine** — evaluates triggers, conditions, and actions
6. **Assist Pipeline** — intent recognition and execution
7. **HAP Bridge** — exposes accessories to HomeKit
All subsystems share the same `HomeCore` instance, so state changes flow through the event bus and trigger automations, record history, and notify WebSocket subscribers in lockstep.
## Features
- **Single unified process** — no external microservices; run with `cargo run -p homecore-server`
- **HA-compatible REST API** — drop-in replacement for Home Assistant's `/api/` on `:8123`
- **SQLite state history** — persistent recording of all state changes
- **Automation engine** — YAML-driven trigger→condition→action execution
- **Intent assistant** — regex-based (P1) intent recognition + service calling
- **HomeKit bridge** — exposes HOMECORE entities as HomeKit accessories
- **Plugin system** — load first-party Rust plugins; Wasmtime WASM plugins (P2, `--features wasmtime`)
- **Configurable via CLI + env vars** — no YAML required; sensible defaults
- **Structured logging** — tracing output with `RUST_LOG` filtering
- **Feature-gated subsystems** — disable recorder (`--no-recorder`), enable ruvector/wasmtime as needed
## Subsystems
| Subsystem | Crate | Role | Notes |
|-----------|-------|------|-------|
| State Machine | `homecore` | Core domain model | All other subsystems depend on this |
| REST API | `homecore-api` | HTTP boundary | Listens on `:8123`; Axum framework |
| Recorder | `homecore-recorder` | Persistence | SQLite; optional `--no-recorder` |
| Plugins | `homecore-plugins` | Extension system | InProcessRuntime default; Wasmtime w/ feature |
| Automation | `homecore-automation` | Trigger execution | Subscribes to event bus; YAML-driven |
| Assist | `homecore-assist` | Intent pipeline | Regex recognizer (P1); semantic (P2) |
| HAP Bridge | `homecore-hap` | HomeKit export | Accessories + characteristics; mDNS (P2) |
## Usage
**Basic startup** (in-memory recorder):
Authentication fails closed. Set one or more comma-separated bearer tokens:
```bash
cargo build -p homecore-server
./target/debug/homecore-server
# Listens on http://localhost:8123
set HOMECORE_TOKENS=replace-with-a-long-random-token
cargo run -p homecore-server
```
**With persistent SQLite**:
`--insecure-dev-auth` explicitly allows any non-empty bearer token and must only
be used in an isolated development environment. The browser UI does not contain
a default token. Configure allowed browser origins with
`HOMECORE_CORS_ORIGINS`.
## Runtime behavior
- SQLite recording is enabled by default at `sqlite://homecore.db`.
- Entity/device registries are restored from `.homecore/storage` before
recorder states, listeners, automations, and API startup.
- The latest recorder state for each entity is restored deterministically.
Restored snapshots retain their timestamps and carry a `homecore.restore`
context marker; malformed rows are logged and isolated.
- Synthetic entities are disabled by default; opt in with
`--seed-demo-entities`.
- Automations can be loaded with `--automations <file>` or
`HOMECORE_AUTOMATIONS`.
- `Ctrl-C` initiates graceful HTTP shutdown and emits `HomeCoreStop`.
- The `ruvector` feature enables the recorder's semantic index.
- Plugin and HAP crates are libraries in this workspace, but this binary does
not claim to load plugins or advertise a HomeKit server yet.
## API
All API requests require `Authorization: Bearer <token>`.
```bash
./target/debug/homecore-server \
--bind 0.0.0.0:8123 \
--db sqlite:~/.homecore/home.db \
--location-name "My Home"
```
curl -H "Authorization: Bearer $HOMECORE_TOKEN" \
http://127.0.0.1:8123/api/states
**Full feature build** (ruvector semantic search + Wasmtime plugins):
```bash
cargo build -p homecore-server --features ruvector,wasmtime --release
```
**Via Docker** (Dockerfile planned P2):
```bash
docker run -p 8123:8123 \
-e HOMECORE_DB=sqlite:///data/home.db \
-v ~/.homecore:/data \
homecore-server:latest
```
**Test the API**:
```bash
# List all entities
curl http://localhost:8123/api/states
# Set a light to "on"
curl -X POST \
-H "Authorization: Bearer $HOMECORE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"state":"on","attributes":{"brightness":200}}' \
http://localhost:8123/api/states/light.kitchen
-d '{"entity_id":"light.kitchen"}' \
http://127.0.0.1:8123/api/services/light/turn_on
# WebSocket subscription (real-time state changes)
wscat -c ws://localhost:8123/api/websocket
curl -X POST \
-H "Authorization: Bearer $HOMECORE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"utterance":"turn on light.kitchen","language":"en"}' \
http://127.0.0.1:8123/api/intent/handle
```
**Configuration via env**:
Built-in executable services are `homecore.ping`,
`homecore.snapshot_state`, and `turn_on`/`turn_off`/`toggle` for the
`homeassistant`, `light`, and `switch` domains. Unsupported services are left
unregistered and return an error rather than a false acknowledgement.
```bash
export HOMECORE_BIND="0.0.0.0:8123"
export HOMECORE_DB="sqlite:~/.homecore/home.db"
export HOMECORE_LOCATION="Living Room"
export RUST_LOG="homecore=debug,homecore_api=info"
./target/debug/homecore-server
```
## Configuration
## CLI Options
| Flag | Environment | Default |
|---|---|---|
| `--bind` | `HOMECORE_BIND` | `0.0.0.0:8123` |
| `--db` | `HOMECORE_DB` | `sqlite://homecore.db` |
| `--storage-dir` | `HOMECORE_STORAGE_DIR` | `.homecore/storage` |
| `--restore-limit` | `HOMECORE_RESTORE_LIMIT` | `100000` |
| `--location-name` | `HOMECORE_LOCATION` | `Home` |
| `--automations` | `HOMECORE_AUTOMATIONS` | unset |
| `--insecure-dev-auth` | `HOMECORE_INSECURE_DEV_AUTH` | `false` |
| `--seed-demo-entities` | — | `false` |
| `--no-recorder` | — | `false` |
| Flag | Env Var | Default | Description |
|------|---------|---------|-------------|
| `--bind` | `HOMECORE_BIND` | `0.0.0.0:8123` | REST API listen address |
| `--db` | `HOMECORE_DB` | `sqlite::memory:` | SQLite path (`:memory:` for ephemeral) |
| `--location-name` | `HOMECORE_LOCATION` | `Home` | Friendly name returned by `/api/config` |
| `--no-recorder` | — | off | Disable SQLite recorder (low-resource deployments) |
| `--ui-dir` | `HOMECORE_UI_DIR` | `<crate>/ui` | HOMECORE-UI asset dir served at `/homecore` (ADR-131); empty disables the mount |
## HOMECORE-UI dashboard (ADR-131)
This binary also serves the **HOMECORE-UI** — the complete operational dashboard
for the two-tier Cognitum stack (v0 Appliance → SEEDs → ESP32 nodes) — at
`/homecore`, alongside the HA-compat `/api` surface. It is a zero-dependency,
no-build-step vanilla TS/JS + CSS frontend living in `ui/`:
```bash
cargo run -p homecore-server # then open http://localhost:8123/homecore/
```
It drives the live `/api` + `/api/websocket` (`subscribe_events`) endpoints; panels
backed by services not in this binary (SEED HTTPS API, calibration ADR-151,
federation ADR-105) render against a DEMO-flagged contract-conformant mock until
those endpoints land (ADR-131 §7.1). Frontend tests + benchmark run under plain
`node` (no `npm install`):
```bash
cd ui && npm test # import graph + render-smoke + interaction (24 checks)
cd ui && npm run bench # bundle budget (~137 KB, ~37× smaller than HA) + render timing
```
## Comparison to Home Assistant
| Aspect | Home Assistant | homecore-server |
|--------|----------------|-----------------|
| Architecture | Python asyncio monolith | Rust async Tokio + component traits |
| API protocol | `/api/` REST (HA wire format) | Identical HA wire format |
| Persistence | SQLite + YAML files | SQLite (P1); Redis (P2) |
| Plugins | Python integrations in `homeassistant/components/` | Rust (P1) + WASM (P2) |
| Automation execution | Python asyncio event loop | Tokio async tasks + trait-based |
| HomeKit bridge | Via `homekit` integration | Built-in `homecore-hap` subsystem |
| CLI | `hass` command with config YAML | `homecore-server` with feature flags |
| Scalability | Single instance (HA Cloud for scale) | Can be load-balanced (future) |
| Binary size | ~200 MB (Python + deps) | ~50 MB (Rust, release build; 200 MB w/ wasmtime) |
## Performance Targets (unreleased; TBD)
- **Startup time** — < 2s to listen on `:8123`
- **REST endpoint latency** — p50 < 1 ms; p99 < 10 ms
- **Event bus throughput** — 10,000+ events/sec
- **Automation evaluation** — < 100 μs per trigger
- **Concurrent WebSocket connections** — 10,000+
- **Memory footprint** — ~100 MB (idle); ~500 MB with 1,000 recorded states
## Development
**Run tests**:
## Validation
```bash
cargo test -p homecore-server
cargo clippy -p homecore-server --all-targets --all-features -- -D warnings
```
**Enable debug logging**:
```bash
RUST_LOG=debug cargo run -p homecore-server -- --bind 127.0.0.1:8123
```
**Build documentation**:
```bash
cargo doc -p homecore-server --open
```
## Relation to other HOMECORE crates
```
homecore-server (orchestration binary)
├── homecore (state machine)
├── homecore-api (REST + WS)
├── homecore-recorder (SQLite persistence)
├── homecore-plugins (extension system)
├── homecore-automation (trigger execution)
├── homecore-assist (intent pipeline)
└── homecore-hap (HomeKit bridge)
```
## References
- [ADR-126: HOMECORE Home Assistant Port (master)](../../docs/adr/ADR-126-homecore-home-assistant-port.md)
- [README — wifi-densepose](../../../README.md)
- [Dockerfile (planned P2)](Dockerfile.planned)
- [Docker Hub image (planned P2)](https://hub.docker.com/r/ruvnet/homecore-server)
+189 -37
View File
@@ -29,12 +29,16 @@ use axum::body::Bytes;
use axum::extract::{Path, RawQuery, State};
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::routing::{get, post};
use axum::{Json, Router};
use serde::Deserialize;
use serde_json::{json, Value};
use homecore_api::auth::BearerAuth;
use homecore_api::SharedState;
use homecore_assist::{AssistPipeline, RegexIntentRecognizer};
#[cfg(test)]
use homecore_assist::{HassCancelAll, HassLightSet, HassNevermind, HassTurnOff, HassTurnOn};
/// Static gateway configuration (from CLI/env in `main`).
pub struct GatewayConfig {
@@ -54,15 +58,36 @@ pub struct GatewayState {
pub shared: SharedState,
pub http: reqwest::Client,
pub cfg: Arc<GatewayConfig>,
pub assist: Arc<AssistPipeline<RegexIntentRecognizer>>,
}
impl GatewayState {
#[cfg(test)]
pub fn new(shared: SharedState, cfg: GatewayConfig) -> Self {
let mut assist = AssistPipeline::new(RegexIntentRecognizer::new());
assist.register_handler(HassTurnOn);
assist.register_handler(HassTurnOff);
assist.register_handler(HassLightSet);
assist.register_handler(HassNevermind);
assist.register_handler(HassCancelAll);
Self::with_assist(shared, cfg, assist)
}
pub fn with_assist(
shared: SharedState,
cfg: GatewayConfig,
assist: AssistPipeline<RegexIntentRecognizer>,
) -> Self {
let http = reqwest::Client::builder()
.timeout(cfg.timeout)
.build()
.unwrap_or_else(|_| reqwest::Client::new());
Self { shared, http, cfg: Arc::new(cfg) }
Self {
shared,
http,
cfg: Arc::new(cfg),
assist: Arc::new(assist),
}
}
}
@@ -76,6 +101,7 @@ pub fn gateway_router(state: GatewayState) -> Router {
.route("/api/homecore/rooms", get(rooms))
.route("/api/homecore/cogs", get(cogs_list))
.route("/api/homecore/appliance", get(appliance))
.route("/api/intent/handle", post(handle_intent))
// ── upstream-dependent stubs (W3 / W5 / W6): typed 503 ───────
.route("/api/homecore/seeds", get(stub_503))
.route("/api/homecore/seeds/:id", get(stub_503))
@@ -89,10 +115,47 @@ pub fn gateway_router(state: GatewayState) -> Router {
.route("/api/homecore/cogs/updates", get(empty_list))
.route("/api/homecore/hailo", get(stub_503))
.route("/api/homecore/tokens", get(stub_503))
.route("/api/events", get(stub_503))
.route("/api/homecore/events", get(stub_503))
.with_state(state)
}
#[derive(Deserialize)]
struct IntentRequest {
#[serde(alias = "text")]
utterance: String,
#[serde(default = "default_language")]
language: String,
}
fn default_language() -> String {
"en".to_owned()
}
async fn handle_intent(
State(st): State<GatewayState>,
headers: HeaderMap,
Json(request): Json<IntentRequest>,
) -> Response {
if let Err(response) = require_auth(&headers, &st).await {
return response;
}
if request.utterance.trim().is_empty() {
return bad_request("utterance cannot be empty");
}
match st
.assist
.process(&request.utterance, &request.language, st.shared.homecore())
.await
{
Ok(response) => Json(response).into_response(),
Err(error) => typed(
StatusCode::UNPROCESSABLE_ENTITY,
"intent_failed",
&error.to_string(),
),
}
}
// ── auth + typed errors ─────────────────────────────────────────────
async fn require_auth(headers: &HeaderMap, st: &GatewayState) -> Result<(), Response> {
@@ -106,7 +169,11 @@ fn typed(status: StatusCode, error: &str, detail: &str) -> Response {
(status, Json(json!({ "error": error, "detail": detail }))).into_response()
}
fn upstream_unavailable(detail: &str) -> Response {
typed(StatusCode::SERVICE_UNAVAILABLE, "upstream_unavailable", detail)
typed(
StatusCode::SERVICE_UNAVAILABLE,
"upstream_unavailable",
detail,
)
}
fn upstream_timeout(detail: &str) -> Response {
typed(StatusCode::GATEWAY_TIMEOUT, "upstream_timeout", detail)
@@ -130,33 +197,32 @@ fn bad_request(detail: &str) -> Response {
/// like `%252e` decodes once to `%2e` and is caught here).
///
/// Legitimate `v1/...` paths (the only shape the UI sends) pass unchanged.
fn validate_proxy_path(path: &str) -> Result<(), Response> {
fn validate_proxy_path(path: &str) -> Result<(), &'static str> {
// 1. Reject on the raw form first (cheap; catches backslash + leading `/`).
if path.starts_with('/') {
return Err(bad_request("proxied path must be relative (leading '/' not allowed)"));
return Err("proxied path must be relative (leading '/' not allowed)");
}
if path.contains('\\') {
return Err(bad_request("proxied path must not contain a backslash"));
return Err("proxied path must not contain a backslash");
}
// 2. Percent-decode once and re-check; reject if decoding is invalid.
let decoded = percent_decode_once(path)
.ok_or_else(|| bad_request("proxied path has invalid percent-encoding"))?;
let decoded = percent_decode_once(path).ok_or("proxied path has invalid percent-encoding")?;
if decoded.starts_with('/') || decoded.contains('\\') {
return Err(bad_request("proxied path resolves to an absolute/traversal path"));
return Err("proxied path resolves to an absolute/traversal path");
}
// 3. Reject any `.`/`..` segment on BOTH the raw and decoded forms so an
// encoded `%2e%2e%2f` cannot slip a dot-segment past the split.
for form in [path, decoded.as_str()] {
for seg in form.split(['/', '\\']) {
if seg == "." || seg == ".." {
return Err(bad_request("proxied path must not contain '.' or '..' segments"));
return Err("proxied path must not contain '.' or '..' segments");
}
}
// Defence in depth: a residual encoded traversal marker survived the
// single decode (e.g. originally double-encoded). Reject it outright.
let lower = form.to_ascii_lowercase();
if lower.contains("%2e") || lower.contains("%2f") || lower.contains("%5c") {
return Err(bad_request("proxied path must not contain encoded traversal markers"));
return Err("proxied path must not contain encoded traversal markers");
}
}
Ok(())
@@ -194,7 +260,9 @@ async fn stub_503(State(st): State<GatewayState>, headers: HeaderMap) -> Respons
if let Err(r) = require_auth(&headers, &st).await {
return r;
}
upstream_unavailable("endpoint not yet wired — see ADR-131 §11/§12 (SEED device / appliance upstream)")
upstream_unavailable(
"endpoint not yet wired — see ADR-131 §11/§12 (SEED device / appliance upstream)",
)
}
/// Auth-gated empty-array response (e.g. OTA updates with no feed wired).
@@ -216,12 +284,14 @@ async fn cal_proxy_get(
if let Err(r) = require_auth(&headers, &st).await {
return r;
}
if let Err(r) = validate_proxy_path(&path) {
return r;
if let Err(detail) = validate_proxy_path(&path) {
return bad_request(detail);
}
let base = match &st.cfg.calibration_url {
Some(u) => u,
None => return upstream_unavailable("calibration service not configured (set --calibration-url / HOMECORE_CALIBRATION_URL)"),
None => return upstream_unavailable(
"calibration service not configured (set --calibration-url / HOMECORE_CALIBRATION_URL)",
),
};
let qs = q.map(|s| format!("?{s}")).unwrap_or_default();
// The wildcard already carries the `v1/...` segment (the UI calls
@@ -239,12 +309,14 @@ async fn cal_proxy_post(
if let Err(r) = require_auth(&headers, &st).await {
return r;
}
if let Err(r) = validate_proxy_path(&path) {
return r;
if let Err(detail) = validate_proxy_path(&path) {
return bad_request(detail);
}
let base = match &st.cfg.calibration_url {
Some(u) => u,
None => return upstream_unavailable("calibration service not configured (set --calibration-url / HOMECORE_CALIBRATION_URL)"),
None => return upstream_unavailable(
"calibration service not configured (set --calibration-url / HOMECORE_CALIBRATION_URL)",
),
};
let url = format!("{}/api/{}", base.trim_end_matches('/'), path);
let rb = st
@@ -264,7 +336,8 @@ async fn proxy(st: &GatewayState, mut rb: reqwest::RequestBuilder) -> Response {
}
match rb.send().await {
Ok(resp) => {
let status = StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
let status =
StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
let ct = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
@@ -325,7 +398,10 @@ async fn rooms(State(st): State<GatewayState>, headers: HeaderMap) -> Response {
let base = base.as_str();
async move {
let url = format!("{base}/api/v1/room/state?bank={bank}");
fetch_json(st, &url).await.ok().map(|v| adapt_room_state(&bank, &v))
fetch_json(st, &url)
.await
.ok()
.map(|v| adapt_room_state(&bank, &v))
}
});
let out: Vec<Value> = futures::future::join_all(fetches)
@@ -352,10 +428,7 @@ fn bank_names(v: &Value) -> Vec<String> {
_ => None,
})
.collect(),
Value::Object(o) => o
.get("baselines")
.map(|b| bank_names(b))
.unwrap_or_default(),
Value::Object(o) => o.get("baselines").map(bank_names).unwrap_or_default(),
_ => Vec::new(),
}
}
@@ -456,7 +529,11 @@ async fn cogs_list(State(st): State<GatewayState>, headers: HeaderMap) -> Respon
}
fn read_pid(dir: &std::path::Path, id: &str) -> Option<i64> {
for name in [format!("{id}.pid"), "pid".to_string(), "app.pid".to_string()] {
for name in [
format!("{id}.pid"),
"pid".to_string(),
"app.pid".to_string(),
] {
if let Ok(s) = std::fs::read_to_string(dir.join(&name)) {
if let Ok(p) = s.trim().parse::<i64>() {
return Some(p);
@@ -515,7 +592,9 @@ async fn appliance(State(st): State<GatewayState>, headers: HeaderMap) -> Respon
}
fn read_first_line(path: &str) -> Option<String> {
std::fs::read_to_string(path).ok().and_then(|s| s.lines().next().map(str::to_string))
std::fs::read_to_string(path)
.ok()
.and_then(|s| s.lines().next().map(str::to_string))
}
fn uptime_secs() -> Option<u64> {
@@ -551,7 +630,9 @@ fn cpu_load_pct() -> Option<f64> {
.next()?
.parse::<f64>()
.ok()?;
let ncpu = std::thread::available_parallelism().map(|n| n.get() as f64).unwrap_or(1.0);
let ncpu = std::thread::available_parallelism()
.map(|n| n.get() as f64)
.unwrap_or(1.0);
Some(((load / ncpu * 100.0).min(100.0) * 10.0).round() / 10.0)
}
@@ -606,7 +687,9 @@ mod tests {
.await
.unwrap();
let status = resp.status();
let b = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
let b = axum::body::to_bytes(resp.into_body(), 1 << 20)
.await
.unwrap();
(status, String::from_utf8_lossy(&b).into_owned())
}
@@ -614,7 +697,12 @@ mod tests {
async fn unauthenticated_is_rejected() {
let app = gateway_router(gw());
let resp = app
.oneshot(Request::builder().uri("/api/homecore/cogs").body(Body::empty()).unwrap())
.oneshot(
Request::builder()
.uri("/api/homecore/cogs")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
@@ -636,7 +724,12 @@ mod tests {
#[tokio::test]
async fn seed_tier_routes_are_typed_503() {
for p in ["/api/homecore/seeds", "/api/homecore/federation", "/api/homecore/witness", "/api/events"] {
for p in [
"/api/homecore/seeds",
"/api/homecore/federation",
"/api/homecore/witness",
"/api/homecore/events",
] {
let (status, body) = send(gateway_router(gw()), "GET", p).await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{p} should be 503");
assert!(body.contains("upstream_unavailable"), "{p} typed body");
@@ -651,6 +744,46 @@ mod tests {
assert!(body.contains("\"ram_pct\""));
}
#[tokio::test]
async fn intent_endpoint_executes_a_real_service() {
let state = gw();
let hc = state.shared.homecore().clone();
let id = homecore::EntityId::parse("light.kitchen").unwrap();
hc.states().set(
id.clone(),
"off",
serde_json::json!({}),
homecore::Context::new(),
);
crate::register_builtin_services(&hc).await;
let assist = crate::build_assist_pipeline().await.unwrap();
let state = GatewayState::with_assist(
state.shared,
GatewayConfig {
calibration_url: None,
calibration_token: None,
apps_dir: PathBuf::from("/nonexistent-apps-dir"),
timeout: Duration::from_millis(200),
},
assist,
);
let response = gateway_router(state)
.oneshot(
Request::builder()
.method("POST")
.uri("/api/intent/handle")
.header("authorization", "Bearer dev")
.header("content-type", "application/json")
.body(Body::from(r#"{"utterance":"turn on light.kitchen"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(hc.states().get(&id).unwrap().state, "on");
}
#[test]
fn adapt_room_state_maps_fields_and_preserves_null() {
// breathing/heartbeat rename; None → null; anomaly gets a threshold.
@@ -668,7 +801,10 @@ mod tests {
assert_eq!(ui["stale"], true);
assert_eq!(ui["presence"]["value"], "occupied");
assert_eq!(ui["breathing_bpm"]["value"], 12.0);
assert!(ui["heart_bpm"].is_null(), "None heartbeat must map to null (not trained)");
assert!(
ui["heart_bpm"].is_null(),
"None heartbeat must map to null (not trained)"
);
// §6 invariant 3: upstream RoomState carries no threshold here, so the
// adapter must emit null (withheld) — NOT a fabricated constant.
assert!(
@@ -685,7 +821,10 @@ mod tests {
"anomaly": {"kind":"Anomaly","value":0.2,"confidence":0.5,"threshold":0.73},
});
let ui = adapt_room_state("bedroom_1", &cal);
assert_eq!(ui["anomaly"]["threshold"], 0.73, "real threshold must pass through");
assert_eq!(
ui["anomaly"]["threshold"], 0.73,
"real threshold must pass through"
);
}
#[test]
@@ -731,8 +870,15 @@ mod tests {
("POST", "/api/cal/v1/../../x"),
] {
let (status, body) = send(gateway_router(gw()), method, path).await;
assert_eq!(status, StatusCode::BAD_REQUEST, "{method} {path} must be 400");
assert!(body.contains("bad_request"), "{method} {path} typed 400 body");
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"{method} {path} must be 400"
);
assert!(
body.contains("bad_request"),
"{method} {path} typed 400 body"
);
assert!(
!body.contains("upstream_unavailable"),
"{method} {path} must NOT reach the upstream-config branch"
@@ -746,13 +892,19 @@ mod tests {
// "not configured" 503 (proving it was NOT blocked as traversal).
let (status, body) = send(gateway_router(gw()), "GET", "/api/cal/v1/room/state").await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
assert!(body.contains("upstream_unavailable"), "legit path should reach upstream branch");
assert!(
body.contains("upstream_unavailable"),
"legit path should reach upstream branch"
);
}
#[test]
fn bank_names_accepts_strings_and_objects() {
assert_eq!(bank_names(&json!(["a", "b"])), vec!["a", "b"]);
assert_eq!(bank_names(&json!([{"name":"x"}, {"id":"y"}])), vec!["x", "y"]);
assert_eq!(
bank_names(&json!([{"name":"x"}, {"id":"y"}])),
vec!["x", "y"]
);
assert_eq!(bank_names(&json!({"baselines":["z"]})), vec!["z"]);
}
}
+178
View File
@@ -0,0 +1,178 @@
//! Optional network HomeKit Accessory Protocol lifecycle.
use std::net::{IpAddr, SocketAddr};
use std::path::PathBuf;
use anyhow::Result;
use homecore::HomeCore;
#[derive(Debug, Clone)]
#[cfg_attr(not(feature = "hap-server"), allow(dead_code))]
pub(crate) struct HapRuntimeConfig {
pub bind_addr: Option<SocketAddr>,
pub device_id: Option<String>,
pub setup_code: Option<String>,
pub advertise_addr: Option<IpAddr>,
pub hostname: String,
pub instance_name: String,
pub pairing_store: PathBuf,
}
pub(crate) struct HapRuntime {
#[cfg(feature = "hap-server")]
handle: Option<homecore_hap::HapServerHandle>,
#[cfg(feature = "hap-server")]
state_task: Option<tokio::task::JoinHandle<()>>,
}
impl HapRuntime {
pub(crate) async fn shutdown(self) -> Result<()> {
#[cfg(feature = "hap-server")]
{
let mut runtime = self;
if let Some(task) = runtime.state_task.take() {
task.abort();
let _ = task.await;
}
if let Some(handle) = runtime.handle.take() {
handle.shutdown().await?;
}
}
Ok(())
}
}
pub(crate) async fn start(hc: &HomeCore, config: HapRuntimeConfig) -> Result<HapRuntime> {
let Some(bind_addr) = config.bind_addr else {
tracing::info!("HAP network server disabled");
return Ok(HapRuntime {
#[cfg(feature = "hap-server")]
handle: None,
#[cfg(feature = "hap-server")]
state_task: None,
});
};
#[cfg(not(feature = "hap-server"))]
{
let _ = (hc, bind_addr);
anyhow::bail!(
"HAP was requested but this binary was built without the `hap-server` feature"
);
}
#[cfg(feature = "hap-server")]
{
use std::sync::Arc;
use homecore_hap::{
start_server, HapBridge, HapServerConfig, HapServiceRecord, MdnsSdAdvertiser,
PairingStore,
};
let device_id = config
.device_id
.as_deref()
.ok_or_else(|| anyhow::anyhow!("--hap-device-id is required when HAP is enabled"))?;
let advertise_addr = config.advertise_addr.ok_or_else(|| {
anyhow::anyhow!("--hap-advertise-addr is required when HAP is enabled")
})?;
if let Some(parent) = config.pairing_store.parent() {
std::fs::create_dir_all(parent)?;
}
let advertiser = Arc::new(MdnsSdAdvertiser::new(
config.hostname.clone(),
advertise_addr,
)?);
let pairings = if config.pairing_store.exists() {
if config.setup_code.is_some() {
tracing::warn!(
"ignoring --hap-setup-code because the pairing store already exists"
);
}
PairingStore::open(&config.pairing_store)?
} else {
let setup_code = config.setup_code.as_deref().ok_or_else(|| {
anyhow::anyhow!("--hap-setup-code is required when creating a HAP pairing store")
})?;
PairingStore::create(
&config.pairing_store,
homecore_hap::SetupCode::parse(setup_code)?,
Some(device_id.to_owned()),
)?
};
let pairings = Arc::new(pairings);
let record =
HapServiceRecord::bridge(config.instance_name.clone(), bind_addr.port(), device_id);
let bridge = HapBridge::new(record);
let mut state_rx = hc.states().subscribe();
synchronize(&bridge, hc);
let sync_bridge = bridge.clone();
let sync_hc = hc.clone();
let state_task = tokio::spawn(async move {
loop {
match state_rx.recv().await {
Ok(change) => match change.new_state {
Some(state) => {
if sync_bridge
.update_accessory(&change.entity_id, &state)
.is_err()
{
let _ = sync_bridge.add_accessory(&change.entity_id, &state);
}
}
None => {
let _ = sync_bridge.remove_accessory(&change.entity_id);
}
},
Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
tracing::warn!(
skipped,
"HAP state listener lagged; rebuilding accessory snapshot"
);
synchronize(&sync_bridge, &sync_hc);
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
});
let handle = start_server(
HapServerConfig {
bind_addr,
..HapServerConfig::default()
},
bridge,
pairings,
advertiser,
)
.await?;
tracing::info!(
address = %handle.local_addr(),
pairing_store = %config.pairing_store.display(),
"HAP network server and mDNS advertisement started"
);
Ok(HapRuntime {
handle: Some(handle),
state_task: Some(state_task),
})
}
}
#[cfg(feature = "hap-server")]
fn synchronize(bridge: &homecore_hap::HapBridge, hc: &HomeCore) {
for accessory in bridge.running_accessories() {
let _ = bridge.remove_accessory(&accessory.entity_id);
}
for state in hc.states().all() {
if let Err(error) = bridge.add_accessory(&state.entity_id, &state) {
tracing::debug!(
entity = %state.entity_id,
%error,
"entity is not exposed through HAP"
);
}
}
}
+559 -159
View File
@@ -5,10 +5,8 @@
//! - HomeCore runtime (state machine + event bus + service registry)
//! - SQLite recorder writing every state_changed event
//! - REST + WebSocket API on :8123 (HA wire-compat)
//! - Plugin runtime (InProcessRuntime by default; Wasmtime with --features wasmtime)
//! - Automation engine subscribed to the state machine
//! - Assist pipeline (intent recognizer + handler set)
//! - HAP bridge surface (accessories registered via the API)
//!
//! Run with:
//!
@@ -19,27 +17,29 @@
//! cargo run -p homecore-server --features ruvector,wasmtime -- ...
use std::net::SocketAddr;
use std::sync::Arc;
use anyhow::Result;
use clap::Parser;
use tracing::{info, warn};
use homecore::{Context, EntityId, HomeCore, ServiceCall, ServiceError, ServiceName};
use homecore::service::FnHandler;
use homecore::{Context, EntityId, HomeCore, ServiceCall, ServiceError, ServiceName};
use homecore_api::{build_cors_layer, router, LongLivedTokenStore, SharedState};
use homecore_assist::pipeline::default_pipeline;
use homecore_assist::RegexIntentRecognizer;
use homecore_assist::{
AssistPipeline, HassCancelAll, HassLightSet, HassNevermind, HassTurnOff, HassTurnOn,
RegexIntentRecognizer,
};
use homecore_automation::AutomationEngine;
use homecore_hap::{bridge::HapBridge, mdns::HapServiceRecord};
use homecore_plugins::{InProcessRuntime, PluginRegistry};
use homecore_recorder::Recorder;
use homecore_recorder::{Recorder, RecorderListener};
use axum::Router;
use tower_http::services::ServeDir;
use tower_http::trace::TraceLayer;
mod gateway;
mod hap;
mod plugins;
mod restore;
use gateway::{GatewayConfig, GatewayState};
/// Compile-time default location of the HOMECORE-UI assets (ADR-131).
@@ -71,7 +71,11 @@ struct Cli {
calibration_token: Option<String>,
/// COG install directory the gateway's supervisor reads (ADR-131 §11.6).
#[arg(long, env = "HOMECORE_APPS_DIR", default_value = "/var/lib/cognitum/apps")]
#[arg(
long,
env = "HOMECORE_APPS_DIR",
default_value = "/var/lib/cognitum/apps"
)]
apps_dir: String,
/// Per-upstream proxy timeout in milliseconds (ADR-131 §11.1).
@@ -79,9 +83,21 @@ struct Cli {
gateway_timeout_ms: u64,
/// SQLite recorder DB path. Use `:memory:` for an ephemeral run.
#[arg(long, env = "HOMECORE_DB", default_value = "sqlite::memory:")]
#[arg(long, env = "HOMECORE_DB", default_value = "sqlite://homecore.db")]
db: String,
/// HOMECORE registry storage directory restored at startup.
#[arg(
long,
env = "HOMECORE_STORAGE_DIR",
default_value = ".homecore/storage"
)]
storage_dir: std::path::PathBuf,
/// Maximum registry rows and latest entity states restored at startup.
#[arg(long, env = "HOMECORE_RESTORE_LIMIT", default_value_t = 100_000)]
restore_limit: usize,
/// Friendly location name surfaced via `/api/config`.
#[arg(long, env = "HOMECORE_LOCATION", default_value = "Home")]
location_name: String,
@@ -90,71 +106,177 @@ struct Cli {
#[arg(long)]
no_recorder: bool,
/// Skip the boot-time entity seeding (10 demo entities including
/// 4 RuView-derived sensors). Use this when wiring real
/// integrations that will populate the state machine themselves.
/// Explicitly allow any non-empty bearer token. Development only.
#[arg(long, env = "HOMECORE_INSECURE_DEV_AUTH", default_value_t = false)]
insecure_dev_auth: bool,
/// Seed synthetic demo entities. Disabled by default so simulated
/// biometric readings are never mistaken for live sensor data.
#[arg(long)]
no_seed_entities: bool,
seed_demo_entities: bool,
/// Optional Home Assistant-style automations YAML file to load at boot.
#[arg(long, env = "HOMECORE_AUTOMATIONS")]
automations: Option<std::path::PathBuf>,
/// Explicit directories containing packaged WebAssembly plugins.
#[arg(
long = "plugin-dir",
env = "HOMECORE_PLUGIN_DIRS",
value_delimiter = ','
)]
plugin_dirs: Vec<std::path::PathBuf>,
/// Base64 Ed25519 publisher keys trusted to sign WebAssembly packages.
#[arg(
long = "plugin-trusted-publisher",
env = "HOMECORE_PLUGIN_TRUSTED_PUBLISHERS",
value_delimiter = ','
)]
plugin_trusted_publishers: Vec<String>,
/// Permit unsigned WebAssembly plugins. Unsafe; development only.
#[arg(long, env = "HOMECORE_PLUGIN_ALLOW_UNSIGNED", default_value_t = false)]
plugin_allow_unsigned: bool,
/// Bind address for the optional HomeKit Accessory Protocol server.
/// The server remains disabled unless this option is supplied.
#[arg(long, env = "HOMECORE_HAP_BIND")]
hap_bind: Option<SocketAddr>,
/// Stable six-octet HAP accessory identifier (for example
/// `AA:BB:CC:DD:EE:FF`). Required when HAP is enabled.
#[arg(long, env = "HOMECORE_HAP_DEVICE_ID")]
hap_device_id: Option<String>,
/// HAP setup code in `XXX-XX-XXX` form. Required only when creating a
/// pairing store for the first time and never persisted in plaintext.
#[arg(long, env = "HOMECORE_HAP_SETUP_CODE", hide_env_values = true)]
hap_setup_code: Option<String>,
/// LAN address published in the HAP mDNS record. Required when HAP is enabled.
#[arg(long, env = "HOMECORE_HAP_ADVERTISE_ADDR")]
hap_advertise_addr: Option<std::net::IpAddr>,
/// DNS hostname published by mDNS for the HAP bridge.
#[arg(long, env = "HOMECORE_HAP_HOSTNAME", default_value = "homecore")]
hap_hostname: String,
/// HAP discovery instance shown to controller applications.
#[arg(
long,
env = "HOMECORE_HAP_INSTANCE_NAME",
default_value = "HOMECORE Bridge"
)]
hap_instance_name: String,
/// Durable controller pairing database.
#[arg(
long,
env = "HOMECORE_HAP_PAIRING_STORE",
default_value = ".homecore/hap/pairings.json"
)]
hap_pairing_store: std::path::PathBuf,
}
#[tokio::main]
async fn main() -> Result<()> {
init_tracing();
let cli = Cli::parse();
let mut cli = Cli::parse();
let has_tokens = std::env::var("HOMECORE_TOKENS")
.map(|value| !value.trim().is_empty())
.unwrap_or(false);
if !has_tokens && !cli.insecure_dev_auth {
anyhow::bail!(
"HOMECORE_TOKENS is required; use --insecure-dev-auth only for isolated development"
);
}
let tokens = if has_tokens {
let store = LongLivedTokenStore::from_env();
info!(
"Provisioned {} bearer token(s) from HOMECORE_TOKENS",
store.len().await
);
store
} else {
warn!(
"Insecure development authentication enabled: any non-empty bearer token is accepted"
);
LongLivedTokenStore::allow_any_non_empty()
};
info!("HOMECORE booting — bind={}, db={}, location={:?}",
cli.bind, cli.db, cli.location_name);
info!(
"HOMECORE booting — bind={}, db={}, location={:?}",
cli.bind, cli.db, cli.location_name
);
// ── 1. HomeCore runtime ─────────────────────────────────────────
let hc = HomeCore::new();
info!("HomeCore state machine + event bus + service registry online");
let recorder = if cli.no_recorder {
None
} else {
match open_recorder(&cli.db).await {
Ok(recorder) => Some(recorder),
Err(error) => {
warn!("Recorder failed to open ({error}) — continuing without persistence");
None
}
}
};
let restored =
restore::restore_startup(&hc, recorder.as_ref(), &cli.storage_dir, cli.restore_limit).await;
info!(
entities = restored.entity_entries,
devices = restored.device_entries,
states = restored.states,
truncated = restored.truncated,
"Startup restoration complete"
);
for warning in restored.warnings {
warn!("{warning}");
}
// Seed a representative set of built-in services so the web UI
// and HA-wire-compat clients see a populated /api/services on
// first boot. These are no-op handlers (they just echo back the
// call as JSON for observability) — integrations override them
// by registering the same ServiceName later.
seed_default_services(&hc).await;
register_builtin_services(&hc).await;
// Seed 10 representative entities so the web UI's Dashboard +
// States pages have content out of the box. Operators registering
// real integrations / plugins overwrite these by writing the same
// entity_id with new values. Opt out with `--no-seed-entities`.
if !cli.no_seed_entities {
if cli.seed_demo_entities {
seed_default_entities(&hc);
} else {
info!("Entity seeding disabled by --no-seed-entities");
info!("Synthetic demo entities disabled (use --seed-demo-entities to opt in)");
}
// ── 2. Recorder (optional) ──────────────────────────────────────
if !cli.no_recorder {
match Recorder::open(&cli.db).await {
Ok(recorder) => {
let recorder = Arc::new(recorder);
let rec_clone = Arc::clone(&recorder);
let mut state_rx = hc.states().subscribe();
tokio::spawn(async move {
while let Ok(event) = state_rx.recv().await {
if let Err(e) = rec_clone.record_state(&event).await {
warn!("recorder write failed: {e}");
}
}
});
info!("Recorder open at {} — state_changed events being persisted", cli.db);
}
Err(e) => {
warn!("Recorder failed to open ({e}) — continuing without persistence");
}
}
if let Some(recorder) = recorder.clone() {
let _recorder_task = RecorderListener::new(hc.states(), recorder).spawn();
info!(
"Recorder open at {} — state_changed events being persisted",
cli.db
);
} else {
info!("Recorder disabled by --no-recorder");
info!("Recorder unavailable or disabled");
}
// ── 3. Plugin runtime ───────────────────────────────────────────
let plugin_runtime = InProcessRuntime;
let plugin_registry: PluginRegistry<InProcessRuntime> = PluginRegistry::new(plugin_runtime);
info!("Plugin registry ready (runtime: InProcess; Wasmtime available with --features wasmtime)");
let _ = plugin_registry; // wired-but-empty at boot; integrations register here
let server_plugins = plugins::ServerPlugins::start(
hc.clone(),
plugins::PluginConfig {
directories: cli.plugin_dirs.clone(),
trusted_publishers: cli.plugin_trusted_publishers.clone(),
allow_unsigned: cli.plugin_allow_unsigned,
limits: homecore_plugins::DiscoveryLimits::default(),
},
)
.await?;
// ── 4. Automation engine ────────────────────────────────────────
// Construct AND start the engine (HC-WS-03, ADR-161). `start()`
@@ -166,6 +288,14 @@ async fn main() -> Result<()> {
// boot yet (YAML loader is P-next); integrations register via
// `engine.register(..)`.
let automation_engine = AutomationEngine::new(hc.clone());
if let Some(path) = &cli.automations {
let raw = tokio::fs::read_to_string(path).await?;
let automations: Vec<homecore_automation::Automation> = serde_yaml::from_str(&raw)
.map_err(|e| anyhow::anyhow!("invalid automations file {}: {e}", path.display()))?;
for automation in automations {
automation_engine.register(automation);
}
}
let _automation_task = automation_engine.start();
info!(
"Automation engine started ({} automations registered) — \
@@ -174,46 +304,41 @@ async fn main() -> Result<()> {
);
// ── 5. Assist pipeline ──────────────────────────────────────────
let recognizer = RegexIntentRecognizer::new();
let pipeline = default_pipeline(recognizer);
info!("Assist pipeline ready (5 built-in intent handlers via default_pipeline)");
let _ = pipeline; // wired-but-idle at boot; voice WS plugs in here
// ── 6. HAP bridge surface ───────────────────────────────────────
let hap_record = HapServiceRecord {
instance_name: "HOMECORE".to_string(),
port: 51826,
setup_code: "123-45-678".to_string(),
device_id: "AA:BB:CC:DD:EE:FF".to_string(),
};
let hap_bridge = HapBridge::new(hap_record);
info!("HAP bridge surface ready ({} accessories registered)", hap_bridge.running_accessories().len());
let _ = hap_bridge;
// ── 7. REST + WS API ────────────────────────────────────────────
// Token provisioning closes audit findings HC-01/HC-02. If
// HOMECORE_TOKENS is set in the env, populate the store from
// its comma-separated list. Otherwise fall back to DEV mode
// (warn-on-each-request) so existing smoke tests still work.
let tokens = if std::env::var("HOMECORE_TOKENS").map(|v| !v.trim().is_empty()).unwrap_or(false) {
let s = LongLivedTokenStore::from_env();
let n = s.len().await;
info!("LongLivedTokenStore provisioned with {} bearer token(s) from HOMECORE_TOKENS", n);
s
} else {
warn!("HOMECORE_TOKENS not set — token store in DEV mode (any non-empty bearer accepted). Provision real tokens before exposing to the network.");
LongLivedTokenStore::allow_any_non_empty()
};
let api_state = SharedState::with_tokens(
hc.clone(),
cli.location_name,
env!("CARGO_PKG_VERSION"),
tokens,
);
)
.with_recorder(recorder);
// BFF gateway (ADR-131 §11): single-origin aggregation of the
// calibration API + SEED/appliance tiers. Shares the same token store
// for auth; upstream credentials stay server-side.
let gw = GatewayState::new(
let assist = build_assist_pipeline().await?;
info!(
"Assist intent endpoint ready with {} handlers",
assist.handler_count()
);
let hap_runtime = hap::start(
&hc,
hap::HapRuntimeConfig {
bind_addr: cli.hap_bind,
device_id: cli.hap_device_id.clone(),
setup_code: cli.hap_setup_code.take(),
advertise_addr: cli.hap_advertise_addr,
hostname: cli.hap_hostname.clone(),
instance_name: cli.hap_instance_name.clone(),
pairing_store: cli.hap_pairing_store.clone(),
},
)
.await?;
let gw = GatewayState::with_assist(
api_state.clone(),
GatewayConfig {
calibration_url: cli.calibration_url.clone(),
@@ -221,6 +346,7 @@ async fn main() -> Result<()> {
apps_dir: std::path::PathBuf::from(&cli.apps_dir),
timeout: std::time::Duration::from_millis(cli.gateway_timeout_ms),
},
assist,
);
// Merge the HA-compat API + UI mount with the BFF gateway, THEN apply the
// audited CORS allowlist + request tracing to the WHOLE surface. The
@@ -233,19 +359,37 @@ async fn main() -> Result<()> {
.layer(build_cors_layer())
.layer(TraceLayer::new_for_http());
let listener = tokio::net::TcpListener::bind(cli.bind).await?;
info!("HOMECORE-API listening on http://{} (HA-compat /api + /api/websocket)", cli.bind);
info!(
"HOMECORE-API listening on http://{} (HA-compat /api + /api/websocket)",
cli.bind
);
info!(
"HOMECORE BFF gateway active: /api/homecore/* + /api/cal/* (calibration_url={:?})",
cli.calibration_url
);
if !cli.ui_dir.trim().is_empty() {
info!("HOMECORE-UI (ADR-131) served at http://{}/homecore/ from {}", cli.bind, cli.ui_dir);
info!(
"HOMECORE-UI (ADR-131) served at http://{}/homecore/ from {}",
cli.bind, cli.ui_dir
);
} else {
info!("HOMECORE-UI mount disabled (--ui-dir empty)");
}
// Run forever (until SIGINT). axum::serve handles graceful shutdown.
axum::serve(listener, app).await?;
let shutdown_hc = hc.clone();
axum::serve(listener, app)
.with_graceful_shutdown(async move {
if let Err(error) = tokio::signal::ctrl_c().await {
warn!("failed to install Ctrl-C handler: {error}");
}
shutdown_hc
.bus()
.fire_system(homecore::SystemEvent::HomeCoreStop);
info!("Shutdown requested; draining active HTTP connections");
})
.await?;
hap_runtime.shutdown().await?;
server_plugins.shutdown().await;
Ok(())
}
@@ -263,11 +407,68 @@ fn build_app(api_state: SharedState, ui_dir: &str) -> Router {
app.nest_service("/homecore", ServeDir::new(ui_dir))
}
#[cfg(not(feature = "ruvector"))]
async fn open_recorder(path: &str) -> anyhow::Result<Recorder> {
Ok(Recorder::open(path).await?)
}
async fn build_assist_pipeline() -> anyhow::Result<AssistPipeline<RegexIntentRecognizer>> {
let recognizer = RegexIntentRecognizer::new();
recognizer
.register(
"HassTurnOn",
r"^turn on (?:the )?(?P<entity_id>[a-z0-9_]+\.[a-z0-9_]+)$",
"*",
)
.await?;
recognizer
.register(
"HassTurnOff",
r"^turn off (?:the )?(?P<entity_id>[a-z0-9_]+\.[a-z0-9_]+)$",
"*",
)
.await?;
recognizer
.register(
"HassLightSet",
r"^set (?P<entity_id>light\.[a-z0-9_]+) to (?P<brightness>[0-9]{1,3})$",
"*",
)
.await?;
recognizer
.register("HassNevermind", r"^(?:never ?mind|cancel that)$", "*")
.await?;
recognizer
.register("HassCancelAll", r"^cancel all automations$", "*")
.await?;
let mut pipeline = AssistPipeline::new(recognizer);
pipeline.register_handler(HassTurnOn);
pipeline.register_handler(HassTurnOff);
pipeline.register_handler(HassLightSet);
pipeline.register_handler(HassNevermind);
pipeline.register_handler(HassCancelAll);
Ok(pipeline)
}
#[cfg(feature = "ruvector")]
async fn open_recorder(path: &str) -> anyhow::Result<Recorder> {
use homecore_recorder::{RuvectorSemanticIndex, SemanticIndex};
use tokio::sync::RwLock;
let index = RuvectorSemanticIndex::new(100_000)
.map_err(|error| anyhow::anyhow!("failed to initialize ruvector index: {error}"))?;
let semantic: std::sync::Arc<RwLock<dyn SemanticIndex>> =
std::sync::Arc::new(RwLock::new(index));
Ok(Recorder::open_with_index(path, semantic).await?)
}
fn init_tracing() {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info,homecore=debug,homecore_server=debug,tower_http=info".into()),
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
"info,homecore=debug,homecore_server=debug,tower_http=info".into()
}),
)
.init();
}
@@ -281,42 +482,125 @@ fn init_tracing() {
/// light / switch / scene / automation domains) plus a `homecore.*`
/// domain so operators can see HOMECORE-native services distinguished
/// from the HA-compat ones.
async fn seed_default_services(hc: &HomeCore) {
let echo = || FnHandler(|call: ServiceCall| async move {
Ok(serde_json::json!({
"called": format!("{}.{}", call.name.domain, call.name.service),
"service_data": call.data,
"acknowledged": true,
}))
});
async fn register_builtin_services(hc: &HomeCore) {
hc.services()
.register(
ServiceName::new("homecore", "ping"),
FnHandler(|_call| async { Ok(serde_json::json!({ "pong": true })) }),
)
.await;
let svcs = [
// Conventional HA wire-compat services
("homeassistant", "restart"),
("homeassistant", "stop"),
("homeassistant", "reload_core_config"),
("light", "turn_on"),
("light", "turn_off"),
("light", "toggle"),
("switch", "turn_on"),
("switch", "turn_off"),
("switch", "toggle"),
("scene", "apply"),
("automation", "trigger"),
// HOMECORE-native services
("homecore", "ping"),
("homecore", "snapshot_state"),
];
let snapshot_states = hc.states().clone();
hc.services()
.register(
ServiceName::new("homecore", "snapshot_state"),
FnHandler(move |_call| {
let states = snapshot_states.clone();
async move { Ok(serde_json::to_value(states.all()).unwrap_or_default()) }
}),
)
.await;
for (domain, service) in svcs {
hc.services()
.register(ServiceName::new(domain, service), echo())
.await;
for domain in ["homeassistant", "light", "switch"] {
for action in ["turn_on", "turn_off", "toggle"] {
let states = hc.states().clone();
let required_domain = (domain != "homeassistant").then(|| domain.to_owned());
hc.services()
.register(
ServiceName::new(domain, action),
FnHandler(move |call: ServiceCall| {
let states = states.clone();
let required_domain = required_domain.clone();
async move {
let ids = service_entity_ids(&call.data)?;
let mut changed = Vec::with_capacity(ids.len());
for id in ids {
if let Some(domain) = required_domain.as_deref() {
if id.domain() != domain {
return Err(ServiceError::HandlerFailed(format!(
"{}.{} only accepts {domain} entities",
call.name.domain, call.name.service
)));
}
}
let current = states.get(&id).ok_or_else(|| {
ServiceError::HandlerFailed(format!(
"entity not found: {}",
id.as_str()
))
})?;
let next = match call.name.service.as_str() {
"turn_on" => "on",
"turn_off" => "off",
"toggle" if current.state == "on" => "off",
"toggle" => "on",
_ => unreachable!("only registered actions are dispatched"),
};
let mut attributes = current.attributes.clone();
if next == "on" {
if let (Some(object), Some(brightness)) =
(attributes.as_object_mut(), call.data.get("brightness"))
{
object.insert("brightness".into(), brightness.clone());
}
}
states.set(
id.clone(),
next,
attributes,
Context::child_of(&call.context),
);
changed.push(id.as_str().to_owned());
}
Ok(serde_json::json!({ "changed": changed }))
}
}),
)
.await;
}
}
let count = hc.services().registered_services().await.len();
let _ = ServiceError::NotRegistered { domain: String::new(), service: String::new() };
info!("Service registry seeded with {} default service(s)", count);
info!(
"Registered {} executable built-in services",
hc.services().registered_services().await.len()
);
}
fn service_entity_ids(data: &serde_json::Value) -> Result<Vec<EntityId>, ServiceError> {
let value = data
.get("entity_id")
.or_else(|| {
data.get("target")
.and_then(|target| target.get("entity_id"))
})
.ok_or_else(|| ServiceError::HandlerFailed("missing entity_id".into()))?;
let raw_ids: Vec<&str> = match value {
serde_json::Value::String(value) => vec![value],
serde_json::Value::Array(values) => values
.iter()
.map(|value| {
value.as_str().ok_or_else(|| {
ServiceError::HandlerFailed("entity_id array must contain strings".into())
})
})
.collect::<Result<_, _>>()?,
_ => {
return Err(ServiceError::HandlerFailed(
"entity_id must be a string or string array".into(),
));
}
};
if raw_ids.is_empty() {
return Err(ServiceError::HandlerFailed(
"entity_id array cannot be empty".into(),
));
}
raw_ids
.into_iter()
.map(|value| {
EntityId::parse(value).map_err(|error| ServiceError::HandlerFailed(error.to_string()))
})
.collect()
}
/// Register 10 representative entities so a fresh `--db :memory:`
@@ -325,45 +609,85 @@ async fn seed_default_services(hc: &HomeCore) {
/// they stay in sync.
fn seed_default_entities(hc: &HomeCore) {
let entities: Vec<(&str, &str, serde_json::Value)> = vec![
("sensor.living_room_presence", "false", serde_json::json!({
"friendly_name": "Living Room Presence", "device_class": "occupancy",
"source": "RuView ESP32-C6 BFLD"
})),
("sensor.living_room_motion_score", "0.0", serde_json::json!({
"friendly_name": "Living Room Motion Score", "unit_of_measurement": "score",
"icon": "mdi:motion-sensor"
})),
("sensor.bedroom_breathing_rate", "14.5", serde_json::json!({
"friendly_name": "Bedroom Breathing Rate", "unit_of_measurement": "BPM",
"device_class": "frequency", "source": "Seeed MR60BHA2 mmWave"
})),
("sensor.bedroom_heart_rate", "68.0", serde_json::json!({
"friendly_name": "Bedroom Heart Rate", "unit_of_measurement": "BPM",
"device_class": "frequency", "source": "Seeed MR60BHA2 mmWave"
})),
("light.kitchen_ceiling", "on", serde_json::json!({
"friendly_name": "Kitchen Ceiling", "brightness": 230,
"color_temp_kelvin": 4000, "supported_color_modes": ["color_temp"]
})),
("light.living_room_lamp", "off", serde_json::json!({
"friendly_name": "Living Room Lamp", "brightness": 0,
"supported_color_modes": ["brightness"]
})),
("switch.coffee_maker", "off", serde_json::json!({
"friendly_name": "Coffee Maker", "device_class": "outlet"
})),
("binary_sensor.front_door", "off", serde_json::json!({
"friendly_name": "Front Door", "device_class": "door"
})),
("climate.thermostat", "heat", serde_json::json!({
"friendly_name": "Thermostat", "current_temperature": 21.5,
"temperature": 22.0, "hvac_modes": ["off", "heat", "cool", "auto"],
"supported_features": 387
})),
("sensor.air_quality_index", "42", serde_json::json!({
"friendly_name": "Air Quality Index", "unit_of_measurement": "AQI",
"device_class": "aqi"
})),
(
"sensor.living_room_presence",
"false",
serde_json::json!({
"friendly_name": "Living Room Presence", "device_class": "occupancy",
"source": "RuView ESP32-C6 BFLD"
}),
),
(
"sensor.living_room_motion_score",
"0.0",
serde_json::json!({
"friendly_name": "Living Room Motion Score", "unit_of_measurement": "score",
"icon": "mdi:motion-sensor"
}),
),
(
"sensor.bedroom_breathing_rate",
"14.5",
serde_json::json!({
"friendly_name": "Bedroom Breathing Rate", "unit_of_measurement": "BPM",
"device_class": "frequency", "source": "Seeed MR60BHA2 mmWave"
}),
),
(
"sensor.bedroom_heart_rate",
"68.0",
serde_json::json!({
"friendly_name": "Bedroom Heart Rate", "unit_of_measurement": "BPM",
"device_class": "frequency", "source": "Seeed MR60BHA2 mmWave"
}),
),
(
"light.kitchen_ceiling",
"on",
serde_json::json!({
"friendly_name": "Kitchen Ceiling", "brightness": 230,
"color_temp_kelvin": 4000, "supported_color_modes": ["color_temp"]
}),
),
(
"light.living_room_lamp",
"off",
serde_json::json!({
"friendly_name": "Living Room Lamp", "brightness": 0,
"supported_color_modes": ["brightness"]
}),
),
(
"switch.coffee_maker",
"off",
serde_json::json!({
"friendly_name": "Coffee Maker", "device_class": "outlet"
}),
),
(
"binary_sensor.front_door",
"off",
serde_json::json!({
"friendly_name": "Front Door", "device_class": "door"
}),
),
(
"climate.thermostat",
"heat",
serde_json::json!({
"friendly_name": "Thermostat", "current_temperature": 21.5,
"temperature": 22.0, "hvac_modes": ["off", "heat", "cool", "auto"],
"supported_features": 387
}),
),
(
"sensor.air_quality_index",
"42",
serde_json::json!({
"friendly_name": "Air Quality Index", "unit_of_measurement": "AQI",
"device_class": "aqi"
}),
),
];
for (id, state, attrs) in entities {
@@ -381,8 +705,11 @@ fn seed_default_entities(hc: &HomeCore) {
context: Context::new(),
};
let total = hc.states().all().len();
info!("State machine seeded with {} default entit{}", total,
if total == 1 { "y" } else { "ies" });
info!(
"State machine seeded with {} default entit{}",
total,
if total == 1 { "y" } else { "ies" }
);
}
#[cfg(test)]
@@ -419,9 +746,19 @@ mod ui_tests {
async fn ui_index_is_served_at_homecore() {
let app = build_app(test_state(), DEFAULT_UI_DIR);
let (status, body) = get(app, "/homecore/").await;
assert_eq!(status, StatusCode::OK, "GET /homecore/ should serve index.html");
assert!(body.contains("HOMECORE"), "index.html should mention HOMECORE");
assert!(body.contains("./js/app.js"), "index.html should bootstrap app.js");
assert_eq!(
status,
StatusCode::OK,
"GET /homecore/ should serve index.html"
);
assert!(
body.contains("HOMECORE"),
"index.html should mention HOMECORE"
);
assert!(
body.contains("./js/app.js"),
"index.html should bootstrap app.js"
);
}
#[tokio::test]
@@ -437,8 +774,18 @@ mod ui_tests {
#[tokio::test]
async fn ui_panels_are_served() {
let app = build_app(test_state(), DEFAULT_UI_DIR);
for p in ["dashboard", "rooms", "calibration", "fleet", "seed-detail",
"entities", "cogs", "events", "audit", "settings"] {
for p in [
"dashboard",
"rooms",
"calibration",
"fleet",
"seed-detail",
"entities",
"cogs",
"events",
"audit",
"settings",
] {
let (status, _) = get(app.clone(), &format!("/homecore/js/panels/{p}.js")).await;
assert_eq!(status, StatusCode::OK, "panel {p}.js should be served");
}
@@ -459,9 +806,15 @@ mod ui_tests {
.await
.unwrap();
let status = resp.status();
let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20)
.await
.unwrap();
let body = String::from_utf8_lossy(&bytes);
assert_eq!(status, StatusCode::OK, "the HA-compat API must coexist with the UI mount");
assert_eq!(
status,
StatusCode::OK,
"the HA-compat API must coexist with the UI mount"
);
assert!(body.contains("API running"));
}
@@ -469,12 +822,16 @@ mod ui_tests {
async fn ui_mount_can_be_disabled() {
let app = build_app(test_state(), "");
let (status, _) = get(app, "/homecore/").await;
assert_eq!(status, StatusCode::NOT_FOUND, "empty --ui-dir disables the mount");
assert_eq!(
status,
StatusCode::NOT_FOUND,
"empty --ui-dir disables the mount"
);
}
/// Build the SAME merged + layered surface `main()` serves: API + UI mount
/// + BFF gateway, with the audited CORS allowlist + tracing applied to the
/// whole thing. Used to prove the gateway routes are CORS-covered.
/// whole thing. Used to prove the gateway routes are CORS-covered.
fn full_app(state: SharedState) -> Router {
use crate::gateway::{GatewayConfig, GatewayState};
let gw = GatewayState::new(
@@ -527,4 +884,47 @@ mod ui_tests {
"gateway route must echo the allowlisted dev origin"
);
}
#[tokio::test]
async fn builtin_light_service_changes_real_state() {
let hc = HomeCore::new();
let id = EntityId::parse("light.kitchen").unwrap();
hc.states().set(
id.clone(),
"off",
serde_json::json!({"friendly_name": "Kitchen"}),
Context::new(),
);
register_builtin_services(&hc).await;
let result = hc
.services()
.call(ServiceCall {
name: ServiceName::new("light", "turn_on"),
data: serde_json::json!({"entity_id": "light.kitchen", "brightness": 123}),
context: Context::new(),
})
.await
.unwrap();
assert_eq!(result["changed"][0], "light.kitchen");
let state = hc.states().get(&id).unwrap();
assert_eq!(state.state, "on");
assert_eq!(state.attributes["brightness"], 123);
}
#[tokio::test]
async fn unsupported_builtin_service_is_not_success_shaped() {
let hc = HomeCore::new();
register_builtin_services(&hc).await;
let result = hc
.services()
.call(ServiceCall {
name: ServiceName::new("homeassistant", "restart"),
data: serde_json::json!({}),
context: Context::new(),
})
.await;
assert!(matches!(result, Err(ServiceError::NotRegistered { .. })));
}
}
+234
View File
@@ -0,0 +1,234 @@
//! Server ownership for plugin discovery, setup, event dispatch, and teardown.
//!
//! Native Rust plugins are never discovered from disk. They must be compiled
//! into this binary and exposed through [`NativePluginRegistration`]. External
//! packages are WebAssembly only and require the `wasmtime` feature.
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::{Context as _, Result};
use homecore::HomeCore;
use homecore_plugins::{
DiscoveryLimits, HomeCorePlugin, InProcessRuntime, PluginError, PluginManifest, PluginRegistry,
StateChangedEventJson,
};
use tokio::task::JoinHandle;
use tracing::{error, info, warn};
#[cfg(feature = "wasmtime")]
use homecore_plugins::{
discover_plugins, ConfigEntryJson, PluginId, PluginPolicy, WasmPlugin, WasmtimeRuntime,
};
/// Factory entry for a trusted plugin linked into the server at compile time.
type NativePluginFactory = fn() -> Result<(PluginManifest, Arc<dyn HomeCorePlugin>), PluginError>;
pub struct NativePluginRegistration {
pub create: NativePluginFactory,
}
/// This is intentionally empty in the generic server. Appliance builds can
/// add first-party registrations here; arbitrary Rust cdylibs are unsupported.
const NATIVE_PLUGINS: &[NativePluginRegistration] = &[];
#[derive(Debug, Clone)]
pub struct PluginConfig {
pub directories: Vec<PathBuf>,
pub trusted_publishers: Vec<String>,
pub allow_unsigned: bool,
#[cfg_attr(not(feature = "wasmtime"), allow(dead_code))]
pub limits: DiscoveryLimits,
}
pub struct ServerPlugins {
native: Arc<PluginRegistry<InProcessRuntime>>,
dispatcher: JoinHandle<()>,
#[cfg(feature = "wasmtime")]
wasm: Vec<(PluginId, WasmPlugin)>,
}
impl ServerPlugins {
/// Start the complete plugin subsystem. Any configured package that fails
/// discovery, trust verification, compilation, or setup aborts startup.
pub async fn start(hc: HomeCore, config: PluginConfig) -> Result<Self> {
let native = Arc::new(PluginRegistry::new(InProcessRuntime));
for registration in NATIVE_PLUGINS {
let (manifest, plugin) =
(registration.create)().context("compiled-in native plugin factory failed")?;
native
.load(manifest, plugin, hc.clone())
.await
.context("compiled-in native plugin setup failed")?;
}
#[cfg(not(feature = "wasmtime"))]
if !config.directories.is_empty() {
anyhow::bail!(
"plugin directories were configured, but homecore-server was built without \
--features wasmtime"
);
}
#[cfg(not(feature = "wasmtime"))]
if config.allow_unsigned || !config.trusted_publishers.is_empty() {
anyhow::bail!(
"plugin trust options were configured, but homecore-server was built without \
--features wasmtime"
);
}
#[cfg(feature = "wasmtime")]
let wasm = load_wasm_plugins(&hc, &config).await?;
let mut receiver = hc.states().subscribe();
let dispatch_native = native.clone();
#[cfg(feature = "wasmtime")]
let dispatch_wasm = wasm.clone();
let dispatcher = tokio::spawn(async move {
loop {
let change = match receiver.recv().await {
Ok(change) => change,
Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
warn!(
skipped,
"plugin state dispatcher lagged; events were dropped"
);
continue;
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
};
let event = StateChangedEventJson::state_changed(
change.entity_id.as_str(),
change.new_state.as_ref().map(|state| state.state.as_str()),
change
.new_state
.as_ref()
.map(|state| state.attributes.clone())
.unwrap_or_else(|| serde_json::json!({})),
);
for (id, error) in dispatch_native.state_changed(&event).await {
error!(plugin = %id, %error, "native plugin state_changed failed");
}
#[cfg(feature = "wasmtime")]
for (id, plugin) in &dispatch_wasm {
if !plugin
.subscriptions()
.iter()
.any(|entity| entity == change.entity_id.as_str())
{
continue;
}
let plugin = plugin.clone();
let id = id.clone();
let event = StateChangedEventJson::state_changed(
&event.entity_id,
event.new_state.as_deref(),
event.attributes.clone(),
);
match tokio::task::spawn_blocking(move || plugin.call_state_changed(&event))
.await
{
Ok(Ok(0)) => {}
Ok(Ok(code)) => {
error!(plugin = %id, code, "WASM state_changed returned failure")
}
Ok(Err(error)) => {
error!(plugin = %id, %error, "WASM state_changed trapped")
}
Err(error) => error!(plugin = %id, %error, "WASM dispatch task failed"),
}
}
}
});
#[cfg(feature = "wasmtime")]
info!(
native = NATIVE_PLUGINS.len(),
wasm = wasm.len(),
"plugin subsystem started"
);
#[cfg(not(feature = "wasmtime"))]
info!(native = NATIVE_PLUGINS.len(), "plugin subsystem started");
Ok(Self {
native,
dispatcher,
#[cfg(feature = "wasmtime")]
wasm,
})
}
/// Stop dispatch first, then tear down WASM and native plugins in reverse
/// deterministic order. All teardown failures are reported.
pub async fn shutdown(self) {
self.dispatcher.abort();
let _ = self.dispatcher.await;
#[cfg(feature = "wasmtime")]
for (id, plugin) in self.wasm.into_iter().rev() {
match tokio::task::spawn_blocking(move || plugin.call_teardown()).await {
Ok(Ok(0)) => {}
Ok(Ok(code)) => error!(plugin = %id, code, "WASM teardown returned failure"),
Ok(Err(error)) => error!(plugin = %id, %error, "WASM teardown trapped"),
Err(error) => error!(plugin = %id, %error, "WASM teardown task failed"),
}
}
for (id, error) in self.native.shutdown().await {
error!(plugin = %id, %error, "native plugin teardown failed");
}
}
}
#[cfg(feature = "wasmtime")]
async fn load_wasm_plugins(
hc: &HomeCore,
config: &PluginConfig,
) -> Result<Vec<(PluginId, WasmPlugin)>> {
let policy = if config.allow_unsigned {
warn!("INSECURE plugin policy enabled: unsigned plugins may execute");
PluginPolicy::AllowUnsigned
} else {
let keys: Vec<_> = config
.trusted_publishers
.iter()
.map(String::as_str)
.collect();
PluginPolicy::trusted(&keys).context("invalid trusted plugin publisher key")?
};
let discovered =
discover_plugins(&config.directories, config.limits).context("plugin discovery failed")?;
let runtime = WasmtimeRuntime::new().context("Wasmtime initialization failed")?;
let mut loaded: Vec<(PluginId, WasmPlugin)> = Vec::with_capacity(discovered.len());
for package in discovered {
let id = PluginId::new(&package.manifest.domain);
let bytes = package
.read_module(config.limits)
.with_context(|| format!("failed reading plugin `{id}`"))?;
let plugin = runtime
.load_plugin(&package.manifest, &bytes, hc.clone(), &policy)
.with_context(|| format!("plugin `{id}` rejected before setup"))?;
let config_entry = serde_json::to_string(&ConfigEntryJson::bootstrap(id.as_str()))?;
let setup_plugin = plugin.clone();
let result = tokio::task::spawn_blocking(move || setup_plugin.call_setup(&config_entry))
.await
.with_context(|| format!("plugin `{id}` setup task failed"))?
.with_context(|| format!("plugin `{id}` setup trapped"))?;
if result != 0 {
let _ = plugin.call_teardown();
teardown_wasm(loaded).await;
anyhow::bail!("plugin `{id}` setup returned failure code {result}");
}
info!(
plugin = %id,
package = %package.package_dir.display(),
"signed WASM plugin loaded"
);
loaded.push((id, plugin));
}
Ok(loaded)
}
#[cfg(feature = "wasmtime")]
async fn teardown_wasm(plugins: Vec<(PluginId, WasmPlugin)>) {
for (_, plugin) in plugins.into_iter().rev() {
let _ = tokio::task::spawn_blocking(move || plugin.call_teardown()).await;
}
}
+219
View File
@@ -0,0 +1,219 @@
//! Bounded startup restoration for registries and latest recorder states.
use std::path::Path;
use homecore::{DeviceEntry, EntityEntry, HomeCore};
use homecore_migrate::storage::read_envelope;
use homecore_recorder::{Recorder, RestoreWarning};
use serde::de::DeserializeOwned;
pub const MAX_REGISTRY_ENTRIES: usize = 100_000;
pub const MAX_REGISTRY_FILE_BYTES: u64 = 64 * 1024 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RestorePhase {
EntityRegistry,
DeviceRegistry,
States,
}
#[derive(Debug, Default)]
pub struct StartupRestoreReport {
pub entity_entries: usize,
pub device_entries: usize,
pub states: usize,
pub warnings: Vec<String>,
pub truncated: bool,
pub phases: Vec<RestorePhase>,
}
pub async fn restore_startup(
homecore: &HomeCore,
recorder: Option<&Recorder>,
storage_dir: &Path,
limit: usize,
) -> StartupRestoreReport {
let limit = limit.min(MAX_REGISTRY_ENTRIES);
let mut report = StartupRestoreReport::default();
report.phases.push(RestorePhase::EntityRegistry);
let entities_path = storage_dir.join("core.entity_registry");
if entities_path.exists() {
match load_rows::<EntityEntry>(&entities_path, "entities", limit, |entry| {
entry.entity_id.as_str().to_owned()
}) {
Ok(batch) => {
report.truncated |= batch.truncated;
report.warnings.extend(batch.warnings);
for entry in batch.rows {
homecore.entities().register(entry).await;
report.entity_entries += 1;
}
}
Err(error) => report.warnings.push(error),
}
}
report.phases.push(RestorePhase::DeviceRegistry);
let devices_path = storage_dir.join("core.device_registry");
if devices_path.exists() {
match load_rows::<DeviceEntry>(&devices_path, "devices", limit, |entry| entry.id.clone()) {
Ok(batch) => {
report.truncated |= batch.truncated;
report.warnings.extend(batch.warnings);
for entry in batch.rows {
homecore.devices().register(entry).await;
report.device_entries += 1;
}
}
Err(error) => report.warnings.push(error),
}
}
report.phases.push(RestorePhase::States);
if let Some(recorder) = recorder {
match recorder.restore_latest(homecore.states(), limit).await {
Ok(state_report) => {
report.states = state_report.restored;
report.truncated |= state_report.truncated;
report
.warnings
.extend(state_report.warnings.into_iter().map(format_state_warning));
}
Err(error) => report
.warnings
.push(format!("state restore failed: {error}")),
}
}
report
}
struct RowBatch<T> {
rows: Vec<T>,
warnings: Vec<String>,
truncated: bool,
}
fn load_rows<T: DeserializeOwned>(
path: &Path,
field: &str,
limit: usize,
key: impl Fn(&T) -> String,
) -> Result<RowBatch<T>, String> {
let file_size = std::fs::metadata(path)
.map_err(|error| format!("{}: {error}", path.display()))?
.len();
if file_size > MAX_REGISTRY_FILE_BYTES {
return Err(format!(
"{}: registry file is {file_size} bytes, exceeding the {} byte startup bound",
path.display(),
MAX_REGISTRY_FILE_BYTES
));
}
let envelope = read_envelope(path).map_err(|error| error.to_string())?;
if envelope.version != 1 || envelope.minor_version > 13 {
return Err(format!(
"{}: unsupported registry version {}.{}",
path.display(),
envelope.version,
envelope.minor_version
));
}
let values = envelope
.data
.get(field)
.and_then(serde_json::Value::as_array)
.ok_or_else(|| format!("{}: missing data.{field} array", path.display()))?;
let scan_limit = limit.saturating_add(1).min(MAX_REGISTRY_ENTRIES + 1);
let mut rows = Vec::with_capacity(values.len().min(scan_limit));
let mut warnings = Vec::new();
for (index, value) in values.iter().take(scan_limit).enumerate() {
match serde_json::from_value::<T>(value.clone()) {
Ok(row) => rows.push(row),
Err(error) => warnings.push(format!(
"{}: isolated malformed data.{field}[{index}]: {error}",
path.display()
)),
}
}
rows.sort_by_key(&key);
let truncated = values.len() > limit || rows.len() > limit;
rows.truncate(limit);
Ok(RowBatch {
rows,
warnings,
truncated,
})
}
fn format_state_warning(warning: RestoreWarning) -> String {
format!("isolated malformed recorder row: {warning:?}")
}
#[cfg(test)]
mod tests {
use super::*;
use homecore::{Context, EntityId};
use homecore_recorder::Recorder;
#[tokio::test]
async fn restores_registries_before_states_and_isolates_bad_rows() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("core.entity_registry"),
r#"{"version":1,"minor_version":13,"key":"core.entity_registry","data":{"entities":[
{"entity_id":"sensor.good","unique_id":null,"platform":"test","name":null,"disabled_by":null,"area_id":null,"device_id":"dev1","entity_category":null,"config_entry_id":null},
{"entity_id":"INVALID","platform":"bad"}
]}}"#,
)
.unwrap();
std::fs::write(
dir.path().join("core.device_registry"),
r#"{"version":1,"minor_version":13,"key":"core.device_registry","data":{"devices":[
{"id":"dev1","config_entries":[],"identifiers":[],"connections":[],"manufacturer":null,"model":null,"model_id":null,"name":"Device","name_by_user":null,"sw_version":null,"hw_version":null,"serial_number":null,"via_device_id":null,"area_id":null,"entry_type":null,"disabled_by":null,"configuration_url":null,"labels":[],"primary_config_entry":null}
]}}"#,
)
.unwrap();
let recorder = Recorder::open("sqlite::memory:").await.unwrap();
let source = HomeCore::new();
source.states().set(
EntityId::parse("sensor.good").unwrap(),
"on",
serde_json::json!({"restorable": true}),
Context::new(),
);
let mut receiver = source.states().subscribe();
// Force one recorder row from a real state event.
source.states().set(
EntityId::parse("sensor.good").unwrap(),
"off",
serde_json::json!({"restorable": true}),
Context::new(),
);
recorder
.record_state(&receiver.recv().await.unwrap())
.await
.unwrap();
let target = HomeCore::new();
let report =
restore_startup(&target, Some(&recorder), dir.path(), MAX_REGISTRY_ENTRIES).await;
assert_eq!(
report.phases,
vec![
RestorePhase::EntityRegistry,
RestorePhase::DeviceRegistry,
RestorePhase::States
]
);
assert_eq!(report.entity_entries, 1);
assert_eq!(report.device_entries, 1);
assert_eq!(report.states, 1);
assert!(!report.warnings.is_empty());
let state = target
.states()
.get(&EntityId::parse("sensor.good").unwrap())
.unwrap();
assert!(state.context.is_restoration());
}
}
+3 -3
View File
@@ -31,7 +31,7 @@ export function demoMode() {
export const api = {
base: '',
token: () => { try { return localStorage.getItem('homecore_token') || 'dev-token'; } catch { return 'dev-token'; } },
token: () => { try { return localStorage.getItem('homecore_token') || ''; } catch { return ''; } },
isDemo: (key) => !!demoFlags[key],
anyDemo: () => demoMode() && Object.keys(demoFlags).length > 0,
demoMode,
@@ -76,7 +76,7 @@ export const api = {
async callService(domain, service, data) { return this._post(`/api/services/${domain}/${service}`, data); },
async setState(entityId, state, attributes) { return this._post(`/api/states/${entityId}`, { state, attributes: attributes || {} }); },
// ── gateway /api/homecore/* + /api/events (§11.2) ─────────────────
// ── gateway /api/homecore/* (§11.2) ───────────────────────────────
async appliance() { return this._data('appliance', '/api/homecore/appliance', (m) => m.applianceHealth()); },
async seeds() { return this._data('fleet', '/api/homecore/seeds', (m) => m.seeds()); },
async seed(id) { return this._data('fleet', '/api/homecore/seeds/' + encodeURIComponent(id), (m) => m.seed(id)); },
@@ -93,7 +93,7 @@ export const api = {
async witnessLog(page = 0, size = 12) { return this._data('audit', `/api/homecore/witness?page=${page}&size=${size}`, (m) => m.witnessLog(page, size)); },
async privacyModes() { return this._data('audit', '/api/homecore/privacy', (m) => m.privacyModes()); },
async setPrivacy(seed, modeValue) { if (demoMode()) return { seed, mode: modeValue }; return this._post('/api/homecore/privacy', { seed, mode: modeValue }); },
async eventHistory(n = 40) { return this._data('events', `/api/events?limit=${n}`, (m) => m.recentEvents(n)); },
async eventHistory(n = 40) { return this._data('events', `/api/homecore/events?limit=${n}`, (m) => m.recentEvents(n)); },
recentEvents(n) { return this.eventHistory(n); }, // back-compat alias (async)
async settings() { return this._data('settings', '/api/homecore/settings', (m) => m.settings()); },
async automations() { return this._data('automations', '/api/homecore/automations', () => []); },
@@ -1,6 +1,6 @@
// §4.8 Event Bus & Automation Feed — ADR-131 / ADR-129.
//
// Live event stream (seeded from /api/events, then prepended live from
// Live event stream (seeded from /api/homecore/events, then prepended live from
// the shared WS bus — never polled, §2/§4.4), a context-causality
// breadcrumb on row expand (Context.id → parent_id → grandparent_id),
// and a trigger→condition→action automation builder (ADR-129 scope:
@@ -50,7 +50,7 @@ export default {
root.appendChild(sectionHeader('Event Bus & Automation', 'Live entity events + causality + automation builder (ADR-131 §4.8, ADR-129)'));
if (api.isDemo('events')) {
root.appendChild(banner('DEMO — event history is contract-conformant mock data until the live /api/events feed lands (§7.1). New rows still arrive over the WS bus.', 'amber'));
root.appendChild(banner('DEMO — event history is contract-conformant mock data until the live /api/homecore/events feed lands (§7.1). New rows still arrive over the WS bus.', 'amber'));
}
// ── live lag indicator (top, fed by the shared WS bus) ──────────
+34 -3
View File
@@ -47,6 +47,9 @@ pub struct Context {
}
impl Context {
/// Marker stored on snapshots loaded from durable state at startup.
pub const RESTORE_USER_ID: &'static str = "homecore.restore";
pub fn new() -> Self {
Self::default()
}
@@ -66,6 +69,20 @@ impl Context {
parent_id: Some(parent.id),
}
}
/// Create a fresh context that identifies a startup restoration. The
/// persisted context, when valid, is retained as the causal parent.
pub fn restoration(parent_id: Option<Uuid>) -> Self {
Self {
id: Uuid::new_v4(),
user_id: Some(Self::RESTORE_USER_ID.to_owned()),
parent_id,
}
}
pub fn is_restoration(&self) -> bool {
self.user_id.as_deref() == Some(Self::RESTORE_USER_ID)
}
}
impl Default for Context {
@@ -84,9 +101,23 @@ impl Default for Context {
#[derive(Clone, Debug)]
pub enum SystemEvent {
StateChanged(StateChangedEvent),
ServiceRegistered { domain: String, service: String },
ServiceRemoved { domain: String, service: String },
ComponentLoaded { component: String },
ServiceCalled {
domain: String,
service: String,
data: serde_json::Value,
context: Context,
},
ServiceRegistered {
domain: String,
service: String,
},
ServiceRemoved {
domain: String,
service: String,
},
ComponentLoaded {
component: String,
},
HomeCoreStart,
HomeCoreStarted,
HomeCoreStop,
+74 -6
View File
@@ -6,7 +6,7 @@
use std::sync::Arc;
use crate::bus::EventBus;
use crate::registry::EntityRegistry;
use crate::registry::{DeviceRegistry, EntityRegistry};
use crate::service::ServiceRegistry;
use crate::state::StateMachine;
@@ -20,16 +20,19 @@ struct HomeCoreInner {
pub states: StateMachine,
pub services: ServiceRegistry,
pub entities: EntityRegistry,
pub devices: DeviceRegistry,
}
impl HomeCore {
pub fn new() -> Self {
let bus = EventBus::new();
Self {
inner: Arc::new(HomeCoreInner {
bus: EventBus::new(),
states: StateMachine::new(),
services: ServiceRegistry::new(),
states: StateMachine::with_event_bus(bus.clone()),
services: ServiceRegistry::with_event_bus(bus.clone()),
bus,
entities: EntityRegistry::new(),
devices: DeviceRegistry::new(),
}),
}
}
@@ -49,6 +52,10 @@ impl HomeCore {
pub fn entities(&self) -> &EntityRegistry {
&self.inner.entities
}
pub fn devices(&self) -> &DeviceRegistry {
&self.inner.devices
}
}
impl Default for HomeCore {
@@ -61,15 +68,76 @@ impl Default for HomeCore {
mod tests {
use super::*;
use crate::entity::EntityId;
use crate::event::Context;
use crate::event::{Context, SystemEvent};
use crate::service::{FnHandler, ServiceCall, ServiceName};
#[tokio::test]
async fn end_to_end_set_then_get() {
let hc = HomeCore::new();
let id = EntityId::parse("light.kitchen").unwrap();
hc.states().set(id.clone(), "on", serde_json::json!({"brightness": 200}), Context::new());
hc.states().set(
id.clone(),
"on",
serde_json::json!({"brightness": 200}),
Context::new(),
);
let snap = hc.states().get(&id).unwrap();
assert_eq!(snap.state, "on");
assert_eq!(snap.attributes["brightness"], 200);
}
#[tokio::test]
async fn state_changes_are_published_on_shared_system_bus() {
let hc = HomeCore::new();
let mut rx = hc.bus().subscribe_system();
let id = EntityId::parse("light.kitchen").unwrap();
hc.states()
.set(id.clone(), "on", serde_json::json!({}), Context::new());
let event = rx.recv().await.unwrap();
match event {
SystemEvent::StateChanged(change) => {
assert_eq!(change.entity_id, id);
assert_eq!(change.new_state.unwrap().state, "on");
}
other => panic!("expected StateChanged, got {other:?}"),
}
}
#[tokio::test]
async fn service_calls_are_published_on_shared_system_bus() {
let hc = HomeCore::new();
let service = ServiceName::new("light", "turn_on");
hc.services()
.register(
service.clone(),
FnHandler(|_| async { Ok(serde_json::json!({})) }),
)
.await;
let mut rx = hc.bus().subscribe_system();
hc.services()
.call(ServiceCall {
name: service,
data: serde_json::json!({"brightness": 42}),
context: Context::new(),
})
.await
.unwrap();
match rx.recv().await.unwrap() {
SystemEvent::ServiceCalled {
domain,
service,
data,
..
} => {
assert_eq!(domain, "light");
assert_eq!(service, "turn_on");
assert_eq!(data["brightness"], 42);
}
other => panic!("expected ServiceCalled, got {other:?}"),
}
}
}
+8 -9
View File
@@ -11,7 +11,7 @@
//! - [`state`] — `StateMachine`: DashMap-backed concurrent state store
//! - [`bus`] — `EventBus`: tokio broadcast wiring for system + domain events
//! - [`service`] — `ServiceRegistry` (stub; full mpsc dispatch lands in P2)
//! - [`registry`] — `EntityRegistry` (in-memory P1; persistence lands in P2)
//! - [`registry`] — in-memory entity and device registries, restored by the server
//! - [`homecore`] — `HomeCore` runtime coordinator: holds bus + states + services
//!
//! ## Threading model
@@ -23,31 +23,30 @@
//!
//! ## What's NOT here yet (deferred to P2+)
//!
//! - Persistence of entity registry to `.homecore/storage/core.entity_registry`
//! - Automatic persistence of registry mutations (startup restoration exists)
//! - Schema validation (`schemas` module from §3 stub)
//! - Service handler mpsc dispatch (`service::ServiceRegistry::call`)
//! - Device registry (mirror of HA's `core.device_registry`)
//! - Witness chain integration (ADR-028)
//!
//! Each is marked `// TODO P2:` at the relevant call site.
pub mod bus;
pub mod entity;
pub mod event;
pub mod state;
pub mod bus;
pub mod service;
pub mod registry;
pub mod service;
pub mod state;
mod homecore;
pub use homecore::HomeCore;
pub use bus::EventBus;
pub use entity::{EntityId, EntityIdError, State};
pub use event::{Context, DomainEvent, EventType, StateChangedEvent, SystemEvent};
pub use state::StateMachine;
pub use bus::EventBus;
pub use registry::{DeviceEntry, DeviceRegistry, EntityCategory, EntityEntry, EntityRegistry};
pub use service::{ServiceCall, ServiceError, ServiceName, ServiceRegistry};
pub use registry::{EntityCategory, EntityEntry, EntityRegistry};
pub use state::StateMachine;
/// HOMECORE protocol/data-model version. Bumped when the public surface
/// or on-disk persistence schema changes in a backwards-incompatible way.
+85 -4
View File
@@ -1,9 +1,9 @@
//! In-memory entity registry (P1). Persistence to
//! `.homecore/storage/core.entity_registry` lands in P2.
//! In-memory entity and device registries. Durable files are loaded by
//! `homecore-server` during bounded startup restoration.
//!
//! Schema fields mirror HA `core.entity_registry` v13 per ADR-127 §2.4.
use std::collections::HashMap;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::sync::Arc;
use serde::{Deserialize, Serialize};
@@ -43,6 +43,41 @@ pub struct EntityEntry {
pub config_entry_id: Option<String>,
}
/// Physical-device metadata persisted in `core.device_registry`.
///
/// The fields track the HA v13 registry surface used by HOMECORE. Identifier
/// and connection pairs are sets because their order is not semantically
/// meaningful in HA.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct DeviceEntry {
pub id: String,
#[serde(default)]
pub config_entries: HashSet<String>,
#[serde(default)]
pub identifiers: HashSet<(String, String)>,
#[serde(default)]
pub connections: HashSet<(String, String)>,
pub manufacturer: Option<String>,
pub model: Option<String>,
pub model_id: Option<String>,
pub name: Option<String>,
pub name_by_user: Option<String>,
pub sw_version: Option<String>,
pub hw_version: Option<String>,
pub serial_number: Option<String>,
pub via_device_id: Option<String>,
pub area_id: Option<String>,
pub entry_type: Option<String>,
pub disabled_by: Option<String>,
pub configuration_url: Option<String>,
#[serde(default)]
pub labels: HashSet<String>,
pub primary_config_entry: Option<String>,
/// Forward-compatible device fields from newer HA v13-compatible rows.
#[serde(default, flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
#[derive(Clone)]
pub struct EntityRegistry {
entries: Arc<RwLock<HashMap<EntityId, EntityEntry>>>,
@@ -56,7 +91,10 @@ impl EntityRegistry {
}
pub async fn register(&self, entry: EntityEntry) {
self.entries.write().await.insert(entry.entity_id.clone(), entry);
self.entries
.write()
.await
.insert(entry.entity_id.clone(), entry);
}
pub async fn get(&self, entity_id: &EntityId) -> Option<EntityEntry> {
@@ -74,6 +112,10 @@ impl EntityRegistry {
pub async fn len(&self) -> usize {
self.entries.read().await.len()
}
pub async fn is_empty(&self) -> bool {
self.entries.read().await.is_empty()
}
}
impl Default for EntityRegistry {
@@ -82,6 +124,45 @@ impl Default for EntityRegistry {
}
}
#[derive(Clone)]
pub struct DeviceRegistry {
entries: Arc<RwLock<HashMap<String, DeviceEntry>>>,
}
impl DeviceRegistry {
pub fn new() -> Self {
Self {
entries: Arc::new(RwLock::new(HashMap::new())),
}
}
pub async fn register(&self, entry: DeviceEntry) {
self.entries.write().await.insert(entry.id.clone(), entry);
}
pub async fn get(&self, id: &str) -> Option<DeviceEntry> {
self.entries.read().await.get(id).cloned()
}
pub async fn all(&self) -> Vec<DeviceEntry> {
self.entries.read().await.values().cloned().collect()
}
pub async fn len(&self) -> usize {
self.entries.read().await.len()
}
pub async fn is_empty(&self) -> bool {
self.entries.read().await.is_empty()
}
}
impl Default for DeviceRegistry {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
+21 -2
View File
@@ -1,4 +1,4 @@
//! Service registry stub.
//! Concurrent service registry with panic-isolated direct dispatch.
//!
//! Mirrors `homeassistant.core.ServiceRegistry`. P1 ships the public
//! surface + a simple direct-dispatch `call` so downstream ADRs can
@@ -15,7 +15,8 @@ use serde::{Deserialize, Serialize};
use thiserror::Error;
use tokio::sync::RwLock;
use crate::event::Context;
use crate::bus::EventBus;
use crate::event::{Context, SystemEvent};
/// Service name within a domain. e.g. `light.turn_on` → domain
/// `"light"`, service `"turn_on"`.
@@ -78,12 +79,22 @@ where
#[derive(Clone)]
pub struct ServiceRegistry {
handlers: Arc<RwLock<HashMap<ServiceName, Arc<dyn ServiceHandler>>>>,
bus: Option<EventBus>,
}
impl ServiceRegistry {
pub fn new() -> Self {
Self::new_inner(None)
}
pub fn with_event_bus(bus: EventBus) -> Self {
Self::new_inner(Some(bus))
}
fn new_inner(bus: Option<EventBus>) -> Self {
Self {
handlers: Arc::new(RwLock::new(HashMap::new())),
bus,
}
}
@@ -111,6 +122,14 @@ impl ServiceRegistry {
/// that drives the engine. Mirrors HA isolating service-handler
/// exceptions.
pub async fn call(&self, call: ServiceCall) -> Result<serde_json::Value, ServiceError> {
if let Some(bus) = &self.bus {
bus.fire_system(SystemEvent::ServiceCalled {
domain: call.name.domain.clone(),
service: call.name.service.clone(),
data: call.data.clone(),
context: call.context.clone(),
});
}
let handler = {
let guard = self.handlers.read().await;
guard.get(&call.name).cloned()
+66 -12
View File
@@ -14,7 +14,6 @@
//!
//! - `async_set_internal` schema validation
//! - Bulk delete of an entire domain (`async_remove_domain`)
//! - Restore-state on startup from the recorder (ADR-132)
use std::sync::Arc;
@@ -22,8 +21,9 @@ use chrono::Utc;
use dashmap::DashMap;
use tokio::sync::broadcast;
use crate::bus::EventBus;
use crate::entity::{EntityId, State};
use crate::event::{Context, StateChangedEvent};
use crate::event::{Context, StateChangedEvent, SystemEvent};
/// Broadcast channel capacity for state-changed events. 4,096 events
/// at 20 Hz per entity covers ~3 minutes of backlog for a single hot
@@ -40,15 +40,28 @@ pub struct StateMachine {
struct StateMachineInner {
states: DashMap<EntityId, Arc<State>>,
tx: broadcast::Sender<StateChangedEvent>,
bus: Option<EventBus>,
}
impl StateMachine {
pub fn new() -> Self {
Self::new_inner(None)
}
/// Create a state machine that also publishes every committed change on
/// the shared HOMECORE system event bus. Standalone state machines retain
/// their lightweight private broadcast channel via [`StateMachine::new`].
pub fn with_event_bus(bus: EventBus) -> Self {
Self::new_inner(Some(bus))
}
fn new_inner(bus: Option<EventBus>) -> Self {
let (tx, _) = broadcast::channel(STATE_CHANGED_CHANNEL_CAPACITY);
Self {
inner: Arc::new(StateMachineInner {
states: DashMap::with_capacity(256),
tx,
bus,
}),
}
}
@@ -135,12 +148,28 @@ impl StateMachine {
fired_at: Utc::now(),
};
// err = no receivers; that's fine, write still committed.
let _ = self.inner.tx.send(event);
let _ = self.inner.tx.send(event.clone());
if let Some(bus) = &self.inner.bus {
bus.fire_system(SystemEvent::StateChanged(event));
}
}
// `_guard` (and the shard lock) drops here, after the event is sent.
next
}
/// Install a durable snapshot during startup without emitting a new
/// state-change event. Callers must mark the snapshot context as a
/// restoration; this prevents accidental use as a silent runtime write.
pub fn restore(&self, snapshot: State) -> Result<Arc<State>, RestoreStateError> {
if !snapshot.context.is_restoration() {
return Err(RestoreStateError::UnmarkedContext);
}
let entity_id = snapshot.entity_id.clone();
let snapshot = Arc::new(snapshot);
self.inner.states.insert(entity_id, Arc::clone(&snapshot));
Ok(snapshot)
}
/// Remove a state. Fires `state_changed` with `new_state = None`.
pub fn remove(&self, entity_id: &EntityId) -> Option<Arc<State>> {
let removed = self.inner.states.remove(entity_id).map(|(_, s)| s);
@@ -151,7 +180,10 @@ impl StateMachine {
new_state: None,
fired_at: Utc::now(),
};
let _ = self.inner.tx.send(event);
let _ = self.inner.tx.send(event.clone());
if let Some(bus) = &self.inner.bus {
bus.fire_system(SystemEvent::StateChanged(event));
}
}
removed
}
@@ -159,7 +191,11 @@ impl StateMachine {
/// Snapshot all current states. Allocates a new Vec — useful for
/// the REST GET /api/states path (ADR-130).
pub fn all(&self) -> Vec<Arc<State>> {
self.inner.states.iter().map(|r| Arc::clone(r.value())).collect()
self.inner
.states
.iter()
.map(|r| Arc::clone(r.value()))
.collect()
}
/// Snapshot all states whose entity_id matches a domain prefix.
@@ -189,6 +225,12 @@ impl Default for StateMachine {
}
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum RestoreStateError {
#[error("restored state context is not marked as a restoration")]
UnmarkedContext,
}
#[cfg(test)]
mod tests {
use super::*;
@@ -201,7 +243,12 @@ mod tests {
async fn set_writes_and_fires() {
let sm = StateMachine::new();
let mut rx = sm.subscribe();
sm.set(id("light.kitchen"), "on", serde_json::json!({"brightness": 200}), Context::new());
sm.set(
id("light.kitchen"),
"on",
serde_json::json!({"brightness": 200}),
Context::new(),
);
let evt = rx.recv().await.unwrap();
assert_eq!(evt.entity_id.as_str(), "light.kitchen");
assert!(evt.old_state.is_none());
@@ -222,9 +269,19 @@ mod tests {
#[tokio::test]
async fn attribute_only_change_fires_but_preserves_last_changed() {
let sm = StateMachine::new();
let s1 = sm.set(id("sensor.t"), "20", serde_json::json!({"unit": "C"}), Context::new());
let s1 = sm.set(
id("sensor.t"),
"20",
serde_json::json!({"unit": "C"}),
Context::new(),
);
tokio::time::sleep(std::time::Duration::from_millis(2)).await;
let s2 = sm.set(id("sensor.t"), "20", serde_json::json!({"unit": "F"}), Context::new());
let s2 = sm.set(
id("sensor.t"),
"20",
serde_json::json!({"unit": "F"}),
Context::new(),
);
assert_eq!(s1.last_changed, s2.last_changed);
assert!(s2.last_updated > s1.last_updated);
}
@@ -367,10 +424,7 @@ mod tests {
drainer.join().unwrap();
let log = log.lock().unwrap();
let dup = log
.windows(2)
.filter(|w| w[0] == w[1])
.count();
let dup = log.windows(2).filter(|w| w[0] == w[1]).count();
assert_eq!(
dup, 0,
"{dup} consecutive fired state_changed events carried an \
@@ -37,6 +37,13 @@ pub const ANOMALY_OUTLIER_SPREADS: f32 = 2.0;
/// drives the human-readable label.
pub const ANOMALY_LABEL_CUTOFF: f32 = 0.5;
/// Fraction by which occupied-anchor variance must exceed the empty-room
/// baseline for [`PresenceSpecialist`]'s variance channel to be trusted
/// (issue #1440). Below this margin the channel is disabled rather than
/// producing a midpoint threshold that can sit below the empty-room
/// baseline itself.
pub const VARIANCE_SEPARATION_MARGIN: f32 = 0.05;
/// Which biological signal a specialist estimates.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SpecialistKind {
@@ -111,6 +118,16 @@ impl PresenceSpecialist {
/// Fit from anchors: variance threshold at the midpoint between the empty
/// variance and the mean occupied variance; mean-shift threshold at half
/// the empty→occupied mean distance (inert when the means don't separate).
///
/// The variance channel is itself inert (never fires) when `occ_var`
/// does not genuinely exceed `empty_var` (issue #1440): a midpoint
/// threshold assumes occupied windows are noisier than the empty-room
/// baseline, but a still/quiet occupant can measure *less* variance
/// than an empty room's ambient/interference noise floor. Left
/// unguarded, the midpoint then sits *below* the empty-room baseline
/// itself, so a genuinely empty room reads "present" on every frame —
/// worse than no signal at all. Matches the mean-shift channel, which
/// already goes inert (`None`) under the equivalent condition.
pub fn train(anchors: &[AnchorFeature]) -> Option<Self> {
let empty = anchors.iter().find(|a| a.label == AnchorLabel::Empty)?;
let occ: Vec<&Features> = anchors
@@ -129,8 +146,15 @@ impl PresenceSpecialist {
let mean_dist = (occ_mean - empty_mean).abs();
let mean_dist_threshold = (mean_dist > 1e-4).then(|| 0.5 * mean_dist);
let variance_separates = occ_var > empty_var * (1.0 + VARIANCE_SEPARATION_MARGIN);
let threshold = if variance_separates {
0.5 * (empty_var + occ_var)
} else {
f32::INFINITY
};
Some(Self {
threshold: 0.5 * (empty_var + occ_var),
threshold,
occupied_var: occ_var.max(empty_var + 1e-3),
empty_mean,
mean_dist_threshold,
@@ -149,8 +173,15 @@ impl Specialist for PresenceSpecialist {
let present = by_variance || by_mean;
// Confidence: strongest margin among the channels that are enabled.
let var_span = (self.occupied_var - self.threshold).max(1e-3);
let var_conf = ((f.variance - self.threshold).abs() / var_span).clamp(0.0, 1.0);
// An infinite threshold means the variance channel was disabled at
// train time (issue #1440) — it must contribute 0, not the spurious
// 1.0 that `(x - inf).abs() / span` would otherwise clamp to.
let var_conf = if self.threshold.is_finite() {
let var_span = (self.occupied_var - self.threshold).max(1e-3);
((f.variance - self.threshold).abs() / var_span).clamp(0.0, 1.0)
} else {
0.0
};
let mean_conf = self
.mean_dist_threshold
.map(|thr| ((mean_dist - thr).abs() / thr.max(1e-3)).clamp(0.0, 1.0))
@@ -472,6 +503,26 @@ mod tests {
assert!(p.infer(&feat(1.0, 0.1, 0.0, 0.0)).unwrap().value == 0.0);
}
/// Issue #1440: a still/quiet occupant can measure LESS variance than an
/// empty room's ambient/interference noise floor. Before the
/// `variance_separates` guard, the midpoint threshold would then sit
/// below the empty-room baseline itself, so a window matching that
/// baseline exactly (empty room) read "present" — the reporter's
/// "known-empty room read present 31/31 frames" symptom. The means are
/// identical here (no mean-shift channel to fall back on), isolating
/// the variance-channel bug.
#[test]
fn presence_inverted_variance_never_reports_empty_room_as_present() {
let anchors = vec![
af(AnchorLabel::Empty, 5.0, 0.1),
af(AnchorLabel::StandStill, 2.0, 0.15),
];
let p = PresenceSpecialist::train(&anchors).unwrap();
let r = p.infer(&feat(5.0, 0.1, 0.0, 0.0)).unwrap();
assert_eq!(r.value, 0.0, "empty-room-baseline variance must not read present when occ_var < empty_var");
assert_eq!(r.confidence, 0.0, "a disabled channel must not report spurious confidence");
}
/// ADR-152 "variance-only presence" regression: a MOTIONLESS person raises
/// the scalar mean (extra multipath energy) but barely the variance — the
/// mean channel must still detect them, and a window matching the empty
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "wifi-densepose-cli"
version = "0.3.1"
version = "0.3.2"
edition.workspace = true
description = "CLI for WiFi-DensePose"
authors.workspace = true
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "wifi-densepose-core"
description = "Core types, traits, and utilities for WiFi-DensePose pose estimation system"
version = "0.3.1" # ADR-136: ComplexSample/CanonicalFrame/provenance + blake3
version = "0.3.2" # ADR-136: ComplexSample/CanonicalFrame/provenance + blake3
edition.workspace = true
authors.workspace = true
license.workspace = true
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "wifi-densepose-hardware"
version = "0.3.1"
version = "0.3.2"
edition.workspace = true
description = "Hardware interface abstractions for WiFi CSI sensors (ESP32, Intel 5300, Atheros)"
license = "MIT OR Apache-2.0"
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "wifi-densepose-mat"
version = "0.3.1"
version = "0.3.2"
edition = "2021"
authors = ["rUv <ruv@ruv.net>", "WiFi-DensePose Contributors"]
description = "Mass Casualty Assessment Tool - WiFi-based disaster survivor detection"
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "wifi-densepose-nn"
version = "0.3.1"
version = "0.3.2"
edition.workspace = true
authors.workspace = true
license.workspace = true
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "wifi-densepose-ruvector"
version = "0.3.2"
version = "0.3.3"
edition.workspace = true
authors.workspace = true
license.workspace = true
@@ -1,6 +1,6 @@
[package]
name = "wifi-densepose-sensing-server"
version = "0.3.4"
version = "0.3.5"
edition.workspace = true
description = "Lightweight Axum server for WiFi sensing UI with RuVector signal processing"
license.workspace = true
@@ -393,6 +393,62 @@ struct ClassificationInfo {
confidence: f64,
}
/// Derives the classification triple from raw ESP32 vitals fields.
///
/// `presence` is derived from `motion_level`, never taken from the raw
/// `presence` flag directly, so `motion_level: "present_moving"` can never
/// pair with `presence: false` (issue #1442) — matches the convention
/// already used elsewhere (the per-node path and `csi.rs`'s label-derived
/// `classification.presence = label != "absent"`).
fn classify_vitals(motion: bool, presence: bool, presence_score: f32) -> ClassificationInfo {
let motion_level = if motion {
"present_moving"
} else if presence {
"present_still"
} else {
"absent"
};
ClassificationInfo {
motion_level: motion_level.to_string(),
presence: motion_level != "absent",
confidence: presence_score as f64,
}
}
#[cfg(test)]
mod classify_vitals_tests {
use super::classify_vitals;
#[test]
fn motion_implies_presence_issue_1442() {
// The exact contradictory frame from issue #1442:
// motion=true, presence=false must not yield presence: false.
let c = classify_vitals(true, false, 0.69);
assert_eq!(c.motion_level, "present_moving");
assert!(c.presence, "motion implies presence regardless of the raw presence flag");
}
#[test]
fn presence_without_motion_is_present_still() {
let c = classify_vitals(false, true, 0.5);
assert_eq!(c.motion_level, "present_still");
assert!(c.presence);
}
#[test]
fn neither_motion_nor_presence_is_absent() {
let c = classify_vitals(false, false, 0.0);
assert_eq!(c.motion_level, "absent");
assert!(!c.presence);
}
#[test]
fn confidence_passes_through_presence_score() {
let c = classify_vitals(true, true, 0.33);
assert!((c.confidence - 0.33_f64).abs() < 1e-6);
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct SignalField {
grid_size: [usize; 3],
@@ -5786,13 +5842,6 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
s.tick += 1;
let tick = s.tick;
let motion_level = if vitals.motion {
"present_moving"
} else if vitals.presence {
"present_still"
} else {
"absent"
};
let motion_score = if vitals.motion {
0.8
} else if vitals.presence {
@@ -5899,11 +5948,8 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
// Cross-node fusion: combine features from all active nodes.
let fused_features = fuse_multi_node_features(&features, &s.node_states);
let mut classification = ClassificationInfo {
motion_level: motion_level.to_string(),
presence: vitals.presence,
confidence: vitals.presence_score as f64,
};
let mut classification =
classify_vitals(vitals.motion, vitals.presence, vitals.presence_score);
// Boost classification confidence with multi-node coverage.
let n_active = s
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "wifi-densepose-signal"
version = "0.3.5"
version = "0.3.6"
edition.workspace = true
description = "WiFi CSI signal processing for DensePose estimation"
license.workspace = true
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "wifi-densepose-train"
version = "0.3.2"
version = "0.3.3"
edition = "2021"
authors = ["rUv <ruv@ruv.net>", "WiFi-DensePose Contributors"]
license = "MIT OR Apache-2.0"
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "wifi-densepose-vitals"
version = "0.3.1"
version = "0.3.2"
edition.workspace = true
description = "ESP32 CSI-grade vital sign extraction (ADR-021): heart rate and respiratory rate from WiFi Channel State Information"
license.workspace = true
@@ -47,8 +47,29 @@ pub struct BreathingExtractor {
freq_high: f64,
/// IIR filter state.
filter_state: IirState,
/// Count of consecutive `extract()` calls that rejected the estimated
/// frequency as out of the breathing band (i.e. "no periodic signal
/// detected"), since the last accepted estimate or reset.
///
/// Without this, a subject leaving mid-lock only clears via the
/// `filtered_history` ring buffer passively flushing over the full
/// `window_secs` (up to 30s) — during which a stale, decreasingly
/// accurate estimate keeps being emitted, and any *new* subject
/// arriving mid-flush gets fused with leftover stale samples instead
/// of starting from a clean window (issue #1422). Once
/// `consecutive_rejections` reaches `STALE_RESET_REJECTIONS`, `reset()`
/// is called so the next accepted estimate is built from fresh data
/// only, instead of waiting out the old window.
consecutive_rejections: usize,
}
/// Number of consecutive out-of-band rejections after which the sliding
/// window and filter state are cleared (ADR-157-adjacent fix, issue #1422).
/// One rejection is enough: once the estimated frequency has left the
/// breathing band, the same rejected estimate must never be echoed again as
/// a stale "lock" while the window slowly flushes over up to `window_secs`.
const STALE_RESET_REJECTIONS: usize = 1;
impl BreathingExtractor {
/// Create a new breathing extractor.
///
@@ -67,6 +88,7 @@ impl BreathingExtractor {
freq_low: 0.1,
freq_high: 0.5,
filter_state: IirState::default(),
consecutive_rejections: 0,
}
}
@@ -127,10 +149,29 @@ impl BreathingExtractor {
let duration_s = history.len() as f64 / self.sample_rate;
let frequency_hz = crossings as f64 / (2.0 * duration_s);
// Validate frequency is within the breathing band
// Validate frequency is within the breathing band. An out-of-band
// estimate means no periodic breathing signal was found in the
// current window (e.g. the subject left, or noise dominates).
//
// Without an active reset here, the stale `filtered_history` window
// only clears by passively flushing over the full `window_secs`
// (up to 30s) as new samples evict old ones. During that flush a
// transiently *accepted* estimate can keep climbing toward
// `freq_high` before the frequency finally leaves the band (issue
// #1422) — and once rejected, any real signal that resumes would
// otherwise have to wait out the rest of that stale window before
// it can dominate a fresh, accurate estimate again. Resetting on
// rejection makes both directions fast: reject-and-forget instead
// of reject-then-linger, and reacquire-from-scratch instead of
// reacquire-diluted-by-ghosts.
if frequency_hz < self.freq_low || frequency_hz > self.freq_high {
self.consecutive_rejections += 1;
if self.consecutive_rejections >= STALE_RESET_REJECTIONS {
self.reset();
}
return None;
}
self.consecutive_rejections = 0;
let bpm = frequency_hz * 60.0;
let confidence = compute_confidence(history);
@@ -200,6 +241,7 @@ impl BreathingExtractor {
pub fn reset(&mut self) {
self.filtered_history.clear();
self.filter_state = IirState::default();
self.consecutive_rejections = 0;
}
/// Current number of samples in the history buffer.
@@ -479,6 +521,197 @@ mod tests {
);
}
/// Deterministic small PRNG (LCG) for reproducible synthetic-signal
/// tests -- mirrors the style already used in
/// `heartrate::tests::pure_noise_is_never_reported_valid`. Returns a
/// value in roughly `[-1, 1)`.
fn lcg_next(seed: &mut u64) -> f64 {
*seed = seed
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
((*seed >> 33) as f64 / (1u64 << 31) as f64) - 1.0
}
/// Issue #1422 bug-catching test.
///
/// Reproduces the reported scenario at ESP32 defaults (56 subcarriers,
/// 100 Hz, 30s window): 60s of a real 0.25 Hz (15 BPM) signal with
/// per-subcarrier gain/phase (lock-on), then broadband noise-floor-only
/// residuals (the "empty room").
///
/// `extract()` already returned `None` once the estimated frequency left
/// the breathing band (that part was not silently broken) -- the actual
/// defect was that the 30s `filtered_history` window only cleared by
/// *passively* flushing sample-by-sample, so a locked-on extractor kept
/// re-fusing whatever noise trickled in with a shrinking-but-still-large
/// fraction of stale real signal, letting a decreasingly-accurate
/// estimate keep being accepted right up to the range ceiling (30 BPM --
/// the exact value from the GH issue) before it finally rejected. Once
/// rejected, the *next* real subject would then have to wait out the
/// rest of that same stale window before a fresh, undiluted estimate
/// could dominate again (see `issue_1422_recovery_after_dropout_is_fast`
/// for that half of the regression).
///
/// This test pins the fix at its source: the very first out-of-band
/// rejection after a real signal disappears must actively clear
/// `filtered_history` (not wait for it to passively drain), so no
/// stale majority-real-signal window can ever linger and re-validate a
/// ghost estimate near the ceiling.
#[test]
fn issue_1422_stale_lock_does_not_persist_after_subject_leaves() {
let n = 56usize;
let fs = 100.0;
let weights = vec![1.0f64; n];
let mut seed: u64 = 0x1422_1422;
let gain: Vec<f64> = (0..n).map(|_| 0.4 + 0.6 * lcg_next(&mut seed).abs()).collect();
let phase: Vec<f64> = (0..n)
.map(|_| lcg_next(&mut seed).abs() * 2.0 * std::f64::consts::PI)
.collect();
let mut ext = BreathingExtractor::esp32_default();
// 60s of a strong, real 15 BPM (0.25 Hz) breathing signal -- lock on.
let mut got_valid_lock = false;
for i in 0..6000usize {
let t = i as f64 / fs;
let residuals: Vec<f64> = (0..n)
.map(|c| {
0.6 * gain[c] * (2.0 * std::f64::consts::PI * 0.25 * t + phase[c]).sin()
+ lcg_next(&mut seed) * 0.05
})
.collect();
if let Some(est) = ext.extract(&residuals, &weights) {
assert!(
(est.value_bpm - 15.0).abs() < 5.0,
"should track ~15 BPM while the subject is present, got {}",
est.value_bpm
);
got_valid_lock = true;
}
}
assert!(got_valid_lock, "extractor must lock onto the real breathing signal first");
assert!(
ext.history_len() > 0,
"a locked-on extractor must carry a non-empty window into the empty-room phase"
);
// Empty room: feed noise-floor-only residuals one at a time until the
// *first* rejection (the first `None` here can only come from the
// frequency-band check, never "insufficient history", since the
// window is already far past `min_samples` from the lock-on phase).
let mut first_reject_at: Option<usize> = None;
let mut history_len_at_reject: Option<usize> = None;
for i in 0..12000usize {
let residuals: Vec<f64> = (0..n).map(|_| lcg_next(&mut seed) * 0.05).collect();
let outcome = ext.extract(&residuals, &weights);
if outcome.is_none() {
first_reject_at = Some(i);
history_len_at_reject = Some(ext.history_len());
break;
}
}
let first_reject_at =
first_reject_at.expect("pure noise must eventually be rejected as out of band");
// The fix: the window must be actively cleared in the *same* call
// that rejected the frequency -- not left to drain passively over
// the remaining ~30s - first_reject_at samples. Before the fix,
// `history_len()` here was still the full ~3000-sample stale window.
assert_eq!(
history_len_at_reject,
Some(0),
"the first out-of-band rejection (at sample {first_reject_at}) must reset the \
history window immediately, not leave the stale lock-on window in place \
(issue #1422)",
);
// And the window must not be allowed to silently regrow back into a
// large, majority-noise "lock" while noise keeps arriving: each
// rebuild-to-`min_samples` cycle must itself reject and reset, so
// `history_len()` never creeps back up toward the full window.
let min_samples = (fs * 10.0) as usize;
let mut max_history_len_after_reject = 0usize;
for _ in 0..(12000 - first_reject_at - 1) {
let residuals: Vec<f64> = (0..n).map(|_| lcg_next(&mut seed) * 0.05).collect();
ext.extract(&residuals, &weights);
max_history_len_after_reject = max_history_len_after_reject.max(ext.history_len());
}
assert!(
max_history_len_after_reject <= min_samples,
"history window regrew to {max_history_len_after_reject} samples while fed pure \
noise -- a stale majority-noise window should never be allowed to accumulate \
past the minimum warm-up size without being rejected and reset (issue #1422)",
);
// Finally: the extractor must be silent at the very end of the long
// empty-room period, not just momentarily quiet.
let final_residuals: Vec<f64> = (0..n).map(|_| lcg_next(&mut seed) * 0.05).collect();
assert!(
ext.extract(&final_residuals, &weights).is_none(),
"BreathingExtractor must report no signal at the end of a long empty-room period",
);
}
/// Issue #1422 companion regression: once the subject leaves (and the
/// extractor has rejected/reset), a *returning* subject must be
/// reacquired quickly -- not have to wait out the full stale 30s window
/// passively flushing via FIFO eviction, which is what produced the
/// reported "stayed at 30.0 BPM for roughly another 30s before starting
/// to track again" secondary symptom.
#[test]
fn issue_1422_recovery_after_dropout_is_fast() {
let n = 56usize;
let fs = 100.0;
let weights = vec![1.0f64; n];
let mut seed: u64 = 0xFEED_1422;
let gain: Vec<f64> = (0..n).map(|_| 0.4 + 0.6 * lcg_next(&mut seed).abs()).collect();
let phase: Vec<f64> = (0..n)
.map(|_| lcg_next(&mut seed).abs() * 2.0 * std::f64::consts::PI)
.collect();
let mut ext = BreathingExtractor::esp32_default();
let signal_residuals = |t: f64, seed: &mut u64| -> Vec<f64> {
(0..n)
.map(|c| {
0.6 * gain[c] * (2.0 * std::f64::consts::PI * 0.25 * t + phase[c]).sin()
+ lcg_next(seed) * 0.05
})
.collect()
};
let noise_residuals = |seed: &mut u64| -> Vec<f64> {
(0..n).map(|_| lcg_next(seed) * 0.05).collect()
};
// Lock on.
for i in 0..6000usize {
ext.extract(&signal_residuals(i as f64 / fs, &mut seed), &weights);
}
// Long empty-room period.
for _ in 0..6000usize {
ext.extract(&noise_residuals(&mut seed), &weights);
}
// Subject returns.
let mut recovered_at = None;
for i in 0..6000usize {
let t = (12000 + i) as f64 / fs;
if ext.extract(&signal_residuals(t, &mut seed), &weights).is_some() {
recovered_at = Some(i);
break;
}
}
let recovered_at = recovered_at.expect("extractor must reacquire the returning subject");
// A passive-flush-only window (pre-fix) needs on the order of the
// full 30s window to dilute stale noise (measured ~29s); the active
// reset-on-rejection fix reacquires close to the 10s minimum warm-up
// instead (measured ~6s). Generous bound: well under half the window.
assert!(
recovered_at < 1500,
"recovery after a dropout took {recovered_at} samples (~{:.1}s) -- expected fast \
reacquisition (issue #1422 secondary symptom: slow recovery after a transient)",
recovered_at as f64 / fs,
);
}
/// ADR-157 §A3 bug-catching test. Divergence needs the pole magnitude
/// `|r| >= 1`, i.e. `bw >= 4`. At `fs = 0.5` Hz with the band widened to
/// 0.1-0.9 Hz, `bw = 2*pi*(0.9-0.1)/0.5 = 10.05`, so the OLD pole radius
@@ -493,12 +726,28 @@ mod tests {
ext.freq_high = 0.9;
// Feed a unit step for 600 frames — enough for the un-clamped resonator
// to overflow to inf.
//
// A constant unit step has essentially no periodic content once the
// resonator settles, so with the issue #1422 reset-on-rejection fix
// this can legitimately cycle `filtered_history` back to empty
// between checks (build up to `min_samples`, get rejected as
// out-of-band, reset, rebuild...). That's an intentional, separate
// behavior change -- this test's actual purpose (ADR-157 §A3) is the
// *filter's* numerical stability under extreme parameters, so it
// tracks the max history length reached and checks finiteness on
// every iteration instead of asserting a nonzero count only at the
// very end.
let mut max_history_len = 0usize;
for _ in 0..600 {
ext.extract(&[1.0, 1.0, 1.0, 1.0], &[0.25, 0.25, 0.25, 0.25]);
max_history_len = max_history_len.max(ext.history_len());
for (i, &v) in ext.filtered_history.iter().enumerate() {
assert!(v.is_finite(), "filtered_history[{i}] must be finite, got {v}");
}
}
assert!(ext.history_len() > 0, "history should accumulate");
for (i, &v) in ext.filtered_history.iter().enumerate() {
assert!(v.is_finite(), "filtered_history[{i}] must be finite, got {v}");
}
assert!(
max_history_len > 0,
"history should have accumulated samples at some point during the run"
);
}
}
+126 -10
View File
@@ -98,7 +98,17 @@ impl HeartRateExtractor {
/// Returns a `VitalEstimate` with heart rate in BPM, or `None`
/// if insufficient data or too few subcarriers.
pub fn extract(&mut self, residuals: &[f64], phases: &[f64]) -> Option<VitalEstimate> {
let n = residuals.len().min(self.n_subcarriers).min(phases.len());
// `n` is driven by `residuals` (and the subcarrier cap) only, NOT by
// `phases.len()`. Before this fix, a missing/short `phases` slice
// (e.g. `phases=[]`, documented as meaning "equal weighting", mirroring
// `BreathingExtractor`'s `weights=[]`) truncated `n` down to
// `phases.len()`, so `phases=[]` forced `n == 0` and `extract()`
// silently returned `None` for every frame (issue #1423). Missing
// phase entries are now treated by `compute_phase_coherence_signal`
// as "no coherence information available" and fused with equal
// weight, exactly like `breathing::fuse_weighted_residuals`'s
// uniform-weight fallback for a missing/partial `weights` slice.
let n = residuals.len().min(self.n_subcarriers);
if n == 0 {
return None;
}
@@ -249,29 +259,44 @@ impl HeartRateExtractor {
/// Combines amplitude residuals with inter-subcarrier phase coherence
/// to enhance the cardiac signal. Subcarriers with similar phase
/// derivatives are likely sensing the same body surface.
///
/// `phases` may be shorter than `n` (including empty). Missing entries mean
/// the caller has no per-subcarrier phase measurement to weight by, so that
/// subcarrier is fused with full coherence (weight 1.0) instead of being
/// excluded -- i.e. `phases=[]` degrades gracefully to equal weighting
/// across all `n` residuals, exactly like `breathing::fuse_weighted_residuals`
/// falling back to a uniform weight for a missing/partial `weights` slice
/// (issue #1423; `phases=[]` is documented as meaning equal weights, the
/// same as `BreathingExtractor`'s `weights=[]`).
fn compute_phase_coherence_signal(residuals: &[f64], phases: &[f64], n: usize) -> f64 {
if n <= 1 {
return residuals.first().copied().unwrap_or(0.0);
}
let phase_at = |i: usize| phases.get(i).copied();
// Compute inter-subcarrier phase differences as coherence weights.
// Adjacent subcarriers with small phase differences are more coherent.
// If either phase value needed for a pair is missing, treat the pair as
// fully coherent (weight 1.0) rather than panicking or excluding it.
let mut weighted_sum = 0.0;
let mut weight_total = 0.0;
for i in 0..n {
let coherence = if i + 1 < n {
let phase_diff = (phases[i + 1] - phases[i]).abs();
// Higher coherence when phase difference is small
(-phase_diff).exp()
for (i, &r) in residuals.iter().enumerate().take(n) {
let neighbor = if i + 1 < n {
Some(i + 1)
} else if i > 0 {
let phase_diff = (phases[i] - phases[i - 1]).abs();
(-phase_diff).exp()
Some(i - 1)
} else {
1.0
None
};
weighted_sum += residuals[i] * coherence;
let coherence = match neighbor.and_then(|j| phase_at(i).zip(phase_at(j))) {
Some((a, b)) => (-(b - a).abs()).exp(),
None => 1.0,
};
weighted_sum += r * coherence;
weight_total += coherence;
}
@@ -561,4 +586,95 @@ mod tests {
assert!(v.is_finite(), "filtered_history[{i}] must be finite, got {v}");
}
}
/// Issue #1423 bug-catching test.
///
/// Reproduces the exact GH-issue repro: 56 subcarriers @ 100 Hz (ESP32
/// defaults), a noiseless 1.2 Hz (72 BPM) sine identical across every
/// subcarrier, fed frame-by-frame with an **empty** `phases` slice.
///
/// Before the fix, `extract()`'s `n` was
/// `residuals.len().min(n_subcarriers).min(phases.len())`, so
/// `phases = []` forced `n == 0` and every single frame returned `None`
/// (0/4000 estimates) even though the identical call with
/// `phases = [1.0; 56]` produced thousands of valid estimates. `phases=[]`
/// must mean "no coherence weighting available", i.e. equal weighting --
/// the same documented fallback `BreathingExtractor` already honors for
/// `weights=[]` -- not "invalid input, refuse everything".
#[test]
fn issue_1423_empty_phases_yields_equal_weighting_not_silent_none() {
let n = 56usize;
let sample_rate = 100.0;
let heart_freq = 1.2; // 72 BPM
let mut ext = HeartRateExtractor::esp32_default();
let mut estimates_with_empty_phases = 0usize;
for i in 0..4000usize {
let t = i as f64 / sample_rate;
let base = (2.0 * std::f64::consts::PI * heart_freq * t).sin();
let residuals = vec![base; n];
if ext.extract(&residuals, &[]).is_some() {
estimates_with_empty_phases += 1;
}
}
assert!(
estimates_with_empty_phases > 0,
"HeartRateExtractor::extract() with phases=[] must not silently return None for \
every frame of a clean 72 BPM signal (issue #1423); got 0/4000 estimates",
);
// Sanity check against the issue's own comparison point: an
// equivalent uniform, non-empty `phases` slice (all subcarriers at
// the same phase, i.e. zero pairwise difference => full coherence,
// exactly what the equal-weighting fallback now produces for the
// empty case too) must yield a comparable number of estimates --
// the two should behave the same, not `0` vs `thousands`.
let mut ext2 = HeartRateExtractor::esp32_default();
let uniform_phases = vec![1.0_f64; n];
let mut estimates_with_uniform_phases = 0usize;
for i in 0..4000usize {
let t = i as f64 / sample_rate;
let base = (2.0 * std::f64::consts::PI * heart_freq * t).sin();
let residuals = vec![base; n];
if ext2.extract(&residuals, &uniform_phases).is_some() {
estimates_with_uniform_phases += 1;
}
}
assert_eq!(
estimates_with_empty_phases, estimates_with_uniform_phases,
"phases=[] (equal-weight fallback) must behave identically to a uniform, \
non-empty phases slice of the same length -- both represent 'no differential \
coherence information', got {estimates_with_empty_phases} vs \
{estimates_with_uniform_phases}",
);
}
/// Issue #1423 companion: a `phases` slice *shorter* than `residuals`
/// (partial coverage) must fall back to equal weighting for the missing
/// tail rather than truncating the whole fusion down to the phases that
/// happen to be present -- mirrors
/// `breathing::partial_weights_are_renormalized_not_scale_mixed`.
#[test]
fn partial_phases_do_not_truncate_subcarrier_count() {
// 8 residuals, only 2 phase values supplied.
let residuals = [1.0_f64; 8];
let phases = [0.0_f64, 0.0];
let fused = compute_phase_coherence_signal(&residuals, &phases, 8);
// All residuals are identical (1.0), so regardless of how coherence
// weights are distributed the fused value must still be 1.0 -- but
// this only holds if all 8 residuals are actually used. Before the
// fix, `n` would have been truncated to `phases.len() == 2`, so the
// caller-facing `extract()` never even reached this function with
// `n == 8`; this test pins `compute_phase_coherence_signal` itself
// to handle a short `phases` slice safely and correctly when called
// with the full `n`.
assert!(
(fused - 1.0).abs() < 1e-12,
"fusion with a partial phases slice must still average all {} residuals, got {fused}",
residuals.len(),
);
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "wifi-densepose-wifiscan"
version = "0.3.1"
version = "0.3.2"
edition.workspace = true
description = "Multi-BSSID WiFi scanning domain layer for enhanced Windows WiFi DensePose sensing (ADR-022)"
license.workspace = true
+81
View File
@@ -0,0 +1,81 @@
# HOMECORE capability status
This document describes the current alpha runtime. “Implemented” means the
capability is wired into `homecore-server` or its public protocol crate and is
covered by tests. It does not imply compatibility with every Home Assistant
integration or every Apple Home controller.
## Runtime capabilities
| Area | Current behavior |
|---|---|
| Core | Concurrent entity state machine; entity and device registries; system/domain event buses; service registry; causal contexts |
| Restore | Entity registry, device registry, and the most recent recorder state are restored in dependency order at startup; malformed rows are isolated and bounded |
| Recorder | SQLite state history listener; deterministic latest-state queries; broadcast lag recovery; optional ruvector semantic index |
| Migration | Entity/device registries and config entries are parsed with version checks, unknown forward-compatible fields are preserved, and output is written atomically without overwriting existing storage |
| Plugins | Compiled-in native plugins use an explicit registry. Packaged Wasm plugins are discovered only in configured directories, bounded and path-checked, signature-verified against configured Ed25519 publishers, and run through Wasmtime when the `wasmtime` feature is enabled |
| Plugin lifecycle | Plugin setup runs after restoration and built-in service registration; state changes are dispatched through a bounded queue; teardown runs during graceful shutdown |
| Automation | State, numeric, event, and time triggers; optional YAML loading at server boot |
| Voice | Bounded PCM16 audio types, async STT/TTS provider contracts, an STT → intent → TTS pipeline, and an authenticated transport-independent satellite session protocol |
| Assist | Authenticated local intent endpoint with bounded regex recognition and service-backed handlers |
| HAP network | With `homecore-server --features hap-server`, an explicitly configured bounded TCP listener, persisted pairing records, live entity/accessory synchronization, and `_hap._tcp` mDNS lifecycle are wired into the server |
| Dashboard/BFF | Static UI, calibration proxy, rooms, COG list, appliance metrics, and typed unavailable responses for absent upstreams |
## Home Assistant-compatible REST surface
- `GET /api/`
- `GET /api/config`
- `GET /api/components`
- `GET /api/states`
- `GET|POST|DELETE /api/states/:entity_id`
- `GET /api/services`
- `POST /api/services/:domain/:service`
- `GET /api/events`
- `POST /api/events/:event_type`
- `POST /api/template`
- `POST /api/config/core/check_config`
- `GET /api/error_log`
- `GET /api/history/period[/<start_time>]`
- `GET /api/logbook[/<start_time>]`
- `GET /api/calendars[/:entity_id]`
- `GET /api/camera_proxy/:entity_id`
- `GET /api/websocket`
- `POST /api/intent/handle`
- `GET /api/homecore/compatibility`
The WebSocket server implements authentication, feature negotiation, ping,
configuration/state/service queries, service calls, event firing,
subscribe/unsubscribe, template rendering, panels, and entity/device/area
registry list commands. Per-connection output is bounded; lagging event
subscribers resynchronize.
`GET /api/homecore/compatibility` is the machine-readable support matrix.
HOMECORE implements the core contract above, not every endpoint contributed by
Home Assistant integrations. History is available when the recorder is
enabled. Calendar discovery and interval queries are implemented, with events
supplied only by calendar integrations. Camera routing is implemented and
returns a typed unavailable response when the entity has no image provider.
Media and Lovelace behavior remain integration-dependent.
Registry mutations require a persistent registry mutation backend. The area
registry currently returns a valid empty list.
## Security and deployment constraints
- `HOMECORE_TOKENS` is required unless insecure development authentication is
explicitly enabled.
- Unsigned Wasm packages are rejected unless the development-only override is
explicitly supplied. Arbitrary native dynamic libraries are never loaded;
native plugins must be linked into the binary and registered in code.
- HAP is disabled by default. Enabling it requires an explicit bind address,
stable six-octet device identifier, advertised LAN address, hostname, and
durable pairing-store path. First provisioning also requires an explicit
non-trivial `XXX-XX-XXX` setup code; only its SRP verifier is persisted.
- The HAP listener implements SRP-6a Pair-Setup, X25519/Ed25519 Pair-Verify,
ChaCha20-Poly1305 HAP IP records, controller administration, and paired-state
mDNS updates. Internal protocol/tamper/replay tests pass; external Apple Home
certification is not claimed.
- STT and TTS are provider contracts; a deployment must supply real providers.
The built-in disabled providers return typed errors rather than fabricated
speech results.
- Synthetic biometric/demo entities are opt-in and must not be interpreted as
live sensing data.