mirror of
https://github.com/ruvnet/RuView
synced 2026-07-29 18:31:44 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ce3bd090b | |||
| 42485495ed | |||
| 9aae7f04ff | |||
| a1a59baf72 |
@@ -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) Beer–Lambert channel-gain queries that degrade to exact Friis on an empty map, inverse gain updates that learn an unseen 6 dB obstruction to <0.5 dB in 20 link observations, and a JITOMA-style task-gated scene graph; (4) a physics-guided synthetic RF world generator — Allen–Berkley image method (order ≤2), complex-permittivity Fresnel materials, bistatic person scattering with *emergent* Doppler (proven against the analytic phase rate), seeded ChaCha20 domain randomization of physics + hardware nuisances (gain/CFO/phase noise/packet loss/interference); (5) an edge sensing control plane — 802.11bf/ETSI-ISAC-aligned purposes and zones, fail-closed authorization, a double-gated identity purpose, retention bounds, and a `BoundedEvent`-only trust boundary that makes raw RF export unrepresentable. Anti-leakage evaluation (`StrictSplit` by room/day/person/chipset/firmware/layout with an independent disjointness verifier, ECE, selective risk, degradation) plus an end-to-end acceptance pipeline: presence F1 1.00 on held-out rooms *and* held-out chipset, degradation 0.0, ECE 0.012, p95 tokenize+encode 2.0 ms debug / 105 µs release — **all SYNTHETIC** (honest labeling propagates from `RfModality::Synthetic` through `Provenance.synthetic`). Criterion benches with an optimization pass: segment-corridor candidate search took `channel_gain` from 139 µs → 27 µs (O(1) in map size; hash/linear crossover at ~4k Gaussians reported honestly), `observe_link` 305 µs → 74 µs, precomputed DFT twiddles 4.9×. 66 unit + 3 acceptance tests, 0 failed.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -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<'_>,
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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!(
|
||||
" {} ({}{})",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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).
|
||||
[](.)
|
||||

|
||||

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