Compare commits

...

23 Commits

Author SHA1 Message Date
rUv e6062977c9 docs: add calibration guide and trust/engine-error diagnostics (#1456) (#1457)
Closes the two documentation gaps from #1456 (follow-up to #1401):

- docs/calibration-guide.md: what calibrate/enroll/train-room actually
  enforce, grounded in v2/crates/wifi-densepose-calibration and
  wifi-densepose-cli source (not just ADR-135/151 aspirational prose).
  Covers the hard 600-frame baseline minimum, per-anchor quality gate
  thresholds, the unsolved pet/small-motion presence-detection gap, and
  what the empty-room baseline capture actually needs (steady vs silent).
  Flags that ADR-135's drift_score/BaselineDrift staleness system is not
  implemented in code — only bank.rs's baseline_id STALE check is real.

- docs/trust-and-engine-errors.md: exact trigger conditions for
  engine_error_count vs the separate, non-sticky `demoted` privacy-class
  flag, where both are exposed (/health/ready and /api/v1/status share a
  handler), the real diagnostic gap (no per-cause breakdown, log line is
  the closest thing), the WDP_GUARD_INTERVAL_US recovery path for
  persistent clock-drift demotion, and an honest "no code path found"
  answer on whether a converted HuggingFace model explains engine errors.

Also adds both docs to the README documentation table. No code changes.
2026-07-28 00:16:17 -04:00
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
81 changed files with 11186 additions and 1474 deletions
+2
View File
@@ -8,6 +8,8 @@ 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.
+2
View File
@@ -632,6 +632,8 @@ Verify the plugin structure: `bash plugins/ruview/scripts/smoke.sh`. Full detail
|----------|-------------|
| [User Guide](docs/user-guide.md) | Step-by-step guide: installation, first run, API usage, hardware setup, training |
| [Build Guide](docs/build-guide.md) | Building from source (Rust and Python) |
| [Calibration & Room Training Guide](docs/calibration-guide.md) | What `calibrate`/`enroll`/`train-room` actually enforce: minimum frame counts, per-anchor quality gates, the pet/small-motion presence-detection caveat, and empty-room baseline conditions — grounded in the real code, not just ADR-135/151 |
| [Trust State & Engine Errors](docs/trust-and-engine-errors.md) | What `engine_error_count` and `demoted` mean on `/api/v1/status`, exact trigger conditions, the current diagnostic gap (no per-cause breakdown), the `WDP_GUARD_INTERVAL_US` recovery path, and why a converted Hugging Face model isn't shown to be the cause in code |
| [**Home Assistant + Matter Integration**](docs/integrations/home-assistant.md) | **Works with Home Assistant** via MQTT auto-discovery + **Works with Matter** (Apple Home / Google Home / Alexa / SmartThings) — full entity catalog, 3 starter blueprints, Lovelace dashboards, privacy mode, threshold tuning ([ADR-115](docs/adr/ADR-115-home-assistant-integration.md)). |
| [**BFLD — Beamforming Feedback Layer for Detection**](v2/crates/wifi-densepose-bfld/README.md) | New privacy-gated WiFi sensing layer that measures + structurally prevents identity leakage from 802.11ac/ax Beamforming Feedback Information. Three type-enforced invariants (raw BFI never exits node, identity embedding is in-RAM-only, cross-site correlation cryptographically impossible via per-site BLAKE3 keyed hash + daily rotation). Ships full operator surface (`BfldPipeline`, `BfldPipelineHandle`, the Soul Signature §3.6 per-channel matcher `EnrolledMatcher`/`SoulMatchOracle` — experimental; named identity is data-gated, **measured** as not-separable on WiFi-only channels alone), MQTT topic router + HA-DISCO + availability + LWT, 3 operator HA blueprints, two runnable examples, eclipse-mosquitto:2 CI service container. 327+ tests. [ADR-118](docs/adr/ADR-118-bfld-beamforming-feedback-layer-for-detection.md) umbrella + sub-ADRs [119](docs/adr/ADR-119-bfld-frame-format-and-wire-protocol.md)/[120](docs/adr/ADR-120-bfld-privacy-class-and-hash-rotation.md)/[121](docs/adr/ADR-121-bfld-identity-risk-scoring.md)/[122](docs/adr/ADR-122-bfld-ruview-ha-matter-exposure.md)/[123](docs/adr/ADR-123-bfld-capture-path-nexmon-and-esp32.md). Research dossier: [`docs/research/BFLD/`](docs/research/BFLD/) (11 files, 13,544 words). |
| [**SENSE-BRIDGE — rvagent MCP server**](tools/ruview-mcp/README.md) | Dual-transport MCP server (`@ruvnet/rvagent`) bridging the RuView sensing stack to AI agents (Claude Code, Cursor, ruflo swarms). 6 tools wired: `ruview.presence.now`, `ruview.vitals.get_{breathing,heart_rate,all}`, `ruview.bfld.last_scan`, `ruview.bfld.subscribe`. stdio + Streamable HTTP (`POST /mcp`, Origin-validated, bearer-token auth, `127.0.0.1` bind). Full 20-tool Zod schema barrel + 5 RUVIEW-POLICY governance tools. 93 tests. [ADR-124](docs/adr/ADR-124-rvagent-mcp-ruvector-npm-integration.md). Try: `npx @ruvnet/rvagent stdio`. |
@@ -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
+300
View File
@@ -0,0 +1,300 @@
# Calibration & Room Training Guide
This guide explains what actually happens — and what is actually *enforced*
when you run `wifi-densepose calibrate`, `enroll`, and `train-room`. It is
written for the person setting up a room, not for developers.
Everything below was checked against the real Rust implementation in
`v2/crates/wifi-densepose-calibration/`, `v2/crates/wifi-densepose-signal/src/ruvsense/calibration.rs`,
and `v2/crates/wifi-densepose-cli/`, not just the design ADRs. Where the design
documents (ADR-135, ADR-151) describe something that isn't actually built yet,
this guide says so explicitly.
## The three-step pipeline
```
wifi-densepose calibrate --port <PORT> # Stage 1: empty-room baseline (no people)
wifi-densepose enroll --room <NAME> # Stage 2+3: 8 guided anchors (~4 minutes)
wifi-densepose train-room --room <NAME> # Stage 4: fit the specialist bank
wifi-densepose room-status --room <NAME> # check what trained / what's stale
wifi-densepose room-watch --room <NAME> # live inference
```
`calibrate` must run first — `enroll` refuses to start without a baseline file
(`--baseline ./baseline.bin` by default), and `train-room` refuses to start
without an enrollment file. Each step writes a file the next step reads; there
is no way to skip a step.
---
## 1. Is there a minimum amount of data required?
**Yes, and for the empty-room baseline it is a hard, enforced minimum — not a
recommendation.**
`wifi-densepose calibrate` will not produce a baseline file with fewer than
**600 recorded frames** (the default for every PHY tier: HT20, HT40, HE20,
HE40). This is `DEFAULT_MIN_FRAMES = 600` in
`v2/crates/wifi-densepose-signal/src/ruvsense/calibration.rs:48`, and it is
checked in `CalibrationRecorder::finalize()`
(`ruvsense/calibration.rs:532-538`): if fewer than `config.min_frames` frames
were recorded, `finalize()` returns
`CalibrationError::InsufficientFrames { got, need }` and calibration fails
outright — there is no partial/degraded baseline. This is pinned by a unit
test (`finalize_requires_min_frames`, same file) so it isn't accidental
behavior.
**Important subtlety:** the CLI's `--duration-s` flag (default 30 seconds)
and the 600-frame minimum are checked *independently*. The capture loop in
`v2/crates/wifi-densepose-cli/src/calibrate.rs:135-183` stops as soon as
**either** the duration timer expires **or** 600 frames have been recorded,
whichever comes first. If your node streams CSI slower than the assumed 20 Hz
(e.g. congested WiFi, a busier ESP32), 30 seconds may not be enough to reach
600 frames, and `calibrate` will fail with an explicit
`"insufficient frames: have X, need 600"` error rather than silently
producing a short baseline. If you hit this, raise `--duration-s` rather than
overriding `--min-frames`.
You *can* override the 600-frame floor with `--min-frames <N>` (0 = use the
tier default). The code prints an explicit warning when you do:
> `[calibrate] WARN: --min-frames=N overrides ADR-135 tier default (600 for
> ht20). This relaxes the phase-concentration guarantee; do not use in
> production.`
(`v2/crates/wifi-densepose-cli/src/calibrate.rs:112-119`). Treat this as a
debugging escape hatch, not a supported way to shorten setup.
The CLI also independently rejects `--duration-s` below 10 seconds
(`"Fewer frames produce unreliable phase-concentration estimates"`,
`calibrate.rs:341-348`) and prints (but does not block on) a warning above 300
seconds.
**Guided enrollment (`enroll`) has a much lower, per-anchor floor.** Each of
the 8 guided anchors (`empty`, `stand_still`, `sit`, `lie_down`,
`breathe_slow`, `breathe_normal`, `small_move`, `sleep_posture`) is captured
for a fixed duration baked into the code — 20 seconds for the static/motion
anchors, 30 seconds for the two breathing anchors and `sleep_posture`
(`AnchorLabel::duration_s()`, `v2/crates/wifi-densepose-calibration/src/anchor.rs:98-104`).
This is **not** a CLI flag — you cannot currently shorten or lengthen an
individual anchor capture from the command line.
Underneath that fixed duration, the anchor is only *accepted* if it clears a
quality gate (`AnchorQualityGate`, `v2/crates/wifi-densepose-calibration/src/enrollment.rs:43-53`):
| Threshold | Default | What it checks |
|---|---|---|
| `min_frames` | **60 frames** | Anchor is rejected if fewer than 60 frames were captured — mainly catches "the ESP32 stopped streaming" mid-capture, not a real duration requirement (60 frames is a fraction of a second of streaming at typical rates) |
| `min_presence_z` | 1.5 | For anchors that expect a person, the mean amplitude z-score must exceed this or the anchor is rejected as "no person detected" |
| `empty_max_z` | 1.0 | For the `empty` anchor, the z-score must stay under this or it's rejected as "room not empty" |
| `max_still_motion` | 0.6 (60%) | For still anchors, motion-flagged frame fraction above this is rejected as "too much motion" |
| `min_move_motion` | 0.3 (30%) | For `small_move`, motion-flagged fraction below this is rejected as "not enough motion" |
A rejected anchor is re-prompted, up to `--attempts` times (default **2**).
If an anchor is still rejected after all attempts, `enroll` moves on without
it and logs `"moving on without '<label>'"` — enrollment does **not** abort;
you end up with a partial anchor set.
**`train-room` itself enforces almost nothing.** It only bails if the
enrollment file has *zero* accepted anchors at all
(`v2/crates/wifi-densepose-cli/src/room.rs:246-248`, `"no accepted anchors …
re-run enroll"`). There is no minimum anchor count beyond that. What actually
happens with a partial anchor set is that individual specialists silently
fail to train and are simply absent from the resulting bank — for example
(from `v2/crates/wifi-densepose-calibration/src/specialist.rs`):
- **presence** needs the `empty` anchor plus at least one anchor where a
person was expected present — missing either, `PresenceSpecialist::train()`
returns `None` and presence detection is unavailable in that bank.
- **anomaly** needs at least 2 anchors total, of any kind.
- **restlessness** needs `sleep_posture` (or `lie_down` as a fallback) *and*
`small_move`.
- **posture** needs at least one anchor that establishes a posture
(`stand_still`, `sit`, `lie_down`, or `sleep_posture`).
So a "successful" `train-room` run can still produce a bank missing one or
more specialists if enrollment didn't collect the anchors those specialists
need. `room-status` (`v2/crates/wifi-densepose-cli/src/room.rs`) is the way
to check what actually trained.
### What we could not verify
The ADR-151 design document (§2.2) claims total guided enrollment is
"~4 minutes of wall-clock" — that arithmetic checks out against the coded
per-anchor durations (5 × 20s + 3 × 30s = 190s ≈ 3.2 min, plus a 3-second
countdown before each anchor ≈ +24s, so ~3.54 minutes is consistent with the
code). But we found **no integration test or measurement showing that this
duration is sufficient for reliable specialist accuracy** — the ADR's own
status section says the full `baseline → enroll → train-room → infer` loop is
proven only against **deterministic synthetic CSI** (`tests/full_loop.rs`),
not yet run start-to-finish on real hardware in an empty room. Treat the
default durations as reasonable code defaults, not as a validated minimum for
real-world accuracy.
---
## 2. Recommended duration if there's no hard minimum
Where a hard minimum *does* exist (the 600-frame baseline, the 60-frame
per-anchor floor), it's documented above. Beyond that:
- **Baseline capture**: the CLI default (`--duration-s 30`) is the number to
use; it's what the 600-frame minimum is designed around at the assumed
20 Hz sensing rate. ADR-135 §2.3 argues 30 s is the shortest duration that
keeps the phase-concentration estimate's standard deviation under
0.02 rad², citing published circular-statistics error bounds — but this is
a paper-derived justification for the *default value*, not a code-enforced
floor beyond the 600-frame check itself.
- **Enrollment anchors**: use the built-in per-anchor durations (20s/30s) —
there's currently no way to change them from the CLI anyway.
---
## 3. Will a pet get classified as "occupied"?
**Honest answer: the code has no way to distinguish a pet (or any small/animal-scale
motion) from a person.** This is a real limitation, not a solved problem —
flagging it here rather than guessing.
Presence detection (`PresenceSpecialist`,
`v2/crates/wifi-densepose-calibration/src/specialist.rs:100-198`) is trained
purely from two scalar channels measured during enrollment:
- **variance** of the CSI amplitude series, thresholded at the midpoint
between the `empty` anchor's variance and the mean variance of the
person-present anchors;
- **mean shift** — `|mean empty_mean|`, thresholded at half the
empty→occupied mean distance.
Presence fires if **either** channel crosses its threshold. Both thresholds
are learned entirely from the amplitude statistics of your enrollment
anchors — there is no body-size, RCS (radar cross-section), Doppler-signature,
or any other physical feature in this code that separates "a full-grown
adult moved" from "a cat walked past" or "a dog jumped on the couch." If a
pet's motion perturbs the CSI amplitude by roughly the same amount as the
`small_move` anchor did during your enrollment, `PresenceSpecialist` will read
it as occupied, because that's mechanically what the threshold measures.
The closest thing to a safeguard is `AnomalySpecialist`
(`specialist.rs:386-448`), a generic novelty detector that flags a live
window as "anomalous" when it's far (in embedding distance) from every
enrolled anchor prototype. It is **not** a validated pet filter — it will
flag *any* statistically unusual signal as anomalous or normal depending on
how close it happens to land to your anchors, with no guarantee it
distinguishes species or motion source. A pet whose motion pattern happens
to resemble the `small_move` anchor would not be flagged as anomalous at all.
**Practical takeaway for a homeowner with pets:** expect presence/posture
readings to occasionally trigger on pet motion, especially larger animals or
motion near the sensor. There is currently no configuration option or code
path to suppress this.
---
## 4. Does the empty-room baseline need "typical" conditions (HVAC running) or true silence?
The short answer, grounded in how the baseline is actually computed: **a
stationary, continuously-running interferer (a fan, HVAC blower, humidifier)
that is present for the *entire* capture window becomes part of what "empty"
means, and gets subtracted out naturally** — that's a direct consequence of
how the statistics are computed, not a documented feature you have to
configure.
`CalibrationRecorder` uses Welford's online algorithm to accumulate a running
mean and variance per subcarrier over however many frames you feed it
(`ruvsense/calibration.rs`). If a fan is running steadily the whole time you
capture the baseline, its contribution is baked into `amp_mean`/`amp_variance`
for every frame equally, so the resulting baseline already represents "empty
room with the fan on" — and at runtime, `BaselineCalibration::subtract()`
removes exactly that reference, so a room in the same steady state reads as
quiet. The design intent documented in ADR-135 §1.1 is explicit about this:
the whole point of baseline subtraction is to remove "hardware-induced gain
bias and environment-fixed multipath" so downstream motion detectors aren't
tripped by things that are always there.
**What actually matters is consistency, not silence**: capture the baseline
under whatever background conditions the room will normally be in during
real use (HVAC/fans running as usual), and try to keep the room in that same
steady state for the entire capture window. What the code cannot correct
for is a background condition that **changes partway through** the capture
(e.g. HVAC cycles on 15 seconds into a 30-second capture) — that would bias
the Welford mean/variance toward an in-between state that matches neither
"HVAC off" nor "HVAC on" well.
There is a **real-time guard during capture** that can catch gross problems:
`--abort-z-threshold` (default `2.0`) aborts the capture if the per-frame
amplitude z-score median stays above that threshold for 20 consecutive
banner intervals (`v2/crates/wifi-densepose-cli/src/calibrate.rs:82-83,
163-178`). This is designed to catch someone walking through mid-capture, not
necessarily short-duration mechanical noise — we found no test exercising it
against an HVAC-cycling scenario specifically, so how it behaves for
"appliance turns on mid-capture" is unverified.
### What we could not verify — and a design gap worth knowing about
ADR-135 §2.5 describes a much more sophisticated staleness-detection system:
a `drift_score` computed from ongoing z-scores, a `BaselineDrift` event fired
after sustained drift, and a `baseline_stale` flag published over the
sensing WebSocket. **We searched the actual `calibration.rs` implementation
and none of that exists in code** — there is no `drift_score` field, no
`BaselineDrift` event, and no `baseline_stale` flag anywhere in
`v2/crates/wifi-densepose-signal/src/ruvsense/calibration.rs`. That part of
ADR-135 is aspirational design, not shipped behavior.
What *is* implemented, at a different layer, is a much simpler check on the
**trained specialist bank** (not the raw baseline): `SpecialistBank` stores
the `baseline_id` it was trained against, and `SpecialistBank::is_stale()`
(`v2/crates/wifi-densepose-calibration/src/bank.rs:102-104`) returns `true`
whenever the *current* baseline's id doesn't match the id the bank was
trained on. Re-running `calibrate` always produces a new baseline id, so
**any** recalibration — whether because of furniture moving, a genuinely
stale reference, or just re-running the command — immediately marks every
previously trained specialist bank stale, and you'll need to re-run `enroll`
and `train-room` afterward. There is no partial/graded staleness signal
(no "how stale"), only this all-or-nothing id comparison.
**Practical guidance:**
1. Calibrate with the room in its normal, steady background state (HVAC,
fans, fridge compressor, etc. running as they normally would) and keep
that state constant for the whole `--duration-s` window.
2. If you significantly change background conditions later (move furniture,
add a permanent appliance, change HVAC routine) or notice the sensing
quality degrade, re-run `calibrate` — this is an explicit, operator-driven
step; there is no code path that recalibrates for you.
3. Re-running `calibrate` invalidates every specialist bank trained against
the old baseline (via the `baseline_id` mismatch above) — plan to re-run
`enroll` and `train-room` right after.
---
## Quick reference: commands and defaults actually in the code
```bash
# Stage 1 — empty-room baseline. Room must be empty for the whole window.
wifi-densepose calibrate \
--udp-port 5005 --duration-s 30 --tier ht20 --output ./baseline.bin
# Hard requirement: >= 600 recorded frames, or calibration fails.
# Stage 2+3 — guided enrollment (8 fixed anchors, ~4 minutes total)
wifi-densepose enroll --baseline ./baseline.bin --room living-room \
--output ./enrollment.json --attempts 2
# Stage 4 — train the specialist bank from whatever anchors were accepted
wifi-densepose train-room --enrollment ./enrollment.json \
--output ./room-bank.json
# Check what actually trained (and whether the bank is stale)
wifi-densepose room-status --room living-room
```
Source references for everything above:
- `v2/crates/wifi-densepose-cli/src/calibrate.rs`
- `v2/crates/wifi-densepose-cli/src/room.rs`
- `v2/crates/wifi-densepose-signal/src/ruvsense/calibration.rs`
- `v2/crates/wifi-densepose-calibration/src/enrollment.rs`
- `v2/crates/wifi-densepose-calibration/src/anchor.rs`
- `v2/crates/wifi-densepose-calibration/src/specialist.rs`
- `v2/crates/wifi-densepose-calibration/src/bank.rs`
- `docs/adr/ADR-135-empty-room-baseline-calibration.md`
- `docs/adr/ADR-151-room-calibration-specialist-training.md`
+250
View File
@@ -0,0 +1,250 @@
# Trust State & Engine Errors
If you've seen the sensing-server log a growing `engine_error_count`, or
noticed your deployment reads as `"demoted": true` on the status endpoint,
this page explains — from the actual code, not the design docs — what those
two things mean, what triggers them, where you can see them, and what your
real options are.
Everything below is grounded in
`v2/crates/wifi-densepose-sensing-server/src/engine_bridge.rs`,
`v2/crates/wifi-densepose-sensing-server/src/main.rs`,
`v2/crates/wifi-densepose-engine/src/lib.rs`, and
`v2/crates/wifi-densepose-signal/src/ruvsense/multistatic.rs`.
## Two different things, easy to conflate
The sensing-server runs a "governed trust cycle" every sensing tick
(`StreamingEngine::process_cycle`, driven by `EngineBridge::observe_cycle` in
`engine_bridge.rs:193-223`). Each cycle produces **one of two outcomes**, and
they are tracked completely separately:
1. **The cycle fails outright** (`Result::Err(EngineError)`) — nothing is
published for that tick. This increments `engine_error_count`, a
monotonically increasing counter.
2. **The cycle succeeds but under a demoted privacy class**
(`Result::Ok(TrustedOutput { demoted: true, .. })`) — a belief *is*
published, just at a more restricted privacy class than normal. This sets
the `demoted` flag, which is recomputed fresh on every successful cycle.
A deployment can have a high `engine_error_count` with `demoted: false` (lots
of failed cycles, but the ones that succeed are clean), or `demoted: true`
with `engine_error_count: 0` (every cycle succeeds, but under a downgraded
privacy class), or both at once — which is what the issue reporter saw.
## 1. Exact conditions for each
### Engine errors (`engine_error_count`)
`engine_error_count` increments only when
`StreamingEngine::process_cycle` returns `Err(EngineError::Fusion(..))`
(`engine_bridge.rs:198-222`). `EngineError` wraps
`wifi_densepose_signal::ruvsense::multistatic::MultistaticError`
(`wifi-densepose-engine/src/lib.rs:54-69`), which has exactly four variants
(`multistatic.rs:36-56`):
| Variant | Condition |
|---|---|
| `NoFrames` | No node frames were passed to fusion. In practice not reachable through the bridge: `process_cycle_from_states` returns `None` (not an error, not counted) before calling the engine at all if there are no frames (`engine_bridge.rs:168-171`). |
| `InsufficientNodes(n)` | Fewer than 2 nodes contributing in multistatic mode. |
| `TimestampMismatch { spread_us, guard_us }` | The spread between contributing nodes' frame timestamps exceeds the **hard guard interval**, default **60,000 µs (60 ms)** (`MultistaticConfig::default()`, `multistatic.rs:133`). |
| `DimensionMismatch { node_idx, expected, got }` | A node's subcarrier count doesn't match the others. As of #1170 the live bridge canonicalizes every node onto a common 56-tone grid before fusion, so this is now rare on real hardware — see the comment on `observe_cycle_counts_engine_errors` in `engine_bridge.rs`. |
Regardless of cause, an error is **rate-limited in the log** to one
`tracing::warn!` line per 10 seconds (`ENGINE_ERROR_WARN_INTERVAL`,
`engine_bridge.rs:50, 206-219`) — errors are still counted every cycle, only
the *log line* is throttled, so a 20 Hz loop failing continuously won't flood
your log with 20 lines/second.
### Trust demotion (`demoted`)
This is a **separate mechanism** from engine errors: it happens on cycles
that *succeed*, and it downgrades the privacy class the output is emitted
under, one step, rather than failing the cycle. From
`wifi-densepose-engine/src/lib.rs:514-515`:
```rust
let demoted = quality.forces_privacy_demotion() || array_contradiction || mesh_at_risk;
let effective_class = if demoted { demote_one(base_class) } else { base_class };
```
Three independent conditions can trigger it:
- **`quality.forces_privacy_demotion()`** — true whenever the fusion
quality record carries any non-empty `contradiction_flags`
(`fusion_quality.rs:111-118`). These are *tolerated* disagreements, distinct
from a hard fusion failure:
- `TimestampMismatch` — spread within the **hard** guard but beyond the
**soft** guard (default **20,000 µs / 20 ms**, `soft_guard_us`,
`multistatic.rs:104-113`) — i.e. loose-but-tolerable timing alignment.
- `CalibrationIdMismatch` — contributing frames disagree on which
calibration epoch (baseline) they were captured under.
- `PhaseAlignmentFailed`, `DriftProfileConflict`, `CoherenceDrop`,
`GeometryInsufficient` — raised upstream by the array coordinator /
baseline drift checks.
- **`array_contradiction`** — a separate array-level directional-fusion
contradiction check.
- **`mesh_at_risk`** — the mesh is close to partitioning (`mesh_guard.rs`).
`demote_one()` (`lib.rs:688-690`) steps the privacy class exactly one notch
toward `Restricted` (it never jumps more than one step, and never relaxes a
class in the same cycle — proven by the `forced_contradiction_never_relaxes_class`
test). At `PrivacyClass::Restricted`, `EngineBridge::suppress_raw_outputs()`
becomes true and `main.rs` strips per-node raw amplitude vectors from the
published `SensingUpdate` (`engine_bridge.rs:251-260`).
**Crucially, `demoted` is not sticky.** It is overwritten on every
successful cycle to reflect *that cycle's* outcome
(`self.demoted = trust.demoted;`, `engine_bridge.rs:203`). If the
contradiction that caused demotion was transient, the very next clean cycle
reports `demoted: false` again with no action from you. If you see
`demoted: true` *persistently*, that means the underlying condition (usually
clock drift beyond the guard, or a geometry/calibration disagreement) is
itself persistent, not that something got "stuck."
## 2. Where this is exposed
Both `GET /health/ready` and `GET /api/v1/status` are wired to the same
handler (`health_ready`, `main.rs:8128,8133`) and return a `trust` block
(`main.rs:4589-4606`):
```json
{
"status": "ready",
"trust": {
"last_witness": "…64 hex chars or null…",
"effective_class": "Anonymous | Restricted | …",
"demoted": false,
"recalibration_recommended": false,
"engine_error_count": 0,
"raw_outputs_suppressed": false
}
}
```
**This is a real, currently-shipped diagnostic surface — but it is honestly
limited.** It tells you *that* errors are occurring and *that* the current
class is demoted, and the total count, but not *why* for your specific run:
- `engine_error_count` is a single running total. There is **no breakdown by
error type** anywhere in the API or in `EngineBridge`'s state — you cannot
tell from `/health/ready` whether your 20,000 errors are 20,000
`TimestampMismatch`es or 20,000 `DimensionMismatch`es.
- `demoted` is a boolean with no accompanying list of which
`ContradictionFlag`s actually fired. The underlying `contradiction_flags`
vector exists in `QualityScore` (`fusion_quality.rs:106`) but is not
surfaced over the wire anywhere we found.
- There's no error history/timeline, and no per-node breakdown (which node
is the one whose clock is drifting, for instance).
**The closest thing to a real diagnostic today is the rate-limited log
line itself.** Unlike the API, the log message includes the `Display` text
of the actual `EngineError`, which for `TimestampMismatch` and
`DimensionMismatch` includes the concrete numbers (e.g. `"Timestamp spread
87000 us exceeds guard interval 60000 us"`, `"Dimension mismatch: node 2 has
114 subcarriers, expected 56"`). If you're trying to diagnose a specific
demotion/error episode today, grepping the sensing-server log for
`"governed trust cycle failed"` is the most concrete answer available — the
status endpoint alone will not tell you the underlying cause. Treat this as
the honest state of the diagnostics, not a missing feature we're pretending
exists.
## 3. Is a demoted / errored state permanent? Is there a reset?
**`demoted` never needs resetting** — as described above, it's recomputed
every successful cycle from that cycle's own contradiction/mesh state. There
is no persistence, no counter, no cooldown timer for it in the code.
**`engine_error_count` has no reset mechanism at all.** It is a plain `u64`
field on `EngineBridge`, initialized to `0` in `EngineBridge::new`
(`engine_bridge.rs:111`) and only ever incremented
(`self.engine_error_count += 1;`, line 207) — there is no method, admin
endpoint, or timer anywhere in the crate that decrements or clears it. The
only way to bring it back to zero is to **restart the sensing-server
process**, which constructs a brand-new `EngineBridge`. If your count is
growing and you want to confirm whether a fix actually worked, restart the
server and watch whether the count starts climbing again — there is
currently no lighter-weight way to "clear the counter" without a restart.
**If demotion (or errors) are persistent rather than one-off**, the
documented, real fix for the most common cause — clock drift between nodes
exceeding the fixed 60 ms hard guard — is an environment-variable override,
not a restart or a wait:
- `WDP_GUARD_INTERVAL_US` — directly overrides the hard guard (e.g.
`WDP_GUARD_INTERVAL_US=200000` for a 200 ms guard). This is the escape
hatch a real deployment (issue #1049) needed: WiFi/ESP-NOW-synced ESP32
nodes were measured drifting 10150 ms, which the published 60 ms default
could not absorb, causing **every** cycle to demote with "no escape hatch"
(see the comment at `main.rs:8336-8339`).
- `WDP_SOFT_GUARD_US` — optionally overrides the soft (tolerated-contradiction)
guard, always clamped below the hard guard.
- `WDP_TDM_SLOTS` + `WDP_TDM_SLOT_US` — derive the guard from your actual TDM
schedule instead of setting it directly.
See `multistatic_guard_config_from_env` / `multistatic_guard_config_from`
(`main.rs:6791-6856`) for the exact precedence rules (a direct
`WDP_GUARD_INTERVAL_US` always wins over the TDM-derived value).
## 4. Does a converted Hugging Face model explain this?
**We could not find a code path connecting `--convert-model` to engine
errors or trust demotion — they appear to be entirely separate subsystems.**
Saying this plainly rather than speculating:
- `--convert-model` (`main.rs:6976-7028`, `run_convert_model` /
`load_or_convert_model` at `main.rs:6925-6974`) converts a **pose-model
weights file** — Hugging Face `safetensors` or a `jsonl` manifest — into
this project's own RVF binary container format, so it can be loaded via
`--model`. This is entirely about which neural-network weights the pose
estimator uses.
- `engine_error_count` and `demoted` come from `StreamingEngine::process_cycle`
in `wifi-densepose-engine`, which performs **multistatic CSI sensor
fusion** — checking node count, per-node timestamp spread, and per-node
subcarrier dimensions across your ESP32 nodes. This code path has no
dependency on which pose model is loaded, and `load_or_convert_model` /
`run_convert_model` never call into `engine_bridge` or
`StreamingEngine` at all.
Because the code shows no coupling between the two, we are not going to
invent one. Two possibilities that the code doesn't rule out, but also
doesn't confirm, if you hit both symptoms together:
- **Coincidence** — the deployment that had trouble loading/using a
converted model separately had a fusion-timing or node-count problem
(e.g. the #1049-style clock-drift issue, or fewer than 2 active nodes),
unrelated to the model conversion itself.
- **A configuration change made alongside the model swap** — e.g. changing
node count, geometry, or guard settings at the same time as switching
models — could produce both symptoms together without the model itself
being the cause.
If you're hitting this, the actionable step from the code is to check
`engine_error_count` and the log line's error text (per §2 above)
**independently** of whatever model you have loaded — if the errors are
`TimestampMismatch`/`DimensionMismatch`/`InsufficientNodes`, the fix is on
the sensor-fusion side (§3), not the model side, regardless of which model
produced the report.
## Quick reference
```bash
# Check current trust state
curl -s http://localhost:3000/api/v1/status | jq .trust
# Watch for the rate-limited error log line (most specific diagnostic today)
# — look for "governed trust cycle failed" in the sensing-server's stderr/log.
# If demotion/errors are persistent due to node clock drift, raise the guard:
WDP_GUARD_INTERVAL_US=200000 wifi-densepose-sensing-server ...
# The only way to reset engine_error_count is a process restart.
```
Source references for everything above:
- `v2/crates/wifi-densepose-sensing-server/src/engine_bridge.rs`
- `v2/crates/wifi-densepose-sensing-server/src/main.rs` (search `trust`, `health_ready`, `multistatic_guard_config_from`, `convert_model`)
- `v2/crates/wifi-densepose-engine/src/lib.rs`
- `v2/crates/wifi-densepose-engine/src/mesh_guard.rs`
- `v2/crates/wifi-densepose-signal/src/ruvsense/multistatic.rs`
- `v2/crates/wifi-densepose-signal/src/ruvsense/fusion_quality.rs`
+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
+466 -273
View File
File diff suppressed because it is too large Load Diff
+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
@@ -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
@@ -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(),
);
}
}
+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.