Merge pull request #1451 from ruvnet/feat/homecore-platform-parity
feat(homecore): complete platform runtime capabilities
This commit is contained in:
commit
42d56fc1a5
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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.
|
||||
|
|
|
|||
|
|
@ -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,316 @@ 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 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(),
|
||||
};
|
||||
if entity_ids.len() > MAX_HISTORY_ENTITIES {
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"history queries are limited to {MAX_HISTORY_ENTITIES} 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 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(),
|
||||
};
|
||||
if entity_ids.len() > MAX_HISTORY_ENTITIES {
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"logbook queries are limited to {MAX_HISTORY_ENTITIES} 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 +426,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 +444,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(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 +483,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 +506,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 +546,162 @@ 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(),
|
||||
))
|
||||
}
|
||||
|
||||
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 event_type.is_empty()
|
||||
|| event_type.len() > 255
|
||||
|| !event_type
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')
|
||||
{
|
||||
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"
|
||||
}
|
||||
})))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
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 {
|
||||
|
|
@ -50,6 +52,19 @@ impl SharedState {
|
|||
homecore_version: homecore_version.into(),
|
||||
location_name: location_name.into(),
|
||||
tokens,
|
||||
recorder: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
|
@ -66,4 +81,7 @@ impl SharedState {
|
|||
pub fn tokens(&self) -> &LongLivedTokenStore {
|
||||
&self.inner.tokens
|
||||
}
|
||||
pub fn recorder(&self) -> Option<&Recorder> {
|
||||
self.inner.recorder.as_ref()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -130,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)]
|
||||
|
|
@ -236,6 +240,12 @@ impl Connection {
|
|||
|
||||
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.try_send(msg.to_string());
|
||||
|
|
@ -254,6 +264,11 @@ 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<
|
||||
|
|
@ -269,6 +284,24 @@ impl Connection {
|
|||
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 {
|
||||
|
|
@ -290,6 +323,50 @@ 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 event_type.is_empty()
|
||||
|| event_type.len() > 255
|
||||
|| !event_type
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')
|
||||
{
|
||||
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" => {
|
||||
// HA uses the subscribing command ID as the subscription ID
|
||||
// in every emitted event and in `unsubscribe_events`.
|
||||
|
|
@ -369,6 +446,7 @@ 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.try_send(payload.to_string()).is_err() { break; }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,104 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -338,3 +338,52 @@ async fn real_state_change_uses_client_subscription_id() {
|
|||
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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,718 @@
|
|||
//! 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(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct StoredAccessory {
|
||||
device_id: String,
|
||||
signing_seed: [u8; 32],
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct StoredSetup {
|
||||
salt: [u8; 16],
|
||||
verifier: Vec<u8>,
|
||||
}
|
||||
|
||||
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])");
|
||||
}
|
||||
|
||||
#[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,144 +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` → an atomically written,
|
||||
HA-compatible HOMECORE registry file; existing destinations are not overwritten
|
||||
- **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).
|
||||
|
|
@ -53,6 +55,19 @@ 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,
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Args)]
|
||||
|
|
|
|||
|
|
@ -1,84 +1,236 @@
|
|||
//! 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_noclobber, 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> {
|
||||
let target = storage_dir.join(DESTINATION_KEY);
|
||||
write_json_atomic_noclobber(&target, envelope)
|
||||
}
|
||||
|
||||
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 +241,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,131 @@
|
|||
//! 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_noclobber},
|
||||
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> {
|
||||
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_noclobber(&target, &payload)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -64,36 +135,49 @@ 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,15 +26,17 @@
|
|||
//! }
|
||||
//! ```
|
||||
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use homecore::{registry::DisabledBy, EntityCategory, EntityEntry, EntityId};
|
||||
|
||||
use crate::{storage::read_envelope, storage_format::v13, MigrateError};
|
||||
use crate::{
|
||||
storage::{read_envelope, write_json_atomic_noclobber},
|
||||
storage_format::v13,
|
||||
MigrateError,
|
||||
};
|
||||
|
||||
// Key used by `inspect` subcommand when scanning the directory.
|
||||
#[allow(dead_code)]
|
||||
|
|
@ -173,21 +175,7 @@ pub fn write_entity_registry(
|
|||
storage_dir: &Path,
|
||||
entries: &[EntityEntry],
|
||||
) -> Result<PathBuf, MigrateError> {
|
||||
fs::create_dir_all(storage_dir).map_err(|source| MigrateError::Io {
|
||||
path: storage_dir.display().to_string(),
|
||||
source,
|
||||
})?;
|
||||
let target = storage_dir.join(FILE_KEY);
|
||||
if target.exists() {
|
||||
return Err(MigrateError::Io {
|
||||
path: target.display().to_string(),
|
||||
source: std::io::Error::new(
|
||||
std::io::ErrorKind::AlreadyExists,
|
||||
"destination exists; refusing to overwrite",
|
||||
),
|
||||
});
|
||||
}
|
||||
let temp = storage_dir.join(format!(".{FILE_KEY}.{}.tmp", std::process::id()));
|
||||
let payload = serde_json::json!({
|
||||
"version": 1,
|
||||
"minor_version": 13,
|
||||
|
|
@ -197,29 +185,7 @@ pub fn write_entity_registry(
|
|||
"deleted_entities": []
|
||||
}
|
||||
});
|
||||
let bytes = serde_json::to_vec_pretty(&payload).map_err(|source| MigrateError::JsonParse {
|
||||
path: target.display().to_string(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
let 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()?;
|
||||
fs::rename(&temp, &target)
|
||||
})();
|
||||
if let Err(source) = result {
|
||||
let _ = fs::remove_file(&temp);
|
||||
return Err(MigrateError::Io {
|
||||
path: target.display().to_string(),
|
||||
source,
|
||||
});
|
||||
}
|
||||
Ok(target)
|
||||
write_json_atomic_noclobber(&target, &payload)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -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,33 +60,35 @@ 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)?;
|
||||
let entries = homecore_migrate::entity_registry::read_entity_registry(&entity_path)?;
|
||||
let destination =
|
||||
homecore_migrate::entity_registry::write_entity_registry(&args.to, &entries)?;
|
||||
println!("Imported {} entity entries", entries.len());
|
||||
println!(" Destination: {}", destination.display());
|
||||
for e in &entries {
|
||||
println!(
|
||||
" {} ({}{})",
|
||||
e.entity_id.as_str(),
|
||||
e.platform,
|
||||
if e.disabled_by.is_some() { " DISABLED" } else { "" }
|
||||
);
|
||||
}
|
||||
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(&args.to, &devices)?;
|
||||
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,
|
||||
|
|
@ -76,6 +96,28 @@ 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(&args.to, &converted)?;
|
||||
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,70 @@ 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> {
|
||||
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()?;
|
||||
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,7 +20,8 @@ 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;
|
||||
|
|
@ -45,6 +46,8 @@ type HistoryStateRecord = (i64, String, Option<String>, f64, f64, Option<String>
|
|||
/// 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)]
|
||||
|
|
@ -302,7 +305,7 @@ impl Recorder {
|
|||
let pattern = format!("%{escaped}%");
|
||||
|
||||
let rows: Vec<SearchStateRecord> = sqlx::query_as(
|
||||
"SELECT s.state_id, s.entity_id, s.state, sa.shared_attrs, \
|
||||
"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 \
|
||||
|
|
@ -312,47 +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<StateRecord> = sqlx::query_as(
|
||||
"SELECT s.entity_id, s.state, sa.shared_attrs, \
|
||||
"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
|
||||
|
|
@ -416,6 +429,24 @@ 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;
|
||||
|
||||
|
|
@ -433,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:
|
||||
|
|
@ -521,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 {
|
||||
|
|
@ -564,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));
|
||||
|
|
@ -595,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");
|
||||
|
|
@ -605,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 ──────────────────────────────────────────────────────────
|
||||
|
|
@ -613,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");
|
||||
|
|
@ -652,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");
|
||||
}
|
||||
|
||||
|
|
@ -677,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);
|
||||
}
|
||||
|
||||
|
|
@ -701,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.
|
||||
|
|
@ -715,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]
|
||||
|
|
@ -749,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
|
||||
|
|
@ -789,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");
|
||||
|
|
@ -816,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
|
||||
|
|
@ -839,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();
|
||||
|
||||
|
|
@ -897,7 +1206,11 @@ mod tests {
|
|||
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;
|
||||
|
|
@ -914,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");
|
||||
|
|
@ -947,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;
|
||||
|
|
@ -956,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");
|
||||
}
|
||||
|
|
@ -995,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");
|
||||
|
|
@ -1017,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.
|
||||
|
|
@ -1039,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,7 +30,10 @@ pub mod schema;
|
|||
pub mod semantic;
|
||||
|
||||
// Re-export the primary public API surface.
|
||||
pub use db::{PurgeStats, Recorder, RecorderError, SemanticIndex, 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.
|
||||
|
|
|
|||
|
|
@ -23,11 +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", optional = true }
|
||||
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"
|
||||
|
|
@ -57,10 +59,13 @@ 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 = []
|
||||
# Pull in ruvector-backed semantic memory.
|
||||
ruvector = ["homecore-recorder/ruvector"]
|
||||
# Pull in real Wasmtime plugin runtime (vs InProcessRuntime).
|
||||
wasmtime = ["dep:homecore-plugins", "homecore-plugins/wasmtime"]
|
||||
wasmtime = ["homecore-plugins/wasmtime"]
|
||||
# Bind the HAP TCP listener and publish `_hap._tcp` over mDNS.
|
||||
hap-server = ["dep:homecore-hap", "homecore-hap/hap-server"]
|
||||
|
|
|
|||
|
|
@ -26,6 +26,11 @@ a default token. Configure allowed browser origins with
|
|||
## 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
|
||||
|
|
@ -67,6 +72,8 @@ unregistered and return an error rather than a false acknowledgement.
|
|||
|---|---|---|
|
||||
| `--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` |
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ 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)
|
||||
}
|
||||
|
||||
|
|
@ -728,7 +728,7 @@ mod tests {
|
|||
"/api/homecore/seeds",
|
||||
"/api/homecore/federation",
|
||||
"/api/homecore/witness",
|
||||
"/api/events",
|
||||
"/api/homecore/events",
|
||||
] {
|
||||
let (status, body) = send(gateway_router(gw()), "GET", p).await;
|
||||
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{p} should be 503");
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -37,6 +37,9 @@ 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).
|
||||
|
|
@ -83,6 +86,18 @@ struct Cli {
|
|||
#[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,
|
||||
|
|
@ -103,12 +118,71 @@ struct Cli {
|
|||
/// 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);
|
||||
|
|
@ -140,6 +214,30 @@ async fn main() -> Result<()> {
|
|||
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
|
||||
|
|
@ -158,24 +256,28 @@ async fn main() -> Result<()> {
|
|||
}
|
||||
|
||||
// ── 2. Recorder (optional) ──────────────────────────────────────
|
||||
if !cli.no_recorder {
|
||||
match open_recorder(&cli.db).await {
|
||||
Ok(recorder) => {
|
||||
let _recorder_task = RecorderListener::new(hc.states(), recorder).spawn();
|
||||
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 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()`
|
||||
// spawns the state-change event loop + the 1 Hz wall-clock timer
|
||||
|
|
@ -213,7 +315,8 @@ async fn main() -> Result<()> {
|
|||
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.
|
||||
|
|
@ -222,6 +325,19 @@ async fn main() -> Result<()> {
|
|||
"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 {
|
||||
|
|
@ -272,6 +388,8 @@ async fn main() -> Result<()> {
|
|||
info!("Shutdown requested; draining active HTTP connections");
|
||||
})
|
||||
.await?;
|
||||
hap_runtime.shutdown().await?;
|
||||
server_plugins.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,6 +20,7 @@ struct HomeCoreInner {
|
|||
pub states: StateMachine,
|
||||
pub services: ServiceRegistry,
|
||||
pub entities: EntityRegistry,
|
||||
pub devices: DeviceRegistry,
|
||||
}
|
||||
|
||||
impl HomeCore {
|
||||
|
|
@ -31,6 +32,7 @@ impl HomeCore {
|
|||
services: ServiceRegistry::with_event_bus(bus.clone()),
|
||||
bus,
|
||||
entities: EntityRegistry::new(),
|
||||
devices: DeviceRegistry::new(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
|
@ -50,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 {
|
||||
|
|
|
|||
|
|
@ -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>>>,
|
||||
|
|
@ -89,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::*;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
@ -158,6 +157,19 @@ impl StateMachine {
|
|||
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);
|
||||
|
|
@ -213,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::*;
|
||||
|
|
|
|||
|
|
@ -1,50 +1,81 @@
|
|||
# HOMECORE capability status
|
||||
|
||||
This document describes the implemented runtime surface as of the current
|
||||
alpha. “Implemented” means wired into `homecore-server` and covered by tests;
|
||||
workspace libraries alone are not presented as server capabilities.
|
||||
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.
|
||||
|
||||
## Implemented
|
||||
## Runtime capabilities
|
||||
|
||||
| Area | Runtime behavior |
|
||||
| Area | Current behavior |
|
||||
|---|---|
|
||||
| Core | Concurrent entity state machine, entity registry API, shared system/domain event bus, service registry, contexts |
|
||||
| Event flow | Committed state changes reach state subscribers and the shared event bus; service calls emit system events |
|
||||
| REST | API root, config, list/get/set/delete state, list/call service |
|
||||
| WebSocket | Auth handshake, ping, config/states/services queries, service calls, event subscribe/unsubscribe |
|
||||
| Backpressure | Per-connection WebSocket output is bounded to 256 messages; slow clients are disconnected from overflowing subscriptions |
|
||||
| Authentication | Token allowlist from `HOMECORE_TOKENS`; missing tokens fail startup unless insecure development mode is explicitly enabled |
|
||||
| Recorder | SQLite state history listener; broadcast lag is recovered by resynchronizing current state; optional ruvector semantic index |
|
||||
| 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 |
|
||||
| Assist | Authenticated `POST /api/intent/handle` with bounded regex recognition and five local handlers |
|
||||
| Built-in services | Real state mutation for `homeassistant`, `light`, and `switch` on/off/toggle; ping and state snapshot |
|
||||
| Migration | HA entity registry v1/minor 1–13 parsing and atomic, no-overwrite persistence into a HOMECORE storage directory |
|
||||
| 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 |
|
||||
|
||||
## Exact HA-style HTTP surface
|
||||
## 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`
|
||||
|
||||
This is a compatible subset, not full Home Assistant parity.
|
||||
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.
|
||||
|
||||
## Explicitly deferred
|
||||
`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.
|
||||
|
||||
- Loading native or Wasmtime plugins from `homecore-server`
|
||||
- A network HomeKit Accessory Protocol server, pairing, and mDNS advertisement
|
||||
- Device-registry persistence and full config-entry conversion
|
||||
- Full HA event/history/template/config/check endpoints
|
||||
- Restore-state on server startup
|
||||
- STT/TTS and satellite voice protocols
|
||||
- SEED/federation/witness/privacy upstream services when their daemons are absent
|
||||
- Claiming Home Assistant integration parity or production-scale performance
|
||||
## Security and deployment constraints
|
||||
|
||||
Unavailable BFF upstreams return typed `503 upstream_unavailable`. Unsupported
|
||||
services are not registered. Synthetic biometric/demo entities are opt-in and
|
||||
must not be interpreted as live sensing data.
|
||||
- `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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue