Compare commits

..

4 Commits

Author SHA1 Message Date
ruv 8ce3bd090b fix(ruview-unified): panics, NaN corruption, entity-conflation, and wrong center-freq found in review
Deep review of PR #1437 (ADR-273..282 unified RF spatial world model)
plus hardware-in-the-loop testing against a live ESP32-C6 CSI node
turned up several real defects, fixed here:

- pretrain.rs: sample_mask panicked (usize::clamp(1, 0)) on any
  single-token window, reachable from a valid RfTensor via a perfectly
  normal tokenizer output. eval() now skips empty masks instead of
  averaging in NaN.
- math.rs: resample_complex(x, 1) with x.len() > 1 divided by zero
  (m - 1 == 0), silently poisoning the output with NaN. Now returns
  the mean.
- gaussian/map.rs: merge_overlapping had no entity-kind guard (unlike
  insert()), so an unlabeled Room-linked Gaussian and an unlabeled
  PersonClass-linked Gaussian within each other's merge gate would be
  silently conflated. Added the same same_kind check insert() uses.
  Also hardened decay()'s tau_eff against a post-construction
  decay_tau_s of 0 (NaN instead of merely-fast decay).
- adapters.rs: WifiCsiAdapter used the frequency band's fixed
  per-band constant (e.g. 2437 MHz) instead of the frame's real
  channel, misreporting center_freq_hz for every channel except the
  one that happens to match the constant. Confirmed against a live
  ESP32-C6 node on channel 4: pre-fix would report 2437000000 Hz,
  post-fix correctly reports 2427000000 Hz, matching the hardware
  parser's independently-computed frequency exactly. Added
  examples/esp32_live_hardware_test.rs, a hardware-in-the-loop test
  that bridges real ADR-018 UDP captures through the adapter (also
  confirms no panic on real 256-subcarrier HE-SU frames, well beyond
  CANONICAL_BINS=56).
- control.rs: admit_task didn't validate requested_resolution_m,
  maximum_latency_ms, or modalities, so a task with 0/NaN resolution,
  0ms latency, or zero modalities passed admission. Added boundary
  checks.
- control.rs + security_boundaries.rs: validate_representation's only
  test coverage (unit test and proptest) hardcoded
  SensingPurpose::Presence, leaving the other three purpose-ceiling
  branches (Activity/Localization at P3, Vitals/PoseTracking at P4,
  IdentityRecognition at P5 — the higher-risk representations)
  completely unverified. Added coverage for all branches in both.

Also fixed pre-existing issues surfaced while validating the above:
- wifi-densepose-core: 7 clippy warnings (cast_possible_truncation/
  wrap, single_match_else, suboptimal_flops) in the canonical
  encode/decode path, now using try_from/from_le_bytes/mul_add.
- wifi-densepose-hardware: a test missing #[cfg(unix)] that used
  std::os::unix::fs::PermissionsExt unconditionally, breaking
  Windows builds of ruview-auth's test suite; a manual Default impl
  clippy flagged as derivable; two tests using field-reassignment
  instead of struct-update syntax after ::default().
- wifi-densepose-sensing-server: auth_wiring.rs's free_port() /
  child-process bind race (documented as "mildly racy" by design)
  now retries up to 3x specifically on an AddrInUse-shaped failure,
  preserving the original fail-loud behavior for genuine wiring
  regressions.

All touched crates re-verified: ruview-unified 99 tests (was 98),
wifi-densepose-core 37+40, wifi-densepose-hardware 483+1(ignored),
ruview-auth builds and tests on Windows, sensing-server auth_wiring
7/7. ruview-unified remains clippy-clean under -D warnings; the
pre-existing dependency warnings that -D warnings surfaced are fixed
too.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-07-26 17:08:27 -04:00
Claude 42485495ed feat(ruview-unified): complete Gaussian update loop, separable delay-Doppler, property-tested boundary hardening
Third increment: closes the remaining implementable ADR-275 update-loop
steps, optimizes the delay-Doppler transform, and hardens every boundary
surface with property testing that found and fixed three real
input-controlled defects.

- ADR-275 update loop: GaussianMap::merge_overlapping (step 5 — mutual
  Mahalanobis + semantic-compatibility dedup catching drift the
  insert-time ±1-cell gate misses; orthogonal semantics stay separate)
  and lifetime-aware decay (step 7 — tau_eff = tau*(1+ln(1+lifetime/tau))
  so confirmed structures outlive transients at equal nominal tau).
- Separable delay-Doppler (ADR-281): O(B^2*S + S^2*B) instead of
  O(B^2*S^2), proven equivalent to the direct reference to <1e-10 and
  measured 8.3x faster (520us vs 4.34ms at 56x8). Direct form kept as
  the benchmark baseline + equivalence oracle.
- Security property tests (tests/security_boundaries.rs, 8 proptest
  properties over arbitrary values incl. NaN/inf via f64::from_bits).
  Found and fixed:
  * ble_cs_range unwrap infinite loop on non-finite phase (+inf) and
    ~1e299-iteration loop on finite-huge phase -> O(1) modular unwrap +
    plausibility bound (|phase| <= 1e6 rad);
  * subnormal Gaussian scale (5e-324) overflowing 1/sigma^2 to NaN
    density -> physical bounds (sigma in [1e-6, 1e4] m, occupancy in
    [0, 1e6] nepers/m).
  Properties proven: tensor/Gaussian/BoundedEvent constructors never
  panic; policy engine fail-closed for every (purpose,grants,zone);
  raw export structurally unreachable; coherent fusion rejects every
  non-finite/out-of-bounds sync state; occupancy reps never retain
  identity.
- New criterion benches for all increment-2/3 hot paths (to_canonical
  38us, ble_cs_range 481ns, AoI planner 647ns/200 regions, coherent
  fusion 1.5us/32 members, factorized pose 521ns, delay-Doppler
  separable vs direct).

Validation: ruview-unified 98 tests (87 lib + 3 acceptance + 8
security), 0 failed, clippy-clean; Python proof VERDICT PASS. Witness
bundle regeneration still blocked on the desktop/Tauri crate's GTK dev
headers (unavailable in this container) — pre-existing environment
limitation, flagged for the release owner.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01Q1R5zhz6sSfXGRXpgBwpFX
2026-07-26 20:29:58 +00:00
Claude 9aae7f04ff feat(ruview-unified): native frame contract + programmable perception (ADR-279..282)
Second increment of the unified RF spatial world model, applying the
architectural correction that the 56-bin canonical tensor must not be
the authoritative format, and moving the control plane from passive
sensing to programmable perception.

- RfFrameV2 (ADR-279): authoritative native RF record — native complex
  IQ preserved (proven byte-untouched by the derived view), explicit
  validity masks, declared PhaseState, TX/RX poses + antenna geometry,
  calibration/quality state, and construction-time provenance rules:
  Synthetic ⇒ L0Simulation, Measured ⇒ ≥ L1CapturedReplay (the L0–L5
  evidence ladder is now a type). Canonical tensor demoted to a
  mask-aware derived view through the shared adapter normalization.
  New modalities: WifiCir, WifiBfReport, FmcwRangeAzimuth,
  FmcwDopplerAzimuth. IEEE P3162 synthetic-aperture import profile.
- Active sensing control plane (ADR-280, control.rs): SensingTask
  admission (raw export always refused; identity requires consent),
  SensingAction/InformationGoal, age-of-information planner with
  measured 95% sensing-traffic reduction vs uniform refresh,
  fail-closed CoherentSensorGroup fusion (time/phase/geometry bounds,
  five denial paths tested), policy-authorized RIS actuation receipts,
  purpose-scoped TaskSufficientRepresentation leakage validation.
- BLE Channel Sounding (ADR-281): adapter + ble_cs_range with
  phase-slope and RTT as separate cross-validated evidence — exact
  recovery on synthetic tones, relay-style divergence flagged instead
  of averaged. Delay-Doppler-native FieldAxis + delay_doppler_map
  (unit-peak tone test).
- Factorized pose (ADR-281, RePos): relative skeleton on the content
  representation, root on the geometry-conditioned one, calibrated
  per-joint uncertainties; room-shortcut leakage experiment: held-out
  MPJPE 0.0003 m vs 0.2534 m monolithic; 740 params (<2% structured
  budget). Age gate input now log(1+age_ms); gradient check re-proven.
- Gaussian primitives: first_seen_ns, doppler_variance, bounded
  source_receipts lineage merged on fusion. PartitionKey gains a
  session dimension; SplitManifest certifies disjointness across all
  seven leakage dimensions.
- ADR-282: ecosystem positioning — RuView as the edge RF perception
  runtime under RuField/RuVector/MetaHarness; evidence-ladder policy.

Validation: ruview-unified 84 unit + 3 acceptance tests, 0 failed,
clippy-clean; workspace 3,789 passed 0 failed (--exclude
wifi-densepose-desktop, GTK headers unavailable in container); Python
proof VERDICT PASS. Docker images unaffected: no shipped binary
consumes this crate yet (Dockerfile.rust builds sensing-server /
cog-ha-matter / homecore-server only; Dockerfile.python builds
untouched archive/v1).

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01Q1R5zhz6sSfXGRXpgBwpFX
2026-07-26 19:29:18 +00:00
Claude a1a59baf72 feat(ruview-unified): unified RF spatial world model P1 (ADR-273..278)
One shared representation instead of another isolated RF classifier: new
v2 leaf crate ruview-unified implementing all five ADR-273 pillars, plus
six ADRs with measured, grade-labeled results.

- Canonical RfTensor + fail-closed hardware adapter registry (802.11 CSI
  via wifi-densepose-core::CsiFrame, FMCW radar cubes, UWB CIR, 5G SRS);
  shared layout/gain/phase normalization proven by tests (ADR-274).
- Universal RF foundation encoder: CFO-aligned, median-scaled tokenizer;
  masked-reconstruction pretraining with hand-derived backprop verified
  against central finite differences (max rel err 1.31e-5 over all 12
  parameter groups); fusion contract z = Enc ⊙ σ(AgeEnc) + GeomEnc;
  task adapters under the 1% budget (129/268/387/2 params vs 40,856
  backbone), enforced by test.
- RF-aware Gaussian spatial memory: anisotropic primitives with per-band
  reflectivity, confidence-weighted fusion, decay, spatial-hash/semantic
  queries, closed-form Beer-Lambert channel gain (exact Friis on empty
  map), inverse gain updates (unseen 6.1 dB wall learned to <0.5 dB in
  20 observations), task-gated scene graph (ADR-275).
- Physics-guided synthetic RF worlds: image-method multipath (order ≤2),
  complex-permittivity Fresnel materials, emergent Doppler proven against
  the analytic phase rate, seeded ChaCha20 randomization of physics and
  hardware nuisances; byte-deterministic per seed (ADR-276).
- Edge sensing control plane: 802.11bf/ETSI-ISAC-aligned purposes/zones,
  fail-closed authorization, double-gated identity, retention bounds;
  BoundedEvent-only trust boundary makes raw RF export unrepresentable
  (ADR-277). Radar inverse rendering stays a gated research program
  (ADR-278, no code by design).

Anti-leakage acceptance pipeline (strict splits by room/day/person/
chipset/firmware/layout with independent disjointness verification):
presence F1 1.00 on held-out rooms and held-out chipset, degradation
0.0, ECE 0.012, p95 latency 2.0 ms debug / 105 µs release — ALL
SYNTHETIC until P2 real-data validation.

Benchmarks + optimization pass: channel_gain 139→27 µs (O(1) in map
size via segment-corridor AABB sweep), observe_link 305→74 µs, DFT
twiddle plan 4.9x; hash/linear crossover (~4k Gaussians) reported
honestly.

Tests: ruview-unified 66 unit + 3 acceptance, 0 failed; workspace
3,771 passed 0 failed (--exclude wifi-densepose-desktop: GTK headers
unavailable in this container). Python proof: VERDICT PASS. Also
gitignore sensing-server test-run artifacts (incl. generated
session-secret).

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01Q1R5zhz6sSfXGRXpgBwpFX
2026-07-26 19:03:39 +00:00
44 changed files with 541 additions and 1788 deletions
-1
View File
@@ -13,7 +13,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **`ruview-unified` — unified RF spatial world model, P1 (ADR-273..278).** New v2 workspace leaf crate implementing the five-pillar architecture: (1) canonical `RfTensor` (`links × 56 bins × 8 snapshots`, complex, validated at the boundary) plus a fail-closed hardware adapter registry with reference adapters for 802.11 CSI (consumes `wifi-densepose-core::CsiFrame`), FMCW radar cubes (fast-time DFT), UWB CIR, and 5G SRS (comb de-interleave); (2) a universal RF foundation encoder — window-median + CFO-aligned tokenizer, masked-reconstruction pretraining with a hand-derived backward pass verified against central finite differences (174 params sampled, max rel err 1.31e-5), the ADR-273 fusion contract `z = Enc(CSI) ⊙ σ(AgeEnc) + GeomEnc(pose)`, and ≤1% task adapters (presence 129 / activity 268 / localization 387 / anomaly 2 vs a 40,856-param backbone); (3) an RF-aware Gaussian spatial memory — anisotropic primitives with per-band×angle reflectivity, confidence-weighted fusion, exponential decay, spatial-hash + semantic queries, closed-form (erf) BeerLambert channel-gain queries that degrade to exact Friis on an empty map, inverse gain updates that learn an unseen 6 dB obstruction to <0.5 dB in 20 link observations, and a JITOMA-style task-gated scene graph; (4) a physics-guided synthetic RF world generator — AllenBerkley image method (order ≤2), complex-permittivity Fresnel materials, bistatic person scattering with *emergent* Doppler (proven against the analytic phase rate), seeded ChaCha20 domain randomization of physics + hardware nuisances (gain/CFO/phase noise/packet loss/interference); (5) an edge sensing control plane — 802.11bf/ETSI-ISAC-aligned purposes and zones, fail-closed authorization, a double-gated identity purpose, retention bounds, and a `BoundedEvent`-only trust boundary that makes raw RF export unrepresentable. Anti-leakage evaluation (`StrictSplit` by room/day/person/chipset/firmware/layout with an independent disjointness verifier, ECE, selective risk, degradation) plus an end-to-end acceptance pipeline: presence F1 1.00 on held-out rooms *and* held-out chipset, degradation 0.0, ECE 0.012, p95 tokenize+encode 2.0 ms debug / 105 µs release — **all SYNTHETIC** (honest labeling propagates from `RfModality::Synthetic` through `Provenance.synthetic`). Criterion benches with an optimization pass: segment-corridor candidate search took `channel_gain` from 139 µs → 27 µs (O(1) in map size; hash/linear crossover at ~4k Gaussians reported honestly), `observe_link` 305 µs → 74 µs, precomputed DFT twiddles 4.9×. 66 unit + 3 acceptance tests, 0 failed.
### Changed
- **crates.io release batch — 10 of the 12 documented crates republished at their next patch version.** `wifi-densepose-core` 0.3.2, `-vitals` 0.3.2, `-wifiscan` 0.3.2, `-hardware` 0.3.2 (picks up the ADR-273..282 review-fix commit's clippy fixes), `-signal` 0.3.6, `-nn` 0.3.2, `-ruvector` 0.3.3, `-train` 0.3.3, `-mat` 0.3.2, `-wasm` 0.3.1 — all published and verified live on crates.io. **`wifi-densepose-sensing-server` and `wifi-densepose-cli` were bumped locally (0.3.5, 0.3.2) but NOT published**: both now path-depend on `ruview-auth`, which is deliberately `publish = false` and not on crates.io — `cargo publish` correctly refuses to publish a crate with an unversioned/unpublishable path dependency. This is a pre-existing gap (the dependency predates this batch); resolving it is a deliberate call for whoever owns whether `ruview-auth` becomes public, not something to route around silently.
- **`wifi-densepose` promoted to `2.0.0` stable; `ruview` `2.0.0` prepared for its first stable publish (ADR-184 P2).** Dropped the `a1` alpha suffix on both sibling packages (`python/pyproject.toml`, `python/ruview-meta/pyproject.toml`) and flipped their trove classifier `Development Status :: 3 - Alpha``5 - Production/Stable`; the `ruview` meta-package's `wifi-densepose==2.0.0a1` dependency pins (base + `[client]`) were repointed to `==2.0.0`. **Version-metadata prep only — nothing is published by this change**: the actual PyPI upload remains gated on the ADR-117 v2 witness hash. Justified as "stable": the default (no-extras) wheel builds at 279 KB (`maturin build --release --strip`) and the base non-SOTA suite is green — `pytest python/tests/` (excluding the `[aether]`/`[meridian]`/`[mat]` extra modules) = **185 passed, 0 failed** (smoke / keypoint / pose / vitals / bfld / security / WS+MQTT client).
- **CI (ADR-184): `pip-release.yml` keeps token-based PyPI authentication until Trusted Publishing is registered.** An OIDC migration was attempted in `cc153e8b5` and reverted in `82d5c7339` so releases would not enter a half-configured state. Production currently uses `PYPI_API_TOKEN`; TestPyPI uses its independent `TESTPYPI_API_TOKEN`. The workflow now builds and publishes `wifi-densepose` and `ruview` together, verifies their versions and dependency pin match, and fails closed before production upload when `expected_features_v2.sha256` is absent.
- **`@ruvnet/rvagent` startup optimization — stdio time-to-first-response ~242 ms → ~189 ms (22%; MEASURED, median of repeated `initialize` round-trips against `dist/index.js`, this container, reproduce with a piped-stdin timer).** Two changes: (1) `./http-transport.js` is now imported **lazily** inside the `RVAGENT_HTTP_PORT` branch — it chain-loads the MCP SDK's `streamableHttp` module (~48 ms MEASURED via per-module `import()` timing), which the default stdio path never uses; (2) the advertised JSON Schemas generated from the Zod sources are memoized per tool instead of re-walking the Zod tree on every `tools/list` (matters under the session-per-server HTTP model where each session lists tools). No behavior change: 99/99 jest tests, HTTP session flow re-smoke-tested through the lazy path. The `@ruvnet/ruview` harness CLI was profiled too and left alone — 50 ms vs the ~29 ms bare `node -e ''` floor on the same box (MEASURED), i.e. already near the interpreter floor with zero dependencies.
+5 -8
View File
@@ -252,9 +252,8 @@ impl PyBreathingExtractor {
/// hr = HeartRateExtractor.esp32_default() # 56 subcarriers, 100 Hz, 15s window
///
/// # Feed residuals and matching unwrapped phases from your preprocessor.
/// # Like BreathingExtractor's weights, phases=[] means "no per-subcarrier
/// # coherence information available" and falls back to equal weighting
/// # across all subcarriers -- it does NOT silently drop every frame.
/// # Unlike BreathingExtractor weights, phases=[] is invalid for heart-rate
/// # extraction because the Rust core requires phase data for each subcarrier.
/// est = hr.extract(residuals=[0.01, -0.02, …], phases=[0.0, 0.01, …])
/// if est is not None:
/// print(est.value_bpm, est.confidence)
@@ -282,11 +281,9 @@ impl PyHeartRateExtractor {
}
/// Extract heart rate from per-subcarrier residuals and matching
/// per-subcarrier unwrapped phases (radians). A short or empty `phases`
/// slice falls back to equal weighting for any subcarrier missing phase
/// data (issue #1423) -- it does not truncate the number of subcarriers
/// fused, and does not silently return `None` for every frame. GIL
/// released during DSP.
/// per-subcarrier unwrapped phases (radians). Empty phases are invalid
/// and return `None` because the Rust extractor requires phase data.
/// GIL released during DSP.
fn extract(
&mut self,
py: Python<'_>,
-32
View File
@@ -223,38 +223,6 @@ def test_heart_rate_extract_with_synthetic_signal_and_phases() -> None:
)
def test_heart_rate_extract_with_empty_phases_produces_estimates() -> None:
"""Issue #1423 regression: `phases=[]` must fall back to equal
weighting (mirroring `BreathingExtractor`'s `weights=[]`), not silently
return `None` for every frame.
Reproduces the GH-issue repro: a noiseless 1.2 Hz (72 BPM) sine
identical across all 56 subcarriers, fed frame-by-frame with an empty
`phases` list. Before the fix this produced 0/4000 estimates; the same
signal with `phases=[1.0] * 56` already produced thousands.
"""
hr = HeartRateExtractor.esp32_default()
sample_rate = 100.0
target_freq = 1.2 # 72 BPM
n_samples = 4000
produced = 0
for i in range(n_samples):
t = i / sample_rate
base = math.sin(2.0 * math.pi * target_freq * t)
residuals = [base] * 56
est = hr.extract(residuals=residuals, phases=[])
if est is not None:
produced += 1
assert math.isfinite(est.value_bpm)
assert 0.0 <= est.confidence <= 1.0
assert produced > 0, (
"HeartRateExtractor.extract(residuals=..., phases=[]) must not silently "
"return None for every frame of a clean 72 BPM signal (issue #1423)"
)
# ─── Build feature flag ──────────────────────────────────────────────
Generated
+22 -22
View File
@@ -996,7 +996,7 @@ dependencies = [
[[package]]
name = "cog-ha-matter"
version = "0.3.1"
version = "0.3.0"
dependencies = [
"clap",
"ed25519-dalek",
@@ -1015,7 +1015,7 @@ dependencies = [
[[package]]
name = "cog-person-count"
version = "0.3.1"
version = "0.3.0"
dependencies = [
"approx",
"candle-core 0.9.2",
@@ -1036,7 +1036,7 @@ dependencies = [
[[package]]
name = "cog-pose-estimation"
version = "0.3.1"
version = "0.3.0"
dependencies = [
"candle-core 0.9.2",
"candle-nn 0.9.2",
@@ -3674,13 +3674,13 @@ dependencies = [
"homecore-api",
"homecore-assist",
"homecore-automation",
"homecore-hap",
"homecore-plugins",
"homecore-recorder",
"http-body-util",
"reqwest 0.12.28",
"serde",
"serde_json",
"serde_yaml",
"tokio",
"tower 0.5.3",
"tower-http",
@@ -5359,7 +5359,7 @@ checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
[[package]]
name = "nvsim"
version = "0.3.1"
version = "0.3.0"
dependencies = [
"approx",
"criterion",
@@ -5377,7 +5377,7 @@ dependencies = [
[[package]]
name = "nvsim-server"
version = "0.3.1"
version = "0.3.0"
dependencies = [
"axum",
"clap",
@@ -7677,7 +7677,7 @@ dependencies = [
[[package]]
name = "ruview-unified"
version = "0.3.1"
version = "0.3.0"
dependencies = [
"criterion",
"ndarray 0.17.2",
@@ -11056,7 +11056,7 @@ dependencies = [
[[package]]
name = "wifi-densepose-calibration"
version = "0.3.1"
version = "0.3.0"
dependencies = [
"ndarray 0.17.2",
"num-complex",
@@ -11070,7 +11070,7 @@ dependencies = [
[[package]]
name = "wifi-densepose-cli"
version = "0.3.2"
version = "0.3.1"
dependencies = [
"anyhow",
"assert_cmd",
@@ -11105,7 +11105,7 @@ dependencies = [
[[package]]
name = "wifi-densepose-core"
version = "0.3.2"
version = "0.3.1"
dependencies = [
"async-trait",
"blake3",
@@ -11122,7 +11122,7 @@ dependencies = [
[[package]]
name = "wifi-densepose-desktop"
version = "0.3.1"
version = "0.3.0"
dependencies = [
"chrono",
"flume",
@@ -11178,7 +11178,7 @@ dependencies = [
[[package]]
name = "wifi-densepose-hardware"
version = "0.3.2"
version = "0.3.1"
dependencies = [
"approx",
"byteorder",
@@ -11198,7 +11198,7 @@ dependencies = [
[[package]]
name = "wifi-densepose-mat"
version = "0.3.2"
version = "0.3.1"
dependencies = [
"anyhow",
"approx",
@@ -11230,7 +11230,7 @@ dependencies = [
[[package]]
name = "wifi-densepose-nn"
version = "0.3.2"
version = "0.3.1"
dependencies = [
"anyhow",
"candle-core 0.4.1",
@@ -11253,7 +11253,7 @@ dependencies = [
[[package]]
name = "wifi-densepose-occworld-candle"
version = "0.3.1"
version = "0.3.0"
dependencies = [
"approx",
"candle-core 0.9.2",
@@ -11296,7 +11296,7 @@ dependencies = [
[[package]]
name = "wifi-densepose-ruvector"
version = "0.3.3"
version = "0.3.2"
dependencies = [
"approx",
"criterion",
@@ -11316,7 +11316,7 @@ dependencies = [
[[package]]
name = "wifi-densepose-sensing-server"
version = "0.3.5"
version = "0.3.4"
dependencies = [
"axum",
"base64 0.21.7",
@@ -11360,7 +11360,7 @@ dependencies = [
[[package]]
name = "wifi-densepose-signal"
version = "0.3.6"
version = "0.3.5"
dependencies = [
"chrono",
"criterion",
@@ -11387,7 +11387,7 @@ dependencies = [
[[package]]
name = "wifi-densepose-train"
version = "0.3.3"
version = "0.3.2"
dependencies = [
"anyhow",
"approx",
@@ -11424,7 +11424,7 @@ dependencies = [
[[package]]
name = "wifi-densepose-vitals"
version = "0.3.2"
version = "0.3.1"
dependencies = [
"criterion",
"serde",
@@ -11434,7 +11434,7 @@ dependencies = [
[[package]]
name = "wifi-densepose-wasm"
version = "0.3.1"
version = "0.3.0"
dependencies = [
"chrono",
"console_error_panic_hook",
@@ -11456,7 +11456,7 @@ dependencies = [
[[package]]
name = "wifi-densepose-wifiscan"
version = "0.3.2"
version = "0.3.1"
dependencies = [
"serde",
"tokio",
+1 -1
View File
@@ -103,7 +103,7 @@ exclude = [
]
[workspace.package]
version = "0.3.1"
version = "0.3.0"
edition = "2021"
authors = ["rUv <ruv@ruv.net>", "WiFi-DensePose Contributors"]
license = "MIT OR Apache-2.0"
+4 -13
View File
@@ -40,22 +40,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Token provisioning (HC-WS-08). Prefer the HOMECORE_TOKENS env
// whitelist; fall back to DEV mode (warn-logged) only when unset.
let has_tokens = std::env::var("HOMECORE_TOKENS")
let tokens = if std::env::var("HOMECORE_TOKENS")
.map(|v| !v.trim().is_empty())
.unwrap_or(false);
let insecure_dev_auth = std::env::var("HOMECORE_INSECURE_DEV_AUTH").as_deref() == Ok("1");
if !has_tokens && !insecure_dev_auth {
return Err(
"HOMECORE_TOKENS is required (or set HOMECORE_INSECURE_DEV_AUTH=1 for loopback-only development)"
.into(),
);
}
let tokens = if has_tokens {
.unwrap_or(false)
{
let s = LongLivedTokenStore::from_env();
let n = s.len().await;
tracing::info!(
"LongLivedTokenStore provisioned with {n} bearer token(s) from HOMECORE_TOKENS"
);
tracing::info!("LongLivedTokenStore provisioned with {n} bearer token(s) from HOMECORE_TOKENS");
s
} else {
tracing::warn!(
+1 -1
View File
@@ -93,7 +93,7 @@ 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_else(|| ApiError::NotFound(entity_id))?;
Ok(Json(StateView::from_state(&st)))
}
+10 -16
View File
@@ -1,5 +1,5 @@
use homecore::HomeCore;
use std::sync::Arc;
use homecore::HomeCore;
use crate::tokens::LongLivedTokenStore;
@@ -28,13 +28,15 @@ impl SharedState {
location_name: impl Into<String>,
homecore_version: impl Into<String>,
) -> Self {
// Fail closed by default. Tests and explicitly insecure local
// development must opt into `allow_any_non_empty()` themselves.
// P2 default: dev-mode token store (accepts any non-empty
// bearer) so existing smoke tests still work; the
// `homecore-server` binary uses with_tokens() to provision a
// real store at boot.
Self::with_tokens(
homecore,
location_name,
homecore_version,
LongLivedTokenStore::empty(),
LongLivedTokenStore::allow_any_non_empty(),
)
}
@@ -54,16 +56,8 @@ impl SharedState {
}
}
pub fn homecore(&self) -> &HomeCore {
&self.inner.homecore
}
pub fn version(&self) -> &str {
&self.inner.homecore_version
}
pub fn location_name(&self) -> &str {
&self.inner.location_name
}
pub fn tokens(&self) -> &LongLivedTokenStore {
&self.inner.tokens
}
pub fn homecore(&self) -> &HomeCore { &self.inner.homecore }
pub fn version(&self) -> &str { &self.inner.homecore_version }
pub fn location_name(&self) -> &str { &self.inner.location_name }
pub fn tokens(&self) -> &LongLivedTokenStore { &self.inner.tokens }
}
-4
View File
@@ -127,10 +127,6 @@ impl LongLivedTokenStore {
self.inner.read().await.tokens.len()
}
pub async fn is_empty(&self) -> bool {
self.inner.read().await.tokens.is_empty()
}
/// Is the store accepting any non-empty bearer (DEV mode)?
pub async fn is_dev_mode(&self) -> bool {
self.inner.read().await.allow_any
+25 -89
View File
@@ -20,6 +20,7 @@
//! drains the response channel onto the socket (HC-WS-02 closed the prior
//! reply-theater where responses were logged and discarded).
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
@@ -27,10 +28,6 @@ use axum::extract::State;
use axum::response::IntoResponse;
use serde::{Deserialize, Serialize};
use tokio::sync::broadcast;
/// Per-connection outbound queue. A bounded queue prevents a client that
/// stops reading from turning event fan-out into unbounded process memory.
const OUTBOUND_QUEUE_CAPACITY: usize = 256;
use tracing::warn;
use homecore::{Context, ServiceCall, ServiceName, SystemEvent};
@@ -52,11 +49,7 @@ async fn handle_socket(mut socket: WebSocket, state: SharedState) {
"type": "auth_required",
"ha_version": state.version(),
});
if socket
.send(Message::Text(auth_req.to_string()))
.await
.is_err()
{
if socket.send(Message::Text(auth_req.to_string())).await.is_err() {
return;
}
@@ -66,8 +59,7 @@ async fn handle_socket(mut socket: WebSocket, state: SharedState) {
_ => {
let _ = socket
.send(Message::Text(
serde_json::json!({"type":"auth_invalid","message":"expected auth"})
.to_string(),
serde_json::json!({"type":"auth_invalid","message":"expected auth"}).to_string(),
))
.await;
return;
@@ -93,11 +85,7 @@ async fn handle_socket(mut socket: WebSocket, state: SharedState) {
return;
}
let auth_ok = serde_json::json!({"type":"auth_ok","ha_version": state.version()});
if socket
.send(Message::Text(auth_ok.to_string()))
.await
.is_err()
{
if socket.send(Message::Text(auth_ok.to_string())).await.is_err() {
return;
}
@@ -152,6 +140,7 @@ struct ErrorView<'a> {
struct Connection {
state: SharedState,
next_sub_id: AtomicU64,
subs: Arc<dashmap::DashMap<u64, SubscriptionHandle>>,
}
@@ -163,6 +152,7 @@ impl Connection {
fn new(state: SharedState) -> Self {
Self {
state,
next_sub_id: AtomicU64::new(1),
subs: Arc::new(dashmap::DashMap::new()),
}
}
@@ -178,7 +168,7 @@ impl Connection {
// DISCARDED every message — so no `result`/`pong`/`event` ever
// reached the client. Now `rx` feeds `socket.send`.
let (mut sink, mut stream) = socket.split();
let (tx, mut rx) = tokio::sync::mpsc::channel::<String>(OUTBOUND_QUEUE_CAPACITY);
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
// Writer task: drain replies onto the socket. A `__pong:<n>`
// sentinel maps to a binary Pong control frame; everything else
@@ -215,7 +205,7 @@ impl Connection {
conn.handle_cmd(cmd, &reader_tx).await;
}
Ok(Message::Ping(p)) => {
let _ = reader_tx.try_send(format!("__pong:{}", p.len()));
let _ = reader_tx.send(format!("__pong:{}", p.len()));
}
Ok(Message::Close(_)) | Err(_) => break,
_ => {}
@@ -234,16 +224,15 @@ impl Connection {
let _ = writer_task.await;
}
async fn handle_cmd(&self, cmd: WsCommand, tx: &tokio::sync::mpsc::Sender<String>) {
async fn handle_cmd(&self, cmd: WsCommand, tx: &tokio::sync::mpsc::UnboundedSender<String>) {
match cmd.kind.as_str() {
"ping" => {
let msg = serde_json::json!({"id": cmd.id, "type": "pong"});
let _ = tx.try_send(msg.to_string());
let _ = tx.send(msg.to_string());
}
"get_states" => {
let snapshots = self.state.homecore().states().all();
let views: Vec<StateView> =
snapshots.iter().map(|s| StateView::from_state(s)).collect();
let views: Vec<StateView> = snapshots.iter().map(|s| StateView::from_state(s)).collect();
self.ack(tx, cmd.id, true, Some(serde_json::to_value(views).unwrap()));
}
"get_config" => {
@@ -256,28 +245,17 @@ impl Connection {
}
"get_services" => {
let services = self.state.homecore().services().registered_services().await;
let mut by_domain: std::collections::HashMap<
String,
serde_json::Map<String, serde_json::Value>,
> = std::collections::HashMap::new();
let mut by_domain: std::collections::HashMap<String, serde_json::Map<String, serde_json::Value>> =
std::collections::HashMap::new();
for s in services {
by_domain
.entry(s.domain)
.or_default()
.insert(s.service, serde_json::json!({}));
by_domain.entry(s.domain).or_default().insert(s.service, serde_json::json!({}));
}
let payload = serde_json::to_value(by_domain).unwrap();
self.ack(tx, cmd.id, true, Some(payload));
}
"call_service" => {
let (Some(domain), Some(service)) = (cmd.domain.clone(), cmd.service.clone())
else {
self.err(
tx,
cmd.id,
"missing_domain_service",
"domain and service are required",
);
let (Some(domain), Some(service)) = (cmd.domain.clone(), cmd.service.clone()) else {
self.err(tx, cmd.id, "missing_domain_service", "domain and service are required");
return;
};
let call = ServiceCall {
@@ -291,13 +269,7 @@ impl Connection {
}
}
"subscribe_events" => {
// HA uses the subscribing command ID as the subscription ID
// in every emitted event and in `unsubscribe_events`.
let sub_id = cmd.id;
if self.subs.contains_key(&sub_id) {
self.err(tx, cmd.id, "id_reused", "subscription id is already active");
return;
}
let sub_id = self.next_sub_id.fetch_add(1, Ordering::Relaxed);
let filter = cmd.event_type.clone();
let tx_clone = tx.clone();
let mut domain_rx = self.state.homecore().bus().subscribe_domain();
@@ -322,27 +294,7 @@ impl Connection {
"time_fired": sc.fired_at.to_rfc3339(),
}
});
if tx_clone.try_send(payload.to_string()).is_err() { break; }
}
}
Ok(SystemEvent::ServiceCalled { domain, service, data, context }) => {
if filter.as_deref() == Some("call_service") || filter.is_none() {
let payload = serde_json::json!({
"id": sub_id,
"type": "event",
"event": {
"event_type": "call_service",
"data": {
"domain": domain,
"service": service,
"service_data": data,
},
"origin": "LOCAL",
"time_fired": chrono::Utc::now().to_rfc3339(),
"context": context,
}
});
if tx_clone.try_send(payload.to_string()).is_err() { break; }
if tx_clone.send(payload.to_string()).is_err() { break; }
}
}
Ok(_) => {}
@@ -371,7 +323,7 @@ impl Connection {
"time_fired": de.fired_at.to_rfc3339(),
}
});
if tx_clone.try_send(payload.to_string()).is_err() { break; }
if tx_clone.send(payload.to_string()).is_err() { break; }
}
}
// Same recoverable-lag handling as the system arm
@@ -401,21 +353,11 @@ impl Connection {
self.err(tx, cmd.id, "not_found", "subscription_id not found");
}
} else {
self.err(
tx,
cmd.id,
"missing_subscription",
"subscription is required",
);
self.err(tx, cmd.id, "missing_subscription", "subscription is required");
}
}
other => {
self.err(
tx,
cmd.id,
"unknown_command",
&format!("unknown ws command: {other}"),
);
self.err(tx, cmd.id, "unknown_command", &format!("unknown ws command: {other}"));
}
}
// entity_id is reserved for future per-entity subscribes
@@ -424,7 +366,7 @@ impl Connection {
fn ack(
&self,
tx: &tokio::sync::mpsc::Sender<String>,
tx: &tokio::sync::mpsc::UnboundedSender<String>,
id: u64,
success: bool,
result: Option<serde_json::Value>,
@@ -436,16 +378,10 @@ impl Connection {
result,
error: None,
};
let _ = tx.try_send(serde_json::to_string(&msg).unwrap());
let _ = tx.send(serde_json::to_string(&msg).unwrap());
}
fn err(
&self,
tx: &tokio::sync::mpsc::Sender<String>,
id: u64,
code: &'static str,
message: &str,
) {
fn err(&self, tx: &tokio::sync::mpsc::UnboundedSender<String>, id: u64, code: &'static str, message: &str) {
let msg = ResultMessage {
id,
kind: "result",
@@ -453,7 +389,7 @@ impl Connection {
result: None,
error: Some(ErrorView { code, message }),
};
let _ = tx.try_send(serde_json::to_string(&msg).unwrap());
let _ = tx.send(serde_json::to_string(&msg).unwrap());
}
}
+3 -78
View File
@@ -80,10 +80,7 @@ async fn wrong_token_is_rejected() {
resp["type"], "auth_invalid",
"wrong token must be rejected with auth_invalid, got: {resp}"
);
assert_ne!(
resp["type"], "auth_ok",
"wrong token must NOT receive auth_ok"
);
assert_ne!(resp["type"], "auth_ok", "wrong token must NOT receive auth_ok");
}
#[tokio::test]
@@ -102,10 +99,7 @@ async fn correct_token_is_accepted() {
.unwrap();
let resp = next_json(&mut ws).await;
assert_eq!(
resp["type"], "auth_ok",
"correct token should be accepted, got: {resp}"
);
assert_eq!(resp["type"], "auth_ok", "correct token should be accepted, got: {resp}");
}
#[tokio::test]
@@ -139,10 +133,7 @@ async fn result_reply_is_received() {
let reply = tokio::time::timeout(std::time::Duration::from_secs(5), next_json(&mut ws))
.await
.expect("did not receive a reply within 5s — reply theater (HC-WS-02)");
assert_eq!(
reply["type"], "result",
"expected a result reply, got: {reply}"
);
assert_eq!(reply["type"], "result", "expected a result reply, got: {reply}");
assert_eq!(reply["id"], 1);
assert_eq!(reply["success"], true);
}
@@ -272,69 +263,3 @@ async fn subscription_survives_broadcast_lag() {
);
assert_eq!(got["event"]["data"]["marker"], "post-lag");
}
#[tokio::test]
async fn real_state_change_uses_client_subscription_id() {
use homecore::{Context, EntityId};
let (addr, hc) = spawn_server_returning_homecore("good_token_abc").await;
let url = format!("ws://{addr}/api/websocket");
let (mut ws, _resp) = connect_async(&url).await.unwrap();
let _ = next_json(&mut ws).await;
ws.send(Message::Text(
serde_json::json!({"type":"auth","access_token":"good_token_abc"}).to_string(),
))
.await
.unwrap();
let _ = next_json(&mut ws).await;
ws.send(Message::Text(
serde_json::json!({
"id": 41,
"type": "subscribe_events",
"event_type": "state_changed"
})
.to_string(),
))
.await
.unwrap();
let ack = next_json(&mut ws).await;
assert_eq!(ack["id"], 41);
assert_eq!(ack["success"], true);
hc.states().set(
EntityId::parse("light.integration_probe").unwrap(),
"on",
serde_json::json!({"source":"test"}),
Context::new(),
);
let event = tokio::time::timeout(std::time::Duration::from_secs(5), next_json(&mut ws))
.await
.expect("state change was not bridged to the WebSocket system-event subscription");
assert_eq!(
event["id"], 41,
"HA events must use the subscribe command id"
);
assert_eq!(event["type"], "event");
assert_eq!(event["event"]["event_type"], "state_changed");
assert_eq!(
event["event"]["data"]["entity_id"],
"light.integration_probe"
);
ws.send(Message::Text(
serde_json::json!({
"id": 42,
"type": "unsubscribe_events",
"subscription": 41
})
.to_string(),
))
.await
.unwrap();
let unsub = next_json(&mut ws).await;
assert_eq!(unsub["id"], 42);
assert_eq!(unsub["success"], true);
}
@@ -74,7 +74,7 @@ impl Condition {
Condition::State { entity_id, state } => {
ctx.states
.get(entity_id)
.is_some_and(|s| s.state == *state)
.map_or(false, |s| s.state == *state)
}
Condition::NumericState { entity_id, above, below } => {
let value: Option<f64> = ctx
@@ -84,13 +84,16 @@ impl Condition {
match value {
None => false,
Some(v) => {
above.is_none_or(|a| v > a) && below.is_none_or(|b| v < b)
above.map_or(true, |a| v > a) && below.map_or(true, |b| v < b)
}
}
}
Condition::Template { value_template } => {
if let Some(env) = &ctx.template_env {
env.render_bool(value_template).unwrap_or_default()
match env.render_bool(value_template) {
Ok(v) => v,
Err(_) => false,
}
} else {
false
}
+2 -2
View File
@@ -106,7 +106,7 @@ impl Trigger {
pub fn matches_sync(&self, ctx: &TriggerContext) -> bool {
match self {
Trigger::State { entity_id, from, to } => {
let eid_match = ctx.entity_id.as_ref() == Some(entity_id);
let eid_match = ctx.entity_id.as_ref().map_or(false, |e| e == entity_id);
if !eid_match {
return false;
}
@@ -125,7 +125,7 @@ impl Trigger {
true
}
Trigger::NumericState { entity_id, above, below } => {
let eid_match = ctx.entity_id.as_ref() == Some(entity_id);
let eid_match = ctx.entity_id.as_ref().map_or(false, |e| e == entity_id);
if !eid_match {
return false;
}
+1 -2
View File
@@ -26,8 +26,7 @@ The tool enforces version schema compatibility: unknown HA schema versions are r
## Features
- **Entity registry import** — `core.entity_registry`an atomically written,
HA-compatible HOMECORE registry file; existing destinations are not overwritten
- **Entity registry import** — `core.entity_registry`HOMECORE entity definitions (ready for import)
- **Device registry inspection** — read HA device metadata; full conversion deferred to P2
- **Config entries analysis** — list active integrations by domain (enables gap analysis)
- **Secrets extraction** — read `secrets.yaml` references for annotation (resolution in P2)
@@ -26,15 +26,17 @@
//! }
//! ```
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::path::Path;
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,
storage_format::v13,
MigrateError,
};
// Key used by `inspect` subcommand when scanning the directory.
#[allow(dead_code)]
@@ -73,21 +75,21 @@ struct HaEntityRow {
// Fields present in v13 that we capture but do not yet map to HOMECORE.
// Forwarded as Q5 items.
#[serde(default)]
hidden_by: Option<String>, // v13: "user" | "integration"
hidden_by: Option<String>, // v13: "user" | "integration"
#[serde(default)]
has_entity_name: Option<bool>, // v13: HA naming convention flag
has_entity_name: Option<bool>, // v13: HA naming convention flag
#[serde(default)]
original_name: Option<String>, // v13: integration-provided default name
original_name: Option<String>, // v13: integration-provided default name
#[serde(default)]
icon: Option<String>, // v13: mdi:xxx icon override
icon: Option<String>, // v13: mdi:xxx icon override
#[serde(default)]
original_icon: Option<String>, // v13: integration-provided icon
original_icon: Option<String>, // v13: integration-provided icon
#[serde(default)]
aliases: Option<Vec<String>>, // v13: user-set aliases for voice assist
aliases: Option<Vec<String>>, // v13: user-set aliases for voice assist
#[serde(default)]
capabilities: Option<serde_json::Value>, // v13: integration-specific caps
#[serde(default)]
supported_features: Option<u64>, // v13: bitmask
supported_features: Option<u64>, // v13: bitmask
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
@@ -164,64 +166,6 @@ pub fn read_entity_registry(path: &Path) -> Result<Vec<EntityEntry>, MigrateErro
Ok(entries)
}
/// Persist imported entries using the HA-compatible v13 storage envelope.
///
/// The destination is created if needed and the file is written through a
/// same-directory temporary file followed by an atomic rename. Existing
/// registries are never overwritten implicitly.
pub fn write_entity_registry(
storage_dir: &Path,
entries: &[EntityEntry],
) -> Result<PathBuf, MigrateError> {
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,
"key": FILE_KEY,
"data": {
"entities": entries,
"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)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -283,10 +227,7 @@ mod tests {
fn entity_fields_round_trip_correctly() {
let f = write_fixture(FIXTURE_V13);
let entries = read_entity_registry(f.path()).unwrap();
let light = entries
.iter()
.find(|e| e.entity_id.as_str() == "light.kitchen")
.unwrap();
let light = entries.iter().find(|e| e.entity_id.as_str() == "light.kitchen").unwrap();
assert_eq!(light.unique_id.as_deref(), Some("hue_lamp_42"));
assert_eq!(light.platform, "hue");
assert_eq!(light.name.as_deref(), Some("Kitchen lamp"));
@@ -297,21 +238,6 @@ mod tests {
assert_eq!(light.config_entry_id.as_deref(), Some("ce_001"));
}
#[test]
fn writes_atomic_compatible_registry_without_overwrite() {
let source = write_fixture(FIXTURE_V13);
let entries = read_entity_registry(source.path()).unwrap();
let destination = tempfile::tempdir().unwrap();
let path = write_entity_registry(destination.path(), &entries).unwrap();
let imported = read_entity_registry(&path).unwrap();
assert_eq!(imported.len(), entries.len());
assert_eq!(imported[0].entity_id, entries[0].entity_id);
let second = write_entity_registry(destination.path(), &entries).unwrap_err();
assert!(second.to_string().contains("refusing to overwrite"));
}
#[test]
fn disabled_by_maps_to_homecore() {
let f = write_fixture(FIXTURE_V13);
@@ -334,13 +260,7 @@ mod tests {
let f = write_fixture(json);
let err = read_entity_registry(f.path()).unwrap_err();
assert!(
matches!(
err,
MigrateError::UnsupportedSchemaVersion {
minor_version: 99,
..
}
),
matches!(err, MigrateError::UnsupportedSchemaVersion { minor_version: 99, .. }),
"got: {err}"
);
let msg = err.to_string();
+2 -4
View File
@@ -44,10 +44,8 @@ fn main() -> anyhow::Result<()> {
let entity_path = args.storage.join("core.entity_registry");
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());
println!("Imported {} entity entries (P1: in-memory only)", entries.len());
println!(" Destination: {} (P2 persistence)", args.to.display());
for e in &entries {
println!(
" {} ({}{})",
+6 -15
View File
@@ -25,18 +25,6 @@ use homecore::event::{DomainEvent, StateChangedEvent};
use crate::dedup::fnv64a_hash;
use crate::schema::ALL_DDL;
type SearchStateRecord = (
i64,
String,
String,
Option<String>,
f64,
f64,
Option<String>,
);
type StateRecord = (String, String, Option<String>, f64, f64, Option<String>);
type HistoryStateRecord = (i64, String, Option<String>, f64, f64, Option<String>);
/// Hard upper bound on rows returned by [`Recorder::get_state_history`].
///
/// Without this cap a wide `[since, until]` window over a high-frequency entity
@@ -301,7 +289,8 @@ impl Recorder {
.replace('_', "\\_");
let pattern = format!("%{escaped}%");
let rows: Vec<SearchStateRecord> = sqlx::query_as(
let rows: Vec<(i64, String, String, Option<String>, f64, f64, Option<String>)> =
sqlx::query_as(
"SELECT s.state_id, s.entity_id, s.state, sa.shared_attrs, \
s.last_changed_ts, s.last_updated_ts, s.context_id \
FROM states s \
@@ -343,7 +332,8 @@ impl Recorder {
/// 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(
let row: Option<(String, String, Option<String>, f64, f64, Option<String>)> =
sqlx::query_as(
"SELECT s.entity_id, s.state, sa.shared_attrs, \
s.last_changed_ts, s.last_updated_ts, s.context_id \
FROM states s \
@@ -419,7 +409,7 @@ impl Recorder {
let since_ts = since.timestamp_micros() as f64 / 1_000_000.0;
let until_ts = until.timestamp_micros() as f64 / 1_000_000.0;
let rows: Vec<HistoryStateRecord> = sqlx::query_as(
let rows: Vec<(i64, String, Option<String>, f64, f64, Option<String>)> = sqlx::query_as(
"SELECT s.state_id, s.state, sa.shared_attrs, \
s.last_changed_ts, s.last_updated_ts, s.context_id \
FROM states s \
@@ -894,6 +884,7 @@ mod tests {
// public method (whose cap is MAX_HISTORY_ROWS) and run the *same* SQL
// shape with a small bind to demonstrate the LIMIT term is effective —
// and separately assert the constant is a sane positive bound.
assert!(MAX_HISTORY_ROWS > 0, "history cap must be positive");
let recorder = open_memory().await;
for v in &["1", "2", "3", "4", "5"] {
recorder
+1 -4
View File
@@ -30,12 +30,9 @@ 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::{PurgeStats, Recorder, RecorderError, StateRow, MAX_HISTORY_ROWS};
pub use listener::RecorderListener;
/// Null semantic index used when the `ruvector` feature is off.
/// Satisfies the [`db::SemanticIndex`] trait bound without any allocation.
pub use db::NullSemanticIndex;
#[cfg(feature = "ruvector")]
pub use semantic::RuvectorSemanticIndex;
+3 -3
View File
@@ -23,7 +23,8 @@ 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-hap = { path = "../homecore-hap", version = "0.1.0-alpha.0" }
homecore-recorder = { path = "../homecore-recorder", 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" }
@@ -49,7 +50,6 @@ tower-http = { version = "0.6", features = ["fs", "trace", "cors"] }
# CHANGELOG / ADR-131 security note).
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_yaml = "0.9"
# Concurrent fan-out of per-bank RoomState fetches in the gateway (§11 perf).
futures = "0.3"
@@ -63,4 +63,4 @@ 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"]
+184 -61
View File
@@ -1,81 +1,204 @@
# homecore-server
`homecore-server` is the alpha integration binary for HOMECORE. It runs one
shared state machine, event bus, service registry, REST/WebSocket API, optional
SQLite recorder, automation engine, intent endpoint, BFF gateway, and static
dashboard.
Integrated HOMECORE server binary that wires state machine, API, recorder, plugins, automations, intent assistant, and HomeKit bridge into one process.
It is not a drop-in replacement for the complete Home Assistant API. The exact
implemented and deferred surfaces are listed in
[`docs/homecore-capabilities.md`](../../docs/homecore-capabilities.md).
[![Crates.io](https://img.shields.io/badge/crates.io-workspace%20binary-inactive)](.)
![License](https://img.shields.io/badge/license-MIT-blue.svg)
![MSRV: 1.89+](https://img.shields.io/badge/MSRV-1.89%2B-purple.svg)
[![ADR-126](https://img.shields.io/badge/ADR-126-orange.svg)](../../docs/adr/ADR-126-homecore-home-assistant-port.md)
## Security defaults
The production-ready HOMECORE binary — boots all 7 subsystems (core, API, recorder, plugins, automation, assist, HAP bridge) in a single process listening on `:8123`.
Authentication fails closed. Set one or more comma-separated bearer tokens:
## What this crate does
`homecore-server` is the integration point for the entire HOMECORE ecosystem. It orchestrates:
1. **HomeCore runtime** — state machine, event bus, service registry
2. **REST + WebSocket API** — Axum server on `:8123` (HA-compatible)
3. **SQLite Recorder** — persists all state changes to disk
4. **Plugin Registry** — loads and manages integrations (InProcessRuntime by default)
5. **Automation Engine** — evaluates triggers, conditions, and actions
6. **Assist Pipeline** — intent recognition and execution
7. **HAP Bridge** — exposes accessories to HomeKit
All subsystems share the same `HomeCore` instance, so state changes flow through the event bus and trigger automations, record history, and notify WebSocket subscribers in lockstep.
## Features
- **Single unified process** — no external microservices; run with `cargo run -p homecore-server`
- **HA-compatible REST API** — drop-in replacement for Home Assistant's `/api/` on `:8123`
- **SQLite state history** — persistent recording of all state changes
- **Automation engine** — YAML-driven trigger→condition→action execution
- **Intent assistant** — regex-based (P1) intent recognition + service calling
- **HomeKit bridge** — exposes HOMECORE entities as HomeKit accessories
- **Plugin system** — load first-party Rust plugins; Wasmtime WASM plugins (P2, `--features wasmtime`)
- **Configurable via CLI + env vars** — no YAML required; sensible defaults
- **Structured logging** — tracing output with `RUST_LOG` filtering
- **Feature-gated subsystems** — disable recorder (`--no-recorder`), enable ruvector/wasmtime as needed
## Subsystems
| Subsystem | Crate | Role | Notes |
|-----------|-------|------|-------|
| State Machine | `homecore` | Core domain model | All other subsystems depend on this |
| REST API | `homecore-api` | HTTP boundary | Listens on `:8123`; Axum framework |
| Recorder | `homecore-recorder` | Persistence | SQLite; optional `--no-recorder` |
| Plugins | `homecore-plugins` | Extension system | InProcessRuntime default; Wasmtime w/ feature |
| Automation | `homecore-automation` | Trigger execution | Subscribes to event bus; YAML-driven |
| Assist | `homecore-assist` | Intent pipeline | Regex recognizer (P1); semantic (P2) |
| HAP Bridge | `homecore-hap` | HomeKit export | Accessories + characteristics; mDNS (P2) |
## Usage
**Basic startup** (in-memory recorder):
```bash
set HOMECORE_TOKENS=replace-with-a-long-random-token
cargo run -p homecore-server
cargo build -p homecore-server
./target/debug/homecore-server
# Listens on http://localhost:8123
```
`--insecure-dev-auth` explicitly allows any non-empty bearer token and must only
be used in an isolated development environment. The browser UI does not contain
a default token. Configure allowed browser origins with
`HOMECORE_CORS_ORIGINS`.
## Runtime behavior
- SQLite recording is enabled by default at `sqlite://homecore.db`.
- Synthetic entities are disabled by default; opt in with
`--seed-demo-entities`.
- Automations can be loaded with `--automations <file>` or
`HOMECORE_AUTOMATIONS`.
- `Ctrl-C` initiates graceful HTTP shutdown and emits `HomeCoreStop`.
- The `ruvector` feature enables the recorder's semantic index.
- Plugin and HAP crates are libraries in this workspace, but this binary does
not claim to load plugins or advertise a HomeKit server yet.
## API
All API requests require `Authorization: Bearer <token>`.
**With persistent SQLite**:
```bash
curl -H "Authorization: Bearer $HOMECORE_TOKEN" \
http://127.0.0.1:8123/api/states
curl -X POST \
-H "Authorization: Bearer $HOMECORE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"entity_id":"light.kitchen"}' \
http://127.0.0.1:8123/api/services/light/turn_on
curl -X POST \
-H "Authorization: Bearer $HOMECORE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"utterance":"turn on light.kitchen","language":"en"}' \
http://127.0.0.1:8123/api/intent/handle
./target/debug/homecore-server \
--bind 0.0.0.0:8123 \
--db sqlite:~/.homecore/home.db \
--location-name "My Home"
```
Built-in executable services are `homecore.ping`,
`homecore.snapshot_state`, and `turn_on`/`turn_off`/`toggle` for the
`homeassistant`, `light`, and `switch` domains. Unsupported services are left
unregistered and return an error rather than a false acknowledgement.
**Full feature build** (ruvector semantic search + Wasmtime plugins):
## Configuration
```bash
cargo build -p homecore-server --features ruvector,wasmtime --release
```
| Flag | Environment | Default |
|---|---|---|
| `--bind` | `HOMECORE_BIND` | `0.0.0.0:8123` |
| `--db` | `HOMECORE_DB` | `sqlite://homecore.db` |
| `--location-name` | `HOMECORE_LOCATION` | `Home` |
| `--automations` | `HOMECORE_AUTOMATIONS` | unset |
| `--insecure-dev-auth` | `HOMECORE_INSECURE_DEV_AUTH` | `false` |
| `--seed-demo-entities` | — | `false` |
| `--no-recorder` | — | `false` |
**Via Docker** (Dockerfile planned P2):
## Validation
```bash
docker run -p 8123:8123 \
-e HOMECORE_DB=sqlite:///data/home.db \
-v ~/.homecore:/data \
homecore-server:latest
```
**Test the API**:
```bash
# List all entities
curl http://localhost:8123/api/states
# Set a light to "on"
curl -X POST \
-H "Content-Type: application/json" \
-d '{"state":"on","attributes":{"brightness":200}}' \
http://localhost:8123/api/states/light.kitchen
# WebSocket subscription (real-time state changes)
wscat -c ws://localhost:8123/api/websocket
```
**Configuration via env**:
```bash
export HOMECORE_BIND="0.0.0.0:8123"
export HOMECORE_DB="sqlite:~/.homecore/home.db"
export HOMECORE_LOCATION="Living Room"
export RUST_LOG="homecore=debug,homecore_api=info"
./target/debug/homecore-server
```
## CLI Options
| Flag | Env Var | Default | Description |
|------|---------|---------|-------------|
| `--bind` | `HOMECORE_BIND` | `0.0.0.0:8123` | REST API listen address |
| `--db` | `HOMECORE_DB` | `sqlite::memory:` | SQLite path (`:memory:` for ephemeral) |
| `--location-name` | `HOMECORE_LOCATION` | `Home` | Friendly name returned by `/api/config` |
| `--no-recorder` | — | off | Disable SQLite recorder (low-resource deployments) |
| `--ui-dir` | `HOMECORE_UI_DIR` | `<crate>/ui` | HOMECORE-UI asset dir served at `/homecore` (ADR-131); empty disables the mount |
## HOMECORE-UI dashboard (ADR-131)
This binary also serves the **HOMECORE-UI** — the complete operational dashboard
for the two-tier Cognitum stack (v0 Appliance → SEEDs → ESP32 nodes) — at
`/homecore`, alongside the HA-compat `/api` surface. It is a zero-dependency,
no-build-step vanilla TS/JS + CSS frontend living in `ui/`:
```bash
cargo run -p homecore-server # then open http://localhost:8123/homecore/
```
It drives the live `/api` + `/api/websocket` (`subscribe_events`) endpoints; panels
backed by services not in this binary (SEED HTTPS API, calibration ADR-151,
federation ADR-105) render against a DEMO-flagged contract-conformant mock until
those endpoints land (ADR-131 §7.1). Frontend tests + benchmark run under plain
`node` (no `npm install`):
```bash
cd ui && npm test # import graph + render-smoke + interaction (24 checks)
cd ui && npm run bench # bundle budget (~137 KB, ~37× smaller than HA) + render timing
```
## Comparison to Home Assistant
| Aspect | Home Assistant | homecore-server |
|--------|----------------|-----------------|
| Architecture | Python asyncio monolith | Rust async Tokio + component traits |
| API protocol | `/api/` REST (HA wire format) | Identical HA wire format |
| Persistence | SQLite + YAML files | SQLite (P1); Redis (P2) |
| Plugins | Python integrations in `homeassistant/components/` | Rust (P1) + WASM (P2) |
| Automation execution | Python asyncio event loop | Tokio async tasks + trait-based |
| HomeKit bridge | Via `homekit` integration | Built-in `homecore-hap` subsystem |
| CLI | `hass` command with config YAML | `homecore-server` with feature flags |
| Scalability | Single instance (HA Cloud for scale) | Can be load-balanced (future) |
| Binary size | ~200 MB (Python + deps) | ~50 MB (Rust, release build; 200 MB w/ wasmtime) |
## Performance Targets (unreleased; TBD)
- **Startup time** — < 2s to listen on `:8123`
- **REST endpoint latency** — p50 < 1 ms; p99 < 10 ms
- **Event bus throughput** — 10,000+ events/sec
- **Automation evaluation** — < 100 μs per trigger
- **Concurrent WebSocket connections** — 10,000+
- **Memory footprint** — ~100 MB (idle); ~500 MB with 1,000 recorded states
## Development
**Run tests**:
```bash
cargo test -p homecore-server
cargo clippy -p homecore-server --all-targets --all-features -- -D warnings
```
**Enable debug logging**:
```bash
RUST_LOG=debug cargo run -p homecore-server -- --bind 127.0.0.1:8123
```
**Build documentation**:
```bash
cargo doc -p homecore-server --open
```
## Relation to other HOMECORE crates
```
homecore-server (orchestration binary)
├── homecore (state machine)
├── homecore-api (REST + WS)
├── homecore-recorder (SQLite persistence)
├── homecore-plugins (extension system)
├── homecore-automation (trigger execution)
├── homecore-assist (intent pipeline)
└── homecore-hap (HomeKit bridge)
```
## References
- [ADR-126: HOMECORE Home Assistant Port (master)](../../docs/adr/ADR-126-homecore-home-assistant-port.md)
- [README — wifi-densepose](../../../README.md)
- [Dockerfile (planned P2)](Dockerfile.planned)
- [Docker Hub image (planned P2)](https://hub.docker.com/r/ruvnet/homecore-server)
+36 -188
View File
@@ -29,16 +29,12 @@ use axum::body::Bytes;
use axum::extract::{Path, RawQuery, State};
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::routing::get;
use axum::{Json, Router};
use serde::Deserialize;
use serde_json::{json, Value};
use homecore_api::auth::BearerAuth;
use homecore_api::SharedState;
use homecore_assist::{AssistPipeline, RegexIntentRecognizer};
#[cfg(test)]
use homecore_assist::{HassCancelAll, HassLightSet, HassNevermind, HassTurnOff, HassTurnOn};
/// Static gateway configuration (from CLI/env in `main`).
pub struct GatewayConfig {
@@ -58,36 +54,15 @@ pub struct GatewayState {
pub shared: SharedState,
pub http: reqwest::Client,
pub cfg: Arc<GatewayConfig>,
pub assist: Arc<AssistPipeline<RegexIntentRecognizer>>,
}
impl GatewayState {
#[cfg(test)]
pub fn new(shared: SharedState, cfg: GatewayConfig) -> Self {
let mut assist = AssistPipeline::new(RegexIntentRecognizer::new());
assist.register_handler(HassTurnOn);
assist.register_handler(HassTurnOff);
assist.register_handler(HassLightSet);
assist.register_handler(HassNevermind);
assist.register_handler(HassCancelAll);
Self::with_assist(shared, cfg, assist)
}
pub fn with_assist(
shared: SharedState,
cfg: GatewayConfig,
assist: AssistPipeline<RegexIntentRecognizer>,
) -> Self {
let http = reqwest::Client::builder()
.timeout(cfg.timeout)
.build()
.unwrap_or_else(|_| reqwest::Client::new());
Self {
shared,
http,
cfg: Arc::new(cfg),
assist: Arc::new(assist),
}
Self { shared, http, cfg: Arc::new(cfg) }
}
}
@@ -101,7 +76,6 @@ pub fn gateway_router(state: GatewayState) -> Router {
.route("/api/homecore/rooms", get(rooms))
.route("/api/homecore/cogs", get(cogs_list))
.route("/api/homecore/appliance", get(appliance))
.route("/api/intent/handle", post(handle_intent))
// ── upstream-dependent stubs (W3 / W5 / W6): typed 503 ───────
.route("/api/homecore/seeds", get(stub_503))
.route("/api/homecore/seeds/:id", get(stub_503))
@@ -119,43 +93,6 @@ pub fn gateway_router(state: GatewayState) -> Router {
.with_state(state)
}
#[derive(Deserialize)]
struct IntentRequest {
#[serde(alias = "text")]
utterance: String,
#[serde(default = "default_language")]
language: String,
}
fn default_language() -> String {
"en".to_owned()
}
async fn handle_intent(
State(st): State<GatewayState>,
headers: HeaderMap,
Json(request): Json<IntentRequest>,
) -> Response {
if let Err(response) = require_auth(&headers, &st).await {
return response;
}
if request.utterance.trim().is_empty() {
return bad_request("utterance cannot be empty");
}
match st
.assist
.process(&request.utterance, &request.language, st.shared.homecore())
.await
{
Ok(response) => Json(response).into_response(),
Err(error) => typed(
StatusCode::UNPROCESSABLE_ENTITY,
"intent_failed",
&error.to_string(),
),
}
}
// ── auth + typed errors ─────────────────────────────────────────────
async fn require_auth(headers: &HeaderMap, st: &GatewayState) -> Result<(), Response> {
@@ -169,11 +106,7 @@ fn typed(status: StatusCode, error: &str, detail: &str) -> Response {
(status, Json(json!({ "error": error, "detail": detail }))).into_response()
}
fn upstream_unavailable(detail: &str) -> Response {
typed(
StatusCode::SERVICE_UNAVAILABLE,
"upstream_unavailable",
detail,
)
typed(StatusCode::SERVICE_UNAVAILABLE, "upstream_unavailable", detail)
}
fn upstream_timeout(detail: &str) -> Response {
typed(StatusCode::GATEWAY_TIMEOUT, "upstream_timeout", detail)
@@ -197,32 +130,33 @@ fn bad_request(detail: &str) -> Response {
/// like `%252e` decodes once to `%2e` and is caught here).
///
/// Legitimate `v1/...` paths (the only shape the UI sends) pass unchanged.
fn validate_proxy_path(path: &str) -> Result<(), &'static str> {
fn validate_proxy_path(path: &str) -> Result<(), Response> {
// 1. Reject on the raw form first (cheap; catches backslash + leading `/`).
if path.starts_with('/') {
return Err("proxied path must be relative (leading '/' not allowed)");
return Err(bad_request("proxied path must be relative (leading '/' not allowed)"));
}
if path.contains('\\') {
return Err("proxied path must not contain a backslash");
return Err(bad_request("proxied path must not contain a backslash"));
}
// 2. Percent-decode once and re-check; reject if decoding is invalid.
let decoded = percent_decode_once(path).ok_or("proxied path has invalid percent-encoding")?;
let decoded = percent_decode_once(path)
.ok_or_else(|| bad_request("proxied path has invalid percent-encoding"))?;
if decoded.starts_with('/') || decoded.contains('\\') {
return Err("proxied path resolves to an absolute/traversal path");
return Err(bad_request("proxied path resolves to an absolute/traversal path"));
}
// 3. Reject any `.`/`..` segment on BOTH the raw and decoded forms so an
// encoded `%2e%2e%2f` cannot slip a dot-segment past the split.
for form in [path, decoded.as_str()] {
for seg in form.split(['/', '\\']) {
if seg == "." || seg == ".." {
return Err("proxied path must not contain '.' or '..' segments");
return Err(bad_request("proxied path must not contain '.' or '..' segments"));
}
}
// Defence in depth: a residual encoded traversal marker survived the
// single decode (e.g. originally double-encoded). Reject it outright.
let lower = form.to_ascii_lowercase();
if lower.contains("%2e") || lower.contains("%2f") || lower.contains("%5c") {
return Err("proxied path must not contain encoded traversal markers");
return Err(bad_request("proxied path must not contain encoded traversal markers"));
}
}
Ok(())
@@ -260,9 +194,7 @@ async fn stub_503(State(st): State<GatewayState>, headers: HeaderMap) -> Respons
if let Err(r) = require_auth(&headers, &st).await {
return r;
}
upstream_unavailable(
"endpoint not yet wired — see ADR-131 §11/§12 (SEED device / appliance upstream)",
)
upstream_unavailable("endpoint not yet wired — see ADR-131 §11/§12 (SEED device / appliance upstream)")
}
/// Auth-gated empty-array response (e.g. OTA updates with no feed wired).
@@ -284,14 +216,12 @@ async fn cal_proxy_get(
if let Err(r) = require_auth(&headers, &st).await {
return r;
}
if let Err(detail) = validate_proxy_path(&path) {
return bad_request(detail);
if let Err(r) = validate_proxy_path(&path) {
return r;
}
let base = match &st.cfg.calibration_url {
Some(u) => u,
None => return upstream_unavailable(
"calibration service not configured (set --calibration-url / HOMECORE_CALIBRATION_URL)",
),
None => return upstream_unavailable("calibration service not configured (set --calibration-url / HOMECORE_CALIBRATION_URL)"),
};
let qs = q.map(|s| format!("?{s}")).unwrap_or_default();
// The wildcard already carries the `v1/...` segment (the UI calls
@@ -309,14 +239,12 @@ async fn cal_proxy_post(
if let Err(r) = require_auth(&headers, &st).await {
return r;
}
if let Err(detail) = validate_proxy_path(&path) {
return bad_request(detail);
if let Err(r) = validate_proxy_path(&path) {
return r;
}
let base = match &st.cfg.calibration_url {
Some(u) => u,
None => return upstream_unavailable(
"calibration service not configured (set --calibration-url / HOMECORE_CALIBRATION_URL)",
),
None => return upstream_unavailable("calibration service not configured (set --calibration-url / HOMECORE_CALIBRATION_URL)"),
};
let url = format!("{}/api/{}", base.trim_end_matches('/'), path);
let rb = st
@@ -336,8 +264,7 @@ async fn proxy(st: &GatewayState, mut rb: reqwest::RequestBuilder) -> Response {
}
match rb.send().await {
Ok(resp) => {
let status =
StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
let status = StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
let ct = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
@@ -398,10 +325,7 @@ async fn rooms(State(st): State<GatewayState>, headers: HeaderMap) -> Response {
let base = base.as_str();
async move {
let url = format!("{base}/api/v1/room/state?bank={bank}");
fetch_json(st, &url)
.await
.ok()
.map(|v| adapt_room_state(&bank, &v))
fetch_json(st, &url).await.ok().map(|v| adapt_room_state(&bank, &v))
}
});
let out: Vec<Value> = futures::future::join_all(fetches)
@@ -428,7 +352,10 @@ fn bank_names(v: &Value) -> Vec<String> {
_ => None,
})
.collect(),
Value::Object(o) => o.get("baselines").map(bank_names).unwrap_or_default(),
Value::Object(o) => o
.get("baselines")
.map(|b| bank_names(b))
.unwrap_or_default(),
_ => Vec::new(),
}
}
@@ -529,11 +456,7 @@ async fn cogs_list(State(st): State<GatewayState>, headers: HeaderMap) -> Respon
}
fn read_pid(dir: &std::path::Path, id: &str) -> Option<i64> {
for name in [
format!("{id}.pid"),
"pid".to_string(),
"app.pid".to_string(),
] {
for name in [format!("{id}.pid"), "pid".to_string(), "app.pid".to_string()] {
if let Ok(s) = std::fs::read_to_string(dir.join(&name)) {
if let Ok(p) = s.trim().parse::<i64>() {
return Some(p);
@@ -592,9 +515,7 @@ async fn appliance(State(st): State<GatewayState>, headers: HeaderMap) -> Respon
}
fn read_first_line(path: &str) -> Option<String> {
std::fs::read_to_string(path)
.ok()
.and_then(|s| s.lines().next().map(str::to_string))
std::fs::read_to_string(path).ok().and_then(|s| s.lines().next().map(str::to_string))
}
fn uptime_secs() -> Option<u64> {
@@ -630,9 +551,7 @@ fn cpu_load_pct() -> Option<f64> {
.next()?
.parse::<f64>()
.ok()?;
let ncpu = std::thread::available_parallelism()
.map(|n| n.get() as f64)
.unwrap_or(1.0);
let ncpu = std::thread::available_parallelism().map(|n| n.get() as f64).unwrap_or(1.0);
Some(((load / ncpu * 100.0).min(100.0) * 10.0).round() / 10.0)
}
@@ -687,9 +606,7 @@ mod tests {
.await
.unwrap();
let status = resp.status();
let b = axum::body::to_bytes(resp.into_body(), 1 << 20)
.await
.unwrap();
let b = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
(status, String::from_utf8_lossy(&b).into_owned())
}
@@ -697,12 +614,7 @@ mod tests {
async fn unauthenticated_is_rejected() {
let app = gateway_router(gw());
let resp = app
.oneshot(
Request::builder()
.uri("/api/homecore/cogs")
.body(Body::empty())
.unwrap(),
)
.oneshot(Request::builder().uri("/api/homecore/cogs").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
@@ -724,12 +636,7 @@ mod tests {
#[tokio::test]
async fn seed_tier_routes_are_typed_503() {
for p in [
"/api/homecore/seeds",
"/api/homecore/federation",
"/api/homecore/witness",
"/api/events",
] {
for p in ["/api/homecore/seeds", "/api/homecore/federation", "/api/homecore/witness", "/api/events"] {
let (status, body) = send(gateway_router(gw()), "GET", p).await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{p} should be 503");
assert!(body.contains("upstream_unavailable"), "{p} typed body");
@@ -744,46 +651,6 @@ mod tests {
assert!(body.contains("\"ram_pct\""));
}
#[tokio::test]
async fn intent_endpoint_executes_a_real_service() {
let state = gw();
let hc = state.shared.homecore().clone();
let id = homecore::EntityId::parse("light.kitchen").unwrap();
hc.states().set(
id.clone(),
"off",
serde_json::json!({}),
homecore::Context::new(),
);
crate::register_builtin_services(&hc).await;
let assist = crate::build_assist_pipeline().await.unwrap();
let state = GatewayState::with_assist(
state.shared,
GatewayConfig {
calibration_url: None,
calibration_token: None,
apps_dir: PathBuf::from("/nonexistent-apps-dir"),
timeout: Duration::from_millis(200),
},
assist,
);
let response = gateway_router(state)
.oneshot(
Request::builder()
.method("POST")
.uri("/api/intent/handle")
.header("authorization", "Bearer dev")
.header("content-type", "application/json")
.body(Body::from(r#"{"utterance":"turn on light.kitchen"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(hc.states().get(&id).unwrap().state, "on");
}
#[test]
fn adapt_room_state_maps_fields_and_preserves_null() {
// breathing/heartbeat rename; None → null; anomaly gets a threshold.
@@ -801,10 +668,7 @@ mod tests {
assert_eq!(ui["stale"], true);
assert_eq!(ui["presence"]["value"], "occupied");
assert_eq!(ui["breathing_bpm"]["value"], 12.0);
assert!(
ui["heart_bpm"].is_null(),
"None heartbeat must map to null (not trained)"
);
assert!(ui["heart_bpm"].is_null(), "None heartbeat must map to null (not trained)");
// §6 invariant 3: upstream RoomState carries no threshold here, so the
// adapter must emit null (withheld) — NOT a fabricated constant.
assert!(
@@ -821,10 +685,7 @@ mod tests {
"anomaly": {"kind":"Anomaly","value":0.2,"confidence":0.5,"threshold":0.73},
});
let ui = adapt_room_state("bedroom_1", &cal);
assert_eq!(
ui["anomaly"]["threshold"], 0.73,
"real threshold must pass through"
);
assert_eq!(ui["anomaly"]["threshold"], 0.73, "real threshold must pass through");
}
#[test]
@@ -870,15 +731,8 @@ mod tests {
("POST", "/api/cal/v1/../../x"),
] {
let (status, body) = send(gateway_router(gw()), method, path).await;
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"{method} {path} must be 400"
);
assert!(
body.contains("bad_request"),
"{method} {path} typed 400 body"
);
assert_eq!(status, StatusCode::BAD_REQUEST, "{method} {path} must be 400");
assert!(body.contains("bad_request"), "{method} {path} typed 400 body");
assert!(
!body.contains("upstream_unavailable"),
"{method} {path} must NOT reach the upstream-config branch"
@@ -892,19 +746,13 @@ mod tests {
// "not configured" 503 (proving it was NOT blocked as traversal).
let (status, body) = send(gateway_router(gw()), "GET", "/api/cal/v1/room/state").await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
assert!(
body.contains("upstream_unavailable"),
"legit path should reach upstream branch"
);
assert!(body.contains("upstream_unavailable"), "legit path should reach upstream branch");
}
#[test]
fn bank_names_accepts_strings_and_objects() {
assert_eq!(bank_names(&json!(["a", "b"])), vec!["a", "b"]);
assert_eq!(
bank_names(&json!([{"name":"x"}, {"id":"y"}])),
vec!["x", "y"]
);
assert_eq!(bank_names(&json!([{"name":"x"}, {"id":"y"}])), vec!["x", "y"]);
assert_eq!(bank_names(&json!({"baselines":["z"]})), vec!["z"]);
}
}
+150 -432
View File
@@ -5,8 +5,10 @@
//! - HomeCore runtime (state machine + event bus + service registry)
//! - SQLite recorder writing every state_changed event
//! - REST + WebSocket API on :8123 (HA wire-compat)
//! - Plugin runtime (InProcessRuntime by default; Wasmtime with --features wasmtime)
//! - Automation engine subscribed to the state machine
//! - Assist pipeline (intent recognizer + handler set)
//! - HAP bridge surface (accessories registered via the API)
//!
//! Run with:
//!
@@ -17,20 +19,21 @@
//! cargo run -p homecore-server --features ruvector,wasmtime -- ...
use std::net::SocketAddr;
use std::sync::Arc;
use anyhow::Result;
use clap::Parser;
use tracing::{info, warn};
use homecore::service::FnHandler;
use homecore::{Context, EntityId, HomeCore, ServiceCall, ServiceError, ServiceName};
use homecore::service::FnHandler;
use homecore_api::{build_cors_layer, router, LongLivedTokenStore, SharedState};
use homecore_assist::{
AssistPipeline, HassCancelAll, HassLightSet, HassNevermind, HassTurnOff, HassTurnOn,
RegexIntentRecognizer,
};
use homecore_assist::pipeline::default_pipeline;
use homecore_assist::RegexIntentRecognizer;
use homecore_automation::AutomationEngine;
use homecore_recorder::{Recorder, RecorderListener};
use homecore_hap::{bridge::HapBridge, mdns::HapServiceRecord};
use homecore_plugins::{InProcessRuntime, PluginRegistry};
use homecore_recorder::Recorder;
use axum::Router;
use tower_http::services::ServeDir;
@@ -68,11 +71,7 @@ struct Cli {
calibration_token: Option<String>,
/// COG install directory the gateway's supervisor reads (ADR-131 §11.6).
#[arg(
long,
env = "HOMECORE_APPS_DIR",
default_value = "/var/lib/cognitum/apps"
)]
#[arg(long, env = "HOMECORE_APPS_DIR", default_value = "/var/lib/cognitum/apps")]
apps_dir: String,
/// Per-upstream proxy timeout in milliseconds (ADR-131 §11.1).
@@ -80,7 +79,7 @@ struct Cli {
gateway_timeout_ms: u64,
/// SQLite recorder DB path. Use `:memory:` for an ephemeral run.
#[arg(long, env = "HOMECORE_DB", default_value = "sqlite://homecore.db")]
#[arg(long, env = "HOMECORE_DB", default_value = "sqlite::memory:")]
db: String,
/// Friendly location name surfaced via `/api/config`.
@@ -91,50 +90,20 @@ struct Cli {
#[arg(long)]
no_recorder: bool,
/// Explicitly allow any non-empty bearer token. Development only.
#[arg(long, env = "HOMECORE_INSECURE_DEV_AUTH", default_value_t = false)]
insecure_dev_auth: bool,
/// Seed synthetic demo entities. Disabled by default so simulated
/// biometric readings are never mistaken for live sensor data.
/// Skip the boot-time entity seeding (10 demo entities including
/// 4 RuView-derived sensors). Use this when wiring real
/// integrations that will populate the state machine themselves.
#[arg(long)]
seed_demo_entities: bool,
/// Optional Home Assistant-style automations YAML file to load at boot.
#[arg(long, env = "HOMECORE_AUTOMATIONS")]
automations: Option<std::path::PathBuf>,
no_seed_entities: bool,
}
#[tokio::main]
async fn main() -> Result<()> {
init_tracing();
let cli = Cli::parse();
let has_tokens = std::env::var("HOMECORE_TOKENS")
.map(|value| !value.trim().is_empty())
.unwrap_or(false);
if !has_tokens && !cli.insecure_dev_auth {
anyhow::bail!(
"HOMECORE_TOKENS is required; use --insecure-dev-auth only for isolated development"
);
}
let tokens = if has_tokens {
let store = LongLivedTokenStore::from_env();
info!(
"Provisioned {} bearer token(s) from HOMECORE_TOKENS",
store.len().await
);
store
} else {
warn!(
"Insecure development authentication enabled: any non-empty bearer token is accepted"
);
LongLivedTokenStore::allow_any_non_empty()
};
info!(
"HOMECORE booting — bind={}, db={}, location={:?}",
cli.bind, cli.db, cli.location_name
);
info!("HOMECORE booting — bind={}, db={}, location={:?}",
cli.bind, cli.db, cli.location_name);
// ── 1. HomeCore runtime ─────────────────────────────────────────
let hc = HomeCore::new();
@@ -145,27 +114,33 @@ async fn main() -> Result<()> {
// first boot. These are no-op handlers (they just echo back the
// call as JSON for observability) — integrations override them
// by registering the same ServiceName later.
register_builtin_services(&hc).await;
seed_default_services(&hc).await;
// Seed 10 representative entities so the web UI's Dashboard +
// States pages have content out of the box. Operators registering
// real integrations / plugins overwrite these by writing the same
// entity_id with new values. Opt out with `--no-seed-entities`.
if cli.seed_demo_entities {
if !cli.no_seed_entities {
seed_default_entities(&hc);
} else {
info!("Synthetic demo entities disabled (use --seed-demo-entities to opt in)");
info!("Entity seeding disabled by --no-seed-entities");
}
// ── 2. Recorder (optional) ──────────────────────────────────────
if !cli.no_recorder {
match open_recorder(&cli.db).await {
match Recorder::open(&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
);
let recorder = Arc::new(recorder);
let rec_clone = Arc::clone(&recorder);
let mut state_rx = hc.states().subscribe();
tokio::spawn(async move {
while let Ok(event) = state_rx.recv().await {
if let Err(e) = rec_clone.record_state(&event).await {
warn!("recorder write failed: {e}");
}
}
});
info!("Recorder open at {} — state_changed events being persisted", cli.db);
}
Err(e) => {
warn!("Recorder failed to open ({e}) — continuing without persistence");
@@ -176,6 +151,11 @@ async fn main() -> Result<()> {
}
// ── 3. Plugin runtime ───────────────────────────────────────────
let plugin_runtime = InProcessRuntime;
let plugin_registry: PluginRegistry<InProcessRuntime> = PluginRegistry::new(plugin_runtime);
info!("Plugin registry ready (runtime: InProcess; Wasmtime available with --features wasmtime)");
let _ = plugin_registry; // wired-but-empty at boot; integrations register here
// ── 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
@@ -186,14 +166,6 @@ async fn main() -> Result<()> {
// boot yet (YAML loader is P-next); integrations register via
// `engine.register(..)`.
let automation_engine = AutomationEngine::new(hc.clone());
if let Some(path) = &cli.automations {
let raw = tokio::fs::read_to_string(path).await?;
let automations: Vec<homecore_automation::Automation> = serde_yaml::from_str(&raw)
.map_err(|e| anyhow::anyhow!("invalid automations file {}: {e}", path.display()))?;
for automation in automations {
automation_engine.register(automation);
}
}
let _automation_task = automation_engine.start();
info!(
"Automation engine started ({} automations registered) — \
@@ -202,12 +174,36 @@ async fn main() -> Result<()> {
);
// ── 5. Assist pipeline ──────────────────────────────────────────
let recognizer = RegexIntentRecognizer::new();
let pipeline = default_pipeline(recognizer);
info!("Assist pipeline ready (5 built-in intent handlers via default_pipeline)");
let _ = pipeline; // wired-but-idle at boot; voice WS plugs in here
// ── 6. HAP bridge surface ───────────────────────────────────────
let hap_record = HapServiceRecord {
instance_name: "HOMECORE".to_string(),
port: 51826,
setup_code: "123-45-678".to_string(),
device_id: "AA:BB:CC:DD:EE:FF".to_string(),
};
let hap_bridge = HapBridge::new(hap_record);
info!("HAP bridge surface ready ({} accessories registered)", hap_bridge.running_accessories().len());
let _ = hap_bridge;
// ── 7. REST + WS API ────────────────────────────────────────────
// Token provisioning closes audit findings HC-01/HC-02. If
// HOMECORE_TOKENS is set in the env, populate the store from
// its comma-separated list. Otherwise fall back to DEV mode
// (warn-on-each-request) so existing smoke tests still work.
let tokens = if std::env::var("HOMECORE_TOKENS").map(|v| !v.trim().is_empty()).unwrap_or(false) {
let s = LongLivedTokenStore::from_env();
let n = s.len().await;
info!("LongLivedTokenStore provisioned with {} bearer token(s) from HOMECORE_TOKENS", n);
s
} else {
warn!("HOMECORE_TOKENS not set — token store in DEV mode (any non-empty bearer accepted). Provision real tokens before exposing to the network.");
LongLivedTokenStore::allow_any_non_empty()
};
let api_state = SharedState::with_tokens(
hc.clone(),
cli.location_name,
@@ -217,12 +213,7 @@ async fn main() -> Result<()> {
// BFF gateway (ADR-131 §11): single-origin aggregation of the
// calibration API + SEED/appliance tiers. Shares the same token store
// for auth; upstream credentials stay server-side.
let assist = build_assist_pipeline().await?;
info!(
"Assist intent endpoint ready with {} handlers",
assist.handler_count()
);
let gw = GatewayState::with_assist(
let gw = GatewayState::new(
api_state.clone(),
GatewayConfig {
calibration_url: cli.calibration_url.clone(),
@@ -230,7 +221,6 @@ async fn main() -> Result<()> {
apps_dir: std::path::PathBuf::from(&cli.apps_dir),
timeout: std::time::Duration::from_millis(cli.gateway_timeout_ms),
},
assist,
);
// Merge the HA-compat API + UI mount with the BFF gateway, THEN apply the
// audited CORS allowlist + request tracing to the WHOLE surface. The
@@ -243,35 +233,19 @@ async fn main() -> Result<()> {
.layer(build_cors_layer())
.layer(TraceLayer::new_for_http());
let listener = tokio::net::TcpListener::bind(cli.bind).await?;
info!(
"HOMECORE-API listening on http://{} (HA-compat /api + /api/websocket)",
cli.bind
);
info!("HOMECORE-API listening on http://{} (HA-compat /api + /api/websocket)", cli.bind);
info!(
"HOMECORE BFF gateway active: /api/homecore/* + /api/cal/* (calibration_url={:?})",
cli.calibration_url
);
if !cli.ui_dir.trim().is_empty() {
info!(
"HOMECORE-UI (ADR-131) served at http://{}/homecore/ from {}",
cli.bind, cli.ui_dir
);
info!("HOMECORE-UI (ADR-131) served at http://{}/homecore/ from {}", cli.bind, cli.ui_dir);
} else {
info!("HOMECORE-UI mount disabled (--ui-dir empty)");
}
let shutdown_hc = hc.clone();
axum::serve(listener, app)
.with_graceful_shutdown(async move {
if let Err(error) = tokio::signal::ctrl_c().await {
warn!("failed to install Ctrl-C handler: {error}");
}
shutdown_hc
.bus()
.fire_system(homecore::SystemEvent::HomeCoreStop);
info!("Shutdown requested; draining active HTTP connections");
})
.await?;
// Run forever (until SIGINT). axum::serve handles graceful shutdown.
axum::serve(listener, app).await?;
Ok(())
}
@@ -289,68 +263,11 @@ fn build_app(api_state: SharedState, ui_dir: &str) -> Router {
app.nest_service("/homecore", ServeDir::new(ui_dir))
}
#[cfg(not(feature = "ruvector"))]
async fn open_recorder(path: &str) -> anyhow::Result<Recorder> {
Ok(Recorder::open(path).await?)
}
async fn build_assist_pipeline() -> anyhow::Result<AssistPipeline<RegexIntentRecognizer>> {
let recognizer = RegexIntentRecognizer::new();
recognizer
.register(
"HassTurnOn",
r"^turn on (?:the )?(?P<entity_id>[a-z0-9_]+\.[a-z0-9_]+)$",
"*",
)
.await?;
recognizer
.register(
"HassTurnOff",
r"^turn off (?:the )?(?P<entity_id>[a-z0-9_]+\.[a-z0-9_]+)$",
"*",
)
.await?;
recognizer
.register(
"HassLightSet",
r"^set (?P<entity_id>light\.[a-z0-9_]+) to (?P<brightness>[0-9]{1,3})$",
"*",
)
.await?;
recognizer
.register("HassNevermind", r"^(?:never ?mind|cancel that)$", "*")
.await?;
recognizer
.register("HassCancelAll", r"^cancel all automations$", "*")
.await?;
let mut pipeline = AssistPipeline::new(recognizer);
pipeline.register_handler(HassTurnOn);
pipeline.register_handler(HassTurnOff);
pipeline.register_handler(HassLightSet);
pipeline.register_handler(HassNevermind);
pipeline.register_handler(HassCancelAll);
Ok(pipeline)
}
#[cfg(feature = "ruvector")]
async fn open_recorder(path: &str) -> anyhow::Result<Recorder> {
use homecore_recorder::{RuvectorSemanticIndex, SemanticIndex};
use tokio::sync::RwLock;
let index = RuvectorSemanticIndex::new(100_000)
.map_err(|error| anyhow::anyhow!("failed to initialize ruvector index: {error}"))?;
let semantic: std::sync::Arc<RwLock<dyn SemanticIndex>> =
std::sync::Arc::new(RwLock::new(index));
Ok(Recorder::open_with_index(path, semantic).await?)
}
fn init_tracing() {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
"info,homecore=debug,homecore_server=debug,tower_http=info".into()
}),
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info,homecore=debug,homecore_server=debug,tower_http=info".into()),
)
.init();
}
@@ -364,125 +281,42 @@ fn init_tracing() {
/// light / switch / scene / automation domains) plus a `homecore.*`
/// domain so operators can see HOMECORE-native services distinguished
/// from the HA-compat ones.
async fn register_builtin_services(hc: &HomeCore) {
hc.services()
.register(
ServiceName::new("homecore", "ping"),
FnHandler(|_call| async { Ok(serde_json::json!({ "pong": true })) }),
)
.await;
async fn seed_default_services(hc: &HomeCore) {
let echo = || FnHandler(|call: ServiceCall| async move {
Ok(serde_json::json!({
"called": format!("{}.{}", call.name.domain, call.name.service),
"service_data": call.data,
"acknowledged": true,
}))
});
let snapshot_states = hc.states().clone();
hc.services()
.register(
ServiceName::new("homecore", "snapshot_state"),
FnHandler(move |_call| {
let states = snapshot_states.clone();
async move { Ok(serde_json::to_value(states.all()).unwrap_or_default()) }
}),
)
.await;
let svcs = [
// Conventional HA wire-compat services
("homeassistant", "restart"),
("homeassistant", "stop"),
("homeassistant", "reload_core_config"),
("light", "turn_on"),
("light", "turn_off"),
("light", "toggle"),
("switch", "turn_on"),
("switch", "turn_off"),
("switch", "toggle"),
("scene", "apply"),
("automation", "trigger"),
// HOMECORE-native services
("homecore", "ping"),
("homecore", "snapshot_state"),
];
for domain in ["homeassistant", "light", "switch"] {
for action in ["turn_on", "turn_off", "toggle"] {
let states = hc.states().clone();
let required_domain = (domain != "homeassistant").then(|| domain.to_owned());
hc.services()
.register(
ServiceName::new(domain, action),
FnHandler(move |call: ServiceCall| {
let states = states.clone();
let required_domain = required_domain.clone();
async move {
let ids = service_entity_ids(&call.data)?;
let mut changed = Vec::with_capacity(ids.len());
for id in ids {
if let Some(domain) = required_domain.as_deref() {
if id.domain() != domain {
return Err(ServiceError::HandlerFailed(format!(
"{}.{} only accepts {domain} entities",
call.name.domain, call.name.service
)));
}
}
let current = states.get(&id).ok_or_else(|| {
ServiceError::HandlerFailed(format!(
"entity not found: {}",
id.as_str()
))
})?;
let next = match call.name.service.as_str() {
"turn_on" => "on",
"turn_off" => "off",
"toggle" if current.state == "on" => "off",
"toggle" => "on",
_ => unreachable!("only registered actions are dispatched"),
};
let mut attributes = current.attributes.clone();
if next == "on" {
if let (Some(object), Some(brightness)) =
(attributes.as_object_mut(), call.data.get("brightness"))
{
object.insert("brightness".into(), brightness.clone());
}
}
states.set(
id.clone(),
next,
attributes,
Context::child_of(&call.context),
);
changed.push(id.as_str().to_owned());
}
Ok(serde_json::json!({ "changed": changed }))
}
}),
)
.await;
}
for (domain, service) in svcs {
hc.services()
.register(ServiceName::new(domain, service), echo())
.await;
}
info!(
"Registered {} executable built-in services",
hc.services().registered_services().await.len()
);
}
fn service_entity_ids(data: &serde_json::Value) -> Result<Vec<EntityId>, ServiceError> {
let value = data
.get("entity_id")
.or_else(|| {
data.get("target")
.and_then(|target| target.get("entity_id"))
})
.ok_or_else(|| ServiceError::HandlerFailed("missing entity_id".into()))?;
let raw_ids: Vec<&str> = match value {
serde_json::Value::String(value) => vec![value],
serde_json::Value::Array(values) => values
.iter()
.map(|value| {
value.as_str().ok_or_else(|| {
ServiceError::HandlerFailed("entity_id array must contain strings".into())
})
})
.collect::<Result<_, _>>()?,
_ => {
return Err(ServiceError::HandlerFailed(
"entity_id must be a string or string array".into(),
));
}
};
if raw_ids.is_empty() {
return Err(ServiceError::HandlerFailed(
"entity_id array cannot be empty".into(),
));
}
raw_ids
.into_iter()
.map(|value| {
EntityId::parse(value).map_err(|error| ServiceError::HandlerFailed(error.to_string()))
})
.collect()
let count = hc.services().registered_services().await.len();
let _ = ServiceError::NotRegistered { domain: String::new(), service: String::new() };
info!("Service registry seeded with {} default service(s)", count);
}
/// Register 10 representative entities so a fresh `--db :memory:`
@@ -491,85 +325,45 @@ fn service_entity_ids(data: &serde_json::Value) -> Result<Vec<EntityId>, Service
/// they stay in sync.
fn seed_default_entities(hc: &HomeCore) {
let entities: Vec<(&str, &str, serde_json::Value)> = vec![
(
"sensor.living_room_presence",
"false",
serde_json::json!({
"friendly_name": "Living Room Presence", "device_class": "occupancy",
"source": "RuView ESP32-C6 BFLD"
}),
),
(
"sensor.living_room_motion_score",
"0.0",
serde_json::json!({
"friendly_name": "Living Room Motion Score", "unit_of_measurement": "score",
"icon": "mdi:motion-sensor"
}),
),
(
"sensor.bedroom_breathing_rate",
"14.5",
serde_json::json!({
"friendly_name": "Bedroom Breathing Rate", "unit_of_measurement": "BPM",
"device_class": "frequency", "source": "Seeed MR60BHA2 mmWave"
}),
),
(
"sensor.bedroom_heart_rate",
"68.0",
serde_json::json!({
"friendly_name": "Bedroom Heart Rate", "unit_of_measurement": "BPM",
"device_class": "frequency", "source": "Seeed MR60BHA2 mmWave"
}),
),
(
"light.kitchen_ceiling",
"on",
serde_json::json!({
"friendly_name": "Kitchen Ceiling", "brightness": 230,
"color_temp_kelvin": 4000, "supported_color_modes": ["color_temp"]
}),
),
(
"light.living_room_lamp",
"off",
serde_json::json!({
"friendly_name": "Living Room Lamp", "brightness": 0,
"supported_color_modes": ["brightness"]
}),
),
(
"switch.coffee_maker",
"off",
serde_json::json!({
"friendly_name": "Coffee Maker", "device_class": "outlet"
}),
),
(
"binary_sensor.front_door",
"off",
serde_json::json!({
"friendly_name": "Front Door", "device_class": "door"
}),
),
(
"climate.thermostat",
"heat",
serde_json::json!({
"friendly_name": "Thermostat", "current_temperature": 21.5,
"temperature": 22.0, "hvac_modes": ["off", "heat", "cool", "auto"],
"supported_features": 387
}),
),
(
"sensor.air_quality_index",
"42",
serde_json::json!({
"friendly_name": "Air Quality Index", "unit_of_measurement": "AQI",
"device_class": "aqi"
}),
),
("sensor.living_room_presence", "false", serde_json::json!({
"friendly_name": "Living Room Presence", "device_class": "occupancy",
"source": "RuView ESP32-C6 BFLD"
})),
("sensor.living_room_motion_score", "0.0", serde_json::json!({
"friendly_name": "Living Room Motion Score", "unit_of_measurement": "score",
"icon": "mdi:motion-sensor"
})),
("sensor.bedroom_breathing_rate", "14.5", serde_json::json!({
"friendly_name": "Bedroom Breathing Rate", "unit_of_measurement": "BPM",
"device_class": "frequency", "source": "Seeed MR60BHA2 mmWave"
})),
("sensor.bedroom_heart_rate", "68.0", serde_json::json!({
"friendly_name": "Bedroom Heart Rate", "unit_of_measurement": "BPM",
"device_class": "frequency", "source": "Seeed MR60BHA2 mmWave"
})),
("light.kitchen_ceiling", "on", serde_json::json!({
"friendly_name": "Kitchen Ceiling", "brightness": 230,
"color_temp_kelvin": 4000, "supported_color_modes": ["color_temp"]
})),
("light.living_room_lamp", "off", serde_json::json!({
"friendly_name": "Living Room Lamp", "brightness": 0,
"supported_color_modes": ["brightness"]
})),
("switch.coffee_maker", "off", serde_json::json!({
"friendly_name": "Coffee Maker", "device_class": "outlet"
})),
("binary_sensor.front_door", "off", serde_json::json!({
"friendly_name": "Front Door", "device_class": "door"
})),
("climate.thermostat", "heat", serde_json::json!({
"friendly_name": "Thermostat", "current_temperature": 21.5,
"temperature": 22.0, "hvac_modes": ["off", "heat", "cool", "auto"],
"supported_features": 387
})),
("sensor.air_quality_index", "42", serde_json::json!({
"friendly_name": "Air Quality Index", "unit_of_measurement": "AQI",
"device_class": "aqi"
})),
];
for (id, state, attrs) in entities {
@@ -587,11 +381,8 @@ fn seed_default_entities(hc: &HomeCore) {
context: Context::new(),
};
let total = hc.states().all().len();
info!(
"State machine seeded with {} default entit{}",
total,
if total == 1 { "y" } else { "ies" }
);
info!("State machine seeded with {} default entit{}", total,
if total == 1 { "y" } else { "ies" });
}
#[cfg(test)]
@@ -628,19 +419,9 @@ mod ui_tests {
async fn ui_index_is_served_at_homecore() {
let app = build_app(test_state(), DEFAULT_UI_DIR);
let (status, body) = get(app, "/homecore/").await;
assert_eq!(
status,
StatusCode::OK,
"GET /homecore/ should serve index.html"
);
assert!(
body.contains("HOMECORE"),
"index.html should mention HOMECORE"
);
assert!(
body.contains("./js/app.js"),
"index.html should bootstrap app.js"
);
assert_eq!(status, StatusCode::OK, "GET /homecore/ should serve index.html");
assert!(body.contains("HOMECORE"), "index.html should mention HOMECORE");
assert!(body.contains("./js/app.js"), "index.html should bootstrap app.js");
}
#[tokio::test]
@@ -656,18 +437,8 @@ mod ui_tests {
#[tokio::test]
async fn ui_panels_are_served() {
let app = build_app(test_state(), DEFAULT_UI_DIR);
for p in [
"dashboard",
"rooms",
"calibration",
"fleet",
"seed-detail",
"entities",
"cogs",
"events",
"audit",
"settings",
] {
for p in ["dashboard", "rooms", "calibration", "fleet", "seed-detail",
"entities", "cogs", "events", "audit", "settings"] {
let (status, _) = get(app.clone(), &format!("/homecore/js/panels/{p}.js")).await;
assert_eq!(status, StatusCode::OK, "panel {p}.js should be served");
}
@@ -688,15 +459,9 @@ mod ui_tests {
.await
.unwrap();
let status = resp.status();
let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20)
.await
.unwrap();
let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
let body = String::from_utf8_lossy(&bytes);
assert_eq!(
status,
StatusCode::OK,
"the HA-compat API must coexist with the UI mount"
);
assert_eq!(status, StatusCode::OK, "the HA-compat API must coexist with the UI mount");
assert!(body.contains("API running"));
}
@@ -704,16 +469,12 @@ mod ui_tests {
async fn ui_mount_can_be_disabled() {
let app = build_app(test_state(), "");
let (status, _) = get(app, "/homecore/").await;
assert_eq!(
status,
StatusCode::NOT_FOUND,
"empty --ui-dir disables the mount"
);
assert_eq!(status, StatusCode::NOT_FOUND, "empty --ui-dir disables the mount");
}
/// Build the SAME merged + layered surface `main()` serves: API + UI mount
/// + BFF gateway, with the audited CORS allowlist + tracing applied to the
/// whole thing. Used to prove the gateway routes are CORS-covered.
/// whole thing. Used to prove the gateway routes are CORS-covered.
fn full_app(state: SharedState) -> Router {
use crate::gateway::{GatewayConfig, GatewayState};
let gw = GatewayState::new(
@@ -766,47 +527,4 @@ mod ui_tests {
"gateway route must echo the allowlisted dev origin"
);
}
#[tokio::test]
async fn builtin_light_service_changes_real_state() {
let hc = HomeCore::new();
let id = EntityId::parse("light.kitchen").unwrap();
hc.states().set(
id.clone(),
"off",
serde_json::json!({"friendly_name": "Kitchen"}),
Context::new(),
);
register_builtin_services(&hc).await;
let result = hc
.services()
.call(ServiceCall {
name: ServiceName::new("light", "turn_on"),
data: serde_json::json!({"entity_id": "light.kitchen", "brightness": 123}),
context: Context::new(),
})
.await
.unwrap();
assert_eq!(result["changed"][0], "light.kitchen");
let state = hc.states().get(&id).unwrap();
assert_eq!(state.state, "on");
assert_eq!(state.attributes["brightness"], 123);
}
#[tokio::test]
async fn unsupported_builtin_service_is_not_success_shaped() {
let hc = HomeCore::new();
register_builtin_services(&hc).await;
let result = hc
.services()
.call(ServiceCall {
name: ServiceName::new("homeassistant", "restart"),
data: serde_json::json!({}),
context: Context::new(),
})
.await;
assert!(matches!(result, Err(ServiceError::NotRegistered { .. })));
}
}
+1 -1
View File
@@ -31,7 +31,7 @@ export function demoMode() {
export const api = {
base: '',
token: () => { try { return localStorage.getItem('homecore_token') || ''; } catch { return ''; } },
token: () => { try { return localStorage.getItem('homecore_token') || 'dev-token'; } catch { return 'dev-token'; } },
isDemo: (key) => !!demoFlags[key],
anyDemo: () => demoMode() && Object.keys(demoFlags).length > 0,
demoMode,
+3 -17
View File
@@ -84,23 +84,9 @@ impl Default for Context {
#[derive(Clone, Debug)]
pub enum SystemEvent {
StateChanged(StateChangedEvent),
ServiceCalled {
domain: String,
service: String,
data: serde_json::Value,
context: Context,
},
ServiceRegistered {
domain: String,
service: String,
},
ServiceRemoved {
domain: String,
service: String,
},
ComponentLoaded {
component: String,
},
ServiceRegistered { domain: String, service: String },
ServiceRemoved { domain: String, service: String },
ComponentLoaded { component: String },
HomeCoreStart,
HomeCoreStarted,
HomeCoreStop,
+5 -67
View File
@@ -24,12 +24,11 @@ struct HomeCoreInner {
impl HomeCore {
pub fn new() -> Self {
let bus = EventBus::new();
Self {
inner: Arc::new(HomeCoreInner {
states: StateMachine::with_event_bus(bus.clone()),
services: ServiceRegistry::with_event_bus(bus.clone()),
bus,
bus: EventBus::new(),
states: StateMachine::new(),
services: ServiceRegistry::new(),
entities: EntityRegistry::new(),
}),
}
@@ -62,76 +61,15 @@ impl Default for HomeCore {
mod tests {
use super::*;
use crate::entity::EntityId;
use crate::event::{Context, SystemEvent};
use crate::service::{FnHandler, ServiceCall, ServiceName};
use crate::event::Context;
#[tokio::test]
async fn end_to_end_set_then_get() {
let hc = HomeCore::new();
let id = EntityId::parse("light.kitchen").unwrap();
hc.states().set(
id.clone(),
"on",
serde_json::json!({"brightness": 200}),
Context::new(),
);
hc.states().set(id.clone(), "on", serde_json::json!({"brightness": 200}), Context::new());
let snap = hc.states().get(&id).unwrap();
assert_eq!(snap.state, "on");
assert_eq!(snap.attributes["brightness"], 200);
}
#[tokio::test]
async fn state_changes_are_published_on_shared_system_bus() {
let hc = HomeCore::new();
let mut rx = hc.bus().subscribe_system();
let id = EntityId::parse("light.kitchen").unwrap();
hc.states()
.set(id.clone(), "on", serde_json::json!({}), Context::new());
let event = rx.recv().await.unwrap();
match event {
SystemEvent::StateChanged(change) => {
assert_eq!(change.entity_id, id);
assert_eq!(change.new_state.unwrap().state, "on");
}
other => panic!("expected StateChanged, got {other:?}"),
}
}
#[tokio::test]
async fn service_calls_are_published_on_shared_system_bus() {
let hc = HomeCore::new();
let service = ServiceName::new("light", "turn_on");
hc.services()
.register(
service.clone(),
FnHandler(|_| async { Ok(serde_json::json!({})) }),
)
.await;
let mut rx = hc.bus().subscribe_system();
hc.services()
.call(ServiceCall {
name: service,
data: serde_json::json!({"brightness": 42}),
context: Context::new(),
})
.await
.unwrap();
match rx.recv().await.unwrap() {
SystemEvent::ServiceCalled {
domain,
service,
data,
..
} => {
assert_eq!(domain, "light");
assert_eq!(service, "turn_on");
assert_eq!(data["brightness"], 42);
}
other => panic!("expected ServiceCalled, got {other:?}"),
}
}
}
+1 -8
View File
@@ -56,10 +56,7 @@ impl EntityRegistry {
}
pub async fn register(&self, entry: EntityEntry) {
self.entries
.write()
.await
.insert(entry.entity_id.clone(), entry);
self.entries.write().await.insert(entry.entity_id.clone(), entry);
}
pub async fn get(&self, entity_id: &EntityId) -> Option<EntityEntry> {
@@ -77,10 +74,6 @@ impl EntityRegistry {
pub async fn len(&self) -> usize {
self.entries.read().await.len()
}
pub async fn is_empty(&self) -> bool {
self.entries.read().await.is_empty()
}
}
impl Default for EntityRegistry {
+2 -21
View File
@@ -1,4 +1,4 @@
//! Concurrent service registry with panic-isolated direct dispatch.
//! Service registry stub.
//!
//! Mirrors `homeassistant.core.ServiceRegistry`. P1 ships the public
//! surface + a simple direct-dispatch `call` so downstream ADRs can
@@ -15,8 +15,7 @@ use serde::{Deserialize, Serialize};
use thiserror::Error;
use tokio::sync::RwLock;
use crate::bus::EventBus;
use crate::event::{Context, SystemEvent};
use crate::event::Context;
/// Service name within a domain. e.g. `light.turn_on` → domain
/// `"light"`, service `"turn_on"`.
@@ -79,22 +78,12 @@ where
#[derive(Clone)]
pub struct ServiceRegistry {
handlers: Arc<RwLock<HashMap<ServiceName, Arc<dyn ServiceHandler>>>>,
bus: Option<EventBus>,
}
impl ServiceRegistry {
pub fn new() -> Self {
Self::new_inner(None)
}
pub fn with_event_bus(bus: EventBus) -> Self {
Self::new_inner(Some(bus))
}
fn new_inner(bus: Option<EventBus>) -> Self {
Self {
handlers: Arc::new(RwLock::new(HashMap::new())),
bus,
}
}
@@ -122,14 +111,6 @@ impl ServiceRegistry {
/// that drives the engine. Mirrors HA isolating service-handler
/// exceptions.
pub async fn call(&self, call: ServiceCall) -> Result<serde_json::Value, ServiceError> {
if let Some(bus) = &self.bus {
bus.fire_system(SystemEvent::ServiceCalled {
domain: call.name.domain.clone(),
service: call.name.service.clone(),
data: call.data.clone(),
context: call.context.clone(),
});
}
let handler = {
let guard = self.handlers.read().await;
guard.get(&call.name).cloned()
+11 -47
View File
@@ -22,9 +22,8 @@ use chrono::Utc;
use dashmap::DashMap;
use tokio::sync::broadcast;
use crate::bus::EventBus;
use crate::entity::{EntityId, State};
use crate::event::{Context, StateChangedEvent, SystemEvent};
use crate::event::{Context, StateChangedEvent};
/// Broadcast channel capacity for state-changed events. 4,096 events
/// at 20 Hz per entity covers ~3 minutes of backlog for a single hot
@@ -41,28 +40,15 @@ pub struct StateMachine {
struct StateMachineInner {
states: DashMap<EntityId, Arc<State>>,
tx: broadcast::Sender<StateChangedEvent>,
bus: Option<EventBus>,
}
impl StateMachine {
pub fn new() -> Self {
Self::new_inner(None)
}
/// Create a state machine that also publishes every committed change on
/// the shared HOMECORE system event bus. Standalone state machines retain
/// their lightweight private broadcast channel via [`StateMachine::new`].
pub fn with_event_bus(bus: EventBus) -> Self {
Self::new_inner(Some(bus))
}
fn new_inner(bus: Option<EventBus>) -> Self {
let (tx, _) = broadcast::channel(STATE_CHANGED_CHANNEL_CAPACITY);
Self {
inner: Arc::new(StateMachineInner {
states: DashMap::with_capacity(256),
tx,
bus,
}),
}
}
@@ -149,10 +135,7 @@ impl StateMachine {
fired_at: Utc::now(),
};
// err = no receivers; that's fine, write still committed.
let _ = self.inner.tx.send(event.clone());
if let Some(bus) = &self.inner.bus {
bus.fire_system(SystemEvent::StateChanged(event));
}
let _ = self.inner.tx.send(event);
}
// `_guard` (and the shard lock) drops here, after the event is sent.
next
@@ -168,10 +151,7 @@ impl StateMachine {
new_state: None,
fired_at: Utc::now(),
};
let _ = self.inner.tx.send(event.clone());
if let Some(bus) = &self.inner.bus {
bus.fire_system(SystemEvent::StateChanged(event));
}
let _ = self.inner.tx.send(event);
}
removed
}
@@ -179,11 +159,7 @@ impl StateMachine {
/// Snapshot all current states. Allocates a new Vec — useful for
/// the REST GET /api/states path (ADR-130).
pub fn all(&self) -> Vec<Arc<State>> {
self.inner
.states
.iter()
.map(|r| Arc::clone(r.value()))
.collect()
self.inner.states.iter().map(|r| Arc::clone(r.value())).collect()
}
/// Snapshot all states whose entity_id matches a domain prefix.
@@ -225,12 +201,7 @@ mod tests {
async fn set_writes_and_fires() {
let sm = StateMachine::new();
let mut rx = sm.subscribe();
sm.set(
id("light.kitchen"),
"on",
serde_json::json!({"brightness": 200}),
Context::new(),
);
sm.set(id("light.kitchen"), "on", serde_json::json!({"brightness": 200}), Context::new());
let evt = rx.recv().await.unwrap();
assert_eq!(evt.entity_id.as_str(), "light.kitchen");
assert!(evt.old_state.is_none());
@@ -251,19 +222,9 @@ mod tests {
#[tokio::test]
async fn attribute_only_change_fires_but_preserves_last_changed() {
let sm = StateMachine::new();
let s1 = sm.set(
id("sensor.t"),
"20",
serde_json::json!({"unit": "C"}),
Context::new(),
);
let s1 = sm.set(id("sensor.t"), "20", serde_json::json!({"unit": "C"}), Context::new());
tokio::time::sleep(std::time::Duration::from_millis(2)).await;
let s2 = sm.set(
id("sensor.t"),
"20",
serde_json::json!({"unit": "F"}),
Context::new(),
);
let s2 = sm.set(id("sensor.t"), "20", serde_json::json!({"unit": "F"}), Context::new());
assert_eq!(s1.last_changed, s2.last_changed);
assert!(s2.last_updated > s1.last_updated);
}
@@ -406,7 +367,10 @@ mod tests {
drainer.join().unwrap();
let log = log.lock().unwrap();
let dup = log.windows(2).filter(|w| w[0] == w[1]).count();
let dup = log
.windows(2)
.filter(|w| w[0] == w[1])
.count();
assert_eq!(
dup, 0,
"{dup} consecutive fired state_changed events carried an \
@@ -37,13 +37,6 @@ pub const ANOMALY_OUTLIER_SPREADS: f32 = 2.0;
/// drives the human-readable label.
pub const ANOMALY_LABEL_CUTOFF: f32 = 0.5;
/// Fraction by which occupied-anchor variance must exceed the empty-room
/// baseline for [`PresenceSpecialist`]'s variance channel to be trusted
/// (issue #1440). Below this margin the channel is disabled rather than
/// producing a midpoint threshold that can sit below the empty-room
/// baseline itself.
pub const VARIANCE_SEPARATION_MARGIN: f32 = 0.05;
/// Which biological signal a specialist estimates.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SpecialistKind {
@@ -118,16 +111,6 @@ impl PresenceSpecialist {
/// Fit from anchors: variance threshold at the midpoint between the empty
/// variance and the mean occupied variance; mean-shift threshold at half
/// the empty→occupied mean distance (inert when the means don't separate).
///
/// The variance channel is itself inert (never fires) when `occ_var`
/// does not genuinely exceed `empty_var` (issue #1440): a midpoint
/// threshold assumes occupied windows are noisier than the empty-room
/// baseline, but a still/quiet occupant can measure *less* variance
/// than an empty room's ambient/interference noise floor. Left
/// unguarded, the midpoint then sits *below* the empty-room baseline
/// itself, so a genuinely empty room reads "present" on every frame —
/// worse than no signal at all. Matches the mean-shift channel, which
/// already goes inert (`None`) under the equivalent condition.
pub fn train(anchors: &[AnchorFeature]) -> Option<Self> {
let empty = anchors.iter().find(|a| a.label == AnchorLabel::Empty)?;
let occ: Vec<&Features> = anchors
@@ -146,15 +129,8 @@ impl PresenceSpecialist {
let mean_dist = (occ_mean - empty_mean).abs();
let mean_dist_threshold = (mean_dist > 1e-4).then(|| 0.5 * mean_dist);
let variance_separates = occ_var > empty_var * (1.0 + VARIANCE_SEPARATION_MARGIN);
let threshold = if variance_separates {
0.5 * (empty_var + occ_var)
} else {
f32::INFINITY
};
Some(Self {
threshold,
threshold: 0.5 * (empty_var + occ_var),
occupied_var: occ_var.max(empty_var + 1e-3),
empty_mean,
mean_dist_threshold,
@@ -173,15 +149,8 @@ impl Specialist for PresenceSpecialist {
let present = by_variance || by_mean;
// Confidence: strongest margin among the channels that are enabled.
// An infinite threshold means the variance channel was disabled at
// train time (issue #1440) — it must contribute 0, not the spurious
// 1.0 that `(x - inf).abs() / span` would otherwise clamp to.
let var_conf = if self.threshold.is_finite() {
let var_span = (self.occupied_var - self.threshold).max(1e-3);
((f.variance - self.threshold).abs() / var_span).clamp(0.0, 1.0)
} else {
0.0
};
let var_span = (self.occupied_var - self.threshold).max(1e-3);
let var_conf = ((f.variance - self.threshold).abs() / var_span).clamp(0.0, 1.0);
let mean_conf = self
.mean_dist_threshold
.map(|thr| ((mean_dist - thr).abs() / thr.max(1e-3)).clamp(0.0, 1.0))
@@ -503,26 +472,6 @@ mod tests {
assert!(p.infer(&feat(1.0, 0.1, 0.0, 0.0)).unwrap().value == 0.0);
}
/// Issue #1440: a still/quiet occupant can measure LESS variance than an
/// empty room's ambient/interference noise floor. Before the
/// `variance_separates` guard, the midpoint threshold would then sit
/// below the empty-room baseline itself, so a window matching that
/// baseline exactly (empty room) read "present" — the reporter's
/// "known-empty room read present 31/31 frames" symptom. The means are
/// identical here (no mean-shift channel to fall back on), isolating
/// the variance-channel bug.
#[test]
fn presence_inverted_variance_never_reports_empty_room_as_present() {
let anchors = vec![
af(AnchorLabel::Empty, 5.0, 0.1),
af(AnchorLabel::StandStill, 2.0, 0.15),
];
let p = PresenceSpecialist::train(&anchors).unwrap();
let r = p.infer(&feat(5.0, 0.1, 0.0, 0.0)).unwrap();
assert_eq!(r.value, 0.0, "empty-room-baseline variance must not read present when occ_var < empty_var");
assert_eq!(r.confidence, 0.0, "a disabled channel must not report spurious confidence");
}
/// ADR-152 "variance-only presence" regression: a MOTIONLESS person raises
/// the scalar mean (extra multipath energy) but barely the variance — the
/// mean channel must still detect them, and a window matching the empty
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "wifi-densepose-cli"
version = "0.3.2"
version = "0.3.1"
edition.workspace = true
description = "CLI for WiFi-DensePose"
authors.workspace = true
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "wifi-densepose-core"
description = "Core types, traits, and utilities for WiFi-DensePose pose estimation system"
version = "0.3.2" # ADR-136: ComplexSample/CanonicalFrame/provenance + blake3
version = "0.3.1" # ADR-136: ComplexSample/CanonicalFrame/provenance + blake3
edition.workspace = true
authors.workspace = true
license.workspace = true
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "wifi-densepose-hardware"
version = "0.3.2"
version = "0.3.1"
edition.workspace = true
description = "Hardware interface abstractions for WiFi CSI sensors (ESP32, Intel 5300, Atheros)"
license = "MIT OR Apache-2.0"
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "wifi-densepose-mat"
version = "0.3.2"
version = "0.3.1"
edition = "2021"
authors = ["rUv <ruv@ruv.net>", "WiFi-DensePose Contributors"]
description = "Mass Casualty Assessment Tool - WiFi-based disaster survivor detection"
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "wifi-densepose-nn"
version = "0.3.2"
version = "0.3.1"
edition.workspace = true
authors.workspace = true
license.workspace = true
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "wifi-densepose-ruvector"
version = "0.3.3"
version = "0.3.2"
edition.workspace = true
authors.workspace = true
license.workspace = true
@@ -1,6 +1,6 @@
[package]
name = "wifi-densepose-sensing-server"
version = "0.3.5"
version = "0.3.4"
edition.workspace = true
description = "Lightweight Axum server for WiFi sensing UI with RuVector signal processing"
license.workspace = true
@@ -393,62 +393,6 @@ struct ClassificationInfo {
confidence: f64,
}
/// Derives the classification triple from raw ESP32 vitals fields.
///
/// `presence` is derived from `motion_level`, never taken from the raw
/// `presence` flag directly, so `motion_level: "present_moving"` can never
/// pair with `presence: false` (issue #1442) — matches the convention
/// already used elsewhere (the per-node path and `csi.rs`'s label-derived
/// `classification.presence = label != "absent"`).
fn classify_vitals(motion: bool, presence: bool, presence_score: f32) -> ClassificationInfo {
let motion_level = if motion {
"present_moving"
} else if presence {
"present_still"
} else {
"absent"
};
ClassificationInfo {
motion_level: motion_level.to_string(),
presence: motion_level != "absent",
confidence: presence_score as f64,
}
}
#[cfg(test)]
mod classify_vitals_tests {
use super::classify_vitals;
#[test]
fn motion_implies_presence_issue_1442() {
// The exact contradictory frame from issue #1442:
// motion=true, presence=false must not yield presence: false.
let c = classify_vitals(true, false, 0.69);
assert_eq!(c.motion_level, "present_moving");
assert!(c.presence, "motion implies presence regardless of the raw presence flag");
}
#[test]
fn presence_without_motion_is_present_still() {
let c = classify_vitals(false, true, 0.5);
assert_eq!(c.motion_level, "present_still");
assert!(c.presence);
}
#[test]
fn neither_motion_nor_presence_is_absent() {
let c = classify_vitals(false, false, 0.0);
assert_eq!(c.motion_level, "absent");
assert!(!c.presence);
}
#[test]
fn confidence_passes_through_presence_score() {
let c = classify_vitals(true, true, 0.33);
assert!((c.confidence - 0.33_f64).abs() < 1e-6);
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct SignalField {
grid_size: [usize; 3],
@@ -5842,6 +5786,13 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
s.tick += 1;
let tick = s.tick;
let motion_level = if vitals.motion {
"present_moving"
} else if vitals.presence {
"present_still"
} else {
"absent"
};
let motion_score = if vitals.motion {
0.8
} else if vitals.presence {
@@ -5948,8 +5899,11 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
// Cross-node fusion: combine features from all active nodes.
let fused_features = fuse_multi_node_features(&features, &s.node_states);
let mut classification =
classify_vitals(vitals.motion, vitals.presence, vitals.presence_score);
let mut classification = ClassificationInfo {
motion_level: motion_level.to_string(),
presence: vitals.presence,
confidence: vitals.presence_score as f64,
};
// Boost classification confidence with multi-node coverage.
let n_active = s
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "wifi-densepose-signal"
version = "0.3.6"
version = "0.3.5"
edition.workspace = true
description = "WiFi CSI signal processing for DensePose estimation"
license.workspace = true
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "wifi-densepose-train"
version = "0.3.3"
version = "0.3.2"
edition = "2021"
authors = ["rUv <ruv@ruv.net>", "WiFi-DensePose Contributors"]
license = "MIT OR Apache-2.0"
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "wifi-densepose-vitals"
version = "0.3.2"
version = "0.3.1"
edition.workspace = true
description = "ESP32 CSI-grade vital sign extraction (ADR-021): heart rate and respiratory rate from WiFi Channel State Information"
license.workspace = true
@@ -47,29 +47,8 @@ pub struct BreathingExtractor {
freq_high: f64,
/// IIR filter state.
filter_state: IirState,
/// Count of consecutive `extract()` calls that rejected the estimated
/// frequency as out of the breathing band (i.e. "no periodic signal
/// detected"), since the last accepted estimate or reset.
///
/// Without this, a subject leaving mid-lock only clears via the
/// `filtered_history` ring buffer passively flushing over the full
/// `window_secs` (up to 30s) — during which a stale, decreasingly
/// accurate estimate keeps being emitted, and any *new* subject
/// arriving mid-flush gets fused with leftover stale samples instead
/// of starting from a clean window (issue #1422). Once
/// `consecutive_rejections` reaches `STALE_RESET_REJECTIONS`, `reset()`
/// is called so the next accepted estimate is built from fresh data
/// only, instead of waiting out the old window.
consecutive_rejections: usize,
}
/// Number of consecutive out-of-band rejections after which the sliding
/// window and filter state are cleared (ADR-157-adjacent fix, issue #1422).
/// One rejection is enough: once the estimated frequency has left the
/// breathing band, the same rejected estimate must never be echoed again as
/// a stale "lock" while the window slowly flushes over up to `window_secs`.
const STALE_RESET_REJECTIONS: usize = 1;
impl BreathingExtractor {
/// Create a new breathing extractor.
///
@@ -88,7 +67,6 @@ impl BreathingExtractor {
freq_low: 0.1,
freq_high: 0.5,
filter_state: IirState::default(),
consecutive_rejections: 0,
}
}
@@ -149,29 +127,10 @@ impl BreathingExtractor {
let duration_s = history.len() as f64 / self.sample_rate;
let frequency_hz = crossings as f64 / (2.0 * duration_s);
// Validate frequency is within the breathing band. An out-of-band
// estimate means no periodic breathing signal was found in the
// current window (e.g. the subject left, or noise dominates).
//
// Without an active reset here, the stale `filtered_history` window
// only clears by passively flushing over the full `window_secs`
// (up to 30s) as new samples evict old ones. During that flush a
// transiently *accepted* estimate can keep climbing toward
// `freq_high` before the frequency finally leaves the band (issue
// #1422) — and once rejected, any real signal that resumes would
// otherwise have to wait out the rest of that stale window before
// it can dominate a fresh, accurate estimate again. Resetting on
// rejection makes both directions fast: reject-and-forget instead
// of reject-then-linger, and reacquire-from-scratch instead of
// reacquire-diluted-by-ghosts.
// Validate frequency is within the breathing band
if frequency_hz < self.freq_low || frequency_hz > self.freq_high {
self.consecutive_rejections += 1;
if self.consecutive_rejections >= STALE_RESET_REJECTIONS {
self.reset();
}
return None;
}
self.consecutive_rejections = 0;
let bpm = frequency_hz * 60.0;
let confidence = compute_confidence(history);
@@ -241,7 +200,6 @@ impl BreathingExtractor {
pub fn reset(&mut self) {
self.filtered_history.clear();
self.filter_state = IirState::default();
self.consecutive_rejections = 0;
}
/// Current number of samples in the history buffer.
@@ -521,197 +479,6 @@ mod tests {
);
}
/// Deterministic small PRNG (LCG) for reproducible synthetic-signal
/// tests -- mirrors the style already used in
/// `heartrate::tests::pure_noise_is_never_reported_valid`. Returns a
/// value in roughly `[-1, 1)`.
fn lcg_next(seed: &mut u64) -> f64 {
*seed = seed
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
((*seed >> 33) as f64 / (1u64 << 31) as f64) - 1.0
}
/// Issue #1422 bug-catching test.
///
/// Reproduces the reported scenario at ESP32 defaults (56 subcarriers,
/// 100 Hz, 30s window): 60s of a real 0.25 Hz (15 BPM) signal with
/// per-subcarrier gain/phase (lock-on), then broadband noise-floor-only
/// residuals (the "empty room").
///
/// `extract()` already returned `None` once the estimated frequency left
/// the breathing band (that part was not silently broken) -- the actual
/// defect was that the 30s `filtered_history` window only cleared by
/// *passively* flushing sample-by-sample, so a locked-on extractor kept
/// re-fusing whatever noise trickled in with a shrinking-but-still-large
/// fraction of stale real signal, letting a decreasingly-accurate
/// estimate keep being accepted right up to the range ceiling (30 BPM --
/// the exact value from the GH issue) before it finally rejected. Once
/// rejected, the *next* real subject would then have to wait out the
/// rest of that same stale window before a fresh, undiluted estimate
/// could dominate again (see `issue_1422_recovery_after_dropout_is_fast`
/// for that half of the regression).
///
/// This test pins the fix at its source: the very first out-of-band
/// rejection after a real signal disappears must actively clear
/// `filtered_history` (not wait for it to passively drain), so no
/// stale majority-real-signal window can ever linger and re-validate a
/// ghost estimate near the ceiling.
#[test]
fn issue_1422_stale_lock_does_not_persist_after_subject_leaves() {
let n = 56usize;
let fs = 100.0;
let weights = vec![1.0f64; n];
let mut seed: u64 = 0x1422_1422;
let gain: Vec<f64> = (0..n).map(|_| 0.4 + 0.6 * lcg_next(&mut seed).abs()).collect();
let phase: Vec<f64> = (0..n)
.map(|_| lcg_next(&mut seed).abs() * 2.0 * std::f64::consts::PI)
.collect();
let mut ext = BreathingExtractor::esp32_default();
// 60s of a strong, real 15 BPM (0.25 Hz) breathing signal -- lock on.
let mut got_valid_lock = false;
for i in 0..6000usize {
let t = i as f64 / fs;
let residuals: Vec<f64> = (0..n)
.map(|c| {
0.6 * gain[c] * (2.0 * std::f64::consts::PI * 0.25 * t + phase[c]).sin()
+ lcg_next(&mut seed) * 0.05
})
.collect();
if let Some(est) = ext.extract(&residuals, &weights) {
assert!(
(est.value_bpm - 15.0).abs() < 5.0,
"should track ~15 BPM while the subject is present, got {}",
est.value_bpm
);
got_valid_lock = true;
}
}
assert!(got_valid_lock, "extractor must lock onto the real breathing signal first");
assert!(
ext.history_len() > 0,
"a locked-on extractor must carry a non-empty window into the empty-room phase"
);
// Empty room: feed noise-floor-only residuals one at a time until the
// *first* rejection (the first `None` here can only come from the
// frequency-band check, never "insufficient history", since the
// window is already far past `min_samples` from the lock-on phase).
let mut first_reject_at: Option<usize> = None;
let mut history_len_at_reject: Option<usize> = None;
for i in 0..12000usize {
let residuals: Vec<f64> = (0..n).map(|_| lcg_next(&mut seed) * 0.05).collect();
let outcome = ext.extract(&residuals, &weights);
if outcome.is_none() {
first_reject_at = Some(i);
history_len_at_reject = Some(ext.history_len());
break;
}
}
let first_reject_at =
first_reject_at.expect("pure noise must eventually be rejected as out of band");
// The fix: the window must be actively cleared in the *same* call
// that rejected the frequency -- not left to drain passively over
// the remaining ~30s - first_reject_at samples. Before the fix,
// `history_len()` here was still the full ~3000-sample stale window.
assert_eq!(
history_len_at_reject,
Some(0),
"the first out-of-band rejection (at sample {first_reject_at}) must reset the \
history window immediately, not leave the stale lock-on window in place \
(issue #1422)",
);
// And the window must not be allowed to silently regrow back into a
// large, majority-noise "lock" while noise keeps arriving: each
// rebuild-to-`min_samples` cycle must itself reject and reset, so
// `history_len()` never creeps back up toward the full window.
let min_samples = (fs * 10.0) as usize;
let mut max_history_len_after_reject = 0usize;
for _ in 0..(12000 - first_reject_at - 1) {
let residuals: Vec<f64> = (0..n).map(|_| lcg_next(&mut seed) * 0.05).collect();
ext.extract(&residuals, &weights);
max_history_len_after_reject = max_history_len_after_reject.max(ext.history_len());
}
assert!(
max_history_len_after_reject <= min_samples,
"history window regrew to {max_history_len_after_reject} samples while fed pure \
noise -- a stale majority-noise window should never be allowed to accumulate \
past the minimum warm-up size without being rejected and reset (issue #1422)",
);
// Finally: the extractor must be silent at the very end of the long
// empty-room period, not just momentarily quiet.
let final_residuals: Vec<f64> = (0..n).map(|_| lcg_next(&mut seed) * 0.05).collect();
assert!(
ext.extract(&final_residuals, &weights).is_none(),
"BreathingExtractor must report no signal at the end of a long empty-room period",
);
}
/// Issue #1422 companion regression: once the subject leaves (and the
/// extractor has rejected/reset), a *returning* subject must be
/// reacquired quickly -- not have to wait out the full stale 30s window
/// passively flushing via FIFO eviction, which is what produced the
/// reported "stayed at 30.0 BPM for roughly another 30s before starting
/// to track again" secondary symptom.
#[test]
fn issue_1422_recovery_after_dropout_is_fast() {
let n = 56usize;
let fs = 100.0;
let weights = vec![1.0f64; n];
let mut seed: u64 = 0xFEED_1422;
let gain: Vec<f64> = (0..n).map(|_| 0.4 + 0.6 * lcg_next(&mut seed).abs()).collect();
let phase: Vec<f64> = (0..n)
.map(|_| lcg_next(&mut seed).abs() * 2.0 * std::f64::consts::PI)
.collect();
let mut ext = BreathingExtractor::esp32_default();
let signal_residuals = |t: f64, seed: &mut u64| -> Vec<f64> {
(0..n)
.map(|c| {
0.6 * gain[c] * (2.0 * std::f64::consts::PI * 0.25 * t + phase[c]).sin()
+ lcg_next(seed) * 0.05
})
.collect()
};
let noise_residuals = |seed: &mut u64| -> Vec<f64> {
(0..n).map(|_| lcg_next(seed) * 0.05).collect()
};
// Lock on.
for i in 0..6000usize {
ext.extract(&signal_residuals(i as f64 / fs, &mut seed), &weights);
}
// Long empty-room period.
for _ in 0..6000usize {
ext.extract(&noise_residuals(&mut seed), &weights);
}
// Subject returns.
let mut recovered_at = None;
for i in 0..6000usize {
let t = (12000 + i) as f64 / fs;
if ext.extract(&signal_residuals(t, &mut seed), &weights).is_some() {
recovered_at = Some(i);
break;
}
}
let recovered_at = recovered_at.expect("extractor must reacquire the returning subject");
// A passive-flush-only window (pre-fix) needs on the order of the
// full 30s window to dilute stale noise (measured ~29s); the active
// reset-on-rejection fix reacquires close to the 10s minimum warm-up
// instead (measured ~6s). Generous bound: well under half the window.
assert!(
recovered_at < 1500,
"recovery after a dropout took {recovered_at} samples (~{:.1}s) -- expected fast \
reacquisition (issue #1422 secondary symptom: slow recovery after a transient)",
recovered_at as f64 / fs,
);
}
/// ADR-157 §A3 bug-catching test. Divergence needs the pole magnitude
/// `|r| >= 1`, i.e. `bw >= 4`. At `fs = 0.5` Hz with the band widened to
/// 0.1-0.9 Hz, `bw = 2*pi*(0.9-0.1)/0.5 = 10.05`, so the OLD pole radius
@@ -726,28 +493,12 @@ mod tests {
ext.freq_high = 0.9;
// Feed a unit step for 600 frames — enough for the un-clamped resonator
// to overflow to inf.
//
// A constant unit step has essentially no periodic content once the
// resonator settles, so with the issue #1422 reset-on-rejection fix
// this can legitimately cycle `filtered_history` back to empty
// between checks (build up to `min_samples`, get rejected as
// out-of-band, reset, rebuild...). That's an intentional, separate
// behavior change -- this test's actual purpose (ADR-157 §A3) is the
// *filter's* numerical stability under extreme parameters, so it
// tracks the max history length reached and checks finiteness on
// every iteration instead of asserting a nonzero count only at the
// very end.
let mut max_history_len = 0usize;
for _ in 0..600 {
ext.extract(&[1.0, 1.0, 1.0, 1.0], &[0.25, 0.25, 0.25, 0.25]);
max_history_len = max_history_len.max(ext.history_len());
for (i, &v) in ext.filtered_history.iter().enumerate() {
assert!(v.is_finite(), "filtered_history[{i}] must be finite, got {v}");
}
}
assert!(
max_history_len > 0,
"history should have accumulated samples at some point during the run"
);
assert!(ext.history_len() > 0, "history should accumulate");
for (i, &v) in ext.filtered_history.iter().enumerate() {
assert!(v.is_finite(), "filtered_history[{i}] must be finite, got {v}");
}
}
}
+10 -126
View File
@@ -98,17 +98,7 @@ impl HeartRateExtractor {
/// Returns a `VitalEstimate` with heart rate in BPM, or `None`
/// if insufficient data or too few subcarriers.
pub fn extract(&mut self, residuals: &[f64], phases: &[f64]) -> Option<VitalEstimate> {
// `n` is driven by `residuals` (and the subcarrier cap) only, NOT by
// `phases.len()`. Before this fix, a missing/short `phases` slice
// (e.g. `phases=[]`, documented as meaning "equal weighting", mirroring
// `BreathingExtractor`'s `weights=[]`) truncated `n` down to
// `phases.len()`, so `phases=[]` forced `n == 0` and `extract()`
// silently returned `None` for every frame (issue #1423). Missing
// phase entries are now treated by `compute_phase_coherence_signal`
// as "no coherence information available" and fused with equal
// weight, exactly like `breathing::fuse_weighted_residuals`'s
// uniform-weight fallback for a missing/partial `weights` slice.
let n = residuals.len().min(self.n_subcarriers);
let n = residuals.len().min(self.n_subcarriers).min(phases.len());
if n == 0 {
return None;
}
@@ -259,44 +249,29 @@ impl HeartRateExtractor {
/// Combines amplitude residuals with inter-subcarrier phase coherence
/// to enhance the cardiac signal. Subcarriers with similar phase
/// derivatives are likely sensing the same body surface.
///
/// `phases` may be shorter than `n` (including empty). Missing entries mean
/// the caller has no per-subcarrier phase measurement to weight by, so that
/// subcarrier is fused with full coherence (weight 1.0) instead of being
/// excluded -- i.e. `phases=[]` degrades gracefully to equal weighting
/// across all `n` residuals, exactly like `breathing::fuse_weighted_residuals`
/// falling back to a uniform weight for a missing/partial `weights` slice
/// (issue #1423; `phases=[]` is documented as meaning equal weights, the
/// same as `BreathingExtractor`'s `weights=[]`).
fn compute_phase_coherence_signal(residuals: &[f64], phases: &[f64], n: usize) -> f64 {
if n <= 1 {
return residuals.first().copied().unwrap_or(0.0);
}
let phase_at = |i: usize| phases.get(i).copied();
// Compute inter-subcarrier phase differences as coherence weights.
// Adjacent subcarriers with small phase differences are more coherent.
// If either phase value needed for a pair is missing, treat the pair as
// fully coherent (weight 1.0) rather than panicking or excluding it.
let mut weighted_sum = 0.0;
let mut weight_total = 0.0;
for (i, &r) in residuals.iter().enumerate().take(n) {
let neighbor = if i + 1 < n {
Some(i + 1)
for i in 0..n {
let coherence = if i + 1 < n {
let phase_diff = (phases[i + 1] - phases[i]).abs();
// Higher coherence when phase difference is small
(-phase_diff).exp()
} else if i > 0 {
Some(i - 1)
let phase_diff = (phases[i] - phases[i - 1]).abs();
(-phase_diff).exp()
} else {
None
1.0
};
let coherence = match neighbor.and_then(|j| phase_at(i).zip(phase_at(j))) {
Some((a, b)) => (-(b - a).abs()).exp(),
None => 1.0,
};
weighted_sum += r * coherence;
weighted_sum += residuals[i] * coherence;
weight_total += coherence;
}
@@ -586,95 +561,4 @@ mod tests {
assert!(v.is_finite(), "filtered_history[{i}] must be finite, got {v}");
}
}
/// Issue #1423 bug-catching test.
///
/// Reproduces the exact GH-issue repro: 56 subcarriers @ 100 Hz (ESP32
/// defaults), a noiseless 1.2 Hz (72 BPM) sine identical across every
/// subcarrier, fed frame-by-frame with an **empty** `phases` slice.
///
/// Before the fix, `extract()`'s `n` was
/// `residuals.len().min(n_subcarriers).min(phases.len())`, so
/// `phases = []` forced `n == 0` and every single frame returned `None`
/// (0/4000 estimates) even though the identical call with
/// `phases = [1.0; 56]` produced thousands of valid estimates. `phases=[]`
/// must mean "no coherence weighting available", i.e. equal weighting --
/// the same documented fallback `BreathingExtractor` already honors for
/// `weights=[]` -- not "invalid input, refuse everything".
#[test]
fn issue_1423_empty_phases_yields_equal_weighting_not_silent_none() {
let n = 56usize;
let sample_rate = 100.0;
let heart_freq = 1.2; // 72 BPM
let mut ext = HeartRateExtractor::esp32_default();
let mut estimates_with_empty_phases = 0usize;
for i in 0..4000usize {
let t = i as f64 / sample_rate;
let base = (2.0 * std::f64::consts::PI * heart_freq * t).sin();
let residuals = vec![base; n];
if ext.extract(&residuals, &[]).is_some() {
estimates_with_empty_phases += 1;
}
}
assert!(
estimates_with_empty_phases > 0,
"HeartRateExtractor::extract() with phases=[] must not silently return None for \
every frame of a clean 72 BPM signal (issue #1423); got 0/4000 estimates",
);
// Sanity check against the issue's own comparison point: an
// equivalent uniform, non-empty `phases` slice (all subcarriers at
// the same phase, i.e. zero pairwise difference => full coherence,
// exactly what the equal-weighting fallback now produces for the
// empty case too) must yield a comparable number of estimates --
// the two should behave the same, not `0` vs `thousands`.
let mut ext2 = HeartRateExtractor::esp32_default();
let uniform_phases = vec![1.0_f64; n];
let mut estimates_with_uniform_phases = 0usize;
for i in 0..4000usize {
let t = i as f64 / sample_rate;
let base = (2.0 * std::f64::consts::PI * heart_freq * t).sin();
let residuals = vec![base; n];
if ext2.extract(&residuals, &uniform_phases).is_some() {
estimates_with_uniform_phases += 1;
}
}
assert_eq!(
estimates_with_empty_phases, estimates_with_uniform_phases,
"phases=[] (equal-weight fallback) must behave identically to a uniform, \
non-empty phases slice of the same length -- both represent 'no differential \
coherence information', got {estimates_with_empty_phases} vs \
{estimates_with_uniform_phases}",
);
}
/// Issue #1423 companion: a `phases` slice *shorter* than `residuals`
/// (partial coverage) must fall back to equal weighting for the missing
/// tail rather than truncating the whole fusion down to the phases that
/// happen to be present -- mirrors
/// `breathing::partial_weights_are_renormalized_not_scale_mixed`.
#[test]
fn partial_phases_do_not_truncate_subcarrier_count() {
// 8 residuals, only 2 phase values supplied.
let residuals = [1.0_f64; 8];
let phases = [0.0_f64, 0.0];
let fused = compute_phase_coherence_signal(&residuals, &phases, 8);
// All residuals are identical (1.0), so regardless of how coherence
// weights are distributed the fused value must still be 1.0 -- but
// this only holds if all 8 residuals are actually used. Before the
// fix, `n` would have been truncated to `phases.len() == 2`, so the
// caller-facing `extract()` never even reached this function with
// `n == 8`; this test pins `compute_phase_coherence_signal` itself
// to handle a short `phases` slice safely and correctly when called
// with the full `n`.
assert!(
(fused - 1.0).abs() < 1e-12,
"fusion with a partial phases slice must still average all {} residuals, got {fused}",
residuals.len(),
);
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "wifi-densepose-wifiscan"
version = "0.3.2"
version = "0.3.1"
edition.workspace = true
description = "Multi-BSSID WiFi scanning domain layer for enhanced Windows WiFi DensePose sensing (ADR-022)"
license.workspace = true
-50
View File
@@ -1,50 +0,0 @@
# 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.
## Implemented
| Area | Runtime 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 |
| 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 113 parsing and atomic, no-overwrite persistence into a HOMECORE storage directory |
| Dashboard/BFF | Static UI, calibration proxy, rooms, COG list, appliance metrics, and typed unavailable responses for absent upstreams |
## Exact HA-style HTTP surface
- `GET /api/`
- `GET /api/config`
- `GET /api/states`
- `GET|POST|DELETE /api/states/:entity_id`
- `GET /api/services`
- `POST /api/services/:domain/:service`
- `GET /api/websocket`
- `POST /api/intent/handle`
This is a compatible subset, not full Home Assistant parity.
## Explicitly deferred
- 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
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.