mirror of
https://github.com/ruvnet/RuView
synced 2026-07-28 18:21:42 +00:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e6062977c9 | |||
| 535043731c | |||
| 42d56fc1a5 | |||
| 5bf820700c | |||
| 546081e628 | |||
| e7c598e64c | |||
| 42684a7a1e | |||
| b41b8c8a82 | |||
| e47d40c5c4 | |||
| bc690ff309 | |||
| fbd5cfa242 | |||
| 5b5c7f323d | |||
| d8dcccda28 | |||
| ec2c64cb62 | |||
| c2abe53e92 | |||
| 0a8e72e762 | |||
| 3136f1305b | |||
| 273bd449c8 | |||
| ac1fdfb725 | |||
| 581af67fbc | |||
| 13015c9d36 | |||
| 931a38abdb | |||
| 4e720540d8 |
@@ -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 M1–M6, X25519/Ed25519 Pair-Verify M1–M4, 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 L0–L5 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) Beer–Lambert 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 — Allen–Berkley 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.5–4 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`
|
||||
@@ -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 10–150 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`
|
||||
@@ -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<'_>,
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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"] }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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.
|
||||
|
||||
[](https://crates.io/crates/homecore-hap)
|
||||

|
||||

|
||||
[](https://github.com/ruvnet/RuView)
|
||||
[](../../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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
[](https://crates.io/crates/homecore-migrate)
|
||||

|
||||

|
||||
[](https://github.com/ruvnet/RuView)
|
||||
[](../../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)
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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 { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,60 +1,142 @@
|
||||
//! Parser for `core.device_registry` (HA storage schema v1, minor_version 1–13).
|
||||
//!
|
||||
//! 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();
|
||||
|
||||
@@ -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 },
|
||||
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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,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"] }
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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.
|
||||
|
||||
[](.)
|
||||

|
||||

|
||||
[](../../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)
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 { .. })));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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) ──────────
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user