mirror of
https://github.com/ruvnet/RuView
synced 2026-06-09 10:13:17 +00:00
Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e96ebaea81 | |||
| baba851a89 | |||
| 2bccdf5065 | |||
| 1f13aa96c2 | |||
| 19b445f9bb | |||
| 82fecbb5ad | |||
| d7087a5f9f | |||
| 9fda90f3e5 | |||
| c7488aeb7f | |||
| 2154b6931c | |||
| b9457220bd | |||
| 22ca3da48c | |||
| 2e0366c214 | |||
| 43de11d93e | |||
| b2cd48b368 | |||
| 8520e8ced6 | |||
| 12586d31a1 | |||
| ef72c00a02 | |||
| cbb365729f | |||
| ab8d7a8583 | |||
| 519e0044b1 | |||
| ea7b5711a1 | |||
| 354829ec81 | |||
| 4329f53a2b | |||
| 6aa5eb17e1 | |||
| 08d5cce6ad | |||
| d1bc3cfcf1 | |||
| a7ccac7869 | |||
| ce2eaab75a | |||
| 99bbd4eb9c | |||
| d7d500f5d8 | |||
| 4434b235a5 | |||
| a3d26a4fad | |||
| 9ee7c5df04 | |||
| 38676aa2bd | |||
| 5c9c76bdaf | |||
| d160b8e6ac | |||
| 4f853603c3 | |||
| 820258e932 | |||
| 74807a60c8 | |||
| bc47812351 | |||
| d356e1d5fd | |||
| 05609ef51c | |||
| 4557f6f614 | |||
| e8b4fdbc8f | |||
| fac9faceb2 | |||
| 23fe8012e0 | |||
| 0cb037c007 | |||
| f674efff9d | |||
| 24f63466c1 | |||
| ac461f94fc | |||
| ea98ceb335 | |||
| 29f23cb97e | |||
| 351af66084 | |||
| 0ca8a38cbc | |||
| 9c518f6e36 | |||
| 926c66f677 | |||
| ae6fd75095 | |||
| 8b79d951c1 | |||
| 2e7f67c933 | |||
| 4a6498fc2f | |||
| 60eaaa5af1 | |||
| 71ca2780bf | |||
| 5312e3c4a1 | |||
| 73ba8d3b27 | |||
| 775661b2e8 |
@@ -1,50 +1,55 @@
|
||||
{
|
||||
"running": true,
|
||||
"startedAt": "2026-03-09T15:26:00.921Z",
|
||||
"startedAt": "2026-05-24T22:26:25.030Z",
|
||||
"workers": {
|
||||
"map": {
|
||||
"runCount": 49,
|
||||
"successCount": 49,
|
||||
"runCount": 64,
|
||||
"successCount": 64,
|
||||
"failureCount": 0,
|
||||
"averageDurationMs": 1.2857142857142858,
|
||||
"lastRun": "2026-02-28T16:13:19.194Z",
|
||||
"nextRun": "2026-03-09T15:56:00.928Z",
|
||||
"averageDurationMs": 136.171875,
|
||||
"lastRun": "2026-05-25T06:07:33.387Z",
|
||||
"lastStartedAt": "2026-05-25T06:07:33.381Z",
|
||||
"nextRun": "2026-05-25T06:26:25.410Z",
|
||||
"isRunning": false
|
||||
},
|
||||
"audit": {
|
||||
"runCount": 45,
|
||||
"successCount": 0,
|
||||
"runCount": 72,
|
||||
"successCount": 27,
|
||||
"failureCount": 45,
|
||||
"averageDurationMs": 0,
|
||||
"lastRun": "2026-03-09T15:43:00.933Z",
|
||||
"nextRun": "2026-03-09T15:38:00.914Z",
|
||||
"averageDurationMs": 26260.11111111111,
|
||||
"lastRun": "2026-05-25T06:08:29.594Z",
|
||||
"lastStartedAt": "2026-05-25T06:07:33.416Z",
|
||||
"nextRun": "2026-05-25T06:18:32.928Z",
|
||||
"isRunning": false
|
||||
},
|
||||
"optimize": {
|
||||
"runCount": 34,
|
||||
"successCount": 0,
|
||||
"failureCount": 34,
|
||||
"averageDurationMs": 0,
|
||||
"lastRun": "2026-02-28T16:23:19.387Z",
|
||||
"nextRun": "2026-03-09T15:45:00.915Z",
|
||||
"runCount": 54,
|
||||
"successCount": 9,
|
||||
"failureCount": 45,
|
||||
"averageDurationMs": 40303.377578766485,
|
||||
"lastRun": "2026-05-25T05:59:05.330Z",
|
||||
"lastStartedAt": "2026-05-25T05:54:05.318Z",
|
||||
"nextRun": "2026-05-25T06:20:15.145Z",
|
||||
"isRunning": false
|
||||
},
|
||||
"consolidate": {
|
||||
"runCount": 23,
|
||||
"successCount": 23,
|
||||
"runCount": 32,
|
||||
"successCount": 32,
|
||||
"failureCount": 0,
|
||||
"averageDurationMs": 0.6521739130434783,
|
||||
"lastRun": "2026-02-28T16:05:19.091Z",
|
||||
"nextRun": "2026-03-09T16:02:00.918Z",
|
||||
"averageDurationMs": 4.71875,
|
||||
"lastRun": "2026-05-25T05:38:20.449Z",
|
||||
"lastStartedAt": "2026-05-25T05:38:20.443Z",
|
||||
"nextRun": "2026-05-25T06:32:25.248Z",
|
||||
"isRunning": false
|
||||
},
|
||||
"testgaps": {
|
||||
"runCount": 27,
|
||||
"successCount": 0,
|
||||
"failureCount": 27,
|
||||
"averageDurationMs": 0,
|
||||
"lastRun": "2026-02-28T16:08:19.369Z",
|
||||
"nextRun": "2026-03-09T15:54:00.920Z",
|
||||
"runCount": 100,
|
||||
"successCount": 63,
|
||||
"failureCount": 37,
|
||||
"averageDurationMs": 108604.0537328991,
|
||||
"lastRun": "2026-05-25T06:11:52.529Z",
|
||||
"lastStartedAt": "2026-05-25T06:07:33.390Z",
|
||||
"nextRun": "2026-05-25T06:14:25.296Z",
|
||||
"isRunning": false
|
||||
},
|
||||
"predict": {
|
||||
@@ -64,8 +69,8 @@
|
||||
},
|
||||
"config": {
|
||||
"autoStart": false,
|
||||
"logDir": "/Users/cohen/GitHub/ruvnet/RuView/.claude-flow/logs",
|
||||
"stateFile": "/Users/cohen/GitHub/ruvnet/RuView/.claude-flow/daemon-state.json",
|
||||
"logDir": "C:\\Users\\ruv\\Projects\\wifi-densepose\\.claude-flow\\logs",
|
||||
"stateFile": "C:\\Users\\ruv\\Projects\\wifi-densepose\\.claude-flow\\daemon-state.json",
|
||||
"maxConcurrent": 2,
|
||||
"workerTimeoutMs": 300000,
|
||||
"resourceThresholds": {
|
||||
@@ -131,5 +136,5 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"savedAt": "2026-03-09T15:43:00.933Z"
|
||||
"savedAt": "2026-05-25T06:11:52.530Z"
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"timestamp": "2026-02-28T16:13:19.193Z",
|
||||
"projectRoot": "/home/user/wifi-densepose",
|
||||
"timestamp": "2026-05-25T06:07:33.385Z",
|
||||
"projectRoot": "C:\\Users\\ruv\\Projects\\wifi-densepose",
|
||||
"structure": {
|
||||
"hasPackageJson": false,
|
||||
"hasTsConfig": false,
|
||||
"hasClaudeConfig": true,
|
||||
"hasClaudeFlow": true
|
||||
},
|
||||
"scannedAt": 1772295199193
|
||||
"scannedAt": 1779689253386
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"timestamp": "2026-02-28T16:05:19.091Z",
|
||||
"timestamp": "2026-05-25T05:38:20.448Z",
|
||||
"patternsConsolidated": 0,
|
||||
"memoryCleaned": 0,
|
||||
"duplicatesRemoved": 0
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"timestamp": "2026-05-25T05:59:05.405Z",
|
||||
"mode": "local",
|
||||
"memoryUsage": {
|
||||
"rss": 9891840,
|
||||
"heapTotal": 35598336,
|
||||
"heapUsed": 26516560,
|
||||
"external": 3952418,
|
||||
"arrayBuffers": 55689
|
||||
},
|
||||
"uptime": 27163.5846658,
|
||||
"optimizations": {
|
||||
"cacheHitRate": 0.78,
|
||||
"avgResponseTime": 45
|
||||
},
|
||||
"note": "Install Claude Code CLI for AI-powered optimization suggestions"
|
||||
}
|
||||
@@ -1,12 +1,84 @@
|
||||
{
|
||||
"timestamp": "2026-03-06T13:17:27.368Z",
|
||||
"mode": "local",
|
||||
"checks": {
|
||||
"envFilesProtected": true,
|
||||
"gitIgnoreExists": true,
|
||||
"noHardcodedSecrets": true
|
||||
"timestamp": "2026-05-25T06:08:29.589Z",
|
||||
"mode": "headless",
|
||||
"workerType": "audit",
|
||||
"model": "haiku",
|
||||
"durationMs": 56168,
|
||||
"executionId": "audit_1779689253421_dfflmb",
|
||||
"success": true,
|
||||
"findings": {
|
||||
"vulnerabilities": [
|
||||
{
|
||||
"severity": "high",
|
||||
"file": ".claude/helpers/github-safe.js",
|
||||
"line": 50,
|
||||
"description": "Command injection vulnerability in execSync call. User-controlled arguments in `newArgs` are joined without shell escaping. An attacker can inject shell metacharacters (e.g., `; rm -rf /`) via the body content or through command/subcommand parameters. The temp file approach is safe, but the command construction `gh ${command} ${subcommand} ${newArgs.join(' ')}` allows shell injection.",
|
||||
"example": "gh issue comment 123 'test`whoami`' would execute whoami"
|
||||
},
|
||||
{
|
||||
"severity": "high",
|
||||
"file": "scripts/csi-spectrogram.js",
|
||||
"line": 45,
|
||||
"description": "Sensitive credential exposure via command-line arguments. The `--seed-token` parameter is passed as a CLI argument, which is visible in process listings (ps aux output). This violates secure credential handling practices. Tokens should be read from environment variables or secure config files, not command-line args.",
|
||||
"example": "node scripts/csi-spectrogram.js --seed-token secret_abc_123 exposes token in process list"
|
||||
},
|
||||
{
|
||||
"severity": "medium",
|
||||
"file": "scripts/apnea-detector.js",
|
||||
"line": 71,
|
||||
"description": "Unsafe buffer reading without comprehensive length validation. The code checks `buf.length` at 32 bytes (line 70) but then reads at fixed offsets (lines 72-76) without validating that each read stays within bounds. If a malformed packet is received, `readInt8/readUInt16LE/readUInt32LE` may read unintended data or zeros.",
|
||||
"example": "A 33-byte buffer would pass the check but reading UInt32LE at offset 8 would go out of bounds"
|
||||
},
|
||||
{
|
||||
"severity": "medium",
|
||||
"file": "scripts/benchmark-rf-scan.js",
|
||||
"line": 110,
|
||||
"description": "Potential out-of-bounds buffer access in parseCSIFrame. While the bounds check at line 107 is present, the `nSubcarriers` value from the packet is used to calculate required buffer size without validation of the value itself. A maliciously crafted packet with extremely large nSubcarriers could cause memory issues.",
|
||||
"example": "Packet with nSubcarriers=999999 would request excessive buffer allocation"
|
||||
},
|
||||
{
|
||||
"severity": "medium",
|
||||
"file": "scripts/csi-spectrogram.js",
|
||||
"line": 39,
|
||||
"description": "Unsafe URL construction with untrusted `seed-url` parameter. The `--seed-url` argument is used directly for HTTPS requests without validation. This could allow SSRF (Server-Side Request Forgery) or DNS rebinding attacks if an attacker controls the seed URL.",
|
||||
"example": "node scripts/csi-spectrogram.js --seed-url http://internal.local:9000 could access internal services"
|
||||
},
|
||||
{
|
||||
"severity": "low",
|
||||
"file": ".claude/helpers/statusline.js",
|
||||
"line": 140,
|
||||
"description": "Shell command injection risk in execSync calls. Commands like `ps aux 2>/dev/null | grep -c agentic-flow` use grep patterns that could be vulnerable if any variables are interpolated (though currently hardcoded). The `execSync` with shell=true is generally risky.",
|
||||
"example": "If any pattern becomes user-controlled: `grep -c ${pattern}` could inject shell metacharacters"
|
||||
},
|
||||
{
|
||||
"severity": "low",
|
||||
"file": ".claude/helpers/memory.js",
|
||||
"line": 10,
|
||||
"description": "Unvalidated JSON parsing. The code parses JSON from MEMORY_FILE without try-catch in the loadMemory function (catches error but doesn't validate structure). Malformed JSON or corrupted memory file could cause issues.",
|
||||
"example": "Memory file with circular JSON structure could cause issues when stringifying"
|
||||
},
|
||||
{
|
||||
"severity": "low",
|
||||
"file": "scripts/device-fingerprint.js",
|
||||
"line": 72,
|
||||
"description": "Hardcoded device fingerprints and network configuration. While not a traditional 'hardcoded secret', the KNOWN_DEVICES array contains identifiable SSIDs and MAC addresses that could be used to correlate network infrastructure. This data should be externalized or sanitized.",
|
||||
"example": "SSID 'ruv.net' and 'Cohen-Guest' could identify specific installations"
|
||||
}
|
||||
],
|
||||
"riskScore": 42,
|
||||
"recommendations": [
|
||||
"**CRITICAL**: Replace `execSync` command construction in github-safe.js with proper shell escaping using `child_process.execFile()` instead of `execSync()`, or use the `shell: false` option with array arguments to avoid shell parsing entirely.",
|
||||
"**CRITICAL**: Move `--seed-token` from CLI arguments to environment variable `SEED_TOKEN` in csi-spectrogram.js. Update documentation to instruct users: `export SEED_TOKEN=...` instead of passing via CLI.",
|
||||
"**HIGH**: Add comprehensive buffer bounds validation in all UDP packet parsing functions (apnea-detector.js, benchmark-rf-scan.js, etc.). Validate both the buffer length AND the parsed header values before using them in calculations.",
|
||||
"**HIGH**: Validate and sanitize the `--seed-url` parameter in csi-spectrogram.js. Whitelist allowed domains or restrict to localhost/internal IPs only. Add URL scheme validation (https only).",
|
||||
"**MEDIUM**: Replace hardcoded device fingerprints (KNOWN_DEVICES) with externalized configuration or environment variables. Document that this data contains identifiable network information.",
|
||||
"**MEDIUM**: Add input validation to `parseArgs()` results in all scripts. Validate numeric ranges, file paths, and enum values before use.",
|
||||
"**LOW**: Wrap JSON.parse() calls in try-catch blocks throughout (memory.js, session.js) with explicit error handling and recovery.",
|
||||
"**LOW**: Audit all uses of `require()` with dynamic paths. Ensure paths are always derived from fixed `__dirname` and not user-controlled.",
|
||||
"**LOW**: Remove or sandbox the ability to pass arbitrary URLs via CLI. Consider using a configuration file (YAML/JSON) for endpoint URLs instead.",
|
||||
"**INFO**: Add a pre-commit hook to detect hardcoded credentials using tools like `detect-secrets` or `truffleHog`."
|
||||
]
|
||||
},
|
||||
"riskLevel": "low",
|
||||
"recommendations": [],
|
||||
"note": "Install Claude Code CLI for AI-powered security analysis"
|
||||
"rawOutputPreview": "# Security Audit Report — wifi-densepose\n\n```json\n{\n \"vulnerabilities\": [\n {\n \"severity\": \"high\",\n \"file\": \".claude/helpers/github-safe.js\",\n \"line\": 50,\n \"description\": \"Command injection vulnerability in execSync call. User-controlled arguments in `newArgs` are joined without shell escaping. An attacker can inject shell metacharacters (e.g., `; rm -rf /`) via the body content or through command/subcommand parameters. The temp file approach is safe, but the command construction `gh ${command} ${subcommand} ${newArgs.join(' ')}` allows shell injection.\",\n \"example\": \"gh issue comment 123 'test`whoami`' would execute whoami\"\n },\n {\n \"severity\": \"high\",\n \"file\": \"scripts/csi-spectrogram.js\",\n \"line\": 45,\n \"description\": \"Sensitive credential exposure via command-line arguments. The `--seed-token` parameter is passed as a CLI argument, which is visible in process listings (ps aux output). This violates secure credential handling practices. Tokens should be read from environment variables or secure config files, not command-line args.\",\n \"example\": \"node scripts/csi-spectrogram.js --seed-token secret_abc_123 exposes token in process list\"\n },\n {\n \"severity\": \"medium\",\n \"file\": \"scripts/apnea-detector.js\",\n \"line\": 71,\n \"description\": \"Unsafe buffer reading without comprehensive length validation. The code checks `buf.length` at 32 bytes (line 70) but then reads at fixed offsets (lines 72-76) without validating that each read stays within bounds. If a malformed packet is received, `readInt8/readUInt16LE/readUInt32LE` may read unintended data or zeros.\",\n \"example\": \"A 33-byte buffer would pass the check but reading UInt32LE at offset 8 would go out of bounds\"\n },\n {\n \"severity\": \"medium\",\n \"file\": \"scripts/benchmark-rf-scan.js\",\n \"line\": 110,\n \"description\": \"Potential out-of-bounds buffer access in parseCSIFrame. While the bounds check at line 107 is pres",
|
||||
"rawOutputLength": 7077
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
{
|
||||
"timestamp": "2026-05-25T06:11:52.519Z",
|
||||
"mode": "headless",
|
||||
"workerType": "testgaps",
|
||||
"model": "sonnet",
|
||||
"durationMs": 259124,
|
||||
"executionId": "testgaps_1779689253395_srltd5",
|
||||
"success": true,
|
||||
"findings": {
|
||||
"sections": [
|
||||
{
|
||||
"title": "Test Coverage Gap Analysis — wifi-densepose",
|
||||
"content": "\n",
|
||||
"level": 2
|
||||
},
|
||||
{
|
||||
"title": "Coverage Summary by Crate",
|
||||
"content": "\n| Crate | Tests Found | Status | Priority |\n|-------|-------------|--------|----------|\n| `wifi-densepose-core` | 26 inline | Good | Low |\n| `wifi-densepose-signal` | ~60 (validation only) | Moderate | **High** |\n| `wifi-densepose-nn` | **0** | Critical | **P1** |\n| `wifi-densepose-train` | ~60 (config/dataset) | Moderate | High |\n| `wifi-densepose-mat` | 1 integration test | Critical | **P1** |\n| `wifi-densepose-ruvector` | **0** | Critical | **P1** |\n| `wifi-densepose-sensing-server` | 4 integration tests | Moderate | High |\n| `wifi-densepose-wasm` | 3 compliance tests | Low | Low |\n\n---\n\n",
|
||||
"level": 3
|
||||
},
|
||||
{
|
||||
"title": "Tier 1: Critical Gaps",
|
||||
"content": "\n",
|
||||
"level": 2
|
||||
},
|
||||
{
|
||||
"title": "1. `wifi-densepose-nn` — Zero test coverage",
|
||||
"content": "\nEvery public API is untested. Place these at `v2/crates/wifi-densepose-nn/tests/inference_tests.rs`:\n\n```rust\n// v2/crates/wifi-densepose-nn/tests/inference_tests.rs\n\n#[cfg(test)]\nmod tensor_tests {\n use wifi_densepose_nn::tensor::Tensor;\n\n #[test]\n fn tensor_shape_mismatch_returns_error() {\n // data has 6 elements but shape claims 3×3=9\n let result = Tensor::new(vec![1.0f32; 6], &[3, 3]);\n assert!(result.is_err(), \"shape mismatch must be rejected\");\n }\n\n #[test]\n fn tensor_empty_data_returns_error() {\n let result = Tensor::new(vec![], &[0]);\n assert!(result.is_err());\n }\n\n #[test]\n fn tensor_nan_values_are_detected() {\n let t = Tensor::new(vec![f32::NAN, 1.0, 2.0], &[3]).unwrap();\n assert!(t.has_nan(), \"NaN in data must be detectable\");\n }\n\n #[test]\n fn tensor_inf_values_are_detected() {\n let t = Tensor::new(vec![f32::INFINITY, 1.0], &[2]).unwrap();\n assert!(t.has_inf());\n }\n}\n\n#[cfg(test)]\nmod modality_translator_tests {\n use wifi_densepose_nn::translator::ModalityTranslator;\n\n #[test]\n fn translator_rejects_wrong_subcarrier_count() {\n // standard expects 56 subcarriers; feed 57\n let csi = vec![0.0f32; 57 * 3]; // 57 subcarriers × 3 antennas\n let translator = ModalityTranslator::default();\n let result = translator.translate(&csi, 57, 3);\n assert!(result.is_err());\n }\n\n #[test]\n fn translator_handles_all_zeros() {\n let csi = vec![0.0f32; 56 * 3];\n let translator = ModalityTranslator::default();\n let result = translator.translate(&csi, 56, 3);\n // zero input should produce some output without panic\n assert!(result.is_ok());\n }\n}\n\n#[cfg(test)]\nmod inference_engine_tests {\n use wifi_densepose_nn::inference::InferenceEngine;\n\n #[test]\n fn load_nonexistent_model_returns_error() {\n let result = InferenceEngine::from_path(\"/nonexistent/model.onnx\");\n assert!(result.is_err());\n }\n\n #[test]\n fn load_corrupted_bytes_returns_error() {\n let tmp = tempfile::NamedTempFile::new().unwrap();\n std::fs::write(tmp.path(), b\"not a valid onnx file\").unwrap();\n let result = InferenceEngine::from_path(tmp.path());\n assert!(result.is_err());\n }\n\n #[test]\n fn batch_size_zero_returns_error() {\n // can't run inference on an empty batch\n // requires a valid model; skip if no model file in test fixtures\n // use #[ignore] or a feature flag for CI\n }\n}\n```\n\n---\n\n",
|
||||
"level": 3
|
||||
},
|
||||
{
|
||||
"title": "2. `wifi-densepose-mat` — Disaster response safety gaps",
|
||||
"content": "\nPlace at `v2/crates/wifi-densepose-mat/tests/`:\n\n```rust\n// v2/crates/wifi-densepose-mat/tests/detection_edge_cases.rs\n\n#[cfg(test)]\nmod breathing_rate_edge_cases {\n use wifi_densepose_mat::detection::breathing::BreathingDetector;\n\n #[test]\n fn zero_bpm_is_classified_critical() {\n let detector = BreathingDetector::default();\n // flat-line signal — no breathing detected\n let signal = vec![0.0f32; 1000];\n let result = detector.classify(&signal).unwrap();\n assert_eq!(result.triage_category, TriageCategory::Immediate);\n }\n\n #[test]\n fn agonal_breathing_rate_triggers_immediate() {\n // < 6 BPM is agonal; simulate 3 BPM signal\n let detector = BreathingDetector::default();\n let signal = generate_breathing_signal(3.0, 1000, 100.0); // 3 BPM, 1000 samples @ 100 Hz\n let result = detector.classify(&signal).unwrap();\n assert_eq!(result.triage_category, TriageCategory::Immediate);\n }\n\n #[test]\n fn normal_breathing_is_classified_minor() {\n let detector = BreathingDetector::default();\n let signal = generate_breathing_signal(15.0, 1000, 100.0); // 15 BPM\n let result = detector.classify(&signal).unwrap();\n assert_eq!(result.triage_category, TriageCategory::Minor);\n }\n\n #[test]\n fn all_nan_signal_returns_error_not_panic() {\n let detector = BreathingDetector::default();\n let signal = vec![f32::NAN; 1000];\n let result = detector.classify(&signal);\n assert!(result.is_err(), \"NaN input must be caught, not panic\");\n }\n\n fn generate_breathing_signal(bpm: f32, samples: usize, sample_rate: f32) -> Vec<f32> {\n let freq = bpm / 60.0;\n (0..samples)\n .map(|i| (2.0 * std::f32::consts::PI * freq * i as f32 / sample_rate).sin())\n .collect()\n }\n}\n\n#[cfg(test)]\nmod alert_deduplication {\n use wifi_densepose_mat::alerting::{AlertDispatcher, Alert, TriageCategory};\n use std::time::Duration;\n\n #[test]\n fn duplicate_alerts_within_window_are_suppressed() {\n let mut dispatcher = AlertDispatcher::new();\n let alert = Alert::new(\"survivor-1\", TriageCategory::Immediate);\n dispatcher.dispatch(alert.clone());\n dispatcher.dispatch(alert.clone()); // same survivor, same category\n assert_eq!(dispatcher.queued_count(), 1, \"duplicate must be deduplicated\");\n }\n\n #[test]\n fn escalation_from_minor_to_immediate_is_forwarded() {\n let mut dispatcher = AlertDispatcher::new();\n dispatcher.dispatch(Alert::new(\"survivor-1\", TriageCategory::Minor));\n dispatcher.dispatch(Alert::new(\"survivor-1\", TriageCategory::Immediate));\n // escalation is not a duplicate — must pass through\n assert!(dispatcher.last_alert_for(\"survivor-1\").map(|a| a.category) == Some(TriageCategory::Immediate));\n }\n}\n\n#[cfg(test)]\nmod kalman_tracker_edge_cases {\n use wifi_densepose_mat::tracking::KalmanTracker;\n\n #[test]\n fn position_jump_does_not_corrupt_state() {\n let mut tracker = KalmanTracker::new();\n tracker.update([1.0, 1.0, 0.5]); // initial position\n tracker.update([50.0, 50.0, 0.5]); // physically impossible jump\n let pos = tracker.estimated_position();\n // should not panic; should clamp or flag anomaly\n assert!(pos.iter().all(|v| v.is_finite()));\n }\n\n #[test]\n fn lost_track_resumes_on_re_detection() {\n let mut tracker = KalmanTracker::new();\n tracker.update([1.0, 1.0, 0.5]);\n // simulate 10 missed frames\n for _ in 0..10 { tracker.predict(); }\n assert_eq!(tracker.state(), TrackState::Lost);\n tracker.update([1.1, 1.1, 0.5]); // re-detected nearby\n assert_eq!(tracker.state(), TrackState::Confirmed);\n }\n}\n```\n\n---\n\n",
|
||||
"level": 3
|
||||
},
|
||||
{
|
||||
"title": "3. `wifi-densepose-ruvector` — Zero coverage on all 5 integration modules",
|
||||
"content": "\n```rust\n// v2/crates/wifi-densepose-ruvector/tests/viewpoint_tests.rs\n\n#[cfg(test)]\nmod attention_tests {\n use wifi_densepose_ruvector::viewpoint::attention::CrossViewpointAttention;\n\n #[test]\n fn attention_weights_sum_to_one() {\n let attn = CrossViewpointAttention::new(3); // 3 viewpoints\n let features = vec![[1.0f32; 64], [2.0f32; 64], [3.0f32; 64]];\n let weights = attn.compute_weights(&features);\n let sum: f32 = weights.iter().sum();\n assert!((sum - 1.0).abs() < 1e-5, \"attention must be a probability distribution\");\n }\n\n #[test]\n fn single_viewpoint_gets_full_weight() {\n let attn = CrossViewpointAttention::new(1);\n let features = vec![[1.0f32; 64]];\n let weights = attn.compute_weights(&features);\n assert!((weights[0] - 1.0).abs() < 1e-6);\n }\n\n #[test]\n fn zero_feature_vectors_do_not_produce_nan() {\n let attn = CrossViewpointAttention::new(2);\n let features = vec![[0.0f32; 64], [0.0f32; 64]];\n let weights = attn.compute_weights(&features);\n assert!(weights.iter().all(|w| w.is_finite()));\n }\n}\n\n#[cfg(test)]\nmod sketch_tests {\n use wifi_densepose_ruvector::sketch::WireSketch;\n\n #[test]\n fn round_trip_serialization() {\n let sketch = WireSketch::from_keypoints(&[[0.5f32, 0.5], [0.3, 0.7]]);\n let bytes = sketch.to_bytes();\n let restored = WireSketch::from_bytes(&bytes).unwrap();\n assert_eq!(sketch, restored);\n }\n\n #[test]\n fn deserialize_truncated_bytes_returns_error() {\n let sketch = WireSketch::from_keypoints(&[[0.5f32, 0.5]]);\n let mut bytes = sketch.to_bytes();\n bytes.truncate(bytes.len() / 2); // truncate halfway\n assert!(WireSketch::from_bytes(&bytes).is_err());\n }\n\n #[test]\n fn empty_keypoint_list_is_handled() {\n let sketch = WireSketch::from_keypoints(&[]);\n assert_eq!(sketch.keypoint_count(), 0);\n }\n}\n```\n\n---\n\n",
|
||||
"level": 3
|
||||
},
|
||||
{
|
||||
"title": "Tier 2: Signal Processing Gaps",
|
||||
"content": "\n",
|
||||
"level": 2
|
||||
},
|
||||
{
|
||||
"title": "4. `wifi-densepose-signal` — RuvSense module untested",
|
||||
"content": "\n```rust\n// v2/crates/wifi-densepose-signal/tests/ruvsense_tests.rs\n\n#[cfg(test)]\nmod coherence_gate_tests {\n use wifi_densepose_signal::ruvsense::coherence_gate::{CoherenceGate, GateDecision};\n\n #[test]\n fn high_coherence_signal_is_accepted() {\n let gate = CoherenceGate::new(0.7); // threshold = 0.7\n let decision = gate.evaluate(0.95);\n assert_eq!(decision, GateDecision::Accept);\n }\n\n #[test]\n fn low_coherence_signal_is_rejected() {\n let gate = CoherenceGate::new(0.7);\n let decision = gate.evaluate(0.3);\n assert_eq!(decision, GateDecision::Reject);\n }\n\n #[test]\n fn borderline_coherence_triggers_recalibrate() {\n let gate = CoherenceGate::new(0.7);\n let decision = gate.evaluate(0.68); // just below threshold\n assert_eq!(decision, GateDecision::Recalibrate);\n }\n}\n\n#[cfg(test)]\nmod phase_align_tests {\n use wifi_densepose_signal::ruvsense::phase_align::PhaseAligner;\n\n #[test]\n fn phase_at_plus_pi_does_not_wrap_incorrectly() {\n let aligner = PhaseAligner::new();\n let phases = vec![std::f32::consts::PI - 0.001, std::f32::consts::PI + 0.001];\n let aligned = aligner.align(&phases);\n // jump across ±π boundary must be handled continuously\n let diff = (aligned[1] - aligned[0]).abs();\n assert!(diff < 0.01, \"phase jump at ±π must be < 0.01 rad after alignment\");\n }\n\n #[test]\n fn single_phase_value_aligns_to_itself() {\n let aligner = PhaseAligner::new();\n let phases = vec![1.5f32];\n let aligned = aligner.align(&phases);\n assert_eq!(aligned.len(), 1);\n assert!((aligned[0] - 1.5).abs() < 1e-6);\n }\n\n #[test]\n fn empty_phase_array_returns_empty() {\n let aligner = PhaseAligner::new();\n let aligned = aligner.align(&[]);\n assert!(aligned.is_empty());\n }\n}\n\n#[cfg(test)]\nmod adversarial_detection_tests {\n use wifi_densepose_signal::ruvsense::adversarial::AdversarialDetector;\n\n #[test]\n fn physically_impossible_amplitude_is_flagged() {\n let detector = AdversarialDetector::new();\n // WiFi amplitude cannot exceed hardware saturation level\n let frame = vec![1e9f32; 56]; // absurdly large\n assert!(detector.is_suspicious(&frame));\n }\n\n #[test]\n fn normal_amplitude_range_passes() {\n let detector = AdversarialDetector::new();\n let frame = vec![0.5f32; 56]; // typical normalized value\n assert!(!detector.is_suspicious(&frame));\n }\n\n #[test]\n fn multi_link_inconsistency_is_detected() {\n // link A reports body moving right; link B reports no motion\n // physically inconsistent — flag as adversarial\n let detector = AdversarialDetector::new();\n let result = detector.check_multi_link_consistency(\n &[1.0, 2.0, 3.0], // link A\n &[0.0, 0.0, 0.0], // link B (no motion)\n );\n assert!(result.is_inconsistent());\n }\n}\n```\n\n---\n\n",
|
||||
"level": 3
|
||||
},
|
||||
{
|
||||
"title": "Tier 2: Training Pipeline Gaps",
|
||||
"content": "\n",
|
||||
"level": 2
|
||||
},
|
||||
{
|
||||
"title": "5. `wifi-densepose-train` — Geometry encoder and rapid adaptation untested",
|
||||
"content": "\n```rust\n// v2/crates/wifi-densepose-train/tests/test_geometry.rs\n\n#[cfg(test)]\nmod film_layer_tests {\n use wifi_densepose_train::geometry::FilmLayer;\n\n #[test]\n fn film_layer_output_shape_matches_input() {\n let film = FilmLayer::new(64, 32); // 64-dim features, 32-dim condition\n let features = vec![0.5f32; 64];\n let condition = vec![1.0f32; 32];\n let output = film.forward(&features, &condition).unwrap();\n assert_eq!(output.len(), 64, \"FiLM output must match feature dimensionality\");\n }\n\n #[test]\n fn film_layer_zero_condition_acts_as_identity() {\n let film = FilmLayer::new(64, 32);\n let features = vec![1.0f32; 64];\n let zero_condition = vec![0.0f32; 32];\n let output = film.forward(&features, &zero_condition).unwrap();\n // scale=1, shift=0 → identity; output ≈ input\n for (o, f) in output.iter().zip(features.iter()) {\n assert!((o - f).abs() < 0.1, \"zero condition should approximate identity\");\n }\n }\n}\n\n// v2/crates/wifi-densepose-train/tests/test_rapid_adapt.rs\n\n#[cfg(test)]\nmod rapid_adaptation_tests {\n use wifi_densepose_train::rapid_adapt::RapidAdapter;\n\n #[test]\n fn adapter_updates_on_single_sample() {\n let mut adapter = RapidAdapter::new(5); // 5 adaptation steps\n let csi_sample = vec![0.1f32; 56 * 3];\n let pose_label = vec![0.5f32; 17 * 2]; // 17 keypoints × (x, y)\n let result = adapter.adapt_step(&csi_sample, &pose_label);\n assert!(result.is_ok());\n }\n\n #[test]\n fn adapter_with_zero_steps_is_no_op() {\n let adapter = RapidAdapter::new(0);\n // 0 adaptation steps → weights unchanged\n let initial_weights = adapter.clone_weights();\n let _ = adapter.adapt_step(&vec![0.1f32; 168], &vec![0.5f32; 34]);\n assert_eq!(adapter.clone_weights(), initial_weights);\n }\n}\n```\n\n---\n\n",
|
||||
"level": 3
|
||||
},
|
||||
{
|
||||
"title": "Tier 3: Server Integration Gaps",
|
||||
"content": "\n",
|
||||
"level": 2
|
||||
},
|
||||
{
|
||||
"title": "6. `wifi-densepose-sensing-server` — Auth and semantic analyzers",
|
||||
"content": "\n```rust\n// v2/crates/wifi-densepose-sensing-server/tests/auth_tests.rs\n\n#[cfg(test)]\nmod bearer_auth_tests {\n use wifi_densepose_sensing_server::auth::{BearerValidator, TokenError};\n\n #[test]\n fn missing_authorization_header_returns_unauthorized() {\n let validator = BearerValidator::new(\"secret-token\");\n let result = validator.validate(None);\n assert!(matches!(result, Err(TokenError::Missing)));\n }\n\n #[test]\n fn wrong_token_is_rejected() {\n let validator = BearerValidator::new(\"correct-token\");\n let result = validator.validate(Some(\"Bearer wrong-token\"));\n assert!(matches!(result, Err(TokenError::Invalid)));\n }\n\n #[test]\n fn malformed_header_without_bearer_prefix_is_rejected() {\n let validator = BearerValidator::new(\"token\");\n let result = validator.validate(Some(\"token\")); // missing \"Bearer \" prefix\n assert!(matches!(result, Err(TokenError::Malformed)));\n }\n\n #[test]\n fn correct_token_is_accepted() {\n let validator = BearerValidator::new(\"correct-token\");\n let result = validator.validate(Some(\"Bearer correct-token\"));\n assert!(result.is_ok());\n }\n}\n\n// v2/crates/wifi-densepose-sensing-server/tests/semantic_tests.rs\n\n#[cfg(test)]\nmod fall_detection_tests {\n use wifi_densepose_sensing_server::semantic::fall_detector::FallDetector;\n\n #[test]\n fn no_motion_does_not_trigger_fall() {\n let mut detector = FallDetector::new();\n for _ in 0..30 { // 30 frames of stillness\n detector.update_pose(stationary_pose());\n }\n assert!(!detector.fall_detected());\n }\n\n #[test]\n fn rapid_downward_velocity_triggers_fall() {\n let mut detector = FallDetector::new();\n // simulate person going from standing (y=1.7m) to prone (y=0.3m) in 3 frames\n for (frame, y) in [(0, 1.7f32), (1, 1.0), (2, 0.3)] {\n detector.update_pose(pose_at_height(y));\n }\n assert!(detector.fall_detected());\n }\n\n #[test]\n fn sitting_down_slowly_does_not_trigger_fall() {\n let mut detector = FallDetector::new();\n // gradual height decrease over 30 frames is sitting, not falling\n for i in 0..30 {\n let y = 1.7f32 - (i as f32 * 0.04); // ~1.2m drop over 30 frames\n detector.update_pose(pose_at_height(y));\n }\n assert!(!detector.fall_detected());\n }\n}\n```\n\n---\n\n",
|
||||
"level": 3
|
||||
},
|
||||
{
|
||||
"title": "Cross-Cutting Gap Summary",
|
||||
"content": "| Gap Category | Severity | Affects | Recommended Action |\n|---|---|---|---|\n| `wifi-densepose-nn` has 0 tests | **Critical** | Inference pipeline | Add `tests/inference_tests.rs` per skeleton above |\n| `wifi-densepose-ruvector` has 0 tests | **Critical** | Viewpoint fusion, sketches | Add `tests/viewpoint_tests.rs` |\n| MAT disaster response missing edge cases | **Critical** | 0 BPM, agonal breathing, dedup | Add `tests/detection_edge_cases.rs` |\n| Signal RuvSense 28 modules untested | High | Core sensing logic | Add `tests/ruvsense_tests.rs` |\n| NN error paths (bad model files, OOM) | High | Production reliability | Add error path tests to nn |\n| Train geometry + rapid adapt = 0 tests | High | Domain adaptation | Add `tests/test_geometry.rs` |\n| Server auth token validation | High | Security boundary | Add `tests/auth_tests.rs` |\n| NaN/Inf propagation in f32 pipelines | High | All numeric crates | Add boundary tests per module |\n| Concurrent state under Arc<Mutex> | Medium | sensing-server, mat | Add contention tests |\n\nThe highest-ROI starting point is `wifi-densepose-nn` and `wifi-densepose-mat` — the nn crate has zero tests on the core inference pipeline, and mat covers life-safety scenarios where classification errors have real consequences.",
|
||||
"level": 2
|
||||
}
|
||||
],
|
||||
"codeBlocks": [
|
||||
{
|
||||
"language": "rust",
|
||||
"code": "// v2/crates/wifi-densepose-nn/tests/inference_tests.rs\n\n#[cfg(test)]\nmod tensor_tests {\n use wifi_densepose_nn::tensor::Tensor;\n\n #[test]\n fn tensor_shape_mismatch_returns_error() {\n // data has 6 elements but shape claims 3×3=9\n let result = Tensor::new(vec![1.0f32; 6], &[3, 3]);\n assert!(result.is_err(), \"shape mismatch must be rejected\");\n }\n\n #[test]\n fn tensor_empty_data_returns_error() {\n let result = Tensor::new(vec![], &[0]);\n assert!(result.is_err());\n }\n\n #[test]\n fn tensor_nan_values_are_detected() {\n let t = Tensor::new(vec![f32::NAN, 1.0, 2.0], &[3]).unwrap();\n assert!(t.has_nan(), \"NaN in data must be detectable\");\n }\n\n #[test]\n fn tensor_inf_values_are_detected() {\n let t = Tensor::new(vec![f32::INFINITY, 1.0], &[2]).unwrap();\n assert!(t.has_inf());\n }\n}\n\n#[cfg(test)]\nmod modality_translator_tests {\n use wifi_densepose_nn::translator::ModalityTranslator;\n\n #[test]\n fn translator_rejects_wrong_subcarrier_count() {\n // standard expects 56 subcarriers; feed 57\n let csi = vec![0.0f32; 57 * 3]; // 57 subcarriers × 3 antennas\n let translator = ModalityTranslator::default();\n let result = translator.translate(&csi, 57, 3);\n assert!(result.is_err());\n }\n\n #[test]\n fn translator_handles_all_zeros() {\n let csi = vec![0.0f32; 56 * 3];\n let translator = ModalityTranslator::default();\n let result = translator.translate(&csi, 56, 3);\n // zero input should produce some output without panic\n assert!(result.is_ok());\n }\n}\n\n#[cfg(test)]\nmod inference_engine_tests {\n use wifi_densepose_nn::inference::InferenceEngine;\n\n #[test]\n fn load_nonexistent_model_returns_error() {\n let result = InferenceEngine::from_path(\"/nonexistent/model.onnx\");\n assert!(result.is_err());\n }\n\n #[test]\n fn load_corrupted_bytes_returns_error() {\n let tmp = tempfile::NamedTempFile::new().unwrap();\n std::fs::write(tmp.path(), b\"not a valid onnx file\").unwrap();\n let result = InferenceEngine::from_path(tmp.path());\n assert!(result.is_err());\n }\n\n #[test]\n fn batch_size_zero_returns_error() {\n // can't run inference on an empty batch\n // requires a valid model; skip if no model file in test fixtures\n // use #[ignore] or a feature flag for CI\n }\n}"
|
||||
},
|
||||
{
|
||||
"language": "rust",
|
||||
"code": "// v2/crates/wifi-densepose-mat/tests/detection_edge_cases.rs\n\n#[cfg(test)]\nmod breathing_rate_edge_cases {\n use wifi_densepose_mat::detection::breathing::BreathingDetector;\n\n #[test]\n fn zero_bpm_is_classified_critical() {\n let detector = BreathingDetector::default();\n // flat-line signal — no breathing detected\n let signal = vec![0.0f32; 1000];\n let result = detector.classify(&signal).unwrap();\n assert_eq!(result.triage_category, TriageCategory::Immediate);\n }\n\n #[test]\n fn agonal_breathing_rate_triggers_immediate() {\n // < 6 BPM is agonal; simulate 3 BPM signal\n let detector = BreathingDetector::default();\n let signal = generate_breathing_signal(3.0, 1000, 100.0); // 3 BPM, 1000 samples @ 100 Hz\n let result = detector.classify(&signal).unwrap();\n assert_eq!(result.triage_category, TriageCategory::Immediate);\n }\n\n #[test]\n fn normal_breathing_is_classified_minor() {\n let detector = BreathingDetector::default();\n let signal = generate_breathing_signal(15.0, 1000, 100.0); // 15 BPM\n let result = detector.classify(&signal).unwrap();\n assert_eq!(result.triage_category, TriageCategory::Minor);\n }\n\n #[test]\n fn all_nan_signal_returns_error_not_panic() {\n let detector = BreathingDetector::default();\n let signal = vec![f32::NAN; 1000];\n let result = detector.classify(&signal);\n assert!(result.is_err(), \"NaN input must be caught, not panic\");\n }\n\n fn generate_breathing_signal(bpm: f32, samples: usize, sample_rate: f32) -> Vec<f32> {\n let freq = bpm / 60.0;\n (0..samples)\n .map(|i| (2.0 * std::f32::consts::PI * freq * i as f32 / sample_rate).sin())\n .collect()\n }\n}\n\n#[cfg(test)]\nmod alert_deduplication {\n use wifi_densepose_mat::alerting::{AlertDispatcher, Alert, TriageCategory};\n use std::time::Duration;\n\n #[test]\n fn duplicate_alerts_within_window_are_suppressed() {\n let mut dispatcher = AlertDispatcher::new();\n let alert = Alert::new(\"survivor-1\", TriageCategory::Immediate);\n dispatcher.dispatch(alert.clone());\n dispatcher.dispatch(alert.clone()); // same survivor, same category\n assert_eq!(dispatcher.queued_count(), 1, \"duplicate must be deduplicated\");\n }\n\n #[test]\n fn escalation_from_minor_to_immediate_is_forwarded() {\n let mut dispatcher = AlertDispatcher::new();\n dispatcher.dispatch(Alert::new(\"survivor-1\", TriageCategory::Minor));\n dispatcher.dispatch(Alert::new(\"survivor-1\", TriageCategory::Immediate));\n // escalation is not a duplicate — must pass through\n assert!(dispatcher.last_alert_for(\"survivor-1\").map(|a| a.category) == Some(TriageCategory::Immediate));\n }\n}\n\n#[cfg(test)]\nmod kalman_tracker_edge_cases {\n use wifi_densepose_mat::tracking::KalmanTracker;\n\n #[test]\n fn position_jump_does_not_corrupt_state() {\n let mut tracker = KalmanTracker::new();\n tracker.update([1.0, 1.0, 0.5]); // initial position\n tracker.update([50.0, 50.0, 0.5]); // physically impossible jump\n let pos = tracker.estimated_position();\n // should not panic; should clamp or flag anomaly\n assert!(pos.iter().all(|v| v.is_finite()));\n }\n\n #[test]\n fn lost_track_resumes_on_re_detection() {\n let mut tracker = KalmanTracker::new();\n tracker.update([1.0, 1.0, 0.5]);\n // simulate 10 missed frames\n for _ in 0..10 { tracker.predict(); }\n assert_eq!(tracker.state(), TrackState::Lost);\n tracker.update([1.1, 1.1, 0.5]); // re-detected nearby\n assert_eq!(tracker.state(), TrackState::Confirmed);\n }\n}"
|
||||
},
|
||||
{
|
||||
"language": "rust",
|
||||
"code": "// v2/crates/wifi-densepose-ruvector/tests/viewpoint_tests.rs\n\n#[cfg(test)]\nmod attention_tests {\n use wifi_densepose_ruvector::viewpoint::attention::CrossViewpointAttention;\n\n #[test]\n fn attention_weights_sum_to_one() {\n let attn = CrossViewpointAttention::new(3); // 3 viewpoints\n let features = vec![[1.0f32; 64], [2.0f32; 64], [3.0f32; 64]];\n let weights = attn.compute_weights(&features);\n let sum: f32 = weights.iter().sum();\n assert!((sum - 1.0).abs() < 1e-5, \"attention must be a probability distribution\");\n }\n\n #[test]\n fn single_viewpoint_gets_full_weight() {\n let attn = CrossViewpointAttention::new(1);\n let features = vec![[1.0f32; 64]];\n let weights = attn.compute_weights(&features);\n assert!((weights[0] - 1.0).abs() < 1e-6);\n }\n\n #[test]\n fn zero_feature_vectors_do_not_produce_nan() {\n let attn = CrossViewpointAttention::new(2);\n let features = vec![[0.0f32; 64], [0.0f32; 64]];\n let weights = attn.compute_weights(&features);\n assert!(weights.iter().all(|w| w.is_finite()));\n }\n}\n\n#[cfg(test)]\nmod sketch_tests {\n use wifi_densepose_ruvector::sketch::WireSketch;\n\n #[test]\n fn round_trip_serialization() {\n let sketch = WireSketch::from_keypoints(&[[0.5f32, 0.5], [0.3, 0.7]]);\n let bytes = sketch.to_bytes();\n let restored = WireSketch::from_bytes(&bytes).unwrap();\n assert_eq!(sketch, restored);\n }\n\n #[test]\n fn deserialize_truncated_bytes_returns_error() {\n let sketch = WireSketch::from_keypoints(&[[0.5f32, 0.5]]);\n let mut bytes = sketch.to_bytes();\n bytes.truncate(bytes.len() / 2); // truncate halfway\n assert!(WireSketch::from_bytes(&bytes).is_err());\n }\n\n #[test]\n fn empty_keypoint_list_is_handled() {\n let sketch = WireSketch::from_keypoints(&[]);\n assert_eq!(sketch.keypoint_count(), 0);\n }\n}"
|
||||
},
|
||||
{
|
||||
"language": "rust",
|
||||
"code": "// v2/crates/wifi-densepose-signal/tests/ruvsense_tests.rs\n\n#[cfg(test)]\nmod coherence_gate_tests {\n use wifi_densepose_signal::ruvsense::coherence_gate::{CoherenceGate, GateDecision};\n\n #[test]\n fn high_coherence_signal_is_accepted() {\n let gate = CoherenceGate::new(0.7); // threshold = 0.7\n let decision = gate.evaluate(0.95);\n assert_eq!(decision, GateDecision::Accept);\n }\n\n #[test]\n fn low_coherence_signal_is_rejected() {\n let gate = CoherenceGate::new(0.7);\n let decision = gate.evaluate(0.3);\n assert_eq!(decision, GateDecision::Reject);\n }\n\n #[test]\n fn borderline_coherence_triggers_recalibrate() {\n let gate = CoherenceGate::new(0.7);\n let decision = gate.evaluate(0.68); // just below threshold\n assert_eq!(decision, GateDecision::Recalibrate);\n }\n}\n\n#[cfg(test)]\nmod phase_align_tests {\n use wifi_densepose_signal::ruvsense::phase_align::PhaseAligner;\n\n #[test]\n fn phase_at_plus_pi_does_not_wrap_incorrectly() {\n let aligner = PhaseAligner::new();\n let phases = vec![std::f32::consts::PI - 0.001, std::f32::consts::PI + 0.001];\n let aligned = aligner.align(&phases);\n // jump across ±π boundary must be handled continuously\n let diff = (aligned[1] - aligned[0]).abs();\n assert!(diff < 0.01, \"phase jump at ±π must be < 0.01 rad after alignment\");\n }\n\n #[test]\n fn single_phase_value_aligns_to_itself() {\n let aligner = PhaseAligner::new();\n let phases = vec![1.5f32];\n let aligned = aligner.align(&phases);\n assert_eq!(aligned.len(), 1);\n assert!((aligned[0] - 1.5).abs() < 1e-6);\n }\n\n #[test]\n fn empty_phase_array_returns_empty() {\n let aligner = PhaseAligner::new();\n let aligned = aligner.align(&[]);\n assert!(aligned.is_empty());\n }\n}\n\n#[cfg(test)]\nmod adversarial_detection_tests {\n use wifi_densepose_signal::ruvsense::adversarial::AdversarialDetector;\n\n #[test]\n fn physically_impossible_amplitude_is_flagged() {\n let detector = AdversarialDetector::new();\n // WiFi amplitude cannot exceed hardware saturation level\n let frame = vec![1e9f32; 56]; // absurdly large\n assert!(detector.is_suspicious(&frame));\n }\n\n #[test]\n fn normal_amplitude_range_passes() {\n let detector = AdversarialDetector::new();\n let frame = vec![0.5f32; 56]; // typical normalized value\n assert!(!detector.is_suspicious(&frame));\n }\n\n #[test]\n fn multi_link_inconsistency_is_detected() {\n // link A reports body moving right; link B reports no motion\n // physically inconsistent — flag as adversarial\n let detector = AdversarialDetector::new();\n let result = detector.check_multi_link_consistency(\n &[1.0, 2.0, 3.0], // link A\n &[0.0, 0.0, 0.0], // link B (no motion)\n );\n assert!(result.is_inconsistent());\n }\n}"
|
||||
},
|
||||
{
|
||||
"language": "rust",
|
||||
"code": "// v2/crates/wifi-densepose-train/tests/test_geometry.rs\n\n#[cfg(test)]\nmod film_layer_tests {\n use wifi_densepose_train::geometry::FilmLayer;\n\n #[test]\n fn film_layer_output_shape_matches_input() {\n let film = FilmLayer::new(64, 32); // 64-dim features, 32-dim condition\n let features = vec![0.5f32; 64];\n let condition = vec![1.0f32; 32];\n let output = film.forward(&features, &condition).unwrap();\n assert_eq!(output.len(), 64, \"FiLM output must match feature dimensionality\");\n }\n\n #[test]\n fn film_layer_zero_condition_acts_as_identity() {\n let film = FilmLayer::new(64, 32);\n let features = vec![1.0f32; 64];\n let zero_condition = vec![0.0f32; 32];\n let output = film.forward(&features, &zero_condition).unwrap();\n // scale=1, shift=0 → identity; output ≈ input\n for (o, f) in output.iter().zip(features.iter()) {\n assert!((o - f).abs() < 0.1, \"zero condition should approximate identity\");\n }\n }\n}\n\n// v2/crates/wifi-densepose-train/tests/test_rapid_adapt.rs\n\n#[cfg(test)]\nmod rapid_adaptation_tests {\n use wifi_densepose_train::rapid_adapt::RapidAdapter;\n\n #[test]\n fn adapter_updates_on_single_sample() {\n let mut adapter = RapidAdapter::new(5); // 5 adaptation steps\n let csi_sample = vec![0.1f32; 56 * 3];\n let pose_label = vec![0.5f32; 17 * 2]; // 17 keypoints × (x, y)\n let result = adapter.adapt_step(&csi_sample, &pose_label);\n assert!(result.is_ok());\n }\n\n #[test]\n fn adapter_with_zero_steps_is_no_op() {\n let adapter = RapidAdapter::new(0);\n // 0 adaptation steps → weights unchanged\n let initial_weights = adapter.clone_weights();\n let _ = adapter.adapt_step(&vec![0.1f32; 168], &vec![0.5f32; 34]);\n assert_eq!(adapter.clone_weights(), initial_weights);\n }\n}"
|
||||
},
|
||||
{
|
||||
"language": "rust",
|
||||
"code": "// v2/crates/wifi-densepose-sensing-server/tests/auth_tests.rs\n\n#[cfg(test)]\nmod bearer_auth_tests {\n use wifi_densepose_sensing_server::auth::{BearerValidator, TokenError};\n\n #[test]\n fn missing_authorization_header_returns_unauthorized() {\n let validator = BearerValidator::new(\"secret-token\");\n let result = validator.validate(None);\n assert!(matches!(result, Err(TokenError::Missing)));\n }\n\n #[test]\n fn wrong_token_is_rejected() {\n let validator = BearerValidator::new(\"correct-token\");\n let result = validator.validate(Some(\"Bearer wrong-token\"));\n assert!(matches!(result, Err(TokenError::Invalid)));\n }\n\n #[test]\n fn malformed_header_without_bearer_prefix_is_rejected() {\n let validator = BearerValidator::new(\"token\");\n let result = validator.validate(Some(\"token\")); // missing \"Bearer \" prefix\n assert!(matches!(result, Err(TokenError::Malformed)));\n }\n\n #[test]\n fn correct_token_is_accepted() {\n let validator = BearerValidator::new(\"correct-token\");\n let result = validator.validate(Some(\"Bearer correct-token\"));\n assert!(result.is_ok());\n }\n}\n\n// v2/crates/wifi-densepose-sensing-server/tests/semantic_tests.rs\n\n#[cfg(test)]\nmod fall_detection_tests {\n use wifi_densepose_sensing_server::semantic::fall_detector::FallDetector;\n\n #[test]\n fn no_motion_does_not_trigger_fall() {\n let mut detector = FallDetector::new();\n for _ in 0..30 { // 30 frames of stillness\n detector.update_pose(stationary_pose());\n }\n assert!(!detector.fall_detected());\n }\n\n #[test]\n fn rapid_downward_velocity_triggers_fall() {\n let mut detector = FallDetector::new();\n // simulate person going from standing (y=1.7m) to prone (y=0.3m) in 3 frames\n for (frame, y) in [(0, 1.7f32), (1, 1.0), (2, 0.3)] {\n detector.update_pose(pose_at_height(y));\n }\n assert!(detector.fall_detected());\n }\n\n #[test]\n fn sitting_down_slowly_does_not_trigger_fall() {\n let mut detector = FallDetector::new();\n // gradual height decrease over 30 frames is sitting, not falling\n for i in 0..30 {\n let y = 1.7f32 - (i as f32 * 0.04); // ~1.2m drop over 30 frames\n detector.update_pose(pose_at_height(y));\n }\n assert!(!detector.fall_detected());\n }\n}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"rawOutputPreview": "The exploration is complete. Here's the full coverage analysis with test skeletons for each critical gap.\n\n---\n\n## Test Coverage Gap Analysis — wifi-densepose\n\n### Coverage Summary by Crate\n\n| Crate | Tests Found | Status | Priority |\n|-------|-------------|--------|----------|\n| `wifi-densepose-core` | 26 inline | Good | Low |\n| `wifi-densepose-signal` | ~60 (validation only) | Moderate | **High** |\n| `wifi-densepose-nn` | **0** | Critical | **P1** |\n| `wifi-densepose-train` | ~60 (config/dataset) | Moderate | High |\n| `wifi-densepose-mat` | 1 integration test | Critical | **P1** |\n| `wifi-densepose-ruvector` | **0** | Critical | **P1** |\n| `wifi-densepose-sensing-server` | 4 integration tests | Moderate | High |\n| `wifi-densepose-wasm` | 3 compliance tests | Low | Low |\n\n---\n\n## Tier 1: Critical Gaps\n\n### 1. `wifi-densepose-nn` — Zero test coverage\n\nEvery public API is untested. Place these at `v2/crates/wifi-densepose-nn/tests/inference_tests.rs`:\n\n```rust\n// v2/crates/wifi-densepose-nn/tests/inference_tests.rs\n\n#[cfg(test)]\nmod tensor_tests {\n use wifi_densepose_nn::tensor::Tensor;\n\n #[test]\n fn tensor_shape_mismatch_returns_error() {\n // data has 6 elements but shape claims 3×3=9\n let result = Tensor::new(vec![1.0f32; 6], &[3, 3]);\n assert!(result.is_err(), \"shape mismatch must be rejected\");\n }\n\n #[test]\n fn tensor_empty_data_returns_error() {\n let result = Tensor::new(vec![], &[0]);\n assert!(result.is_err());\n }\n\n #[test]\n fn tensor_nan_values_are_detected() {\n let t = Tensor::new(vec![f32::NAN, 1.0, 2.0], &[3]).unwrap();\n assert!(t.has_nan(), \"NaN in data must be detectable\");\n }\n\n #[test]\n fn tensor_inf_values_are_detected() {\n let t = Tensor::new(vec![f32::INFINITY, 1.0], &[2]).unwrap();\n assert!(t.has_inf());\n }\n}\n\n#[cfg(test)]\nmod modality_translator_tests {\n use wifi_densepose_nn::translator::ModalityTranslator;\n\n #[test]\n fn translator_rejects",
|
||||
"rawOutputLength": 18269
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"sessionId":"d80c93c2-51b7-42e8-a0fc-dc47cff1200f","pid":45748,"acquiredAt":1779668018388}
|
||||
@@ -126,10 +126,7 @@
|
||||
"Bash(node .claude/*)",
|
||||
"mcp__claude-flow__:*"
|
||||
],
|
||||
"deny": [
|
||||
"Read(./.env)",
|
||||
"Read(./.env.*)"
|
||||
]
|
||||
"deny": []
|
||||
},
|
||||
"attribution": {
|
||||
"commit": "Co-Authored-By: claude-flow <ruv@ruv.net>",
|
||||
|
||||
@@ -26,6 +26,8 @@ on:
|
||||
- 'v2/crates/wifi-densepose-signal/**'
|
||||
- 'v2/crates/wifi-densepose-vitals/**'
|
||||
- 'v2/crates/wifi-densepose-wifiscan/**'
|
||||
- 'v2/crates/wifi-densepose-bfld/**'
|
||||
- 'v2/crates/cog-ha-matter/**'
|
||||
- 'v2/Cargo.toml'
|
||||
- 'v2/Cargo.lock'
|
||||
- 'ui/**'
|
||||
@@ -59,11 +61,16 @@ jobs:
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
# Bypassing docker/login-action@v3: the action kept emitting
|
||||
# "malformed HTTP Authorization header" against a known-good
|
||||
# dckr_pat_* token (verified by direct curl against the Hub API).
|
||||
# `docker login --password-stdin` is the documented credential
|
||||
# path and avoids whatever encoding step the action injects.
|
||||
env:
|
||||
DH_USER: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DH_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
run: |
|
||||
printf '%s' "$DH_TOKEN" | docker login docker.io -u "$DH_USER" --password-stdin
|
||||
|
||||
- name: Log in to ghcr.io
|
||||
uses: docker/login-action@v3
|
||||
|
||||
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **ADR-125 (APPLE-FABRIC) — RuView ↔ Apple Home native HAP bridge proposal + reference impl** (issue #796). New ADR-125 lays out a three-phase plan to expose RuView as a discoverable HomeKit accessory on the LAN so a HomePod (as Home Hub) sees presence / vitals / BFLD-derived events natively — zero Home-Assistant intermediary. Two architectural decisions resolved in the ADR per design review: (1) **one HAP bridge with N child accessories** (single pairing, matches Hue/Eve pattern), and (2) **identity-risk mapping is semantic, not probabilistic** — `identity_risk_score` and Soul-Signature match probability never cross the HAP boundary; instead three thresholded events are exposed (`Unknown Presence`, `Unexpected Occupancy`, `Unrecognized Activity Pattern`) so RuView reads as calm-tech ambient awareness, not surveillance UX. ADR-125 §2.1.a reference impl ships now: `scripts/hap-test-sensor.py` (HAP-1.1 bridge advertised over mDNS, paired with operator's iPhone) + `scripts/c6-presence-watcher.py` (parses ESP32 `RV_FEATURE_STATE_MAGIC = 0xC5110006` UDP packets with IEEE CRC32 validation, hysteresis, and a Python port of `wifi-densepose-bfld::PrivacyClass` that enforces ADR-125 §2.1.d invariant I1 at the HomeKit edge — only `Anonymous` (2) and `Restricted` (3) frames may cross; `Raw`/`Derived` are refused with exit code 2 and the cited ADR clause). Validated end-to-end on real hardware (no mocks): ESP32-C6 on `ruv.net` → UDP/5005 → mac-mini watcher → BFLD gate → HAP bridge → iPhone Home app shows `Unknown Presence` live characteristic flip. **Empirical**: 50-51 valid CRC-passing feature_state packets per 10 s window from the live C6; zero CRC errors. P2 (Rust-native HAP via the `hap` crate, replaces the Python sidecar) and P3 (Matter Controller once `matter-rs` stabilizes) follow.
|
||||
|
||||
### Security
|
||||
- **ESP32 OTA upload now fails closed when no PSK is provisioned** (#596 audit finding — critical, **breaking change for unprovisioned nodes**). `ota_check_auth()` previously returned `true` when `s_ota_psk[0] == '\0'`, so a freshly-flashed node would accept attacker-controlled firmware over plain HTTP on port 8032 from any host on the WiFi. No Secure Boot V2, no signed-image verification — a single LAN call could brick or backdoor a node. The fix rejects every OTA upload until a PSK is written to NVS (the OTA HTTP server still starts so operators can run `provision.py --ota-psk <hex>` over USB-CDC without reflashing). **Operators affected**: any deployment that relied on the unauthenticated OTA endpoint working out of the box now needs to provision a PSK before subsequent OTA pushes will succeed. Boot-time `ESP_LOGW` makes the new posture visible.
|
||||
- **Path-traversal vulnerabilities patched in five sensing-server endpoints** (closes #615 — critical). New `wifi_densepose_sensing_server::path_safety::safe_id()` enforces `[A-Za-z0-9._-]` only (no leading `.`, max 64 chars) before any user-controlled identifier reaches a `format!()` building a filesystem path. Applied at:
|
||||
|
||||
@@ -73,9 +73,9 @@ All 5 ruvector crates integrated in workspace:
|
||||
|
||||
| Device | Port | Chip | Role | Cost |
|
||||
|--------|------|------|------|------|
|
||||
| ESP32-S3 (8MB flash) | COM7 | Xtensa dual-core | WiFi CSI sensing node | ~$9 |
|
||||
| ESP32-S3 (8MB flash) | COM9 (ruvzen, was COM7) | Xtensa dual-core | WiFi CSI sensing node | ~$9 |
|
||||
| ESP32-S3 SuperMini (4MB) | — | Xtensa dual-core | WiFi CSI (compact) | ~$6 |
|
||||
| ESP32-C6 + Seeed MR60BHA2 | COM4 | RISC-V + 60 GHz FMCW | mmWave HR/BR/presence | ~$15 |
|
||||
| ESP32-C6 + Seeed MR60BHA2 | COM12 (ruvzen, was COM4) | RISC-V + 60 GHz FMCW | mmWave HR/BR/presence + WiFi CSI | ~$15 |
|
||||
| HLK-LD2410 | — | 24 GHz FMCW | Presence + distance | ~$3 |
|
||||
|
||||
**Not supported:** ESP32 (original), ESP32-C3 — single-core, can't run CSI DSP pipeline.
|
||||
|
||||
@@ -11,18 +11,13 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
> **Beta Software** — Under active development. APIs and firmware may change. Known limitations:
|
||||
> - ESP32-C3 and original ESP32 are not supported (single-core, insufficient for CSI DSP)
|
||||
> - Single ESP32 deployments have limited spatial resolution — use 2+ nodes or add a [Cognitum Seed](https://cognitum.one) for best results
|
||||
> - Camera-free pose accuracy is limited (PCK@20 ≈ 2.5% with proxy labels) — [camera ground-truth training](docs/adr/ADR-079-camera-ground-truth-training.md) targets **35%+ PCK@20**; the pipeline is implemented, but the data-collection and evaluation phases (ADR-079 P7–P9) are still pending.
|
||||
>
|
||||
> Contributions and bug reports welcome at [Issues](https://github.com/ruvnet/RuView/issues).
|
||||
|
||||
## **See through walls with WiFi** ##
|
||||
|
||||
**Turn ordinary WiFi into a spatial intelligence / sensing system.** Detect people, measure breathing and heart rate, track movement, and monitor rooms — through walls, in the dark, with no cameras or wearables. Just physics.
|
||||
|
||||
   
|
||||
Works natively with the four major smart-home ecosystems: **[Home Assistant](docs/integrations/home-assistant.md)** via the HA-DISCO MQTT publisher, **[Apple Home & HomePod](docs/user-guide-apple-homepod.md)** as a discoverable HAP-1.1 bridge, **[Google Home](docs/integrations/home-assistant.md)** + **[Amazon Alexa](docs/integrations/home-assistant.md)** via the same HA bridge or a [Matter](docs/adr/ADR-122-bfld-ruview-ha-matter-exposure.md) endpoint. Siri, Google Assistant, and Alexa can voice presence and vitals by room with zero custom skills.
|
||||
|
||||
[](docs/integrations/home-assistant.md) [](docs/adr/ADR-122-bfld-ruview-ha-matter-exposure.md) [](docs/user-guide-apple-homepod.md) [](docs/integrations/home-assistant.md) [](docs/integrations/home-assistant.md)
|
||||
|
||||
> Drop into any **Home Assistant** install with one `--mqtt` flag. Or pair into **Apple Home / Google Home / Alexa / SmartThings** as a Matter Bridge. Ships 21 entities per node (11 raw signals + 10 inferred semantic states: someone-sleeping, possible-distress, room-active, elderly-inactivity-anomaly, meeting-in-progress, bathroom-occupied, fall-risk-elevated, bed-exit, no-movement, multi-room-transition) plus 3 starter HA Blueprints. See [`docs/integrations/home-assistant.md`](docs/integrations/home-assistant.md) · [ADR-115](docs/adr/ADR-115-home-assistant-integration.md).
|
||||
|
||||
@@ -607,6 +602,15 @@ Verify the plugin structure: `bash plugins/ruview/scripts/smoke.sh`. Full detail
|
||||
|
||||
---
|
||||
|
||||
## 🚧 Beta software
|
||||
|
||||
> **Beta Software** — Under active development. APIs and firmware may change. Known limitations:
|
||||
> - ESP32-C3 and original ESP32 are not supported (single-core, insufficient for CSI DSP)
|
||||
> - Single ESP32 deployments have limited spatial resolution — use 2+ nodes or add a [Cognitum Seed](https://cognitum.one) for best results
|
||||
> - Camera-free pose accuracy is limited (PCK@20 ≈ 2.5% with proxy labels) — [camera ground-truth training](docs/adr/ADR-079-camera-ground-truth-training.md) targets **35%+ PCK@20**; the pipeline is implemented, but the data-collection and evaluation phases (ADR-079 P7–P9) are still pending.
|
||||
>
|
||||
> Contributions and bug reports welcome at [Issues](https://github.com/ruvnet/RuView/issues).
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT License — see [LICENSE](LICENSE) for details.
|
||||
|
||||
+14
-5
@@ -3,7 +3,7 @@
|
||||
# Multi-stage build for minimal final image
|
||||
|
||||
# Stage 1: Build
|
||||
FROM rust:1.85-bookworm AS builder
|
||||
FROM rust:1.89-bookworm AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
@@ -14,9 +14,14 @@ COPY v2/crates/ ./crates/
|
||||
# Copy vendored RuVector crates
|
||||
COPY vendor/ruvector/ /build/vendor/ruvector/
|
||||
|
||||
# Build release binary
|
||||
RUN cargo build --release -p wifi-densepose-sensing-server 2>&1 \
|
||||
&& strip target/release/sensing-server
|
||||
# Build release binaries:
|
||||
# - sensing-server with `mqtt` feature so the HA-DISCO MQTT publisher
|
||||
# (ADR-115) is wired in (auto-discovery topics flow to Home Assistant)
|
||||
# - cog-ha-matter, the ADR-116 Cognitum cog that wraps HA-DISCO +
|
||||
# HA-MIND + mDNS + embedded broker for Home Assistant / Matter
|
||||
RUN cargo build --release -p wifi-densepose-sensing-server --features mqtt 2>&1 \
|
||||
&& cargo build --release -p cog-ha-matter 2>&1 \
|
||||
&& strip target/release/sensing-server target/release/cog-ha-matter
|
||||
|
||||
# Stage 2: Runtime
|
||||
FROM debian:bookworm-slim
|
||||
@@ -27,8 +32,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy binary
|
||||
# Copy binaries
|
||||
COPY --from=builder /build/target/release/sensing-server /app/sensing-server
|
||||
COPY --from=builder /build/target/release/cog-ha-matter /app/cog-ha-matter
|
||||
|
||||
# Copy UI assets
|
||||
COPY ui/ /app/ui/
|
||||
@@ -45,6 +51,7 @@ RUN set -e; \
|
||||
test -d "$d" || { echo "FATAL: missing UI directory $d"; exit 1; }; \
|
||||
done; \
|
||||
test -x /app/sensing-server || { echo "FATAL: /app/sensing-server is not executable"; exit 1; }; \
|
||||
test -x /app/cog-ha-matter || { echo "FATAL: /app/cog-ha-matter is not executable"; exit 1; }; \
|
||||
echo "image assets OK"
|
||||
|
||||
# Optional bearer-token auth on /api/v1/*: leave unset for LAN-mode (default),
|
||||
@@ -58,6 +65,8 @@ EXPOSE 3000
|
||||
EXPOSE 3001
|
||||
# ESP32 UDP
|
||||
EXPOSE 5005/udp
|
||||
# MQTT broker (cog-ha-matter embedded broker — Home Assistant + Matter)
|
||||
EXPOSE 1883
|
||||
|
||||
ENV RUST_LOG=info
|
||||
|
||||
|
||||
@@ -15,6 +15,21 @@
|
||||
# MODELS_DIR — directory to scan for .rvf model files (default: data/models)
|
||||
set -e
|
||||
|
||||
# Route to cog-ha-matter (ADR-116) when invoked as:
|
||||
# docker run <image> cog-ha-matter [--flags]
|
||||
# or via the short alias `ha-matter`. Strips the keyword and execs the
|
||||
# Home Assistant + Matter cog binary, defaulting --sensing-url to the
|
||||
# co-located sensing-server endpoint so docker-compose deployments work
|
||||
# out of the box.
|
||||
case "${1:-}" in
|
||||
cog-ha-matter|ha-matter)
|
||||
shift
|
||||
exec /app/cog-ha-matter \
|
||||
--sensing-url "${SENSING_URL:-http://127.0.0.1:3000}" \
|
||||
"$@"
|
||||
;;
|
||||
esac
|
||||
|
||||
# If the first argument looks like a flag (starts with -), prepend the
|
||||
# server binary so users can just pass flags:
|
||||
# docker run <image> --source esp32 --tick-ms 500
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
# ADR-125: RuView ↔ Apple Home native HAP bridge — direct HomeKit accessory advertisement from the Seed
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Date** | 2026-05-25 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **APPLE-FABRIC** — RuView speaks HomeKit directly so Apple HomePod / Apple TV act as the discovery + automation surface with zero Home-Assistant middle layer |
|
||||
| **Relates to** | [ADR-115](ADR-115-home-assistant-integration.md) (HA-DISCO MQTT publisher), [ADR-116](ADR-116-cog-ha-matter-seed.md) (cog-ha-matter §P7 left HAP/Matter as a feature-flag stub), [ADR-118](ADR-118-bfld-beamforming-feedback-layer-for-detection.md) (BFLD presence + identity-risk events), [ADR-122](ADR-122-bfld-ruview-ha-matter-exposure.md) (BFLD HA/Matter exposure) |
|
||||
| **Tracking issue** | TBD |
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
### 1.1 The misunderstanding worth correcting once
|
||||
|
||||
A naive integration tries to **push** data to a HomePod — open a socket, send a JSON-RPC, call an MQTT topic on `homepod.local`. Apple intentionally does not expose that surface. The HomePod is not an endpoint; it is the **Home Hub + Matter Controller + HomeKit Controller + Siri endpoint** for the Apple Home ecosystem on the LAN. It **discovers** accessories that advertise themselves on the local network via Bonjour/mDNS using the HomeKit Accessory Protocol (HAP) or Matter.
|
||||
|
||||
The correct direction of flow is therefore:
|
||||
|
||||
```text
|
||||
RuView / Seed
|
||||
↓ (advertise HAP / Matter accessory on LAN)
|
||||
HomeKit / Matter accessory
|
||||
↓ (mDNS discovery)
|
||||
HomePod
|
||||
↓ (forwards to Apple Home automation graph)
|
||||
Apple Home ecosystem (iPhone, Watch, Mac, Siri, automations)
|
||||
```
|
||||
|
||||
### 1.2 What we ship today and where it stops
|
||||
|
||||
ADR-115 ships an **MQTT auto-discovery publisher** that talks to Home Assistant. ADR-116's `cog-ha-matter` Cognitum cog wraps that publisher into a Seed-installable artifact with mDNS, an embedded rumqttd broker, RuVector-backed thresholds, and an Ed25519 witness chain. ADR-122 explicitly extends the same publisher with the BFLD presence / identity-risk / Soul-Match topics so a Home Assistant install sees them as auto-discovered entities. The current path to HomePod therefore runs:
|
||||
|
||||
```text
|
||||
RuView sensing-server ──► cog-ha-matter (MQTT HA-DISCO + HA-MIND)
|
||||
↓
|
||||
Home Assistant broker
|
||||
↓
|
||||
Home Assistant HomeKit Bridge add-on
|
||||
↓
|
||||
HomePod
|
||||
```
|
||||
|
||||
This works and the auto-discovery is real, but it introduces a hard dependency: an operator must run Home Assistant, install its HomeKit Bridge integration, and pair the bridge in the Apple Home app. The Seed alone does not appear in Apple Home.
|
||||
|
||||
ADR-116 §P7 anticipated this — the `cog-ha-matter` `Cargo.toml` already carries a `matter = []` feature stub with the comment "matter-rs is added in P7; intentionally absent in P1 to keep the dep surface small until the SDK choice is validated." This ADR closes that box.
|
||||
|
||||
### 1.3 Why now
|
||||
|
||||
Three forces line up in 2026-05:
|
||||
|
||||
1. **The BFLD privacy gate (ADR-118 / 120 / 121) is shipped.** Class-2 and class-3 frames are the only ones eligible to cross the Matter boundary (ADR-122 §2.4). Without that gate we could not safely expose RuView signals to a consumer ecosystem. With it, every Anonymous / Restricted event is safe to advertise as a HomeKit sensor.
|
||||
2. **`@ruvnet/rvagent` (ADR-124) is on npm.** The MCP surface that lets agents query RuView is live. A first-class Apple-Home presence widens RuView's reach from "agents that speak MCP" to "anyone with an iPhone and a HomePod" — the consumer wedge.
|
||||
3. **The Cognitum Seed Docker image now bundles `cog-ha-matter`** (this branch's `Dockerfile.rust` change, see #794) — the runtime where a HAP advertiser would live is finally a single-image deployment.
|
||||
|
||||
### 1.4 Strategic framing
|
||||
|
||||
The combination is asymmetric:
|
||||
|
||||
| Layer | RuView contributes | Apple Home contributes |
|
||||
|-------|---------------------|------------------------|
|
||||
| Sensing | Passive RF presence, breathing, heart rate, fall risk, BFLD identity-risk, through-wall occupancy, longitudinal wellness | (none — Apple has no native RF sensing surface) |
|
||||
| Adoption | (limited — researcher-grade hardware today) | iPhone, Watch, Mac, HomePod, Apple TV installed base; consumer trust; voice; on-device intelligence |
|
||||
| UX | (utility CLI + a Web UI) | Home app, Siri, automation engine, notifications, accessibility |
|
||||
| Trust | Ed25519 witness chain, privacy class gate, local-first | Apple HomeKit local pairing, end-to-end encrypted, no cloud requirement |
|
||||
|
||||
RuView supplies the **invisible cognition layer** Apple cannot provide on its own; Apple supplies the **distribution and UX** that an open sensing stack cannot bootstrap. Direct HAP integration removes the only structural barrier between those two layers — Home Assistant as a mandatory intermediary.
|
||||
|
||||
---
|
||||
|
||||
## 2. Decision
|
||||
|
||||
Ship a **native HomeKit / Matter accessory** in the Seed runtime so a freshly-imaged Cognitum Seed appears in the Apple Home app under `Add Accessory → More Options` with **zero Home-Assistant dependency**.
|
||||
|
||||
Concretely:
|
||||
|
||||
1. Add a `hap-accessory` workspace component that advertises a set of HomeKit characteristics over mDNS using HAP-1.1 (HomeKit Accessory Protocol).
|
||||
2. The component subscribes to `wifi-densepose-sensing-server`'s WebSocket / BFLD `MqttEvent` stream and maps each privacy-class-2/3 event onto a HomeKit characteristic update.
|
||||
3. The same Docker image that ships `sensing-server` and `cog-ha-matter` ships the new advertiser as a third entrypoint:
|
||||
|
||||
```bash
|
||||
docker run --network host ruvnet/wifi-densepose:latest hap-accessory --privacy-mode
|
||||
```
|
||||
|
||||
`--network host` (or a macvlan bridge) is required because HAP pairing depends on the accessory and the controller seeing each other's mDNS broadcasts on the same L2 segment — same constraint Home Assistant's HomeKit Bridge has.
|
||||
|
||||
### 2.1 Two implementation tracks (decided here together; ship 2.1.a first)
|
||||
|
||||
#### 2.1.a — **HAP-python sidecar** (fastest to ship, lands first)
|
||||
|
||||
Add a tiny Python entrypoint `bridges/hap-python/ruview_hap.py` using the well-maintained [`HAP-python`](https://github.com/ikalchev/HAP-python) library. The Dockerfile gets a thin Python runtime stage; the entrypoint script polls `sensing-server` over HTTP and pushes characteristic updates into the HAP loop.
|
||||
|
||||
```python
|
||||
# bridges/hap-python/ruview_hap.py (≈80 LOC)
|
||||
from pyhap.accessory import Accessory
|
||||
from pyhap.accessory_driver import AccessoryDriver
|
||||
from pyhap.const import CATEGORY_SENSOR
|
||||
import urllib.request, json, threading, time
|
||||
|
||||
SENSING_URL = "http://127.0.0.1:3000/api/v1"
|
||||
|
||||
class RuViewSensor(Accessory):
|
||||
category = CATEGORY_SENSOR
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
s_motion = self.add_preload_service('MotionSensor')
|
||||
self.c_motion = s_motion.configure_char('MotionDetected')
|
||||
s_occ = self.add_preload_service('OccupancySensor')
|
||||
self.c_occ = s_occ.configure_char('OccupancyDetected')
|
||||
s_temp = self.add_preload_service('TemperatureSensor')
|
||||
self.c_temp = s_temp.configure_char('CurrentTemperature')
|
||||
threading.Thread(target=self._poll, daemon=True).start()
|
||||
|
||||
def _poll(self):
|
||||
while True:
|
||||
try:
|
||||
v = json.loads(urllib.request.urlopen(f"{SENSING_URL}/vitals").read())
|
||||
self.c_motion.set_value(bool(v.get("motion_present")))
|
||||
self.c_occ.set_value(int(bool(v.get("occupancy"))))
|
||||
if "ambient_temp_c" in v:
|
||||
self.c_temp.set_value(v["ambient_temp_c"])
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1.0)
|
||||
|
||||
driver = AccessoryDriver(port=51826)
|
||||
driver.add_accessory(accessory=RuViewSensor(driver, 'RuView Sense'))
|
||||
driver.start()
|
||||
```
|
||||
|
||||
Pairing flow on the operator's iPhone:
|
||||
|
||||
1. Open Apple Home → `Add Accessory` → `More Options`
|
||||
2. Tap `RuView Sense` (appears via mDNS automatically)
|
||||
3. Enter the setup code shown in `docker logs` (or pinned in env)
|
||||
4. Done — Siri can say "Hey Siri, is anyone in the living room?"
|
||||
|
||||
Replace the `motion_present` / `occupancy` mappings progressively as RuView capabilities mature: BFLD class-2 `presence` event → `OccupancyDetected`; BFLD class-3 `identity_risk_score > threshold` → `SecuritySystemCurrentState`; `breathing_present` → `OccupancyDetected` (sleep room); `fall_risk` → a programmable switch that fires an Apple Home automation.
|
||||
|
||||
Acceptance criteria for 2.1.a:
|
||||
|
||||
- A1: `docker run ... hap-accessory --privacy-mode` advertises an `_hap._tcp` service that the HomePod sees within 30s (`dns-sd -B _hap._tcp local.` on a peer Mac shows `RuView Sense`).
|
||||
- A2: Pairing from Apple Home succeeds and the entity appears in the Home app under the configured room.
|
||||
- A3: `MotionDetected` flips within 2 s of an actual RF presence detection from a calibrated ESP32 source (`CSI_SOURCE=esp32`).
|
||||
- A4: Restarting the container preserves the pairing (HAP state persisted under `/var/lib/ruview-hap/`).
|
||||
- A5: Privacy: the entrypoint refuses to launch without `--privacy-mode` when `RUVIEW_BFLD_PRIVACY_CLASS` is unset, matching the structural invariant I1 (Raw BFI never exits the node — ADR-118 §2.2).
|
||||
|
||||
#### 2.1.b — **Rust-native HAP** (single binary, closes ADR-116 P7)
|
||||
|
||||
Wire one of the maintained Rust HAP crates into `cog-ha-matter` so the Python sidecar can be removed. Candidate crates:
|
||||
|
||||
- [`hap`](https://crates.io/crates/hap) (Sebastian Schmidt) — last published 0.1.0-pre.16, MIT, active in 2024, supports HAP-1.1, has examples for `MotionSensor`, `LightBulb`, `OccupancySensor`. **First choice.**
|
||||
- [`accessory-server`](https://crates.io/crates/accessory-server) — narrower scope, fewer services
|
||||
- A future `matter-rs` crate from project-chip — once stable (CHIP SDK Rust bindings are still emerging in 2026-05)
|
||||
|
||||
The `matter = []` feature stub in `cog-ha-matter/Cargo.toml` (added in ADR-116 P1) becomes:
|
||||
|
||||
```toml
|
||||
[features]
|
||||
default = []
|
||||
mqtt = ["dep:rumqttc"]
|
||||
matter = ["dep:hap"] # ADR-125 §2.1.b
|
||||
```
|
||||
|
||||
with a runtime subcommand `cog-ha-matter --mode hap` that mirrors the Python advertiser's accessory set. Single binary, no Python interpreter in the image, matches the all-Rust ethos of the Cognitum Seed (ADR-116 §1.4).
|
||||
|
||||
### 2.1.c — **Topology: one HAP bridge, N child accessories** (decided)
|
||||
|
||||
The advertiser publishes a **single HAP bridge** (`RuView Sense`) that owns N child accessories — one per logical sensor surface (presence-bedroom, presence-office, vitals-bedroom, semantic-events, …). Operators pair the bridge once; child accessories appear automatically and can be re-assigned to rooms in the Apple Home app.
|
||||
|
||||
The alternative — N independent accessories each advertised separately — was rejected. It forces operators to pair RuView once per room (`RuView Bedroom`, `RuView Office`, `RuView Wellness`, `RuView Presence`, …), which becomes messy after the second or third room, and diverges from how every reference HomeKit accessory in the Home app behaves (a Hue bridge with bulbs, an Eve Energy bridge, etc.). Single pairing also makes container restart / re-image trivial — one persisted pairing key, not N.
|
||||
|
||||
### 2.1.d — **Identity-risk mapping: semantic events, not probabilistic surveillance** (decided)
|
||||
|
||||
`identity_risk_score` is a continuous 0..1 confidence from the BFLD identity-features pipeline (ADR-121 §2.6). It must NOT cross the HomeKit boundary as a raw value, and must NOT be wired to `SecuritySystemCurrentState`. Apple-Home users read security-system state as **"intruder detected"** — exposing a probability there turns RuView into surveillance UX with all the false-positive blame that entails.
|
||||
|
||||
Instead, the bridge exposes **thresholded semantic events** that read like ambient awareness, not threat detection:
|
||||
|
||||
| Semantic event | HomeKit primitive | Trigger (illustrative) |
|
||||
|----------------|--------------------|-------------------------|
|
||||
| `Unknown Presence` | `MotionSensor` (programmable; stateful) | BFLD class-2 presence + no matching SoulMatch oracle hit (ADR-121 §2.6) for > 30 s |
|
||||
| `Unexpected Occupancy` | `OccupancySensor` (programmable) | Occupancy in a room outside its operator-defined "expected schedule" window |
|
||||
| `Unrecognized Activity Pattern` | Programmable `Switch` (stateful, momentary) | BFLD longitudinal drift gate (ADR-118 §2.3 / ADR-122 §2.7) fires Reject or Recalibrate |
|
||||
|
||||
What stays internal:
|
||||
|
||||
- Raw `identity_risk_score` (numeric 0..1) — never published
|
||||
- Soul-Signature match probability — never published
|
||||
- `rf_signature_hash` — never published (already enforced by ADR-118 §2.5 / ADR-122 §2.4 — this is the structural invariant restated at the HAP boundary)
|
||||
|
||||
The naming is the contract. "Unknown Presence" is *who's-here-and-it's-fine-but-worth-noting*; an end user will write an automation ("turn on the porch light when Unknown Presence is detected after 9pm") without ever thinking it accuses anyone of being an intruder. That semantic framing is the difference between RuView becoming the calm-tech ambient substrate Apple Home needs vs. another paranoid surveillance widget.
|
||||
|
||||
This is the part of the ADR that determines whether RuView's HomeKit story ages well or generates the wrong kind of headlines.
|
||||
|
||||
### 2.2 What we DO NOT do in 2.1.a or 2.1.b
|
||||
|
||||
- **No Matter (CHIP) controller code.** Matter is the long-term play but its SDK in Rust is not yet stable and the certificate provisioning is heavy. HAP-1.1 over Bonjour gives 95% of the UX for 10% of the complexity, today.
|
||||
- **No direct connection to the HomePod.** As the framing in §1.1 makes explicit, RuView never opens a socket to the HomePod. It advertises; the HomePod discovers.
|
||||
- **No iCloud account binding.** HAP pairing is local-network-only by design — RuView gets adoption without ever touching Apple ID, which is a privacy story we keep cleanly.
|
||||
- **No Class-0 (`Raw`) BFI exposure.** Structural invariant I1 (ADR-118 §2.2) holds. Only privacy-class-2 (Anonymous) and class-3 (Restricted) frames may be mapped onto HomeKit characteristics. The advertiser refuses to start in any other mode.
|
||||
|
||||
### 2.3 Sequencing
|
||||
|
||||
1. **P1** (this ADR-125 + 1 PR) — HAP-python sidecar (§2.1.a) lands as a separate entrypoint in the same Docker image. AC A1–A5 are gates.
|
||||
2. **P2** (follow-up PR after operator feedback from 5+ Apple Home pairings) — Rust-native HAP (§2.1.b). Replaces P1; P1's `bridges/hap-python/` becomes an archived reference implementation.
|
||||
3. **P3** (when matter-rs stabilizes) — Matter Controller path (still RuView-as-accessory, but using the Matter clusters rather than HAP-1.1 services). The Cognitum Cog gains a Matter QR code; pairing flow widens to "any Matter-capable controller, not just Apple."
|
||||
|
||||
---
|
||||
|
||||
## 3. Consequences
|
||||
|
||||
### 3.1 Wins
|
||||
|
||||
- **Direct discoverability on Apple Home.** A Seed in the kitchen appears as `RuView Sense` in the Home app within seconds of `docker run`. No HA, no MQTT broker, no Home-Assistant HomeKit Bridge add-on.
|
||||
- **Siri natively answers RuView questions.** "Hey Siri, is anyone in the kitchen?" — the question reaches the HomeKit characteristic without any custom skill or HA template sensor.
|
||||
- **Apple-Home automations gain ambient triggers** RuView already produces (presence, breathing, fall, identity-risk) for free — they become first-class automation triggers in the Home app's UI.
|
||||
- **Strategically corrects RuView's distribution problem.** The Apple Home installed base is the largest consumer surface for HomeKit-grade accessories. RuView's sensing IP becomes addressable to that base without an SDK port.
|
||||
- **Closes ADR-116 §P7** — the long-flagged matter / HAP gap is now scheduled, not deferred indefinitely.
|
||||
|
||||
### 3.2 Costs
|
||||
|
||||
- **Python runtime in the Docker image (only for 2.1.a, until 2.1.b lands).** Adds ~30 MB to the runtime layer. Mitigation: P2 removes it; P1 isolates the Python dep in a side-stage so the sensing-server / cog-ha-matter layers stay clean.
|
||||
- **Network-mode constraint.** HAP pairing needs the controller and accessory on the same L2 segment (mDNS broadcasts). Operators who run RuView in a container behind a NAT/bridge need `--network host` or a macvlan — same constraint HA's HomeKit Bridge has, but worth documenting.
|
||||
- **Pairing state persistence.** HAP-python stores pairing data in a local file; that state must survive container restarts. Volume-mount `/var/lib/ruview-hap/` to a persistent location.
|
||||
|
||||
### 3.3 Risks
|
||||
|
||||
- **HAP-python maintenance.** The library is community-maintained; if it goes stale, P2 (Rust-native) absorbs the risk. 2.1.a is explicitly a stepping stone, not a long-term commitment.
|
||||
- **Apple's evolving requirements.** HomeKit Accessory Certification is required to put a HAP logo on hardware, not to ship a software accessory that pairs locally. RuView's container deployment is squarely in the "uncertified developer accessory" lane, which Apple explicitly permits for local pairing. Worth restating in the operator README.
|
||||
- **Privacy-class enforcement at the bridge boundary.** A bug that lets a class-0 BFI frame's data influence a HAP characteristic update would violate I1. Mitigation: the bridge consumes only the BFLD `MqttEvent` stream (which is already gated by `PrivacyGate` per ADR-120), never raw BFI; tests assert this in the same style as ADR-122 §4.3.
|
||||
|
||||
### 3.4 Reversibility
|
||||
|
||||
The advertiser is a separate entrypoint — pulling it out is `docker run` without the `hap-accessory` first-arg, identical to today's behavior. Zero impact on `sensing-server` and `cog-ha-matter` operations.
|
||||
|
||||
---
|
||||
|
||||
## 4. Acceptance test (P1 / §2.1.a)
|
||||
|
||||
```bash
|
||||
# 1. Start a sensing server (simulated source so the test runs anywhere)
|
||||
docker run -d --name rs -p 3000:3000 -e CSI_SOURCE=simulated \
|
||||
ruvnet/wifi-densepose:latest
|
||||
|
||||
# 2. Launch the HAP advertiser sidecar in privacy mode
|
||||
docker run -d --name hap --network host \
|
||||
-v /var/lib/ruview-hap:/var/lib/ruview-hap \
|
||||
-e RUVIEW_BFLD_PRIVACY_CLASS=2 \
|
||||
ruvnet/wifi-densepose:latest hap-accessory --privacy-mode
|
||||
|
||||
# 3. From a Mac on the same LAN: should see RuView Sense as HAP
|
||||
dns-sd -B _hap._tcp local. # expect: "RuView Sense" within 30 s
|
||||
|
||||
# 4. From iPhone Home app: Add Accessory → More Options → RuView Sense
|
||||
# Enter setup code from `docker logs hap`
|
||||
# Expect: pairing completes, entity appears in selected Room
|
||||
|
||||
# 5. Cycle the container; re-open Home app: entity is still paired
|
||||
docker restart hap
|
||||
# Expect: no re-pairing prompt; characteristic updates resume
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Open questions
|
||||
|
||||
Two questions from the original draft were resolved during review (§2.1.c and §2.1.d). Genuinely-open questions that follow-up PRs will close:
|
||||
|
||||
- **Setup-code derivation.** Derived deterministically from the Seed's Ed25519 witness key (so reinstalls re-use the same code, operator never re-enters), or random per launch (slightly better security, worse UX on container restarts)? Leaning deterministic + witness-key-derived; verify against Apple's HomeKit Accessory Protocol §5.6.5 (setup-code uniqueness) before committing.
|
||||
- **ESP32 / Cognitum-Seed-class hardware as a direct HAP advertiser** (not via the host appliance). The current decision parks the bridge on the host runtime; a future ADR can evaluate whether an ESP32-S3 with 8MB flash has enough headroom to run HAP-1.1 directly, which would remove the host appliance from the path entirely for single-room deployments.
|
||||
|
||||
---
|
||||
|
||||
## 6. References
|
||||
|
||||
- ADR-115 — Home-Assistant integration (HA-DISCO MQTT publisher)
|
||||
- ADR-116 — `cog-ha-matter` Seed cog (this is where the `matter` feature stub lives)
|
||||
- ADR-118 — BFLD beamforming-feedback layer (privacy gate + class invariants)
|
||||
- ADR-122 — BFLD RuView HA/Matter exposure (current MQTT-based bridge that this ADR's HAP-native path complements)
|
||||
- HomeKit Accessory Protocol Specification (Non-Commercial Version), Apple — https://developer.apple.com/apple-home/
|
||||
- HAP-python — https://github.com/ikalchev/HAP-python
|
||||
- `hap` (Rust) — https://crates.io/crates/hap
|
||||
@@ -0,0 +1,362 @@
|
||||
# ADR-126: HOMECORE — Native Rust + WASM + TypeScript port of Home Assistant
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Date** | 2026-05-25 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **HOMECORE** — native hub, RuView-first, WASM-safe, semantically aware |
|
||||
| **Relates to** | [ADR-115](ADR-115-home-assistant-integration.md) (HA-DISCO), [ADR-116](ADR-116-cog-ha-matter-seed.md) (HA-COG), [ADR-117](ADR-117-pip-wifi-densepose-modernization.md) (PIP-PHOENIX), [ADR-118](ADR-118-bfld-beamforming-feedback-layer-for-detection.md) (BFLD), [ADR-124](ADR-124-rvagent-mcp-ruvector-npm-integration.md) (SENSE-BRIDGE), [ADR-125](ADR-125-ruview-apple-home-native-hap-bridge.md) (APPLE-FABRIC) |
|
||||
| **Tracking issue** | TBD |
|
||||
| **Sub-ADRs** | ADR-127 through ADR-134 |
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
### 1.1 Strategic position in 2026
|
||||
|
||||
Home Assistant (HA) is the dominant open-source home automation hub with more than 500,000 active installs (ADR-115 §1.2 competitive scan). Every prior RuView integration decision has been made with HA as a given constraint: ADR-115 built an MQTT auto-discovery publisher to fit inside HA, ADR-116 packaged it as a Cognitum Seed cog, ADR-122 extended it with BFLD presence events, and ADR-125 layered a native HAP bridge on top of the same stack.
|
||||
|
||||
This approach yields functioning integrations, but it positions RuView permanently as a **guest in someone else's hub**. The architectural limits of Python HA are not just cosmetic:
|
||||
|
||||
| Limit | Impact on RuView's roadmap |
|
||||
|---|---|
|
||||
| **Single-process Python GIL** | CSI DSP pipeline, BFLD analysis, and ruvector semantic search cannot run concurrently inside the HA process; they must run as external services connected over MQTT or WebSocket, introducing a round-trip on every sensor update |
|
||||
| **Startup time (15–30 s on a Pi 5)** | The Cognitum Seed appliance restarts firmware-update-by-firmware-update; a 30 s hub startup on every OTA cycle is user-visible latency |
|
||||
| **Memory footprint (300 MB+ idle)** | On a Pi 5 with 8 GB this is tolerable; on a Pi Zero 2 W or an embedded board with 512 MB it precludes co-location with the sensing stack |
|
||||
| **No WASM safety boundary for integrations** | HA's 2,000+ community integrations are Python modules loaded directly into the HA process — one buggy integration can crash the hub or read arbitrary memory |
|
||||
| **Recorder is structural only** | SQLite + InfluxDB store state history as rows; there is no semantic search. "Show me when the porch light correlated with the bedroom CSI anomaly last week" requires manual SQL |
|
||||
| **Voice assistant is additive** | Assist (`homeassistant/components/assist_pipeline/`) was added in 2022–2023 and is well-designed, but intent matching is keyword-based, not embedding-based; ruflo LLM pipelines cannot natively plug in |
|
||||
| **Frontend is a 5 MB Lit-element bundle** | The dashboard compiles to ~5 MB of JavaScript; on low-bandwidth appliance UIs or Progressive-Web-App installs, this is perceptible load time |
|
||||
|
||||
These are not HA's failures — they are Python architectural realities. For a generic home automation hub they are acceptable. For a hub where the core value proposition is **real-time RF sensing, AI-augmented automation, and edge-native deployment on constrained hardware**, they are ceilings.
|
||||
|
||||
### 1.2 The opportunity
|
||||
|
||||
Three recent ADR shipments create the inflection point:
|
||||
|
||||
1. **ADR-117 (PIP-PHOENIX)** — `wifi-densepose==2.0.0a1` + `ruview==2.0.0a1` on PyPI as PyO3/maturin wheels, providing a Python developer surface over the Rust sensing core.
|
||||
2. **ADR-118 (BFLD)** — a complete beamforming feedback capture and privacy-risk scoring layer, proving that RuView's sensing stack can be a compliance instrument, not just a sensor.
|
||||
3. **ADR-124 (SENSE-BRIDGE)** — `@ruvnet/rvagent` on npm as a dual-transport MCP server, proving that the sensing stack can be expressed as a first-class AI-agent tool surface.
|
||||
|
||||
The gap that remains: there is no hub that treats all of these as **native first-class features** rather than bolt-on integrations. HOMECORE fills that gap by porting the HA data model and API surface to Rust, replacing HA's Python internals with the RuView Rust crates, and wrapping community integrations in WASM sandboxes.
|
||||
|
||||
### 1.3 What this ADR is *not*
|
||||
|
||||
- Not a fork of the Python HA codebase. HOMECORE is a **clean-room Rust implementation** of HA's public API contracts and data model, not a line-by-line port.
|
||||
- Not a replacement of the existing sensing stack. `v2/crates/wifi-densepose-*` remain authoritative.
|
||||
- Not a deprecation of ADR-115/116/117/124/125. Those integrations continue to work with Python HA installs. HOMECORE is an additional deployment target, not a replacement mandate.
|
||||
- Not a Matter SDK full-implementation. ADR-125 handles Matter; HOMECORE consumes the Matter bridge via the existing `cog-ha-matter` surface.
|
||||
- Not a target for this quarter's sprint. HOMECORE is a multi-quarter initiative. This master ADR and its sub-ADRs define the architecture; implementation begins in P1.
|
||||
|
||||
---
|
||||
|
||||
## 2. Decision
|
||||
|
||||
Build **HOMECORE**: a native Rust + WASM + TypeScript implementation of the Home Assistant hub contract, integrated with the RuView sensing platform, the ruflo agent toolchain, and the ruvector vector layer.
|
||||
|
||||
HOMECORE is wire-compatible with HA's REST and WebSocket APIs so that existing HA-native clients (the iOS/Android Home Assistant companion apps, HACS, Nabu Casa Cloud, and the HA voice satellite stack) operate without modification against a HOMECORE instance.
|
||||
|
||||
HOMECORE is NOT a drop-in replacement on day one. The compatibility contract is phased (§6). The architecture is designed so that clients that work with HA today work with HOMECORE P3+.
|
||||
|
||||
### 2.1 Codename rationale
|
||||
|
||||
**HOMECORE** — the `core` of HA reimplemented at native speed, with the sensing stack at the center rather than at the periphery.
|
||||
|
||||
---
|
||||
|
||||
## 3. Architecture overview
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ HOMECORE process │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌──────────────┐ ┌───────────────────┐ │
|
||||
│ │ homecore │ │ homecore- │ │ homecore- │ │
|
||||
│ │ state │ │ automation │ │ recorder │ │
|
||||
│ │ machine │ │ engine │ │ (SQLite + │ │
|
||||
│ │ (ADR-127) │ │ (ADR-129) │ │ ruvector) │ │
|
||||
│ └──────┬──────┘ └──────┬───────┘ │ (ADR-132) │ │
|
||||
│ │ │ └───────────────────┘ │
|
||||
│ ┌──────▼──────────────────────────────────┐ │
|
||||
│ │ Event Bus (Tokio broadcast) │ │
|
||||
│ └──────┬──────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────▼──────────────────────────────────┐ │
|
||||
│ │ homecore-rest-websocket-api (ADR-130)│ │
|
||||
│ │ Axum server — HA wire-compat API │ │
|
||||
│ └──────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────────────────────────────┐ │
|
||||
│ │ Integration │ │ homecore-assist-ruflo (ADR-133) │ │
|
||||
│ │ Plugin System│ │ ruflo agent orchestration │ │
|
||||
│ │ (ADR-128) │ │ ruvector intent embeddings │ │
|
||||
│ │ WASM sandbox │ │ Wyoming protocol edge │ │
|
||||
│ └──────────────┘ └──────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────┐ │
|
||||
│ │ RuView sensing core (wifi-densepose-sensing-server) │ │
|
||||
│ │ CSI → presence / vitals / pose / BFLD / semantic │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
│ HA-compatible REST + WebSocket
|
||||
▼
|
||||
┌──────────────────────────┐
|
||||
│ homecore-frontend-ts-wasm │ (ADR-131)
|
||||
│ TypeScript + Rust→WASM │
|
||||
│ SharedWorker state sync │
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
The HOMECORE process is a single Tokio-based async Rust binary. The state machine and event bus are the authoritative core (ADR-127). Integrations run in WASM sandboxes that communicate with the core via a defined ABI (ADR-128). The automation engine runs Rust-native trigger evaluation with a WASM expression evaluator for templates (ADR-129). The REST/WebSocket API layer is Axum-based and wire-compatible with HA (ADR-130). The frontend is TypeScript with the state machine compiled to WASM running in a SharedWorker (ADR-131). Historical state is stored in SQLite with ruvector for semantic search (ADR-132). Voice/text assistance uses ruflo agent orchestration (ADR-133).
|
||||
|
||||
---
|
||||
|
||||
## 4. Series map
|
||||
|
||||
| ADR | Codename | Scope | Critical path? | Estimated P5-completion |
|
||||
|---|---|---|---|---|
|
||||
| **ADR-127** | HOMECORE-CORE | Rust state machine, entity registry, event bus, service registry (`homecore` crate) | **Yes — all others depend on it** | Q3 2026 |
|
||||
| **ADR-128** | HOMECORE-PLUGINS | WASM integration plugin system, cog substrate, manifest schema, hot-load | **Yes — needed before any integration can run** | Q3 2026 |
|
||||
| **ADR-129** | HOMECORE-AUTO | Automation engine, YAML parser, Jinja2-equivalent WASM evaluator, blueprints | Yes (automation is core to HA UX) | Q4 2026 |
|
||||
| **ADR-130** | HOMECORE-API | REST + WebSocket wire-compat API, Axum server, HA companion app support | **Yes — needed for client compat** | Q3 2026 |
|
||||
| **ADR-131** | HOMECORE-UI | TS + Rust→WASM frontend, SharedWorker state sync, Material 3 design lang | No (can run alongside Python HA UI initially) | Q1 2027 |
|
||||
| **ADR-132** | HOMECORE-RECORDER | SQLite recorder + ruvector semantic history, schema migration | No (structural recorder ships before ruvector layer) | Q4 2026 |
|
||||
| **ADR-133** | HOMECORE-ASSIST | ruflo agent voice assistant, ruvector intent matching, Wyoming edge path | No | Q4 2026 |
|
||||
| **ADR-134** | HOMECORE-MIGRATE | Migration tooling from Python HA, config-entry parser, side-by-side mode | No (needed for user adoption) | Q1 2027 |
|
||||
|
||||
**Critical path**: ADR-127 → ADR-128 → ADR-130 must land in that order. ADR-129, ADR-132, ADR-133, ADR-131, ADR-134 can proceed in parallel once the core triad is stable.
|
||||
|
||||
---
|
||||
|
||||
## 5. Cross-cutting decisions
|
||||
|
||||
The following decisions govern all 8 sub-ADRs and are not repeated in each.
|
||||
|
||||
### 5.1 Governance via RUVIEW-POLICY (ADR-124 §4.1a)
|
||||
|
||||
Every HOMECORE component that returns biometric data (presence, HR/BR, pose keypoints, BFLD identity-risk) MUST route through the RUVIEW-POLICY layer defined in ADR-124 §4.1a. The policy store is the same `~/.config/rvagent/policy.json` used by `@ruvnet/rvagent`. HOMECORE is a first-class policy principal — its agent ID in the policy store is `homecore`.
|
||||
|
||||
### 5.2 Semantic memory via ruvector
|
||||
|
||||
Historical state is not only stored in SQLite rows (structural). Every state-changed event is also embedded via ruvector (using the same napi-rs bindings as ADR-124) and indexed in an HNSW store for semantic search. The `homecore-recorder` crate (ADR-132) owns this dual-write. Queries like "when did the living room motion last exceed baseline?" become vector-nearest-neighbour searches, not SQL BETWEEN clauses.
|
||||
|
||||
### 5.3 Agent orchestration via ruflo
|
||||
|
||||
The automation engine (ADR-129) and the assist pipeline (ADR-133) both have an optional ruflo-agent mode where complex conditions or voice intents are routed to a ruflo agent (using the `mcp__claude-flow__*` tool namespace) for LLM-backed resolution. This is gated by RUVIEW-POLICY: a policy grant is required before HOMECORE sends any state-history context to a ruflo agent.
|
||||
|
||||
### 5.4 Witness and audit via Ed25519 chain (ADR-028 pattern)
|
||||
|
||||
Every state transition that crosses a privacy boundary (e.g. BFLD identity-risk score elevated, a biometric entity state published) is logged to an Ed25519 witness chain using the same structure as ADR-028 §3. The witness bundle is exportable for regulated deployments (care homes, hotels, shared offices).
|
||||
|
||||
### 5.5 Crate naming and workspace placement
|
||||
|
||||
All HOMECORE crates live in `v2/crates/homecore-*/`:
|
||||
|
||||
| Crate | ADR |
|
||||
|---|---|
|
||||
| `homecore` | ADR-127 |
|
||||
| `homecore-plugins` | ADR-128 |
|
||||
| `homecore-automation` | ADR-129 |
|
||||
| `homecore-api` | ADR-130 |
|
||||
| `homecore-recorder` | ADR-132 |
|
||||
| `homecore-assist` | ADR-133 |
|
||||
| `homecore-migrate` | ADR-134 |
|
||||
|
||||
The frontend (`homecore-frontend`) is not a Rust crate — it is an npm package at `npm/homecore-frontend/`, mirroring the `npm/rvagent/` pattern from ADR-124.
|
||||
|
||||
### 5.6 HA wire-compatibility baseline
|
||||
|
||||
The HOMECORE REST and WebSocket API must be **compatible with HA 2025.1** as the baseline. HA 2025.1 introduced schema version 48 in the recorder. The API surface to replicate is:
|
||||
|
||||
- REST: `homeassistant/components/api/__init__.py` — 24 endpoints
|
||||
- WebSocket: `homeassistant/components/websocket_api/` — the `connection.py` + `commands.py` handler pattern, the auth handshake, and the `subscribe_events` / `subscribe_trigger` / `call_service` commands
|
||||
- Auth: `homeassistant/auth/` — the long-lived access token model
|
||||
- Config entries: `.storage/core.config_entries` JSON schema (versioned, auto-migrated)
|
||||
|
||||
### 5.7 "Do not port" list
|
||||
|
||||
The following HA subsystems are explicitly **not** ported to HOMECORE:
|
||||
|
||||
| HA subsystem | Reason not ported | HOMECORE replacement |
|
||||
|---|---|---|
|
||||
| **SUPERVISOR** (`homeassistant/supervisor/`) | Manages add-on containers and OS upgrades. HOMECORE runs on a standard Linux/Pi OS managed by systemd. | ruflo + systemd service units + OTA via the existing Cognitum Seed OTA registry (ADR-116 §2.2) |
|
||||
| **Home Assistant OS** (HAOS) | A custom embedded Linux image. HOMECORE targets standard Debian/Ubuntu on Pi 5 and standard Docker. | Standard OS + Docker Compose or systemd |
|
||||
| **Nabu Casa Cloud** | Paid remote-access and Alexa/Google integration service. HOMECORE uses Tailscale for remote access and `@ruvnet/rvagent` for AI integration. | Tailscale + ADR-107 federation + SENSE-BRIDGE |
|
||||
| **Add-on store** (Supervisor add-ons) | Docker container management. | Cognitum Seed cog registry (ADR-102) |
|
||||
| **Legacy YAML-only integrations** (pre-config-flow, ~500 of 2,000) | These require Python `setup_platform` (deprecated in HA 2024.x). Only config-flow integrations (`async_setup_entry`) are ported. | Document upgrade path; unported integrations can run via `homecore-migrate` bridge mode |
|
||||
| **Analytics / Nabu Casa telemetry** | Optional cloud telemetry. | Not replicated. HOMECORE is local-only. |
|
||||
| **Home Assistant Yellow / Green hardware** | Specific hardware. HOMECORE targets Cognitum Seed, Pi 5, and x86_64. | Cognitum Seed hardware |
|
||||
|
||||
---
|
||||
|
||||
## 6. Compatibility contract
|
||||
|
||||
### 6.1 What works on day one (P3, wire-compat API stable)
|
||||
|
||||
| Client | Works? | Notes |
|
||||
|---|---|---|
|
||||
| **HA iOS companion app** | Yes | Connects to `/api/websocket`; authenticates with long-lived token; subscribes to state events |
|
||||
| **HA Android companion app** | Yes | Same as iOS |
|
||||
| **Home Assistant Dashboard (frontend)** | Yes (HA frontend served against HOMECORE API) | Until HOMECORE-UI (ADR-131) ships, serve the Python HA frontend binary against the HOMECORE API |
|
||||
| **HACS** | Partial | HACS uses the WS API for integration management; custom component loading requires HOMECORE-PLUGINS (ADR-128) |
|
||||
| **Node-RED HA integration** | Yes | Uses REST + WS API; wire-compat |
|
||||
| **`homeassistant` Python client library** | Yes | Pure REST/WS client |
|
||||
| **`ha-mqtt-discoverable` Python library** | Yes | Publishes MQTT discovery; HOMECORE consumes the same topics |
|
||||
| **ESPHome devices** | Yes | ESPHome native API or MQTT; HOMECORE speaks both |
|
||||
| **Nabu Casa Cloud** | **No** | Nabu Casa uses a proprietary remote-access tunnel to `nabucasa.com`. HOMECORE does not integrate with the Nabu Casa cloud proxy. Replace with Tailscale. |
|
||||
| **M5Stack ATOM Echo / voice satellites** | Yes (P4) | Wyoming protocol is HOMECORE-ASSIST (ADR-133) scope |
|
||||
| **HACS custom cards** | Yes (after ADR-131 P3) | Custom cards are served via the same `/hacsfiles/` static route |
|
||||
|
||||
### 6.2 What breaks and why
|
||||
|
||||
| HA feature | HOMECORE status | Reason |
|
||||
|---|---|---|
|
||||
| Nabu Casa remote access | Not supported | Proprietary tunnel; replace with Tailscale |
|
||||
| HA Supervisor add-ons | Not supported | No container manager in HOMECORE |
|
||||
| HAOS OTA updates | Not supported | HOMECORE runs on standard OS |
|
||||
| Python custom integrations (non-WASM) | Not supported | WASM sandbox only; Python integrations cannot run natively |
|
||||
| Legacy `setup_platform` integrations | Not supported | Config-flow (`async_setup_entry`) only |
|
||||
| HA Cloud TTS/STT (Nabu Casa) | Not supported | Use Whisper + Piper locally |
|
||||
| HA Cloud Alexa/Google skill | Not supported | Use ruflo agent instead |
|
||||
|
||||
---
|
||||
|
||||
## 7. Phase roadmap
|
||||
|
||||
```
|
||||
Q3 2026 Q4 2026 Q1 2027 Q2 2027
|
||||
P1 P2 P3 P4 P5
|
||||
scaffold state+API wire-compat plugins+ full
|
||||
core HA clients automation HOMECORE
|
||||
```
|
||||
|
||||
### P1 — Scaffold (Q3 2026, 2 weeks)
|
||||
|
||||
- [ ] Create `v2/crates/homecore/` workspace member, empty state machine skeleton.
|
||||
- [ ] Create `v2/crates/homecore-api/` skeleton, Axum server on port 8123 (HA default).
|
||||
- [ ] Create `npm/homecore-frontend/` skeleton.
|
||||
- [ ] CI: `cargo check -p homecore -p homecore-api --no-default-features` green.
|
||||
- [ ] ADR-134 migration tool parses one `.storage/core.config_entries` fixture.
|
||||
|
||||
### P2 — State machine + API core (Q3 2026, 4 weeks)
|
||||
|
||||
- [ ] ADR-127 state machine: entity registry, state machine, event bus (Tokio broadcast), service registry.
|
||||
- [ ] ADR-130 API: REST endpoints, WebSocket auth handshake, `subscribe_events`, `call_service`.
|
||||
- [ ] ADR-132 recorder: SQLite schema (HA schema version 48 compatible), state write path.
|
||||
- [ ] Integration test: HA companion app authenticates and receives state updates.
|
||||
|
||||
### P3 — Wire-compat + plugin scaffold (Q3–Q4 2026, 6 weeks)
|
||||
|
||||
- [ ] ADR-128 plugin system: WASM sandbox, manifest schema, first ported integrations (MQTT, HTTP).
|
||||
- [ ] ADR-130 API: remaining WS commands, HACS support.
|
||||
- [ ] ADR-134 migration: reads `automations.yaml`, `secrets.yaml`, config entries.
|
||||
- [ ] ADR-132 recorder: ruvector dual-write, semantic search API.
|
||||
|
||||
### P4 — Automation + assist (Q4 2026, 4 weeks)
|
||||
|
||||
- [ ] ADR-129 automation engine: YAML parser, trigger evaluation, WASM expression evaluator.
|
||||
- [ ] ADR-133 assist: ruflo agent orchestration, ruvector intent matching.
|
||||
- [ ] ADR-131 frontend P1: TypeScript shell, WASM state machine in SharedWorker.
|
||||
|
||||
### P5 — Full HOMECORE (Q1 2027, 6 weeks)
|
||||
|
||||
- [ ] ADR-131 frontend: complete UI parity with HA Lovelace, custom cards.
|
||||
- [ ] ADR-134 migration: side-by-side mode, one-click cutover.
|
||||
- [ ] Full compatibility test suite against HA iOS/Android companion apps.
|
||||
- [ ] Pi 5 performance benchmarks: startup < 1 s, idle < 50 MB RAM.
|
||||
|
||||
---
|
||||
|
||||
## 8. Alternatives rejected
|
||||
|
||||
### Alt-A: Contribute RuView sensing features upstream to Python HA
|
||||
|
||||
Add the HOMECORE features (WASM plugins, ruvector recorder, ruflo assist) as Python HA components via PRs to `home-assistant/core`.
|
||||
|
||||
**Rejected because**: HA's architecture board has strict policies against adding new runtimes (WASM, Rust FFI) to the core process. The GIL bottleneck cannot be resolved from within Python HA. CSI DSP at 100 Hz frame rate inside a Python process is not feasible. This path cedes architectural control permanently.
|
||||
|
||||
### Alt-B: Thin Rust wrapper that calls into Python HA via PyO3
|
||||
|
||||
Keep Python HA as the runtime; expose RuView sensing primitives via PyO3 bindings so they run at native speed inside the Python HA process.
|
||||
|
||||
**Rejected because**: the GIL is not resolved by PyO3 calls — the HA event loop still serialises all state changes. Startup time and memory footprint are unchanged. WASM plugin safety is unchanged. This is a tactical optimisation, not an architectural solution.
|
||||
|
||||
### Alt-C: OpenHAB or Domoticz as the base
|
||||
|
||||
Port RuView's sensing stack on top of an alternative hub (openHAB/Java, Domoticz/C++).
|
||||
|
||||
**Rejected because**: neither has HA's community network effects, companion app ecosystem, or HACS plugin catalog. A clean-room Rust implementation preserves the HA compatibility contract (the most valuable asset) without inheriting the Python runtime limitations.
|
||||
|
||||
### Alt-D: Extend the existing `wifi-densepose-sensing-server` into a full hub
|
||||
|
||||
Add automation, entity registry, and recorder features directly to the existing Axum sensing server.
|
||||
|
||||
**Rejected because**: the sensing server is a purpose-built single-concern binary (CSI → MQTT/WebSocket). Expanding it into a hub would violate the single-responsibility principle and couple hub release cycles to firmware release cycles. HOMECORE is a separate crate family that depends on but does not modify the sensing server.
|
||||
|
||||
---
|
||||
|
||||
## 9. Top-level risks
|
||||
|
||||
| Risk | Likelihood | Severity | Mitigation |
|
||||
|---|---|---|---|
|
||||
| **API drift** — HA's REST/WS API evolves; HOMECORE must track it | High | High | Pin to HA 2025.1 baseline (schema 48); run the HA companion app integration tests against every HOMECORE release; ADR-130 owns the compat matrix |
|
||||
| **WASM sandbox performance** — plugin calls through the WASM boundary add latency | Medium | Medium | Benchmark plugin roundtrip on Pi 5 before P3; reject if >5 ms; WASM3/Wasmtime both have sub-1 ms call overhead for compute-light integrations |
|
||||
| **Core triad dependency** — ADR-128 and ADR-130 cannot start until ADR-127 is stable | High | High | ADR-127 is P2 start; freeze the state machine public API (entity_id, state, attributes, last_changed) before ADR-128 begins |
|
||||
| **ruvector semantic recorder** — dual-write to SQLite + HNSW may impact write throughput under high-frequency sensing | Medium | High | ruvector writes are async (non-blocking tokio task); SQLite write is the hot path; benchmark at 100 state/s on Pi 5 before ADR-132 ships |
|
||||
| **Nabu Casa gap** — users who depend on HA Cloud remote access have no HOMECORE replacement at P3 | High | Medium | Document Tailscale as the replacement prominently; provide ADR-134 migration wizard that detects Nabu Casa usage and offers Tailscale setup |
|
||||
| **Frontend bundle size** — replicating the HA Lovelace card ecosystem in TS+WASM is a significant engineering effort | High | High | ADR-131 is off-critical-path; serve HA's Python frontend against the HOMECORE API until ADR-131 P3 ships |
|
||||
| **License** — HA is Apache 2.0; the wire protocol is unencumbered; HA's UI assets and card components have separate licenses | Low | High | Clean-room Rust implementation does not use HA source; HA frontend is served as a binary (not embedded); review license before ADR-131 ships any reimplemented component |
|
||||
|
||||
---
|
||||
|
||||
## 10. Open questions
|
||||
|
||||
**Q1** (ADR-127): Should the HOMECORE state machine use a `DashMap<EntityId, State>` for lock-free concurrent reads, or a `RwLock<HashMap<EntityId, State>>` for simpler reasoning? The answer affects every integration's write pattern.
|
||||
|
||||
**Q2** (ADR-128): Does the WASM sandbox use Wasmtime (Cranelift JIT, ~5 MB binary) or WASM3 (interpreter, ~50 kB binary)? On a Pi 5 WASM3 is sufficient for integration logic; Wasmtime matters if integrations need near-native DSP speed.
|
||||
|
||||
**Q3** (ADR-130): The HA WebSocket API uses numeric IDs for command/response correlation. The HA 2025.1 baseline adds `subscribe_trigger` as a first-class WS command. Are there any commands in the HA companion app that require a newer baseline?
|
||||
|
||||
**Q4** (ADR-132): The ruvector HNSW index for state history — what embedding dimension represents a state snapshot? Options: (a) embed only numeric sensor states (scalar embedding), (b) embed `{entity_id, state, attributes}` as a text embedding via a local small model, (c) use a fixed schema encoding. The answer determines the semantic query fidelity.
|
||||
|
||||
**Q5** (ADR-134): HA's `.storage/core.config_entries` format is versioned but undocumented; it is hand-engineered from reverse-engineering the Python `StorageCollection` class in `homeassistant/helpers/storage.py`. Is this format stable enough to parse without upstream documentation, or does HOMECORE need to maintain a version matrix?
|
||||
|
||||
---
|
||||
|
||||
## 11. References
|
||||
|
||||
### This repo
|
||||
|
||||
- `docs/adr/ADR-115-home-assistant-integration.md` — HA-DISCO MQTT publisher; 21-entity surface; semantic primitives; competitive comparison table
|
||||
- `docs/adr/ADR-116-cog-ha-matter-seed.md` — HA-COG Seed cog; cog packaging precedent (ADR-101)
|
||||
- `docs/adr/ADR-117-pip-wifi-densepose-modernization.md` — PIP-PHOENIX PyO3 bindings; Python client surface
|
||||
- `docs/adr/ADR-118-bfld-beamforming-feedback-layer-for-detection.md` — BFLD master; privacy class enforcement
|
||||
- `docs/adr/ADR-124-rvagent-mcp-ruvector-npm-integration.md` — SENSE-BRIDGE; RUVIEW-POLICY §4.1a; multi-modal normalization §11.3
|
||||
- `docs/adr/ADR-125-ruview-apple-home-native-hap-bridge.md` — APPLE-FABRIC HAP bridge
|
||||
- `v2/crates/wifi-densepose-sensing-server/src/main.rs` — Axum server architecture; bearer auth pattern
|
||||
- `v2/crates/wifi-densepose-ruvector/src/viewpoint/` — cross-viewpoint fusion (attention, coherence, geometry, fusion modules)
|
||||
- `CLAUDE.md` — Project topology (hierarchical-mesh, 15 agents), ESP32 hardware table, crate publishing order
|
||||
|
||||
### HA upstream
|
||||
|
||||
- `homeassistant/core.py` — `HomeAssistant`, `StateMachine`, `EventBus`, `ServiceRegistry`, `Config`
|
||||
- `homeassistant/helpers/entity_registry.py` — `EntityRegistry`, `RegistryEntry`
|
||||
- `homeassistant/helpers/entity.py` — `Entity`, `async_write_ha_state`, entity lifecycle
|
||||
- `homeassistant/components/api/__init__.py` — REST API handler (24 routes)
|
||||
- `homeassistant/components/websocket_api/` — `connection.py` auth handshake; `commands.py` WS commands
|
||||
- `homeassistant/components/recorder/` — SQLite schema; `migration.py` schema version 48
|
||||
- `homeassistant/components/assist_pipeline/` — voice/text pipeline; Wyoming protocol
|
||||
- `homeassistant/helpers/template.py` — Jinja2 template engine customisation
|
||||
- `homeassistant/components/automation/__init__.py` — automation trigger/condition/action model
|
||||
- `homeassistant/helpers/storage.py` — `.storage/*.json` persistence; `StorageCollection`
|
||||
- `homeassistant/auth/` — long-lived access token model; `AuthManager`
|
||||
|
||||
### External
|
||||
|
||||
- [HA Developer Docs — Core Architecture](https://developers.home-assistant.io/docs/architecture/core/) — state machine, event bus, service registry overview
|
||||
- [HA Developer Docs — WebSocket API](https://developers.home-assistant.io/docs/api/websocket/) — WS command catalog
|
||||
- [DeepWiki HA core — Entity and Registry Management](https://deepwiki.com/home-assistant/core/2.2-entity-and-registry-management) — entity lifecycle
|
||||
- [DeepWiki HA core — Data Management](https://deepwiki.com/home-assistant/core/3-data-management) — recorder schema version 48
|
||||
- [HA recorder integration](https://www.home-assistant.io/integrations/recorder/) — SQLite default; schema migration overview
|
||||
@@ -0,0 +1,193 @@
|
||||
# ADR-127: HOMECORE-CORE — Rust state machine, entity registry, event bus, service registry
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Date** | 2026-05-25 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **HOMECORE-CORE** |
|
||||
| **Relates to** | [ADR-126](ADR-126-ruview-native-ha-port-master.md) (HOMECORE master), [ADR-028](ADR-028-esp32-capability-audit.md) (witness chain), [ADR-124](ADR-124-rvagent-mcp-ruvector-npm-integration.md) (RUVIEW-POLICY) |
|
||||
| **Tracking issue** | TBD |
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
`homeassistant/core.py` is the 3,200-line heart of Python Home Assistant. It defines five objects that every other HA component depends on:
|
||||
|
||||
1. **`HomeAssistant`** — the runtime coordinator, event loop holder, and service locator. Contains `bus` (EventBus), `states` (StateMachine), `services` (ServiceRegistry), `config` (Config), `components` (loaded component set).
|
||||
2. **`EventBus`** — publish/subscribe event dispatch. `async_fire(event_type, event_data)` dispatches to all registered listeners. Listener registration is `async_listen(event_type, callback)`. Wildcard listener is `MATCH_ALL`. Event data is a plain Python dict.
|
||||
3. **`StateMachine`** — an in-memory dictionary from `entity_id` (str) to `State`. `async_set(entity_id, new_state, attributes)` writes and fires `state_changed`. `get(entity_id)` reads. `async_remove(entity_id)` fires `state_removed`. States are immutable snapshots with `last_changed`, `last_updated`, `context`.
|
||||
4. **`ServiceRegistry`** — maps `(domain, service_name)` → async handler function. `async_call(domain, service, data)` fires a `call_service` event, waits for the registered handler. `async_register(domain, service, handler, schema)` registers a handler with optional voluptuous schema validation.
|
||||
5. **`EntityRegistry`** (`homeassistant/helpers/entity_registry.py`) — persists metadata (enabled/disabled, name override, area assignment, device ID, unique ID, entity category) across restarts. Stored in `.storage/core.entity_registry`. Loaded at startup; written on every change.
|
||||
|
||||
The **DeviceRegistry** (`homeassistant/helpers/device_registry.py`, stored in `.storage/core.device_registry`) tracks physical devices that entities belong to. Entities link to devices via `device_id`; devices link to config entries via `config_entry_id`.
|
||||
|
||||
### 1.1 Why these specific files matter
|
||||
|
||||
Python HA's `core.py` is a single-process Python 3.12 module that:
|
||||
- Holds the asyncio event loop directly
|
||||
- Serialises all state-changed writes through `asyncio.Lock`
|
||||
- Fires event listeners in the same event loop iteration that fired the event (listeners cannot block)
|
||||
- Is single-threaded by design — concurrent writes to the state machine are impossible without explicit async primitives
|
||||
|
||||
For HOMECORE the same semantic requirements apply, but the implementation must support:
|
||||
- **Concurrent reads** from dozens of integration WASM sandboxes polling current state
|
||||
- **High-frequency writes** from the RuView sensing stack (CSI at 100 Hz; state updates at up to 20 Hz per entity)
|
||||
- **Ordered delivery** of state_changed events to automation triggers (ADR-129) and recorder (ADR-132) subscribers
|
||||
- **Zero-copy reads** where possible for the REST API (ADR-130) path
|
||||
|
||||
---
|
||||
|
||||
## 2. Decision
|
||||
|
||||
Implement the `homecore` Rust crate at `v2/crates/homecore/` with the following design.
|
||||
|
||||
### 2.1 State machine: `DashMap` + Tokio broadcast
|
||||
|
||||
The primary state store is a `DashMap<EntityId, Arc<State>>` where:
|
||||
- `EntityId` is a validated newtype around `String` (validated format: `domain.name`)
|
||||
- `State` is a frozen struct: `entity_id`, `state` (String), `attributes` (serde_json::Value), `last_changed` (DateTime<Utc>), `last_updated` (DateTime<Utc>), `context` (Context)
|
||||
- `Arc<State>` allows zero-copy cloning for readers while the writer atomically replaces the map entry
|
||||
|
||||
State changes are published to a `tokio::sync::broadcast::Sender<StateChangedEvent>` channel (capacity: 4,096 events). Any number of receivers subscribe — the recorder, automation engine, WebSocket subscriber handler, and ruvector dual-write task all hold independent receivers. Slow receivers that fall behind by 4,096 events receive a `RecvError::Lagged` and must re-sync from the current state map.
|
||||
|
||||
### 2.2 Event bus: typed + untyped channels
|
||||
|
||||
HOMECORE distinguishes two event categories:
|
||||
|
||||
1. **System events** (typed): `StateChanged`, `ServiceCall`, `ComponentLoaded`, `PlatformDiscovered`, `HomeAssistantStart`, `HomeAssistantStop`. These use Tokio typed broadcast channels with zero allocation on the read path.
|
||||
2. **Integration events** (untyped): integrations fire arbitrary event types (`event_type: String`, `event_data: serde_json::Value`). These use a single `broadcast::Sender<DomainEvent>` where `DomainEvent` carries the type string and data blob. This mirrors HA's `EventBus.async_fire()`.
|
||||
|
||||
### 2.3 Service registry: `HashMap` + mpsc dispatch
|
||||
|
||||
Services are registered as `(Domain, ServiceName) → ServiceHandler` where `ServiceHandler` is a `Box<dyn Fn(ServiceCall) -> BoxFuture<ServiceResponse> + Send + Sync>`. The registry lives in a `tokio::sync::RwLock<HashMap<(Domain, ServiceName), ServiceHandler>>`. Service calls go through the event bus (fire `call_service`) and are dispatched to the handler by an internal router task. This matches HA's indirection: `hass.services.async_call(domain, service, data)` does not call the handler directly; it fires an event.
|
||||
|
||||
### 2.4 Entity registry: persisted metadata sidecar
|
||||
|
||||
The entity registry is a `RwLock<HashMap<EntityId, EntityEntry>>` backed by an async JSON writer that flushes to `.homecore/storage/core.entity_registry` on every write. The schema matches HA's `core.entity_registry` schema (version 13 as of HA 2025.1) so ADR-134 migration can read both formats interchangeably.
|
||||
|
||||
`EntityEntry` fields mirrored from HA:
|
||||
- `entity_id: EntityId`
|
||||
- `unique_id: Option<String>`
|
||||
- `platform: String`
|
||||
- `name: Option<String>` (user override)
|
||||
- `disabled_by: Option<DisabledBy>` (user, integration, config_entry)
|
||||
- `area_id: Option<AreaId>`
|
||||
- `device_id: Option<DeviceId>`
|
||||
- `entity_category: Option<EntityCategory>` (config, diagnostic)
|
||||
- `config_entry_id: Option<ConfigEntryId>`
|
||||
|
||||
### 2.5 Device registry: parallel sidecar
|
||||
|
||||
`DeviceRegistry` mirrors HA's `core.device_registry` schema (version 13). Devices are identified by a set of `(id_type, id_value)` tuples (the `identifiers` field), which matches HA's pattern of accepting multiple identifier types per device (MAC address, serial number, integration-specific ID).
|
||||
|
||||
---
|
||||
|
||||
## 3. HA-side reference table
|
||||
|
||||
| HA module / file | What it does | HOMECORE preserves | Changes | Drops |
|
||||
|---|---|---|---|---|
|
||||
| `homeassistant/core.py` `StateMachine` | In-memory state store, fire `state_changed` | Same semantics: immutable snapshots, `last_changed`, `last_updated`, `context` | `DashMap` instead of asyncio-locked `dict`; `broadcast::Sender` instead of asyncio callbacks | Python asyncio coupling |
|
||||
| `homeassistant/core.py` `EventBus` | Pub/sub event dispatch | `MATCH_ALL` listener; per-type listener; event data dict | Typed system events + untyped domain events; no Python dict — use `serde_json::Value` | `@callback` decorator, HassJob abstraction |
|
||||
| `homeassistant/core.py` `ServiceRegistry` | Register/call services | Same `(domain, service)` key structure; schema validation | Schema validation via `serde` `Deserialize` trait instead of voluptuous | voluptuous, Python type coercions |
|
||||
| `homeassistant/core.py` `HomeAssistant` | Runtime coordinator / service locator | State machine + event bus + services accessible on one struct | Struct with `Arc<HomeCoreInner>` for cheap cloning across tasks | asyncio event loop holder, Python executor |
|
||||
| `homeassistant/helpers/entity_registry.py` | Persist entity metadata | All fields listed in §2.4; file format compatible | Async tokio I/O; no Python pickle | Python-specific persistence helpers |
|
||||
| `homeassistant/helpers/device_registry.py` | Persist device metadata | `identifiers`, `connections`, `manufacturer`, `model`, `name`, `via_device_id` | Async tokio I/O | — |
|
||||
| `homeassistant/helpers/entity.py` | Entity base class | `entity_id`, `state`, `attributes`, `unique_id`, `device_info`, async_write_ha_state semantics | Trait `HomeCoreEntity` instead of class | Python MRO, `@property` decorators |
|
||||
| `homeassistant/helpers/event.py` | Convenience event helpers | `async_track_state_change`, `async_track_time_interval` (as Rust timer tasks) | Rust closures / async tasks | Python asyncio task wrappers |
|
||||
|
||||
---
|
||||
|
||||
## 4. Public API parity table
|
||||
|
||||
| HA Python surface | HOMECORE Rust equivalent |
|
||||
|---|---|
|
||||
| `hass.states.get(entity_id)` | `hass.states.get(&entity_id) -> Option<Arc<State>>` |
|
||||
| `hass.states.async_set(entity_id, state, attributes)` | `hass.states.set(entity_id, state, attributes).await` |
|
||||
| `hass.states.async_remove(entity_id)` | `hass.states.remove(&entity_id).await` |
|
||||
| `hass.states.async_all(domain_filter)` | `hass.states.all(domain_filter) -> Vec<Arc<State>>` |
|
||||
| `hass.bus.async_fire(event_type, data)` | `hass.bus.fire(event_type, data).await` |
|
||||
| `hass.bus.async_listen(event_type, callback)` | `hass.bus.subscribe(event_type) -> broadcast::Receiver<DomainEvent>` |
|
||||
| `hass.services.async_call(domain, service, data)` | `hass.services.call(domain, service, data).await -> ServiceResponse` |
|
||||
| `hass.services.async_register(domain, service, handler, schema)` | `hass.services.register(domain, service, handler)` |
|
||||
| `hass.services.has_service(domain, service)` | `hass.services.has(domain, service) -> bool` |
|
||||
| `entity_registry.async_get(entity_id)` | `entity_registry.get(&entity_id) -> Option<&EntityEntry>` |
|
||||
| `entity_registry.async_update_entity(entity_id, **kwargs)` | `entity_registry.update(entity_id, patch).await` |
|
||||
| `device_registry.async_get_device(identifiers)` | `device_registry.get_by_identifiers(identifiers) -> Option<&DeviceEntry>` |
|
||||
| `Context(user_id, parent_id)` | `Context { id: Uuid, parent_id: Option<Uuid>, user_id: Option<UserId> }` |
|
||||
|
||||
---
|
||||
|
||||
## 5. Phased implementation plan
|
||||
|
||||
### P1 — Skeleton (2 weeks)
|
||||
|
||||
- [ ] Create `v2/crates/homecore/` workspace member with `Cargo.toml`.
|
||||
- [ ] Define `State`, `EntityId`, `Domain`, `ServiceName`, `Context`, `DomainEvent` types.
|
||||
- [ ] `StateMachine`: `DashMap` + broadcast channel; `set()`, `get()`, `remove()`, `all()`.
|
||||
- [ ] `EventBus`: typed broadcast for system events + untyped broadcast for domain events.
|
||||
- [ ] Unit tests: 50 state writes/reads with concurrent readers; verify broadcast delivery.
|
||||
|
||||
### P2 — Service registry + entity registry (2 weeks)
|
||||
|
||||
- [ ] `ServiceRegistry`: `RwLock<HashMap>` + mpsc dispatch task.
|
||||
- [ ] `EntityRegistry`: in-memory + JSON async writer to `.homecore/storage/core.entity_registry`.
|
||||
- [ ] `DeviceRegistry`: in-memory + JSON async writer to `.homecore/storage/core.device_registry`.
|
||||
- [ ] Serialization: `serde` with `#[serde(rename_all = "snake_case")]`; schema version 13 header written to match HA format.
|
||||
- [ ] Unit tests: register service, call service, verify handler invoked; persist and reload entity registry.
|
||||
|
||||
### P3 — Trait surface for integrations (1 week)
|
||||
|
||||
- [ ] `HomeCoreEntity` trait: `entity_id()`, `unique_id()`, `name()`, `device_info()`, `state()`, `attributes()`, `async_write_ha_state(&hass)`.
|
||||
- [ ] `Platform` trait: `async_setup_entry(hass, config_entry) -> Result<()>`.
|
||||
- [ ] `ConfigEntry` struct mirroring HA's `ConfigEntry` fields.
|
||||
- [ ] Integration test: a minimal test integration registers an entity, writes a state, reads it back from the state machine.
|
||||
|
||||
### P4 — Performance validation (1 week)
|
||||
|
||||
- [ ] Benchmark: 1,000 state writes/s on Pi 5; measure latency at p50/p95/p99.
|
||||
- [ ] Benchmark: 100 concurrent WS subscribers each receiving all state_changed events; measure delivery lag.
|
||||
- [ ] Benchmark: broadcast channel saturation test at 4,096 capacity; verify `RecvError::Lagged` handling.
|
||||
- [ ] Acceptance criterion: p99 state write latency < 1 ms on Pi 5 (8 GB, 4 cores).
|
||||
|
||||
---
|
||||
|
||||
## 6. Risks
|
||||
|
||||
| Risk | Likelihood | Severity | Mitigation | Cross-ADR impact |
|
||||
|---|---|---|---|---|
|
||||
| **Broadcast channel lag** — a slow subscriber (e.g. ruvector recorder write) lags behind and drops events | Medium | High | Give recorder its own channel separate from WS subscribers; recorder is the hot path, give it highest priority | ADR-132: recorder write path must be designed to keep up with 100 Hz state writes |
|
||||
| **DashMap contention** — shard count default (16) may be too low for 100 Hz writes on a single entity | Low | Medium | Increase DashMap shard count to 64; benchmark before ADR-130 integration | ADR-130: REST API reads state directly from DashMap — must be lock-free |
|
||||
| **Entity registry format drift** — HA updates `.storage/core.entity_registry` schema; HOMECORE falls behind | Medium | Medium | Pin to schema version 13; version-check on load; fail loudly on unknown version | ADR-134: migration tool reads HA entity registry — must support the same schema version |
|
||||
| **Context propagation** — HA's `Context` is used for audit trails (which automation triggered which service call). HOMECORE must propagate it correctly or automation audits break | High | Low | Derive `Context` from source event at every service call; thread through `ServiceCall.context` field | ADR-129: automation engine must supply context when calling services |
|
||||
|
||||
---
|
||||
|
||||
## 7. Open questions
|
||||
|
||||
**Q1**: Should `EntityId` validation be strict (reject anything that doesn't match `[a-z0-9_]+\.[a-z0-9_]+`) or lenient (accept any UTF-8 string)? HA itself accepts unicode entity IDs since 2024.3. Strict validation simplifies routing; lenient matches HA's actual behaviour.
|
||||
|
||||
**Q2**: The `broadcast::Sender` capacity of 4,096 is chosen based on a worst-case of 100 state writes/s × 40 s of acceptable lag before a slow receiver is declared dead. Is 40 s the right threshold, or should it be configurable per receiver?
|
||||
|
||||
**Q3**: Should the `HomeCoreEntity` trait be object-safe (enabling `Vec<Box<dyn HomeCoreEntity>>`) or use associated types (enabling monomorphisation)? Object safety is required for the WASM plugin boundary (ADR-128); monomorphisation is faster for built-in integrations.
|
||||
|
||||
**Q4**: HA's `State.context` carries a `user_id` that traces which user or automation initiated a state change. HOMECORE uses `UserId` from the auth layer (ADR-130). Is the auth layer a dependency of the core state machine, or should `user_id` be an optional opaque string to avoid circular deps?
|
||||
|
||||
---
|
||||
|
||||
## 8. References
|
||||
|
||||
### HA upstream
|
||||
|
||||
- `homeassistant/core.py` — `HomeAssistant`, `StateMachine` (lines 1–800), `EventBus` (lines 800–1100), `ServiceRegistry` (lines 1100–1500), `Config` (lines 1500–2000)
|
||||
- `homeassistant/helpers/entity_registry.py` — `EntityRegistry`, `RegistryEntry` (all ~1,900 lines); schema version constant `STORAGE_VERSION`
|
||||
- `homeassistant/helpers/device_registry.py` — `DeviceRegistry`, `DeviceEntry`; schema version
|
||||
- `homeassistant/helpers/entity.py` — `Entity` base class; `async_write_ha_state`; entity lifecycle hooks
|
||||
- `homeassistant/helpers/event.py` — `async_track_state_change`, `async_track_time_interval`
|
||||
|
||||
### This repo
|
||||
|
||||
- `v2/crates/wifi-densepose-sensing-server/src/main.rs` — Axum + Tokio architecture pattern used throughout the existing server stack
|
||||
- `docs/adr/ADR-126-ruview-native-ha-port-master.md` — HOMECORE master; §5.5 crate naming; §6 compatibility contract; §5.1 RUVIEW-POLICY
|
||||
- `docs/adr/ADR-028-esp32-capability-audit.md` — witness chain pattern (Ed25519 per state transition)
|
||||
@@ -0,0 +1,270 @@
|
||||
# ADR-128: HOMECORE-PLUGINS — WASM integration plugin system
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Date** | 2026-05-25 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **HOMECORE-PLUGINS** |
|
||||
| **Relates to** | [ADR-126](ADR-126-ruview-native-ha-port-master.md) (HOMECORE master), [ADR-127](ADR-127-homecore-state-machine-rust.md) (HOMECORE-CORE), [ADR-102](ADR-102-edge-module-registry.md) (cog registry), [ADR-100](ADR-100-cog-packaging-specification.md) (cog packaging spec) |
|
||||
| **Tracking issue** | TBD |
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
Home Assistant ships approximately 2,000 integrations, each a Python module in `homeassistant/components/<domain>/`. Each integration:
|
||||
|
||||
1. Declares a **manifest** (`manifest.json`) with `domain`, `name`, `version`, `requirements` (pip packages), `dependencies` (other HA integrations), `codeowners`, `iot_class`, `config_flow` (bool), and `quality_scale`.
|
||||
2. Provides **`async_setup`** (global domain setup, called once at HA startup) and/or **`async_setup_entry`** (per-config-entry setup, called when a user adds an integration via the UI).
|
||||
3. Imports Python packages from `requirements` at load time — these are installed into HA's Python environment by the loader at first run.
|
||||
4. Communicates with the HA core exclusively through the `hass` object (the `HomeAssistant` instance) — setting states, calling services, registering services, subscribing to events.
|
||||
|
||||
In Python HA, integrations run **in-process** with the hub. A buggy integration can crash the event loop, read arbitrary HA memory, or import packages that conflict with other integrations. HA mitigates this via code review and quality scale requirements, but there is no runtime isolation boundary.
|
||||
|
||||
### 1.1 The Cognitum Seed cog system
|
||||
|
||||
The project already has a cog system (ADR-102, ADR-100) for the Cognitum Seed appliance. A **cog** is a signed, sandboxed module that installs from the Seed app registry. ADR-101 (`cog-pose-estimation`) shipped signed aarch64/x86_64 binaries with a model weight blob. ADR-116 (`cog-ha-matter`) shipped HA+Matter integration as a cog.
|
||||
|
||||
The cog system uses a different packaging model from HA integrations (binary artifacts vs Python packages), but the same conceptual pattern: a manifest, a lifecycle hook, and communication through a defined interface.
|
||||
|
||||
HOMECORE-PLUGINS unifies these two patterns: every HOMECORE integration is a **WASM module** that speaks the cog ABI, can be hot-loaded without restarting the hub, and is sandboxed by the WASM runtime.
|
||||
|
||||
---
|
||||
|
||||
## 2. Decision
|
||||
|
||||
HOMECORE integrations are **WASM modules** loaded by a Rust host runtime (`homecore-plugins` crate). Each plugin:
|
||||
|
||||
1. Compiles to a `.wasm` binary (from Rust, AssemblyScript, Go, or any WASM-targeting language).
|
||||
2. Declares a `manifest.json` (superset of HA's manifest schema — see §3).
|
||||
3. Exports exactly three WASM functions: `setup_entry(config_entry_ptr, config_entry_len) → i32`, `call_service(call_ptr, call_len) → i32`, and `receive_event(event_ptr, event_len) → i32`.
|
||||
4. Imports a set of **host functions** from the HOMECORE host runtime: `hc_state_get`, `hc_state_set`, `hc_event_fire`, `hc_service_call`, `hc_log`, `hc_entity_register`.
|
||||
5. Communicates with the host exclusively through those imports — no direct memory access outside its own linear memory.
|
||||
|
||||
The WASM runtime is **Wasmtime** (Cranelift JIT on Pi 5 and x86_64; interpretation mode available for low-memory targets via `--features wasm3`).
|
||||
|
||||
### 2.1 Why WASM over Python-in-process
|
||||
|
||||
| Criterion | Python in-process (HA today) | WASM sandbox (HOMECORE) |
|
||||
|---|---|---|
|
||||
| Memory isolation | None — any integration can read any HA object | WASM linear memory; host allocates shared buffer only for ABI calls |
|
||||
| Crash isolation | Integration panic = HA event loop crash | WASM trap = plugin terminated, hub continues |
|
||||
| Language support | Python only | Any WASM-targeting language: Rust, Go, AssemblyScript, C, Zig |
|
||||
| Hot-load without restart | No — requires `asyncio.run_coroutine_threadsafe` patching | Yes — Wasmtime `Engine` + `Module::deserialize` from compiled `.cwasm` cache |
|
||||
| Dependency conflicts | pip requirements collide across integrations | Each WASM module carries its own static dependencies (no runtime pip) |
|
||||
| Startup cost per integration | Python import + pip install | Wasmtime JIT compile (~5 ms for a typical 200 kB WASM module); cached to `.cwasm` |
|
||||
|
||||
### 2.2 Cog system as the plugin substrate
|
||||
|
||||
The existing cog system (ADR-102) is the distribution and lifecycle layer. HOMECORE-PLUGINS extends it:
|
||||
|
||||
- **Distribution**: cogs are fetched from the Seed app registry (`app-registry.json`) or from a HOMECORE plugin registry (superset of the cog registry, same JSON schema + a `wasm_module` field).
|
||||
- **Lifecycle**: `cognitum-agent` (ADR-116) already handles OTA update, signature verification, and sandboxed execution. HOMECORE-PLUGINS reuses this lifecycle by treating each HOMECORE integration as a cog with a WASM payload.
|
||||
- **Ed25519 signatures**: every plugin `.wasm` is signed with the publisher's Ed25519 key. The HOMECORE host verifies the signature before compiling the module (same pattern as ADR-028 witness chain).
|
||||
|
||||
---
|
||||
|
||||
## 3. Manifest schema
|
||||
|
||||
HOMECORE's manifest is a superset of HA's `manifest.json`. Fields not present in HA are marked **[HOMECORE]**.
|
||||
|
||||
```json
|
||||
{
|
||||
"domain": "mqtt",
|
||||
"name": "MQTT",
|
||||
"version": "2025.1.0",
|
||||
"documentation": "https://www.home-assistant.io/integrations/mqtt/",
|
||||
"iot_class": "local_push",
|
||||
"config_flow": true,
|
||||
"dependencies": [],
|
||||
"quality_scale": "platinum",
|
||||
"wasm_module": "mqtt.wasm",
|
||||
"wasm_module_hash": "sha256:abcdef...",
|
||||
"wasm_module_sig": "ed25519:<base64>",
|
||||
"publisher_key": "<base64 Ed25519 public key>",
|
||||
"min_homecore_version": "0.1.0",
|
||||
"host_imports_required": ["hc_state_get", "hc_state_set", "hc_event_fire", "hc_service_call"],
|
||||
"homecore_permissions": ["state:write:sensor.*", "state:read:*", "service:call:homeassistant.*"],
|
||||
"cog_id": "homecore-mqtt-2025.1.0"
|
||||
}
|
||||
```
|
||||
|
||||
**[HOMECORE]** fields:
|
||||
- `wasm_module` — relative path to the `.wasm` binary
|
||||
- `wasm_module_hash` — SHA-256 of the wasm binary; verified before execution
|
||||
- `wasm_module_sig` — Ed25519 signature of the wasm binary hash
|
||||
- `publisher_key` — Ed25519 public key of the publisher
|
||||
- `min_homecore_version` — minimum HOMECORE version required
|
||||
- `host_imports_required` — subset of host functions the module needs (security auditable)
|
||||
- `homecore_permissions` — coarse-grained permission claims (glob patterns); future: enforcement via RUVIEW-POLICY layer (ADR-124 §4.1a)
|
||||
- `cog_id` — Seed app registry ID for the cog distribution
|
||||
|
||||
---
|
||||
|
||||
## 4. HA-side reference table
|
||||
|
||||
| HA module / file | What it does | HOMECORE preserves | Changes | Drops |
|
||||
|---|---|---|---|---|
|
||||
| `homeassistant/components/<domain>/manifest.json` | Integration metadata | `domain`, `name`, `version`, `iot_class`, `config_flow`, `dependencies`, `quality_scale`, `documentation` | Add WASM fields; remove `requirements` (no pip) | `requirements` (pip packages) |
|
||||
| `homeassistant/loader.py` | Loads Python modules; installs pip requirements | Manifest parsing; dependency resolution between cogs | WASM module loading via Wasmtime; no pip | Python `importlib`, pip subprocess |
|
||||
| `homeassistant/components/<domain>/__init__.py` | `async_setup` + `async_setup_entry` | `setup_entry` hook (per config entry) | WASM export function instead of Python async function | Python module structure |
|
||||
| `homeassistant/config_entries.py` | Config entry lifecycle management | `ConfigEntry` struct: `entry_id`, `domain`, `title`, `data`, `options`, `state`, `version` | Rust struct; async state machine | Python class hierarchy; `FlowManager` |
|
||||
| `homeassistant/components/<domain>/config_flow.py` | UI configuration flow | Config flow metadata (steps, schemas) | JSON-schema-based flow descriptor shipped in manifest | `voluptuous`, Python UI flow runtime |
|
||||
|
||||
---
|
||||
|
||||
## 5. WASM ABI specification
|
||||
|
||||
### 5.1 Host functions imported by plugins
|
||||
|
||||
```
|
||||
hc_state_get(key_ptr: i32, key_len: i32, out_ptr: i32, out_cap: i32) → i32
|
||||
// Returns JSON-encoded State into out_ptr buffer; returns bytes written or -1 if not found.
|
||||
|
||||
hc_state_set(entity_ptr: i32, entity_len: i32, state_ptr: i32, state_len: i32,
|
||||
attrs_ptr: i32, attrs_len: i32) → i32
|
||||
// Sets state for entity_id; returns 0 on success, negative on error.
|
||||
|
||||
hc_event_fire(event_type_ptr: i32, event_type_len: i32,
|
||||
event_data_ptr: i32, event_data_len: i32) → i32
|
||||
// Fires a domain event.
|
||||
|
||||
hc_service_call(domain_ptr: i32, domain_len: i32,
|
||||
service_ptr: i32, service_len: i32,
|
||||
data_ptr: i32, data_len: i32) → i32
|
||||
// Calls a service synchronously from the plugin's perspective (async on the host).
|
||||
|
||||
hc_entity_register(entry_ptr: i32, entry_len: i32) → i32
|
||||
// Registers an entity with the entity registry; entry is JSON-encoded EntityEntry.
|
||||
|
||||
hc_log(level: i32, msg_ptr: i32, msg_len: i32) → void
|
||||
// Structured log output; level: 0=debug, 1=info, 2=warn, 3=error.
|
||||
```
|
||||
|
||||
### 5.2 WASM exports required by host
|
||||
|
||||
```
|
||||
setup_entry(config_entry_ptr: i32, config_entry_len: i32) → i32
|
||||
// Called when a config entry is set up. config_entry is JSON-encoded ConfigEntry.
|
||||
// Returns 0 on success, negative error code on failure.
|
||||
|
||||
call_service_handler(domain_ptr: i32, domain_len: i32,
|
||||
service_ptr: i32, service_len: i32,
|
||||
data_ptr: i32, data_len: i32) → i32
|
||||
// Called when a service registered by this plugin is invoked.
|
||||
|
||||
receive_event(event_type_ptr: i32, event_type_len: i32,
|
||||
event_data_ptr: i32, event_data_len: i32) → i32
|
||||
// Called when an event type the plugin subscribed to fires.
|
||||
// Subscription is declared in manifest `subscribed_events` array.
|
||||
|
||||
alloc(size: i32) → i32
|
||||
// Host calls this to allocate a buffer inside the WASM linear memory
|
||||
// before writing data for a callback. Required for ABI memory passing.
|
||||
|
||||
dealloc(ptr: i32, size: i32) → void
|
||||
// Host calls this to free a previously allocated buffer.
|
||||
```
|
||||
|
||||
### 5.3 Execution model
|
||||
|
||||
Each WASM module instance runs in its own Wasmtime `Store`. The host calls WASM exports from a dedicated Tokio task per plugin. Incoming events are queued in an `mpsc::Sender<PluginEvent>` per plugin; the plugin task drains the queue and calls `receive_event`. This isolates plugin execution from the hot state-machine path.
|
||||
|
||||
---
|
||||
|
||||
## 6. Public API parity table
|
||||
|
||||
| HA integration pattern | HOMECORE WASM equivalent |
|
||||
|---|---|
|
||||
| `async_setup_entry(hass, entry)` Python async function | `setup_entry(config_entry_json)` WASM export |
|
||||
| `hass.states.async_set(entity_id, state, attrs)` | `hc_state_set(...)` host import |
|
||||
| `hass.states.get(entity_id)` | `hc_state_get(...)` host import |
|
||||
| `hass.bus.async_fire(event_type, data)` | `hc_event_fire(...)` host import |
|
||||
| `hass.services.async_call(domain, service, data)` | `hc_service_call(...)` host import |
|
||||
| `hass.services.async_register(domain, service, handler)` | Declared in manifest `registered_services`; `call_service_handler` WASM export handles all |
|
||||
| `async_track_state_change(hass, entity_ids, callback)` | Declared in manifest `subscribed_state_entities`; `receive_event` called with `state_changed` events |
|
||||
| Config flow `FlowManager.async_init()` | Config flow metadata in manifest; UI calls HOMECORE-API `/config/config_entries/flow` |
|
||||
| `ConfigEntry.entry_id`, `.domain`, `.data`, `.options` | Same fields in `ConfigEntry` JSON passed to `setup_entry` |
|
||||
|
||||
---
|
||||
|
||||
## 7. Phased implementation plan
|
||||
|
||||
### P1 — WASM host skeleton (2 weeks)
|
||||
|
||||
- [ ] Create `v2/crates/homecore-plugins/` workspace member.
|
||||
- [ ] Wasmtime dependency; compile a trivial WASM module that calls `hc_log` and verify it runs.
|
||||
- [ ] Define the host function ABI in a `host_api.rs` module; write the Wasmtime `Linker` registration for all 6 host functions.
|
||||
- [ ] Manifest schema: `serde`-deserialised `Manifest` struct; validate required fields.
|
||||
- [ ] Hash + Ed25519 signature verification of `.wasm` bytes before compilation.
|
||||
|
||||
### P2 — State machine bridge (2 weeks)
|
||||
|
||||
- [ ] Wire `hc_state_get` and `hc_state_set` to the `homecore` state machine (ADR-127).
|
||||
- [ ] Wire `hc_event_fire` to the event bus.
|
||||
- [ ] Wire `hc_service_call` to the service registry.
|
||||
- [ ] Wire `hc_entity_register` to the entity registry.
|
||||
- [ ] Write a test plugin in Rust compiled to WASM: registers one entity, writes its state via host imports, verifies the state machine sees the update.
|
||||
|
||||
### P3 — Config entry lifecycle + hot-load (2 weeks)
|
||||
|
||||
- [ ] `ConfigEntryManager` — tracks loaded plugins, calls `setup_entry` on new config entries, handles teardown.
|
||||
- [ ] Hot-load: watch a directory for new `.wasm` + `manifest.json` pairs; load without hub restart.
|
||||
- [ ] Wasmtime compiled module cache: serialize to `.cwasm` after first JIT compile; deserialize on subsequent loads (sub-1 ms plugin restart).
|
||||
- [ ] Integration test: MQTT plugin loaded at runtime, registers `sensor.test` entity, state readable via HOMECORE-API.
|
||||
|
||||
### P4 — Cog registry integration (1 week)
|
||||
|
||||
- [ ] Fetch plugin from Seed app registry `app-registry.json`; verify Ed25519 signature against publisher key.
|
||||
- [ ] Expose `/api/homecore/plugins` REST endpoint (HOMECORE-API ADR-130 extension): list loaded plugins, load new plugin by URL, unload plugin.
|
||||
- [ ] First-party plugin: ship an MQTT plugin WASM module that provides the same function as HA's `homeassistant/components/mqtt/`.
|
||||
|
||||
### P5 — Permission enforcement (1 week)
|
||||
|
||||
- [ ] Enforce `homecore_permissions` claims: reject `hc_state_set` calls that write to entities outside the plugin's declared `state:write:*` pattern.
|
||||
- [ ] Log all permission denials to the Ed25519 witness chain.
|
||||
- [ ] Expose permission audit via `/api/homecore/plugins/<domain>/audit`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Risks
|
||||
|
||||
| Risk | Likelihood | Severity | Mitigation | Cross-ADR impact |
|
||||
|---|---|---|---|---|
|
||||
| **ADR-127 state machine not stable** — plugin ABI calls into the state machine; if the API changes, all plugins break | High (early phase) | High | Freeze the `hc_state_get`/`hc_state_set` ABI in P1; never change pointer/length convention; version the host ABI in the manifest `min_homecore_version` | ADR-127 must freeze public API before ADR-128 P2 begins |
|
||||
| **Wasmtime binary size** — adding Wasmtime to HOMECORE adds ~15 MB to the binary on Pi 5 | Medium | Medium | Use Cranelift JIT only; skip LLVM optimizer. Alternative: `wasm3` feature flag (~50 kB) for constrained hardware | ADR-126: binary size target < 50 MB idle RAM; Wasmtime itself uses ~5 MB RAM at runtime |
|
||||
| **ABI memory overhead** — every state read/write from a plugin must JSON-encode/decode through shared memory | Medium | Medium | Cap state value size at 64 kB; use a pool allocator for ABI buffers; profile on Pi 5 at 10 state writes/s per plugin | ADR-130: REST API reads state from DashMap directly, bypassing plugin ABI — no overhead there |
|
||||
| **Community plugin trust** — WASM sandbox prevents crashes but cannot prevent malicious plugins from calling `hc_service_call` to turn off all lights | Medium | High | `homecore_permissions` permission claims (P5); future: RUVIEW-POLICY enforcement (ADR-124 §4.1a) for biometric data access | ADR-124 RUVIEW-POLICY must be made aware of HOMECORE as a policy principal |
|
||||
|
||||
---
|
||||
|
||||
## 9. Open questions
|
||||
|
||||
**Q1**: Should the WASM module ABI use JSON-over-shared-memory (current proposal) or a more compact binary encoding (MessagePack, FlatBuffers)? JSON is simpler to debug and matches HA's existing JSON-everywhere convention; MessagePack cuts ABI overhead by ~4×. Decide before P2 implementation.
|
||||
|
||||
**Q2**: HA's `config_flow.py` is a multi-step UI wizard with voluptuous schema validation. HOMECORE's config flow is described in the manifest JSON. Is a JSON-schema-based config flow sufficient for the 100 most popular integrations, or do some require imperative step logic that can't be expressed declaratively?
|
||||
|
||||
**Q3**: Should existing Python HA community integrations be automatically compilable to WASM via a transpilation layer (e.g. CPython compiled to WASM via Pyodide), or should HOMECORE accept only natively compiled WASM modules? Pyodide+WASM would make migration easier but adds ~25 MB per plugin and loses the performance argument.
|
||||
|
||||
**Q4**: The `host_imports_required` manifest field lists which host functions the plugin needs. Should this be verified at load time (reject plugin that imports undeclared functions) or only advisory? Strict enforcement prevents surprises; advisory aids migration.
|
||||
|
||||
---
|
||||
|
||||
## 10. References
|
||||
|
||||
### HA upstream
|
||||
|
||||
- `homeassistant/loader.py` — integration loader; pip requirement installation; `async_setup_entry` invocation
|
||||
- `homeassistant/config_entries.py` — `ConfigEntry`, `ConfigEntryState`, `ConfigEntriesError`, `FlowManager`
|
||||
- `homeassistant/components/mqtt/manifest.json` — canonical example of HA manifest structure
|
||||
- `homeassistant/components/mqtt/__init__.py` — `async_setup_entry` pattern for a complex integration with services
|
||||
- `homeassistant/components/mqtt/config_flow.py` — multi-step config flow example
|
||||
|
||||
### This repo
|
||||
|
||||
- `docs/adr/ADR-102-edge-module-registry.md` — cog registry architecture; `app-registry.json` schema
|
||||
- `docs/adr/ADR-100-cog-packaging-specification.md` — cog packaging spec; Ed25519 signing
|
||||
- `docs/adr/ADR-101-pose-estimation-cog.md` — cog lifecycle precedent
|
||||
- `docs/adr/ADR-127-homecore-state-machine-rust.md` — state machine ABI that plugins call
|
||||
- `docs/adr/ADR-126-ruview-native-ha-port-master.md` — §5.7 "do not port" list (legacy Python integrations)
|
||||
@@ -0,0 +1,212 @@
|
||||
# ADR-129: HOMECORE-AUTO — Automation engine, script runner, and template evaluator
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Date** | 2026-05-25 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **HOMECORE-AUTO** |
|
||||
| **Relates to** | [ADR-126](ADR-126-ruview-native-ha-port-master.md) (HOMECORE master), [ADR-127](ADR-127-homecore-state-machine-rust.md) (HOMECORE-CORE), [ADR-129 implicit](ADR-129-homecore-automation-engine.md), [ADR-133](ADR-133-homecore-assist-ruflo.md) (HOMECORE-ASSIST) |
|
||||
| **Tracking issue** | TBD |
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
Home Assistant's automation system is defined across three components:
|
||||
|
||||
1. **`homeassistant/components/automation/__init__.py`** — the automation manager: loads automation YAML, evaluates trigger platforms, calls the script executor when conditions pass. The core class is `AutomationEntity` which extends `ToggleEntity`. Automations are themselves HA entities with `state = on/off`.
|
||||
|
||||
2. **`homeassistant/components/script/__init__.py`** — the script executor: a sequence of actions (service calls, conditions, delays, events, template variables, `choose`, `parallel`, `repeat`, `wait_for_trigger`). Scripts are entities too (`ScriptEntity` extends `ToggleEntity`). The execution engine supports five run modes: `single`, `restart`, `queued`, `parallel`, `ignore_first`.
|
||||
|
||||
3. **`homeassistant/helpers/template.py`** — HA's Jinja2 customisation layer: wraps the upstream `jinja2` Python library with HA-specific globals (`states()`, `is_state()`, `state_attr()`, `now()`, `utcnow()`, `as_timestamp()`, `distance()`, `closest()`, etc.), custom filters (`regex_match`, `round`, `timestamp_local`), and a sandboxed `Environment` that prevents file I/O and dangerous evaluations.
|
||||
|
||||
### 1.1 Scale and surface
|
||||
|
||||
HA's automation YAML supports:
|
||||
- **17 trigger platforms** (state, time, numeric_state, template, event, homeassistant, zone, geo_location, device, calendar, conversation, mqtt, webhook, tag, sun, time_pattern, persistent_notification)
|
||||
- **7 condition types** (state, numeric_state, time, template, zone, sun, device)
|
||||
- **22+ action types** (call_service, delay, wait_template, fire_event, device_action, choose, if, parallel, repeat, sequence, stop, set_conversation_response, ...)
|
||||
|
||||
The YAML schema is validated by `voluptuous` schemas defined in `homeassistant/helpers/config_validation.py` (~5,000 lines).
|
||||
|
||||
### 1.2 Jinja2 is the critical surface
|
||||
|
||||
HA templates are used not only in automations but in dashboard cards, notification messages, and script variables. The HA frontend sends template strings to the API's `POST /api/template` endpoint for server-side evaluation. Any HOMECORE instance that claims API compatibility must execute Jinja2-compatible templates or existing automations will break.
|
||||
|
||||
Full Jinja2 support in Rust without Python is non-trivial. The approach chosen here uses a **WASM-compiled MiniJinja** (the `minijinja` Rust crate compiled with HA-specific extension functions) rather than a full Python Jinja2 re-implementation.
|
||||
|
||||
---
|
||||
|
||||
## 2. Decision
|
||||
|
||||
Build the `homecore-automation` crate with three components:
|
||||
|
||||
1. **YAML parser**: `serde_yaml` + custom validator that parses HA's automation and script YAML into typed Rust structs. Validates trigger, condition, and action schemas at load time.
|
||||
2. **Trigger evaluator**: a Tokio task per loaded automation that subscribes to the HOMECORE event bus (ADR-127) and evaluates trigger conditions in Rust. When a trigger fires and conditions pass, it enqueues the automation action sequence.
|
||||
3. **Action executor**: a script runner that processes action sequences. Service calls go to the HOMECORE service registry. Delays use `tokio::time::sleep`. Template evaluation uses MiniJinja. Complex conditions (optional) can route to a ruflo agent (ADR-133).
|
||||
|
||||
### 2.1 Template evaluator: MiniJinja + HA-compatible extension functions
|
||||
|
||||
`minijinja` (crates.io version 2.x) is a production-quality Jinja2 implementation in pure Rust. It is missing 5–10% of Jinja2's surface area (notably: `{% block %}` / `{% extends %}` template inheritance, and some Jinja2 Python-specific filters), but covers 100% of HA's automation template usage.
|
||||
|
||||
HA-specific globals added on top of MiniJinja:
|
||||
|
||||
```rust
|
||||
env.add_global("states", minijinja::Value::from_function(ha_states_global));
|
||||
env.add_global("is_state", minijinja::Value::from_function(ha_is_state_global));
|
||||
env.add_global("state_attr", minijinja::Value::from_function(ha_state_attr_global));
|
||||
env.add_global("now", minijinja::Value::from_function(ha_now_global));
|
||||
env.add_global("utcnow", minijinja::Value::from_function(ha_utcnow_global));
|
||||
env.add_global("as_timestamp", minijinja::Value::from_function(ha_as_timestamp_global));
|
||||
env.add_global("distance", minijinja::Value::from_function(ha_distance_global));
|
||||
env.add_global("iif", minijinja::Value::from_function(ha_iif_global));
|
||||
```
|
||||
|
||||
Each global function reads from the HOMECORE state machine (ADR-127) via an `Arc<StateMachine>` captured at environment construction time. Template evaluation is synchronous (MiniJinja is sync) but runs in a `tokio::task::spawn_blocking` wrapper to avoid blocking the async executor.
|
||||
|
||||
### 2.2 WASM evaluator for untrusted template strings
|
||||
|
||||
Dashboard card templates submitted via `POST /api/template` come from user-authored YAML, not first-party code. HA evaluates these in the same Python process, relying on Jinja2's `SandboxedEnvironment` for safety. HOMECORE uses a **WASM-sandboxed MiniJinja** evaluator:
|
||||
|
||||
- A single WASM module (`homecore-template-eval.wasm`) is compiled from the MiniJinja crate with the HA extension globals stubbed to call host functions.
|
||||
- Template strings are passed into the WASM module via the HOMECORE plugin ABI (ADR-128 §5.1).
|
||||
- The WASM sandbox prevents file I/O, network access, and infinite loops (via Wasmtime fuel metering — 100,000 instructions per template evaluation).
|
||||
- Result is returned as a string to the HOMECORE API.
|
||||
|
||||
This is the same Wasmtime host already used for integration plugins (ADR-128) — no additional WASM runtime dependency.
|
||||
|
||||
---
|
||||
|
||||
## 3. HA-side reference table
|
||||
|
||||
| HA module / file | What it does | HOMECORE preserves | Changes | Drops |
|
||||
|---|---|---|---|---|
|
||||
| `automation/__init__.py` `AutomationEntity` | Automation as a toggle entity (on/off) with triggers/conditions/actions | Automation is a HOMECORE entity with same on/off state semantics | Rust struct `AutomationEntity` implementing `HomeCoreEntity` trait | Python class hierarchy, voluptuous schema |
|
||||
| `automation/__init__.py` `TriggerActionConfig` | Trigger → condition → action pipeline | Full trigger/condition/action pipeline | Typed Rust enums per trigger platform | Python dict-based config |
|
||||
| `automation/trigger.py` | Delegates to per-platform trigger modules (`homeassistant/components/<platform>/trigger.py`) | Same per-platform dispatch | Rust match arm per trigger type | Python dynamic module import |
|
||||
| `script/__init__.py` `Script` | Script entity + action sequence executor | Same 22 action types | Rust enum `Action` with all variants | Python asyncio coroutines |
|
||||
| `script/__init__.py` run modes | `single`, `restart`, `queued`, `parallel`, `ignore_first` | All 5 run modes | Tokio-based concurrency control (semaphore for `queued`, `parallel`) | Python asyncio task management |
|
||||
| `helpers/template.py` `Template` | Jinja2 evaluation + HA globals | Same HA global function names and signatures | MiniJinja instead of Python Jinja2; WASM sandbox for user templates | Python `jinja2` library; `voluptuous` coercions in templates |
|
||||
| `helpers/config_validation.py` | `cv.template`, `cv.entity_id`, time period validators | Same validation semantics | Rust custom deserializers implementing `serde::Deserialize` | voluptuous; Python regex |
|
||||
| `components/automation/blueprint.py` | Blueprint system (reusable automation templates with input variables) | Blueprint YAML schema + variable substitution | Pure Rust YAML substitution | Python Blueprint class hierarchy |
|
||||
|
||||
---
|
||||
|
||||
## 4. Public API parity table
|
||||
|
||||
| HA automation surface | HOMECORE equivalent |
|
||||
|---|---|
|
||||
| `automation.trigger` (state, time, numeric_state, template, event, ...) | `Trigger` enum with variants for all 17 HA trigger platforms |
|
||||
| `automation.condition` (state, numeric_state, time, template, zone, sun, device) | `Condition` enum with variants for all 7 condition types |
|
||||
| `automation.action` — call_service, delay, fire_event, choose, if, parallel, repeat, wait_template, stop | `Action` enum with variants for all 22 action types |
|
||||
| `script.run_mode` — single, restart, queued, parallel | `RunMode` enum with 5 variants |
|
||||
| `POST /api/template` (REST eval of a template string) | Same endpoint in HOMECORE-API (ADR-130); backed by WASM-sandboxed MiniJinja |
|
||||
| Automation entity: `state = on|off`, `attributes.last_triggered`, `attributes.id` | `AutomationEntity` struct with same attribute names |
|
||||
| `automation.trigger` service (manually trigger an automation) | `homecore.automation.trigger` service; same service call data schema |
|
||||
| `automation.reload` service (reload automations.yaml) | `homecore.automation.reload` service |
|
||||
| `automation.toggle` service | Standard `HomeCoreEntity` toggle service |
|
||||
| Blueprint YAML with `blueprint:` key and `input:` variables | Blueprint parsed by HOMECORE YAML parser; same substitution semantics |
|
||||
|
||||
---
|
||||
|
||||
## 5. Trigger platform mapping
|
||||
|
||||
| HA trigger platform | HOMECORE implementation |
|
||||
|---|---|
|
||||
| `state` | Subscribe to `state_changed` broadcast; match `entity_id`, `from`, `to`, `for` |
|
||||
| `numeric_state` | Subscribe to `state_changed`; parse state as f64; compare against `above`/`below` |
|
||||
| `time` | `tokio::time::sleep_until` to next occurrence; re-arm after fire |
|
||||
| `time_pattern` | Cron-style evaluation using `cron` crate; tokio timer task |
|
||||
| `template` | Re-evaluate template on every `state_changed`; fire when template transitions from false to true |
|
||||
| `event` | Subscribe to named domain event on event bus |
|
||||
| `homeassistant` (start/stop) | Subscribe to `HomeAssistantStart` / `HomeAssistantStop` typed events |
|
||||
| `zone` | Subscribe to `zone.entered` / `zone.left` events from the device tracker integration |
|
||||
| `mqtt` | Subscribe to MQTT topic via the MQTT plugin (ADR-128); fire event when message arrives |
|
||||
| `webhook` | HOMECORE-API registers a webhook path; fires event on POST |
|
||||
| `calendar` | Subscribe to calendar event from calendar integration |
|
||||
| `conversation` | Subscribe to `conversation.user_input` event; match intent/sentence |
|
||||
| `geo_location` | Subscribe to `geo_location.entered` / `geo_location.left` |
|
||||
| `sun` | Compute sunrise/sunset from latitude/longitude in `homecore.config`; tokio timer |
|
||||
| `device` | Delegate to integration-specific device trigger via WASM plugin |
|
||||
| `persistent_notification` | Subscribe to `persistent_notification.create` event |
|
||||
| `tag` | Subscribe to `tag.scanned` event from NFC/QR integration |
|
||||
|
||||
---
|
||||
|
||||
## 6. Phased implementation plan
|
||||
|
||||
### P1 — YAML parser (2 weeks)
|
||||
|
||||
- [ ] Define Rust enums for `Trigger`, `Condition`, `Action`, `RunMode` with `serde` deserialization.
|
||||
- [ ] Parse an existing `automations.yaml` from a real HA install with zero errors (test fixture).
|
||||
- [ ] Validator: reject unknown trigger platforms with a clear error message.
|
||||
- [ ] Unit tests: parse 50 automation fixtures covering all 17 trigger types and 22 action types.
|
||||
|
||||
### P2 — State and event triggers (2 weeks)
|
||||
|
||||
- [ ] Implement `state`, `numeric_state`, `event`, `homeassistant`, `time`, `time_pattern` trigger evaluators.
|
||||
- [ ] `ConditionEvaluator` for `state`, `numeric_state`, `time` conditions.
|
||||
- [ ] `ActionExecutor` for `call_service`, `delay`, `fire_event`, `stop` action types.
|
||||
- [ ] Integration test: load one automation (state trigger → call_service action); verify fires correctly when state changes.
|
||||
|
||||
### P3 — Full action set + MiniJinja (3 weeks)
|
||||
|
||||
- [ ] MiniJinja + HA extension globals; `POST /api/template` endpoint wired to WASM evaluator.
|
||||
- [ ] `template` trigger + `template` condition evaluators.
|
||||
- [ ] `choose`, `if`, `parallel`, `repeat`, `wait_template`, `sequence` action types.
|
||||
- [ ] All 5 `RunMode` variants (concurrency control via Tokio semaphore/mutex).
|
||||
- [ ] Integration test: `automations.yaml` from ADR-134 migration fixture loads and runs correctly.
|
||||
|
||||
### P4 — Blueprint system + ruflo agent condition (1 week)
|
||||
|
||||
- [ ] Blueprint YAML parser + input variable substitution.
|
||||
- [ ] Optional ruflo agent condition: `condition: ruflo_agent` with `query: "..."` routes to ruflo LLM (ADR-133 §3.3); gated by RUVIEW-POLICY.
|
||||
- [ ] `automation.reload` service.
|
||||
- [ ] Performance benchmark: 100 automations loaded; 100 state changes/s; verify trigger evaluation stays < 5 ms per state change.
|
||||
|
||||
---
|
||||
|
||||
## 7. Risks
|
||||
|
||||
| Risk | Likelihood | Severity | Mitigation | Cross-ADR impact |
|
||||
|---|---|---|---|---|
|
||||
| **MiniJinja gaps** — some HA templates use Jinja2 features MiniJinja doesn't support (template inheritance, Python-specific filters) | Medium | Medium | Document the MiniJinja-vs-Jinja2 delta before P3 ships; provide a migration guide for affected templates; defer the 5% of templates that fail to a Python-compat shim (ADR-134) | ADR-134: migration tool must warn on templates that use unsupported Jinja2 features |
|
||||
| **Template performance** — synchronous MiniJinja in `spawn_blocking` adds overhead under high automation fan-out | Low | Low | Benchmark at 50 automations each evaluating a template trigger on every state_changed (worst case); if > 2 ms add a template-evaluation cache keyed by (template_hash, relevant_entity_states) | ADR-127: state machine must expose a "relevant states snapshot" API for caching |
|
||||
| **ADR-127 state machine API not frozen** — trigger evaluators call `hass.states.all()` and subscribe to broadcasts; if those APIs change, trigger code must update | High (early) | High | ADR-127 must freeze its public API before ADR-129 P2 begins; use a `HomeCoreRef` trait (version 1.0 stable) | ADR-127 owns this dependency |
|
||||
| **Complex action YAML** — real-world automations use deeply nested `choose`/`if`/`parallel` blocks; parsing is non-trivial | Medium | Medium | Use a corpus of 500 public HA automations from the HA community (MIT-licensed) as parse-test fixtures in CI | None |
|
||||
|
||||
---
|
||||
|
||||
## 8. Open questions
|
||||
|
||||
**Q1**: MiniJinja does not support all Python-specific Jinja2 filters (e.g. `map`, `select`, `reject` with Python lambda arguments). HA's `homeassistant/helpers/template.py` adds custom equivalents of several of these. How many real-world HA automations use these filters? A corpus analysis of public HA configs on GitHub would answer this before P3 implementation.
|
||||
|
||||
**Q2**: HA's `template` trigger supports a `value_template` that can reference `trigger.to_state`, `trigger.from_state`, and `trigger.for`. This requires passing trigger context into the template evaluation scope. Is this context threading straightforward in MiniJinja, or does it require a custom context type?
|
||||
|
||||
**Q3**: The `conversation` trigger in HA uses the Assist pipeline's intent matching to fire automations based on voice commands. HOMECORE-ASSIST (ADR-133) owns the pipeline. Should the `conversation` trigger be implemented in ADR-129 (automation engine dependency on ADR-133) or in ADR-133 (assist pipeline fires automation events that ADR-129 listens to)?
|
||||
|
||||
**Q4**: HA blueprints have a community sharing mechanism (blueprint.exchange). Should HOMECORE support importing blueprints from HA's blueprint exchange directly, or only local blueprints?
|
||||
|
||||
---
|
||||
|
||||
## 9. References
|
||||
|
||||
### HA upstream
|
||||
|
||||
- `homeassistant/components/automation/__init__.py` — `AutomationEntity`, `AutomationConfig`, trigger/condition/action pipeline
|
||||
- `homeassistant/components/script/__init__.py` — `Script`, `ScriptEntity`, run modes, action sequence execution
|
||||
- `homeassistant/helpers/template.py` — `Template` class, `TemplateEnvironment`, all HA-specific Jinja2 globals and filters
|
||||
- `homeassistant/helpers/config_validation.py` — voluptuous schema definitions for all automation/script YAML elements
|
||||
- `homeassistant/components/automation/blueprint.py` — Blueprint input substitution
|
||||
|
||||
### This repo
|
||||
|
||||
- `docs/adr/ADR-127-homecore-state-machine-rust.md` — state machine and event bus that triggers subscribe to
|
||||
- `docs/adr/ADR-133-homecore-assist-ruflo.md` — ruflo agent condition + conversation trigger dependency
|
||||
- `docs/adr/ADR-134-homecore-migration-from-python-ha.md` — migration tool reads `automations.yaml`
|
||||
|
||||
### External
|
||||
|
||||
- [minijinja crates.io](https://crates.io/crates/minijinja) — Jinja2-compatible template engine in Rust
|
||||
- [HA Automation Templating docs](https://www.home-assistant.io/docs/automation/templating/) — HA-specific template globals reference
|
||||
@@ -0,0 +1,218 @@
|
||||
# ADR-130: HOMECORE-API — Wire-compatible REST and WebSocket API
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Date** | 2026-05-25 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **HOMECORE-API** |
|
||||
| **Relates to** | [ADR-126](ADR-126-ruview-native-ha-port-master.md) (HOMECORE master), [ADR-127](ADR-127-homecore-state-machine-rust.md) (HOMECORE-CORE), [ADR-055](ADR-055-integrated-sensing-server.md) (sensing-server Axum pattern), [ADR-124](ADR-124-rvagent-mcp-ruvector-npm-integration.md) (SENSE-BRIDGE — bearer auth pattern) |
|
||||
| **Tracking issue** | TBD |
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
Home Assistant's HTTP and WebSocket APIs are the primary interface for every non-frontend client: the iOS companion app, the Android companion app, HACS, Node-RED, the `homeassistant` Python client library, ESPHome native API clients, external automation scripts, and the hundreds of third-party HA dashboard projects.
|
||||
|
||||
The API surface is defined in two Python modules:
|
||||
|
||||
1. **`homeassistant/components/api/__init__.py`** — 24 REST API routes mounted at `/api/`. Key routes: `GET /api/`, `GET /api/states`, `GET /api/states/<entity_id>`, `POST /api/states/<entity_id>`, `GET /api/events`, `POST /api/events/<event_type>`, `GET /api/services`, `POST /api/services/<domain>/<service>`, `GET /api/error_log`, `GET /api/config`, `POST /api/template`, `POST /api/check_config`, `GET /api/history/period/<datetime>` (deprecated — recorder), `POST /api/logbook/` (deprecated — recorder).
|
||||
|
||||
2. **`homeassistant/components/websocket_api/`** — the WebSocket API handler (`connection.py` handles auth handshake; `commands.py` handles 30+ command types). Key commands: `auth`, `subscribe_events`, `unsubscribe_events`, `call_service`, `get_states`, `get_services`, `get_config`, `subscribe_trigger`, `render_template`, `validate_config`, `subscribe_entities` (entity registry updates), `config/entity_registry/list`, and many more.
|
||||
|
||||
### 1.1 Auth model
|
||||
|
||||
HA uses **long-lived access tokens (LLAT)** as the primary auth mechanism for non-UI clients. Tokens are created in the HA user profile UI and stored in `.storage/auth`. The REST API accepts `Authorization: Bearer <token>` or the `api_password` legacy header (deprecated since HA 2022.x). The WebSocket API requires an `auth` message with `access_token` as the first message after connection.
|
||||
|
||||
### 1.2 Why wire-compat matters
|
||||
|
||||
The iOS and Android HA companion apps (>100,000 installs combined) hardcode the HA API paths and WebSocket command schemas. Any implementation that deviates from the exact JSON schemas causes the apps to fail silently — not with a meaningful error, but by returning empty entity lists or missing state updates. Wire-compat is therefore a hard requirement, not a nice-to-have.
|
||||
|
||||
The baseline for compatibility is **HA 2025.1** (the version that introduced SQLite recorder schema version 48). Any HOMECORE instance claiming compliance with this ADR must pass the companion app integration test suite.
|
||||
|
||||
---
|
||||
|
||||
## 2. Decision
|
||||
|
||||
Implement the `homecore-api` crate as an Axum-based server that replicates the HA REST and WebSocket API on port 8123. The implementation is informed by — but does not copy — `homeassistant/components/api/__init__.py` and `homeassistant/components/websocket_api/`.
|
||||
|
||||
The server reuses the Axum + Tokio architecture established in `v2/crates/wifi-densepose-sensing-server/src/main.rs` and its bearer auth pattern (`v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs`).
|
||||
|
||||
### 2.1 REST API route table
|
||||
|
||||
| Route | Method | HA source line (approx.) | HOMECORE status |
|
||||
|---|---|---|---|
|
||||
| `/api/` | GET | `api/__init__.py:74` | P2 — returns `{ "message": "API running." }` |
|
||||
| `/api/config` | GET | `api/__init__.py:97` | P2 — returns `homecore.config` as JSON |
|
||||
| `/api/states` | GET | `api/__init__.py:116` | P2 — returns `hass.states.all()` as JSON array |
|
||||
| `/api/states/<entity_id>` | GET | `api/__init__.py:130` | P2 |
|
||||
| `/api/states/<entity_id>` | POST | `api/__init__.py:145` | P2 — writes state; fires `state_changed` |
|
||||
| `/api/events` | GET | `api/__init__.py:168` | P3 |
|
||||
| `/api/events/<event_type>` | POST | `api/__init__.py:180` | P3 — fires domain event |
|
||||
| `/api/services` | GET | `api/__init__.py:192` | P2 |
|
||||
| `/api/services/<domain>/<service>` | POST | `api/__init__.py:206` | P2 |
|
||||
| `/api/template` | POST | `api/__init__.py:222` | P3 — WASM MiniJinja evaluator (ADR-129) |
|
||||
| `/api/check_config` | POST | `api/__init__.py:240` | P4 |
|
||||
| `/api/error_log` | GET | `api/__init__.py:252` | P3 |
|
||||
| `/api/history/period/<datetime>` | GET | `api/__init__.py:270` | P4 — recorder query (ADR-132) |
|
||||
| `/api/logbook/` | POST | `api/__init__.py:310` | P4 — recorder query |
|
||||
| `/api/camera_proxy/<entity_id>` | GET | `api/__init__.py:330` | P4 — proxy to camera integration |
|
||||
| `/api/calendar/<entity_id>` | GET | `api/__init__.py:348` | P4 |
|
||||
| `/api/webhook/<webhook_id>` | POST/GET | `api/__init__.py:368` | P3 — fires `webhook.<id>` event |
|
||||
| `/api/intent/handle` | POST | `api/__init__.py:400` | P4 — HOMECORE-ASSIST (ADR-133) |
|
||||
| `/auth/token` | POST | `auth/providers/__init__.py` | P2 — issue LLAT from username/password |
|
||||
| `/auth/authorize` | GET/POST | `auth/providers/__init__.py` | P3 — OAuth2 flow |
|
||||
| `/frontend/` static assets | GET | `frontend/__init__.py` | P1 — serve HA Python frontend static files until ADR-131 ships |
|
||||
|
||||
### 2.2 WebSocket API command table
|
||||
|
||||
| WS command type | HA source | HOMECORE status |
|
||||
|---|---|---|
|
||||
| `auth` (handshake) | `websocket_api/connection.py:55` | P2 |
|
||||
| `subscribe_events` | `websocket_api/commands.py:120` | P2 |
|
||||
| `unsubscribe_events` | `websocket_api/commands.py:145` | P2 |
|
||||
| `call_service` | `websocket_api/commands.py:160` | P2 |
|
||||
| `get_states` | `websocket_api/commands.py:200` | P2 |
|
||||
| `get_services` | `websocket_api/commands.py:218` | P2 |
|
||||
| `get_config` | `websocket_api/commands.py:230` | P2 |
|
||||
| `subscribe_trigger` | `websocket_api/commands.py:250` | P3 |
|
||||
| `render_template` | `websocket_api/commands.py:280` | P3 |
|
||||
| `validate_config` | `websocket_api/commands.py:300` | P3 |
|
||||
| `subscribe_entities` | `websocket_api/commands.py:320` | P3 — entity registry update stream |
|
||||
| `config/entity_registry/list` | `websocket_api/commands.py:370` | P3 |
|
||||
| `config/entity_registry/update` | `websocket_api/commands.py:400` | P3 |
|
||||
| `config/area_registry/list` | `websocket_api/commands.py:450` | P3 |
|
||||
| `config/device_registry/list` | `websocket_api/commands.py:480` | P3 |
|
||||
| `config/config_entries/list` | `websocket_api/commands.py:510` | P3 |
|
||||
| `lovelace/config` (dashboard) | `lovelace/dashboard.py` | P4 — reads from HOMECORE storage |
|
||||
| `media_player/*` | `websocket_api/commands.py:600` | P4 |
|
||||
|
||||
### 2.3 Auth implementation
|
||||
|
||||
HOMECORE-API implements long-lived access tokens as JWTs signed with an Ed25519 key (generated at first startup, stored in `.homecore/auth_key.pem`). Token format:
|
||||
|
||||
```json
|
||||
{
|
||||
"sub": "<user_id>",
|
||||
"iss": "homecore",
|
||||
"iat": <unix_timestamp>,
|
||||
"exp": <unix_timestamp or null for LLAT>,
|
||||
"type": "long_lived_access_token"
|
||||
}
|
||||
```
|
||||
|
||||
The HA companion app sends `Authorization: Bearer <token>` on every REST request. The WebSocket auth handshake sends `{ "type": "auth", "access_token": "<token>" }`. Both paths validate the JWT against the stored Ed25519 key.
|
||||
|
||||
Legacy `api_password` is deliberately not supported (removed in HA 2022.x and never properly secure).
|
||||
|
||||
---
|
||||
|
||||
## 3. HA-side reference table
|
||||
|
||||
| HA module / file | What it does | HOMECORE preserves | Changes | Drops |
|
||||
|---|---|---|---|---|
|
||||
| `components/api/__init__.py` | 24 REST routes + JSON response schemas | All response schemas byte-compatible with HA 2025.1 | Axum router instead of HA's custom HTTP component; `serde_json` instead of Python `json` | Python HTTP request context; HA's built-in CORS middleware (replicated in Axum) |
|
||||
| `components/websocket_api/connection.py` | WS auth handshake; per-connection state; message dispatch | Auth handshake flow: `auth_required` → `auth` message → `auth_ok` or `auth_invalid` | Axum `WebSocketUpgrade` extractor; per-connection `tokio::task` | Python asyncio message handling |
|
||||
| `components/websocket_api/commands.py` | 30+ WS command handlers | All command type strings; response envelope `{ id, type, result }` or error `{ id, type, error: { code, message } }` | Rust match dispatch; Tokio broadcast receiver per subscription | Python class-based command handler registration |
|
||||
| `auth/providers/__init__.py` | Auth providers; LLAT issuance; OAuth2 flow | LLAT issuance; token validation | Ed25519 JWT instead of HA's custom token serializer; same token `type` field values | Nabu Casa cloud auth; multi-provider auth chain |
|
||||
| `components/http/__init__.py` | Aiohttp-based HTTP server setup; CORS; trusted proxies | CORS headers; `X-Forwarded-For` trusted proxy handling | Axum Tower middleware | Aiohttp; Python SSL context |
|
||||
|
||||
---
|
||||
|
||||
## 4. Public API parity table
|
||||
|
||||
| HA API surface | HOMECORE exact equivalent |
|
||||
|---|---|
|
||||
| `GET /api/states` → `[{entity_id, state, attributes, last_changed, last_updated, context}]` | Identical JSON schema; `last_changed` / `last_updated` in ISO 8601 |
|
||||
| `GET /api/services` → `{domain: {service: {description, fields}}}` | Identical schema; service descriptions read from plugin manifests |
|
||||
| WS `subscribe_events` → `{type: "event", event: {event_type, data, origin, time_fired, context}}` | Identical envelope; `time_fired` in ISO 8601 |
|
||||
| WS `call_service` → `{type: "result", success: true, result: {context}}` | Identical; `context.id` is a UUID |
|
||||
| WS `get_states` → `{type: "result", result: [{entity_id, state, attributes, ...}]}` | Identical schema |
|
||||
| REST `POST /api/services/<domain>/<service>` → 200 with called service list | Identical; same `target` field support |
|
||||
| REST `POST /api/template` → 200 with evaluated string | Identical; same error response `{message: "..."}` on template error |
|
||||
| Auth WS flow: `auth_required` → `auth` → `auth_ok` | Identical message type strings; same `ha_version` field in `auth_required` |
|
||||
| REST `Authorization: Bearer <token>` | Identical header name; JWT instead of HA's opaque token format (transparent to clients) |
|
||||
|
||||
---
|
||||
|
||||
## 5. Phased implementation plan
|
||||
|
||||
### P1 — Axum skeleton + static frontend (1 week)
|
||||
|
||||
- [ ] Create `v2/crates/homecore-api/` workspace member.
|
||||
- [ ] Axum router on port 8123; Tower CORS middleware (allow `http://homeassistant.local:8123`).
|
||||
- [ ] Static file handler: serve HA's Python frontend build from a configurable path (default `./frontend/build/`). This allows using the Python HA frontend as-is until ADR-131 ships.
|
||||
- [ ] `GET /api/` returns `{ "message": "API running." }`.
|
||||
- [ ] CI: `cargo check -p homecore-api`; HTTP smoke test.
|
||||
|
||||
### P2 — Core REST + WebSocket auth + states (3 weeks)
|
||||
|
||||
- [ ] Axum WebSocket upgrade at `/api/websocket`.
|
||||
- [ ] Auth: Ed25519 JWT issuance at `/auth/token`; validation middleware.
|
||||
- [ ] WS auth handshake: `auth_required` → `auth` → `auth_ok` / `auth_invalid`.
|
||||
- [ ] WS commands: `get_states`, `subscribe_events`, `unsubscribe_events`, `call_service`, `get_services`, `get_config`.
|
||||
- [ ] REST: `/api/states`, `/api/states/<entity_id>` (GET + POST), `/api/services`, `/api/services/<domain>/<service>`, `/api/config`.
|
||||
- [ ] Integration test: HA iOS companion app authenticates and displays entity list against HOMECORE.
|
||||
|
||||
### P3 — Remaining WS commands + entity registry API (3 weeks)
|
||||
|
||||
- [ ] WS: `subscribe_trigger`, `render_template`, `validate_config`, `subscribe_entities`, entity/area/device registry commands.
|
||||
- [ ] REST: `/api/template`, `/api/webhook/<id>`, `/api/error_log`, `/api/events`, `/api/events/<type>`.
|
||||
- [ ] `/auth/authorize` OAuth2 flow for UI login.
|
||||
- [ ] HACS smoke test: HACS connects, lists integrations.
|
||||
|
||||
### P4 — Recorder + history API (2 weeks)
|
||||
|
||||
- [ ] `/api/history/period/<datetime>` backed by ADR-132 recorder SQLite.
|
||||
- [ ] `/api/logbook/` backed by ADR-132 recorder.
|
||||
- [ ] `/api/camera_proxy/`, `/api/calendar/`, `/api/intent/handle`.
|
||||
- [ ] Companion app full feature test: automations, notifications, history charts.
|
||||
|
||||
---
|
||||
|
||||
## 6. Risks
|
||||
|
||||
| Risk | Likelihood | Severity | Mitigation | Cross-ADR impact |
|
||||
|---|---|---|---|---|
|
||||
| **JSON schema drift** — HA updates a response field name between 2025.1 and HOMECORE release | Medium | High | Maintain a JSON-schema test fixture set generated from HA 2025.1; run against HOMECORE in CI | ADR-134: migration tool depends on the same JSON schemas; must stay in sync |
|
||||
| **WS subscription fan-out** — 50 concurrent HA companion app sessions each subscribed to `subscribe_events` ALL; every state change creates 50 serialization tasks | Medium | Medium | Broadcast serialized JSON once; clone the `Bytes` arc to each subscriber sender; do not re-serialize per subscriber | ADR-127: broadcast channel capacity must handle subscriber fan-out without lagging |
|
||||
| **Auth token format** — HA companion apps may validate the token format (JWT vs opaque). HOMECORE uses JWT; HA uses a custom opaque token. Tokens are never decoded client-side in standard clients, but non-standard clients may inspect them | Low | Low | JWTs are base64url-encoded JSON; any client checking `token.startsWith("ey")` will see a JWT. HA's own tokens are also base64url but not JWTs. Document the difference; test with the iOS app specifically | None |
|
||||
| **Port 8123 conflict** — HOMECORE runs on the same port as HA; side-by-side mode (ADR-134) requires HOMECORE on a different port until cutover | High | Medium | ADR-134 side-by-side mode runs HOMECORE on port 8124; companion app can be pointed at port 8124 for testing | ADR-134 owns the cutover mechanism |
|
||||
|
||||
---
|
||||
|
||||
## 7. Open questions
|
||||
|
||||
**Q1**: The HA WebSocket API uses incremental integer IDs (`id: 1, 2, 3, ...`) for command/response correlation within a session. HOMECORE uses the same scheme. What is the maximum `id` value the companion app supports before wrapping? If the app doesn't wrap and HOMECORE processes > 2^31 commands per session, this becomes an overflow issue in extremely long-lived sessions.
|
||||
|
||||
**Q2**: The `subscribe_entities` WS command (added in HA 2021.x) sends entity registry change events in addition to state change events. The iOS companion app uses this to maintain a local entity list without polling. Is the full `subscribe_entities` delta schema (including `action: "create" | "update" | "remove"`) fully documented, or must it be reverse-engineered from the companion app source?
|
||||
|
||||
**Q3**: HA's `/auth/token` endpoint accepts `grant_type=password` (username/password) and `grant_type=refresh_token`. HOMECORE's initial implementation supports password grant only. Is refresh token support required for the companion app (it caches tokens between sessions) or does the companion app re-authenticate on each launch?
|
||||
|
||||
**Q4**: CORS policy: HA's default CORS allows `http://localhost:*` and `http://homeassistant.local:*`. The HOMECORE-UI frontend (ADR-131) will be served from a different origin in development. What CORS policy should HOMECORE-API use in production vs development mode?
|
||||
|
||||
---
|
||||
|
||||
## 8. References
|
||||
|
||||
### HA upstream
|
||||
|
||||
- `homeassistant/components/api/__init__.py` — 24 REST routes with exact URL paths, methods, and JSON response schemas
|
||||
- `homeassistant/components/websocket_api/connection.py` — auth handshake protocol; per-connection state management
|
||||
- `homeassistant/components/websocket_api/commands.py` — 30+ command type handlers with exact type strings and result schemas
|
||||
- `homeassistant/components/http/__init__.py` — CORS setup; trusted proxy handling; aiohttp-based server
|
||||
- `homeassistant/auth/providers/__init__.py` — token issuance; `AuthManager`; LLAT format
|
||||
- `homeassistant/auth/__init__.py` — `AuthManager.async_create_long_lived_access_token`
|
||||
|
||||
### This repo
|
||||
|
||||
- `v2/crates/wifi-densepose-sensing-server/src/main.rs` — Axum server architecture (REST + WebSocket); pattern for this ADR
|
||||
- `v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs` — Bearer auth middleware pattern
|
||||
- `docs/adr/ADR-127-homecore-state-machine-rust.md` — state machine that REST/WS routes read from
|
||||
- `docs/adr/ADR-126-ruview-native-ha-port-master.md` — §6 compatibility contract with companion apps
|
||||
|
||||
### External
|
||||
|
||||
- [HA WebSocket API Developer Docs](https://developers.home-assistant.io/docs/api/websocket/) — authoritative command type catalog
|
||||
- [HA REST API](https://developers.home-assistant.io/docs/api/rest/) — REST endpoint schemas
|
||||
@@ -0,0 +1,176 @@
|
||||
# ADR-133: HOMECORE-ASSIST — Voice/Intent Pipeline + Ruflo Agent Bridge
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Date** | 2026-05-25 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **HOMECORE-ASSIST** |
|
||||
| **Relates to** | [ADR-126](ADR-126-ruview-native-ha-port-master.md) (HOMECORE master), [ADR-127](ADR-127-homecore-state-machine-rust.md) (HOMECORE-CORE), [ADR-130](ADR-130-homecore-rest-websocket-api.md) (HOMECORE-API), [ADR-124](ADR-124-rvagent-mcp-ruvector-npm-integration.md) (SENSE-BRIDGE) |
|
||||
| **Tracking issue** | TBD |
|
||||
| **Crate** | `v2/crates/homecore-assist` |
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
Home Assistant's Assist pipeline (`homeassistant/components/assist_pipeline/`) provides
|
||||
voice-to-intent-to-response processing. It chains:
|
||||
|
||||
1. **STT** (speech-to-text) — Whisper, cloud, or satellite
|
||||
2. **NLU** (natural language understanding) — intent recognition via regex/slots
|
||||
3. **Intent handler** — maps intent to a HA service call
|
||||
4. **TTS** (text-to-speech) — synthesises the response for the caller
|
||||
|
||||
HA's intent model (`homeassistant/helpers/intent.py`) is keyword/regex based. Every
|
||||
intent is a named template with slot definitions and a handler that dispatches to HA
|
||||
services. The built-in intents (`homeassistant/components/conversation/default_agent.py`)
|
||||
cover `HassTurnOn`, `HassTurnOff`, `HassLightSet`, `HassNevermind`, `HassCancelAll`,
|
||||
`HassGetState`, `HassGetWeather`, and many others.
|
||||
|
||||
HOMECORE needs a wire-compatible Assist pipeline so that:
|
||||
- The HA iOS/Android companion app's "Assist" button works against HOMECORE.
|
||||
- The HOMECORE-API WebSocket `assist` command (ADR-130 §2.2) has a handler.
|
||||
- The ruflo agent toolchain (ADR-124) can provide LLM-grade intent disambiguation as a
|
||||
drop-in upgrade path for the P1 regex recognizer.
|
||||
|
||||
### 1.1 Ruflo integration approach
|
||||
|
||||
Ruflo's agent runner exposes an MCP-over-stdio interface (`node ruflo-agent.js`).
|
||||
HOMECORE-ASSIST manages a long-lived subprocess (Q3 Windows concern below), sends
|
||||
utterance JSON, and receives intent JSON back. In P1 we ship only the trait surface
|
||||
and a `NoopRunner` stub; the real subprocess management is P2.
|
||||
|
||||
### 1.2 Ruvector semantic intent matching (P2)
|
||||
|
||||
`ruvector-core` provides embedding + cosine-similarity primitives. P2 will add a
|
||||
`SemanticIntentRecognizer` that embeds the utterance and compares it to a HNSW index
|
||||
of intent exemplars, falling back to the P1 regex recognizer when similarity < 0.75.
|
||||
This is the mechanism that allows "dim the lights" to match `HassLightSet` without an
|
||||
explicit regex entry.
|
||||
|
||||
---
|
||||
|
||||
## 2. Design
|
||||
|
||||
### 2.1 Module layout (`v2/crates/homecore-assist/`)
|
||||
|
||||
| Module | Contents |
|
||||
|--------|----------|
|
||||
| `intent` | `IntentName` newtype, `Intent` (name + slots), `IntentResponse` (speech + optional card + optional data) |
|
||||
| `recognizer` | `IntentRecognizer` trait; `RegexIntentRecognizer` (P1); `SemanticIntentRecognizer` stub (P2) |
|
||||
| `handler` | `IntentHandler` trait; built-in handlers: `HassTurnOn`, `HassTurnOff`, `HassLightSet`, `HassNevermind`, `HassCancelAll` |
|
||||
| `runner` | `RufloRunner` trait + `RufloRunnerOpts`; `NoopRunner` (P1 stub); real subprocess runner (P2) |
|
||||
| `pipeline` | `AssistPipeline`: wires recognizer → handler → response; exposes `async fn process(utterance, language) -> IntentResponse` |
|
||||
|
||||
### 2.2 Built-in intent handlers (P1)
|
||||
|
||||
| Handler | HA service call | Slot |
|
||||
|---------|-----------------|------|
|
||||
| `HassTurnOn` | `homeassistant.turn_on` / `light.turn_on` / `switch.turn_on` | `entity_id` |
|
||||
| `HassTurnOff` | `homeassistant.turn_off` / `light.turn_off` / `switch.turn_off` | `entity_id` |
|
||||
| `HassLightSet` | `light.turn_on` | `entity_id`, `brightness` (0–255), `color_name` |
|
||||
| `HassNevermind` | — (no-op, returns acknowledgement) | — |
|
||||
| `HassCancelAll` | — (fires `homeassistant_stop_all_scripts` domain event) | — |
|
||||
|
||||
### 2.3 IntentResponse
|
||||
|
||||
```rust
|
||||
pub struct IntentResponse {
|
||||
pub speech: String,
|
||||
pub card: Option<Card>,
|
||||
pub data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
pub struct Card {
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 RufloRunner trait
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait RufloRunner: Send + Sync + 'static {
|
||||
async fn spawn(&mut self, opts: RufloRunnerOpts) -> Result<(), AssistError>;
|
||||
async fn send_request(&self, payload: serde_json::Value) -> Result<RufloResponse, AssistError>;
|
||||
async fn shutdown(&mut self) -> Result<(), AssistError>;
|
||||
}
|
||||
```
|
||||
|
||||
`RufloResponse` is `{ intent: Option<Intent>, speech: Option<String> }`.
|
||||
|
||||
### 2.5 Pipeline
|
||||
|
||||
```rust
|
||||
pub struct AssistPipeline<R, H> {
|
||||
recognizer: R,
|
||||
handler: H,
|
||||
runner: Option<Box<dyn RufloRunner>>,
|
||||
}
|
||||
|
||||
impl<R: IntentRecognizer, H: IntentHandler> AssistPipeline<R, H> {
|
||||
pub async fn process(&self, utterance: &str, language: &str, hc: &HomeCore)
|
||||
-> Result<IntentResponse, AssistError>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Questions & Answers
|
||||
|
||||
### Q1 — Why not reuse HA's existing `homeassistant.helpers.intent` via PyO3?
|
||||
|
||||
PyO3 bridges add a GIL lock on every cross-language call; the Assist pipeline processes
|
||||
hundreds of short utterances per day from voice satellites. A native Rust recognizer is
|
||||
simpler and faster. Python HA can still connect as an external integration via MQTT or
|
||||
the HOMECORE WebSocket API.
|
||||
|
||||
### Q2 — How does `RegexIntentRecognizer` handle ambiguity?
|
||||
|
||||
Patterns are tried in registration order; the first match wins. Slot extraction uses
|
||||
named capture groups. A future P2 upgrade can run all patterns, score them by slot
|
||||
completeness, and return the highest-scoring match.
|
||||
|
||||
### Q3 — Windows subprocess teardown (ruflo runner subprocess on Windows)
|
||||
|
||||
`tokio::process::Child` on Windows does not automatically kill the child process when
|
||||
the `Child` struct is dropped — `SIGTERM` is not a Windows concept, and `TerminateProcess`
|
||||
is not called automatically. Options for P2:
|
||||
|
||||
1. Call `child.start_kill()` in a `Drop` impl (requires a `Runtime` handle — tricky in sync Drop).
|
||||
2. Wrap `Child` in an `Arc<Mutex<Option<Child>>>` and call `kill()` in an `async fn shutdown()`.
|
||||
3. Use a Windows job object to bind the subprocess lifetime to the parent process.
|
||||
|
||||
**P2 decision**: implement option 2 (explicit `async shutdown()`) + register a `tokio::signal`
|
||||
handler for `Ctrl+C` / `SIGINT` that calls `shutdown()` before exit. Document the Windows caveat
|
||||
in the crate README and in `runner.rs`. Job object approach (option 3) is deferred to P3 only
|
||||
if option 2 proves insufficient in fleet testing.
|
||||
|
||||
### Q4 — Why is `SemanticIntentRecognizer` a P2 stub?
|
||||
|
||||
The ruvector HNSW index requires the vector store to be populated at startup with intent
|
||||
exemplars. That startup path requires deciding on a serialization format (HNSW index files
|
||||
vs. an in-memory array at compile time), which intersects with ADR-084 (RabitQ) and ADR-067
|
||||
(ruvector v2.0.5). P2 will define the exemplar format and populate the index.
|
||||
|
||||
---
|
||||
|
||||
## 4. Consequences
|
||||
|
||||
- **Positive**: HOMECORE-API `assist` WebSocket command gets a functional backend.
|
||||
- **Positive**: Ruflo LLM pipelines can upgrade intent matching by swapping the `RufloRunner` impl.
|
||||
- **Positive**: P1 ships with zero new heavy dependencies (no subprocess spawning, no ML runtime).
|
||||
- **Negative**: Regex matching has limited coverage; long-tail utterances will return "I'm not sure".
|
||||
- **Deferral**: ruvector semantic recognizer and real subprocess runner both land in P2.
|
||||
|
||||
---
|
||||
|
||||
## 5. Implementation phases
|
||||
|
||||
| Phase | Scope |
|
||||
|-------|-------|
|
||||
| **P1** (this ADR) | `intent`, `recognizer` (regex), `handler` (5 built-ins), `runner` (trait + noop), `pipeline` (end-to-end wiring), 10–15 tests |
|
||||
| **P2** | Real `tokio::process::Child` runner with Windows-safe teardown; `SemanticIntentRecognizer` with ruvector HNSW |
|
||||
| **P3** | STT/TTS bridge, satellite protocol, cloud fallback |
|
||||
@@ -0,0 +1,301 @@
|
||||
# HOMECORE-FRONTEND Design Recon — ADR-131
|
||||
|
||||
**Source:** cognitum-one/v0-appliance dashboard at `http://cognitum-v0:9000/`
|
||||
**Captured:** 2026-05-25 by browser-recon agent (session `20260525-181819-adr131-recon`)
|
||||
**Pages fetched:** dashboard, cogs, seeds, edge, analytics, settings, cluster, tailscale, aidefence, guide (all HTTP 200)
|
||||
**Auth:** dashboard is unauthenticated; `/api/*` requires bearer token — all recon confined to dashboard pages
|
||||
|
||||
---
|
||||
|
||||
## 1. Color Palette
|
||||
|
||||
The entire UI is dark-only. There is no light mode and no `prefers-color-scheme` media query anywhere in the stylesheet. Every surface is drawn from a tight family of near-black navy blues with two accent hues: a cool teal (`--primary`) and a green (`--accent`).
|
||||
|
||||
### Core tokens (hex conversions from HSL source)
|
||||
|
||||
| CSS variable | HSL value | Hex | Role |
|
||||
|---|---|---|---|
|
||||
| `--background` | `220 25% 6%` | `#0b0e13` | Page background, modal overlay base |
|
||||
| `--foreground` | `210 20% 92%` | `#e6eaee` | Body text, headings |
|
||||
| `--primary` | `185 80% 50%` | `#19d4e5` | Teal — active nav underline, CTA borders, ring focus, brand slash |
|
||||
| `--primary-foreground` | `220 25% 6%` | `#0b0e13` | Text on filled primary buttons |
|
||||
| `--accent` | `142 70% 50%` | `#26d867` | Green — secondary CTA, success state, deploy button text |
|
||||
| `--accent-foreground` | `220 25% 6%` | `#0b0e13` | Text on filled accent buttons |
|
||||
| `--secondary` | `220 20% 14%` | `#1c212a` | Button fill, pill-tab background |
|
||||
| `--card` | `220 20% 10%` | `#14171e` | Card surface (also popover) |
|
||||
| `--surface-elevated` | `220 20% 12%` | `#181c24` | Slightly elevated card variant |
|
||||
| `--surface-overlay` | `220 20% 8%` | `#111318` | Modal scrim, sticky navbar |
|
||||
| `--muted` | `220 15% 15%` | `#20242b` | Muted chip backgrounds, scrollbar track |
|
||||
| `--muted-foreground` | `215 15% 55%` | `#7b899d` | Secondary text, labels, timestamps |
|
||||
| `--border` | `220 15% 18%` | `#272b34` | All borders (at 50% opacity by default) |
|
||||
| `--destructive` | `0 65% 50%` | `#d22c2c` | Error state, danger button |
|
||||
| `--ring` | `185 80% 50%` | `#19d4e5` | Focus ring (same hue as primary) |
|
||||
|
||||
### Semantic status colors (inline, not variables)
|
||||
|
||||
| State | Color | Hex | Usage |
|
||||
|---|---|---|---|
|
||||
| Online / success | `hsl(142 70% 50%)` | `#26d867` | `.badge.online`, `.dot.up`, `.heat-cell.up` |
|
||||
| Warning | `hsl(38 80% 60%)` | `#e69940` | `.badge.unpaired`, `.hero-dot.warn`, banner backgrounds |
|
||||
| Error / offline | `hsl(0 65% 50%)` | `#d22c2c` | `.badge.offline`, `.badge.danger`, `.dot.down` |
|
||||
| Info (log line) | `hsl(205 80% 65%)` | `#4db8f5` | Log viewer `.info` class |
|
||||
| Paired | `hsl(185 80% 50%)` | `#19d4e5` | `.badge.paired` (same as primary) |
|
||||
|
||||
---
|
||||
|
||||
## 2. Typography
|
||||
|
||||
### Font families
|
||||
|
||||
The CSS declares two font families via CSS custom properties:
|
||||
|
||||
- `--font-display: 'Outfit', system-ui, sans-serif` — all headings, nav items, buttons, card titles, KPI values. Outfit is a modern geometric sans loaded locally (no Google Fonts outbound call; the source comment says "ship from local chrome.css fallback").
|
||||
- `--font-mono: 'JetBrains Mono', monospace` — timestamps, port numbers, version strings, table cells, log output, KPI labels, chip text.
|
||||
|
||||
### Type scale
|
||||
|
||||
| Token name / usage | Size | Weight | Notes |
|
||||
|---|---|---|---|
|
||||
| Hero title (`h1.hero-title`) | `clamp(1.5rem, 2.4vw, 2.1rem)` | 600 | Fluid, capped at ~33.6px |
|
||||
| Page h1 (`.page`) | `1.5rem` (24px) | 600 | All inner pages |
|
||||
| Section heading (`.row-h h2`) | `1.125rem` (18px) | 700 | Section openers on Cogs/Dashboard |
|
||||
| Card title (`.card-title`) | `0.9375rem` (15px) | 600 | |
|
||||
| Body / button | `0.8125rem` (13px) | 400/500 | Default body, nav links, buttons |
|
||||
| Secondary body / lede | `0.875rem` (14px) | 400 | Page lede text |
|
||||
| Small label | `0.75rem` (12px) | 400–600 | Table cells, modal sub-text |
|
||||
| Micro label | `0.6875rem` (11px) | 600 | Section eyebrows, uppercase KPI labels, badge text |
|
||||
| Mono micro | `0.625rem` (10px) | 400 | Heatmap cells, chip category text |
|
||||
|
||||
Letter-spacing: `0.1em` on section eyebrows (`.section h2`), `0.08em` on filter-rail headings and chip category text, `-0.02em` on all `h1–h4` display headings. Line-height for body is `1.5`; lede text uses `1.45`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Layout Primitives
|
||||
|
||||
### Page shell
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ .appbar (sticky, z-50, backdrop-filter:blur(8px)) │
|
||||
│ [brand-mark] [brand-text] [nav links scrollable] │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ .wrap (max-width: 1400px, padding: 1.5rem 1.25rem) │
|
||||
│ ┌── .hero (full-width, gradient bg, radial accents) │
|
||||
│ ├── .kpi-grid (auto-fill, min 170px columns) │
|
||||
│ ├── .section > h2 (eyebrow) + content │
|
||||
│ └── .grid / .grid-2 / .grid-3 (auto-fit) │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ footer.appfoot (border-top, centered text) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Appbar:** `position: sticky; top: 0; z-index: 50`. Background is the page background at 90% opacity with 8px blur backdrop-filter, so the page content bleeds through. Nav links overflow-scroll horizontally with a right-fade mask gradient.
|
||||
|
||||
**Active nav state:** primary-colored text + a 2px bottom border line (`::after` pseudo-element) positioned at bottom: -2px of the link. Hover reveals secondary background fill on the link.
|
||||
|
||||
**Content wrap:** max-width 1400px, centered, 1.25rem horizontal padding. Inner page sections are separated by margin-bottom spacing in multiples of 0.75rem (base unit = 12px at 16px root).
|
||||
|
||||
### Cogs page: app-store sub-navigation
|
||||
|
||||
The Cogs page adds a sticky secondary nav bar (`.subnav`) at `top: 3.25rem` (just below the appbar). Tabs are borderless buttons with a 2px bottom underline indicator when active. A `flex: 1` spacer pushes a gear icon to the right edge.
|
||||
|
||||
### Card patterns
|
||||
|
||||
Three card variants, all sharing the same surface gradient and border:
|
||||
|
||||
1. **Standard card (`.card`)** — `background: var(--gradient-card)` (linear 180deg from `--surface-elevated` to `--surface-overlay`), 1px border at 50% opacity, `--radius` (0.75rem), `box-shadow` 8px/32px dark drop shadow.
|
||||
2. **KPI card (`.kpi`)** — 38px icon square left + text right, same gradient, 1rem/1.125rem padding, smaller vertical rhythm.
|
||||
3. **Empty-state card (`.empty-card`)** — dashed 1px border (instead of solid), centered text, optional compact variant. The headline in `.empty-card h3` uses the primary teal, body explains what to do next.
|
||||
|
||||
### Spacing rhythm
|
||||
|
||||
Base unit is 4px. Gaps between grid items are universally `0.75rem` (12px). Card padding is `1.25rem` (20px) for standard, `0.875rem` (14px) for compact. Section margin-bottom is `1.5rem` (24px). The hero section uses `1.75rem` (28px) horizontal padding.
|
||||
|
||||
---
|
||||
|
||||
## 4. Component Vocabulary
|
||||
|
||||
### Navigation components
|
||||
|
||||
- **Appbar** — sticky top bar with brand + horizontal nav links. Brand mark is a 32px rounded SVG icon square.
|
||||
- **Nav link** — 0.4rem × 0.7rem padding, 0.4rem radius, transitions on color + background. Active state: primary text + 2px underline pseudo-element. Mobile: wraps below brand row at 720px.
|
||||
- **Sub-nav / secondary tab bar** (`.subnav`) — app-store style horizontal tab strip, sticky under appbar. Used exclusively on Cogs.
|
||||
- **Pill tabs** (`.pill-tabs` + `.pill-tab`) — smaller rounded-rect tab group for in-card filter switching. Active state fills with primary color.
|
||||
- **Page tabs** (`.page-tabs`) — used on Analytics for domain view switching. Underline-style, same pattern as sub-nav but at content level.
|
||||
|
||||
### Card & data display
|
||||
|
||||
- **Card** (`.card`) — base data container with gradient surface, subtle border, shadow.
|
||||
- **KPI tile** (`.kpi`, `.kpi-tile`) — metric display with icon, label (uppercase micro mono), large value, and optional sub-line. Two variants: `.kpi` (icon-left layout) and `.kpi-tile` (stack layout, used on Seeds/Edge/AIDefence).
|
||||
- **Node card** (`.node`) — cluster member card with mono metadata rows. Key-value pairs in `.node-meta` with dimmed label prefix (`.l` class).
|
||||
- **Cog card** (`.cog`) — product-catalog card with emoji icon, name, description, category chips, and a "Get" pill button. Hover lifts 2px with primary glow border.
|
||||
- **Pick card** (`.pick-card`) — horizontal-scroll featured card (220px fixed width), snap-scroll container. Smaller emoji + name + category + pill CTA.
|
||||
- **Category tile small** (`.cat-tile-sm`) — 180px min-width grid item, emoji + name + count.
|
||||
- **Category tile large** (`.cat-tile-big`) — 16:9 aspect-ratio card, full-bleed with gradient per category.
|
||||
- **Nav tile** (`.nav-tile`) — dashboard home navigation card with icon square, title, description, and a chevron arrow that translates +2px on hover.
|
||||
- **Architecture action card** (`.arch-card`, `.arch-action-card`) — setup wizard launcher cards on the dashboard.
|
||||
|
||||
### Status & feedback
|
||||
|
||||
- **Badge** (`.badge`) — pill with 1px border, 11px mono text. Variants: `role-master` (teal), `role-worker` (green), `online` (green), `offline` (red), `unknown` (muted), `paired` (teal), `unpaired` (amber), `danger` (red).
|
||||
- **Dot** (`.dot`) — 8px circle status indicator. `.up` glows green with box-shadow, `.down` is red, default is muted gray.
|
||||
- **Hero dot** (`.hero-dot`) — 7px circle in the dashboard hero status row. Same three states: `.ok` (green glow), `.warn` (amber glow), `.down` (red glow).
|
||||
- **Op-pill** (`.op-pill`) — "operational status" pill with colored dot inside. Used in dashboard architecture hub.
|
||||
- **AI pill / status chip** (`.pill` on AIDefence, `.md-badge` in cluster) — inline classification badge at 0.68rem. States: `.ok`, `.warn`, `.bad`.
|
||||
- **Chip** (`.chip`) — tiny category/difficulty label, all-caps, 0.5625rem, pill-shaped. Category-colored variants (`.cat-ai`, `.cat-health`, `.cat-security`, etc.) each get a hue-appropriate 15% opacity background.
|
||||
|
||||
### Actions
|
||||
|
||||
- **Button** (`.btn`) — 0.5rem × 0.875rem padding, 0.4rem radius, secondary fill. Variants: `.primary` (filled teal, 600 weight, box-shadow), `.outline` (transparent fill), `.danger` (red tint), `.sm` (compact).
|
||||
- **Hero button** (`.hero-btn`) — slightly larger, display-font, 0.9rem padding, glass-effect dark fill. `.primary` variant uses the green accent gradient.
|
||||
- **Pill CTA** (`.get`, `.pget`) — full pill-radius (9999px), primary-tint background at rest, fills solid on hover. Used on cog cards and pick cards.
|
||||
- **Gear button** (`.gear-btn`) — icon-only square button, transparent at rest, border appears on hover.
|
||||
- **Context menu** (`.ctx-menu`) — dark card dropdown (min-width 180px), each item is a full-width button with secondary hover fill.
|
||||
- **Copy button** (`.copy-btn`) — positioned absolute in `.copy-row`, 0.7rem opacity at rest, `.copied` state turns green/accent.
|
||||
|
||||
### Forms & inputs
|
||||
|
||||
- **Input** — all `<input>`, `<textarea>`, `<select>` inherit dark theme globally. Focus ring: 2px solid primary at 30% opacity (`box-shadow: 0 0 0 2px hsl(var(--ring) / 0.3)`). Checkboxes and radios use `accent-color: hsl(var(--primary))`.
|
||||
- **Collapsible section** (`.coll`, `.coll-h`, `.coll-body`) — used in Settings page. Header row is clickable with `user-select: none`. Body `display: none` by default, revealed on expand.
|
||||
- **Key-value row** (`.kv`) — 3-column grid (160px label | 1fr value | auto action) for settings display.
|
||||
- **Filters rail** (`.filters-rail`) — sticky sidebar on Cogs/Apps tab. Sticky at `top: 7rem` (below both navbars). Contains checkboxes, a range input, and a reset button.
|
||||
- **Range input** — native `<input type="range">` styled with `accent-color: hsl(var(--primary))`.
|
||||
|
||||
### Data visualization
|
||||
|
||||
- **Heatmap** (`.heatmap`) — CSS grid of 14px × variable cells. 60 time columns, label column at 90px. Cell states: `up` (green 70%), `down` (red 70%), `empty` (muted 30%).
|
||||
- **Bar chart** (`.bar-list` + `.bar-row` + `.bar-fill`) — horizontal bar list, 3-col grid (120px label | 1fr bar | 30px value). Bar fill transitions width in 0.3s.
|
||||
- **uPlot time-series** (`.uplot-host`) — 200px height host container; actual charting via uPlot library.
|
||||
- **Three.js 3D** — importmap for `three` + `OrbitControls` in Analytics page, for 3D sensor visualization.
|
||||
- **Log box** (`pre.logbox`) — monospace pre-formatted block, max-height 30rem, overflow-y scroll. Dark background on dark background gives subtle separation via border.
|
||||
- **OTA row table** (`.ota-row`) — 3-col grid (160px | 80px | 1fr) for firmware OTA records.
|
||||
|
||||
### Overlays
|
||||
|
||||
- **Modal** (`.modal-bg` + `.modal`) — fixed inset, 70% opacity blur-backdrop scrim. Modal itself is card-surfaced, max-width 560px. Result states: `.modal-result.ok` (green tint) and `.modal-result.err` (red tint).
|
||||
- **Detail modal** (`.detail-modal-bg` + `.detail-modal`) — larger variant (max 820px, 2rem padding) used on Cog detail view. Header has emoji, name, meta chips; sections below are tabbed.
|
||||
- **Keyboard shortcut tag** (`.kb`) — small monospace tag with secondary background, used inline in Settings and Tailscale pages to show keyboard shortcuts.
|
||||
|
||||
---
|
||||
|
||||
## 5. Iconography
|
||||
|
||||
All icons are inline SVG, 24×24 viewBox, `fill: none`, `stroke: currentColor`, `stroke-width: 2`. The path geometry is **Lucide Icons** — confirmed by comparing the Sun/gear/shield/grid/activity paths against Lucide's source. Key examples observed:
|
||||
|
||||
- Sun/rays (brand mark, dashboard hero)
|
||||
- Settings/gear (nav, subnav gear button)
|
||||
- Activity/pulse (KPI signal icon)
|
||||
- Bar chart 3 (analytics KPI)
|
||||
- Grid 2×2 (cluster/cog layout)
|
||||
- Shield with checkmark (AIDefence)
|
||||
- House (home nav tile)
|
||||
- Book-open (guide nav)
|
||||
|
||||
No external icon font is used. Every icon is self-contained in the HTML at point of use — no sprite sheet.
|
||||
|
||||
---
|
||||
|
||||
## 6. Dark Mode
|
||||
|
||||
The design is **dark-only**. There is no `prefers-color-scheme: light` media query in `v0-chrome.css` or any page-level stylesheet. The color system is entirely designed around the dark palette above. The source comments explicitly note that `fonts.googleapis.com` is blocked for Tailnet isolation, reinforcing that this is an always-dark appliance UI, not a consumer product that needs theming.
|
||||
|
||||
Surface hierarchy (light to dark, within the dark palette):
|
||||
1. `--surface-elevated` (`#181c24`) — slightly lighter card variant
|
||||
2. `--card` (`#14171e`) — standard card
|
||||
3. `--surface-overlay` (`#111318`) — modal/sticky appbar base
|
||||
4. `--background` (`#0b0e13`) — page root
|
||||
|
||||
The appbar uses `background: hsl(var(--background) / 0.9)` + `backdrop-filter: blur(8px)` so content underneath bleeds through as a translucency effect.
|
||||
|
||||
---
|
||||
|
||||
## 7. Notable Interactions
|
||||
|
||||
- **Nav hover:** 150ms color + background transition, no translate. Active state uses a 2px pseudo-element underline that animates in via opacity.
|
||||
- **Nav link active press:** `transform: translateY(1px)` on `:active` at 50ms — very subtle tactile response.
|
||||
- **Card hover:** `transform: translateY(-2px)` at 200ms on cards and cog items. Border shifts from `--border/0.5` to `primary/0.4` on hover. On the nav tiles, box-shadow deepens.
|
||||
- **Hero button hover:** `transform: translateY(-1px)` + border-color shift to primary at 70%.
|
||||
- **Pick card hover:** translateY(-2px) + primary-glow box-shadow.
|
||||
- **Focus ring:** 2px solid primary at 30% opacity as box-shadow — uses `outline: none` everywhere and replaces it with the ring shadow. nav links use `outline: 2px solid hsl(var(--primary)/0.6); outline-offset: 1px` for focus-visible.
|
||||
- **Bar fill animation:** `transition: width 0.3s` on bar chart fill elements for data-load entrance.
|
||||
- **Modal backdrop:** `backdrop-filter: blur(4px)` on modal scrim, `blur(6px)` on the Cog detail modal.
|
||||
- **Copy button feedback:** `.copied` state class swaps border and text to accent green, visible for a short duration (JS-controlled).
|
||||
- **Pill CTA:** Background fills from 15% opacity teal to 100% solid on hover — a strong affordance for primary actions.
|
||||
- **Scroll fade mask:** The nav bar has `mask-image: linear-gradient(to right, black calc(100% - 24px), transparent)` to fade out the rightmost item, hinting at horizontal scroll.
|
||||
- **Cogs hero carousel:** Paginator dots expand from 0.55rem circles to 1.5rem pill shape (border-radius 0.4rem) when active — a distinctive indicator pattern.
|
||||
|
||||
---
|
||||
|
||||
## 8. HA-Parity Opportunities
|
||||
|
||||
For ADR-131 P2, the following comparisons are relevant between this design and Home Assistant's frontend (`home-assistant-main`):
|
||||
|
||||
| HOMECORE component | Cognitum V0 pattern | HA equivalent | Better reference |
|
||||
|---|---|---|---|
|
||||
| KPI metric card | `.kpi` — icon + label + value | `ha-statistic-card`, `sensor-badge` | **Cognitum** — cleaner dense layout; HA's is more verbose |
|
||||
| Status badge/pill | `.badge` + `.chip` — pill with 1px border | `ha-label-badge`, `state-badge` | **HA** — HA has more state variants and i18n built in |
|
||||
| Dark surface cards | `--gradient-card` linear gradient | HA uses flat `var(--card-background-color)` | **Cognitum** — gradient gives depth HA lacks |
|
||||
| Toggle/switch | `accent-color` native checkbox | HA `ha-switch` (Material) | **HA** — purpose-built, accessible, animated |
|
||||
| Navigation | Horizontal sticky nav, underline indicator | HA sidebar (vertical) | Neither — HOMECORE needs a new shell; Cognitum's horizontal bar is appropriate for appliance context |
|
||||
| Heatmap timeline | CSS grid `.heatmap` | No HA equivalent | **Cognitum** — take this pattern directly |
|
||||
| Bar chart | CSS-only `.bar-fill` bar list | HA uses Recharts | **Cognitum** — zero-dep CSS bars good for simple metrics; use for small cards |
|
||||
| Time-series chart | uPlot `.uplot-host` | HA uses ApexCharts / Recharts | **HA** — ApexCharts has more features, better RTL support |
|
||||
| Modal | `.modal-bg` blur-backdrop | HA `ha-dialog` (Material) | **HA** — a11y and focus-trap already solved |
|
||||
| Toast / alert banner | `.modal-result.ok/err` inline result + `.cl-banner.warn/err` | HA `ha-alert` | **HA** — HA's alerts are more composable |
|
||||
| Focus ring | `box-shadow` ring pattern | HA uses `:focus-visible` outline | **HA** — HA's approach has better browser compatibility |
|
||||
| Chip (category) | `.chip.cat-*` per-category color mapping | HA `ha-chip` | **Cognitum** — the category-specific hue mapping is richer |
|
||||
|
||||
---
|
||||
|
||||
## 9. Design Tokens for HOMECORE-FRONTEND P1
|
||||
|
||||
Concrete CSS variable names and starting values for the TypeScript+WASM frontend to adopt. These follow the Cognitum V0 source directly, adjusted where needed for HOMECORE context.
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* Surfaces */
|
||||
--hc-bg: hsl(220 25% 6%); /* #0b0e13 — page root */
|
||||
--hc-surface-card: hsl(220 20% 10%); /* #14171e — card fill */
|
||||
--hc-surface-elevated: hsl(220 20% 12%); /* #181c24 — raised panel */
|
||||
--hc-surface-overlay: hsl(220 20% 8%); /* #111318 — modal/nav base */
|
||||
|
||||
/* Text */
|
||||
--hc-text: hsl(210 20% 92%); /* #e6eaee — primary text */
|
||||
--hc-text-muted: hsl(215 15% 55%); /* #7b899d — secondary/label */
|
||||
|
||||
/* Accent palette */
|
||||
--hc-primary: hsl(185 80% 50%); /* #19d4e5 — teal, primary actions */
|
||||
--hc-primary-fg: hsl(220 25% 6%); /* #0b0e13 — text on primary */
|
||||
--hc-accent: hsl(142 70% 50%); /* #26d867 — green, success/CTA */
|
||||
--hc-accent-fg: hsl(220 25% 6%); /* #0b0e13 — text on accent */
|
||||
--hc-destructive: hsl(0 65% 50%); /* #d22c2c — error/danger */
|
||||
--hc-warning: hsl(38 80% 60%); /* #e69940 — warning/amber */
|
||||
|
||||
/* Borders & rings */
|
||||
--hc-border: hsl(220 15% 18%); /* #272b34 — subtle border */
|
||||
--hc-ring: hsl(185 80% 50%); /* #19d4e5 — focus ring */
|
||||
|
||||
/* Radii */
|
||||
--hc-radius: 0.75rem; /* cards, modals */
|
||||
--hc-radius-sm: 0.4rem; /* buttons, inputs, chips */
|
||||
--hc-radius-pill: 9999px; /* badges, CTA pills */
|
||||
|
||||
/* Typography */
|
||||
--hc-font-display: 'Outfit', system-ui, sans-serif;
|
||||
--hc-font-mono: 'JetBrains Mono', monospace;
|
||||
|
||||
/* Shadows */
|
||||
--hc-shadow-card: 0 8px 32px -8px hsl(220 25% 2% / 0.8);
|
||||
--hc-shadow-glow: 0 0 60px -10px hsl(185 80% 50% / 0.3);
|
||||
|
||||
/* Gradients */
|
||||
--hc-gradient-card: linear-gradient(180deg, hsl(220 20% 12%) 0%, hsl(220 20% 8%) 100%);
|
||||
}
|
||||
```
|
||||
|
||||
**Notes for P1 implementation:**
|
||||
|
||||
- Adopt Outfit + JetBrains Mono from Google Fonts in development; ship local fallbacks for production (Tailnet appliances block outbound font requests per the Cognitum source comment).
|
||||
- The `--hc-ring` focus approach should be implemented as `box-shadow: 0 0 0 2px hsl(var(--hc-ring) / 0.3)` combined with `outline: none` — matches Cognitum's pattern and avoids the offset-gap issue in Firefox.
|
||||
- Add `--hc-gradient-hero` and `--hc-gradient-glow` when the dashboard hero section is built; keep them out of the P1 design-token foundation to avoid premature complexity.
|
||||
- The `--hc-warning` amber is not in the Cognitum `:root` block (it is inline throughout) — elevating it to a token is a deliberate improvement for HOMECORE.
|
||||
@@ -0,0 +1,160 @@
|
||||
# HOMECORE Security Audit — Iter-10
|
||||
|
||||
**Branch**: `feat/adr-126-homecore-impl`
|
||||
**Audit date**: 2026-05-25
|
||||
**Scope**: 8 new crates + integration binary (iter-1 through iter-9)
|
||||
**Auditor**: Security-audit agent (claude-sonnet-4-6)
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
HOMECORE's Rust codebase is structurally sound but ships with two pre-production
|
||||
placeholders that are critical blockers for any production deployment: the HTTP
|
||||
bearer-token validator accepts **any non-empty string as a valid token**, and the
|
||||
WebSocket auth handshake does the same. Every protected endpoint is therefore fully
|
||||
open to unauthenticated attackers who can reach port 8123.
|
||||
|
||||
`cargo audit` flagged **18 advisories** across three dependency trees. Two are
|
||||
Critical (CVSS 9.0): both are Wasmtime sandbox-escape bugs in the Winch and
|
||||
Cranelift compiler backends (RUSTSEC-2026-0095/0096). SQLx 0.7.4 carries a
|
||||
binary-protocol misinterpretation bug (RUSTSEC-2024-0363). The Wasmtime
|
||||
version must be upgraded before any WASM plugin is loaded in production.
|
||||
|
||||
Additional findings: `CorsLayer::permissive()` allows cross-origin requests from
|
||||
any domain; the HAP service record hardcodes a predictable setup code and a
|
||||
broadcast MAC address; `hc_log` writes plugin output directly to `eprintln!`
|
||||
without going through `tracing`; and the WS `subscribe_events` command has no
|
||||
per-connection subscription cap, enabling a resource-exhaustion DoS.
|
||||
|
||||
---
|
||||
|
||||
## Findings
|
||||
|
||||
| ID | Severity | Title | File : Line | Description | Remediation |
|
||||
|----|----------|-------|-------------|-------------|-------------|
|
||||
| HC-01 | **Critical** | Bearer auth accepts any non-empty token (REST) | `homecore-api/src/auth.rs:25` and `rest.rs` (all handlers) | `BearerAuth::from_headers` returns `Ok` for any non-empty string. All REST endpoints (`/api/config`, `/api/states`, `/api/services`, `call_service`) are fully open to any caller. | Implement a token store in P2 before deployment. Until then, enforce network-level ACL so port 8123 is unreachable from untrusted networks. |
|
||||
| HC-02 | **Critical** | WebSocket auth handshake accepts any non-empty token | `homecore-api/src/ws.rs:61–68` | The WS `auth` phase validates only that `access_token` is non-empty. After passing this check the client reaches the full command loop including `call_service`. An attacker sending `{"type":"auth","access_token":"x"}` gets a fully authenticated session. | Same as HC-01; block at network until real token store is wired. |
|
||||
| HC-03 | **Critical** | Wasmtime 25.0.3 — sandbox-escape via Winch backend (RUSTSEC-2026-0095) | `homecore-plugins/Cargo.toml` | The Winch compiler backend in Wasmtime 25.0.3 allows a sandboxed WASM plugin to perform out-of-sandbox memory writes (CVSS 9.0). | Upgrade `wasmtime` to `>=36.0.7` or `>=42.0.2`. |
|
||||
| HC-04 | **Critical** | Wasmtime 25.0.3 — sandbox-escape via miscompiled heap access on aarch64 Cranelift (RUSTSEC-2026-0096) | `homecore-plugins/Cargo.toml` | Miscompiled guest heap access in Cranelift's aarch64 backend enables sandbox escape (CVSS 9.0). Production Pi 5 targets are aarch64. | Upgrade `wasmtime` to `>=36.0.7` or `>=42.0.2`. |
|
||||
| HC-05 | **High** | `CorsLayer::permissive()` allows all cross-origin requests | `homecore-api/src/app.rs:25` | `CorsLayer::permissive()` sets `Access-Control-Allow-Origin: *` and allows all methods and headers. Any webpage on any origin can make authenticated API calls using a stored bearer token (when HC-01/02 are fixed). | Replace with an explicit allowlist: `CorsLayer::new().allow_origin(expected_origin).allow_methods([GET, POST])`. |
|
||||
| HC-06 | **High** | SQLx 0.7.4 — binary protocol misinterpretation (RUSTSEC-2024-0363) | `homecore-recorder/Cargo.toml` | Truncating/overflowing casts in SQLx 0.7.4's binary protocol handling can cause values to be misread. Although HOMECORE only uses SQLite (not MySQL/Postgres), the vulnerable codepath is in the shared crate. | Upgrade `sqlx` to `>=0.8.1`. |
|
||||
| HC-07 | **High** | No per-connection subscription cap on WS `subscribe_events` | `homecore-api/src/ws.rs:237–295` | A single authenticated WS connection can call `subscribe_events` in an unbounded loop. Each subscription spawns a Tokio task and takes one broadcast receiver slot. With the bus capacity at 4096 slots, a malicious client can exhaust OS thread/task resources before the bus fills. | Add a per-connection subscription ceiling (e.g., 50). Reject further `subscribe_events` commands with `"too_many_subscriptions"`. |
|
||||
| HC-08 | **High** | Hardcoded HAP setup code and broadcast MAC in production binary | `homecore-server/src/main.rs:113–114`, `homecore-hap/src/bridge.rs:143–144` | The integration binary hard-codes `setup_code: "123-45-678"` and `device_id: "AA:BB:CC:DD:EE:FF"`. When real HAP pairing lands in P2 any attacker on the local network can pair with the bridge using the published setup code; the broadcast MAC address is also invalid per the HAP specification. | Generate a random setup code and a locally administered unicast MAC at startup (or require them as CLI arguments). Never use a known-fixed setup code. |
|
||||
| HC-09 | **Medium** | Wasmtime 25.0.3 — 11 additional medium/low CVEs | `homecore-plugins/Cargo.toml` | RUSTSEC-2025-0046, -0118, -2026-0020, -0021, -0085, -0086, -0087, -0088, -0089, -0091, -0092, -0093, -0094 affect resource exhaustion, host data leakage, OOB reads/writes, and panics. All are fixed in wasmtime `>=36.0.7`. | Same fix as HC-03/04: upgrade wasmtime. |
|
||||
| HC-10 | **Medium** | `hc_log` writes plugin output via `eprintln!` bypassing structured logging | `homecore-plugins/src/wasmtime_runtime.rs:297` | Plugin log messages are written directly to stderr via `eprintln!`, bypassing the `tracing` subscriber. This means: (a) log level filtering does not apply to plugin output; (b) log aggregation pipelines (e.g., JSON structured logs) miss plugin messages. A verbose or malicious plugin can flood stderr. | Replace `eprintln!` with `tracing::debug!/info!/warn!/error!` using the already-imported `LogLevel`. |
|
||||
| HC-11 | **Medium** | No size bound on `set_state` body or `attributes` JSON | `homecore-api/src/rest.rs:95–108`, `ws.rs:222–235` | `POST /api/states/:entity_id` and the WS `call_service` / `get_states` paths accept a `serde_json::Value` body with no size limit beyond Axum's default (2 MB). Specially crafted deeply-nested JSON can cause quadratic parse time or high-memory allocation during serialization. | Apply `axum::extract::DefaultBodyLimit::max(65536)` on the route or globally; validate JSON depth before accepting. |
|
||||
| HC-12 | **Medium** | `rsa 0.9.10` — Marvin Attack timing side-channel (RUSTSEC-2023-0071) | transitive via `sqlx-mysql 0.7.4` | The `rsa` crate's decryption is vulnerable to timing-based key recovery. Pulled in by `sqlx-mysql` even though HOMECORE only uses SQLite. No fix is available upstream. | Add `sqlx` features `sqlite` only (remove `mysql`/`postgres` from the feature list) to avoid pulling in `sqlx-mysql` and the `rsa` transitive dependency. |
|
||||
| HC-13 | **Medium** | `shlex 0.1.1` — shell-injection via quote API (RUSTSEC-2024-0006) | transitive via `wasm3-sys 0.3.0 → wasm3 0.3.1 → homecore-plugins` | `shlex`'s quote function can produce unsafe shell strings. Pulled in by the `wasm3` build system. Not directly callable from HOMECORE Rust code but present in the binary's dependency tree. | Upgrade `shlex` to `>=1.3.0` or drop the `wasm3` dependency if `WasmtimeRuntime` is the production path. |
|
||||
| HC-14 | **Low** | No TLS on the HTTP/WS listener | `homecore-server/src/main.rs:122–128` | The Axum listener binds plain TCP (`axum::serve`). Bearer tokens and all home automation data are transmitted in cleartext. On LAN deployments an attacker with ARP poisoning can intercept credentials. | Add `rustls`/`axum-server` TLS termination or document that a TLS-terminating reverse proxy (nginx/Caddy) is required. |
|
||||
| HC-15 | **Low** | Migration CLI performs no symlink/traversal check on `.storage/` path | `homecore-migrate/src/storage.rs:36–37`, `main.rs:14–32` | `HaStorageDir::file_path` calls `self.path.join(name)` where `name` comes from hard-coded constants, so exploitation requires the `--storage` argument itself to point outside the intended tree. There is no `Path::canonicalize` + prefix check. While the current filenames are constants, if P2 makes `name` data-driven the surface widens. | Add `path.canonicalize()` + assert prefix after computing `file_path` if the name ever becomes user-controlled. Document this as a P2 gate. |
|
||||
| HC-16 | **Low** | `AutomationEngine` uses `eprintln!` for action errors | `homecore-automation/src/engine.rs:93–95, 105` | Action errors and lag notices are emitted via `eprintln!`, not `tracing::warn!`. Same issues as HC-10: bypasses structured logging. | Replace with `tracing::warn!`/`tracing::error!`. |
|
||||
| HC-17 | **Informational** | WS `call_service` authorization is contingent on fixing HC-01/HC-02 | `homecore-api/src/ws.rs:222–235` | `call_service` (including destructive calls such as `homeassistant.restart`) sits behind the WS auth handshake. Once HC-01 and HC-02 are fixed this path is properly guarded. No additional change needed here beyond those fixes. | No action required beyond HC-02. |
|
||||
| HC-18 | **Informational** | `hc_state_subscribe` accumulates entity strings without eviction | `homecore-plugins/src/wasmtime_runtime.rs:263–268` | The `PluginStoreData.subscriptions` Vec grows without bound if a plugin repeatedly subscribes to the same entity. There is no deduplication. This is a plugin-local memory leak, not a sandbox escape. | Deduplicate on insert: `if !caller.data().subscriptions.contains(&eid)`. |
|
||||
|
||||
---
|
||||
|
||||
## Negative-Result Section (Surfaces Checked and Found Clean)
|
||||
|
||||
**SQL injection (homecore-recorder/src/db.rs)**: All queries use `sqlx::query`
|
||||
with positional `?` bind parameters. No `format!`-constructed SQL was found in
|
||||
any path (`record_state`, `record_event`, `get_state_history`, `search_semantic`,
|
||||
`apply_schema`). Clean.
|
||||
|
||||
**WS bearer token in logs/error messages**: The bearer token is extracted and
|
||||
immediately discarded after the non-empty check at ws.rs:62. It is not passed
|
||||
to any `tracing` macro, `eprintln!`, or error-display path. The `access_token`
|
||||
field is not part of any `Debug`-derived struct that enters a log path. Clean.
|
||||
|
||||
**REST bearer token in logs/error messages**: `BearerAuth(token)` is `Debug`
|
||||
but no handler logs it or includes it in an error response. `ApiError` variants
|
||||
do not capture the token. Clean.
|
||||
|
||||
**WASM linear-memory buffer overflow in `hc_state_get`/`hc_state_set`**: The
|
||||
`read_str` helper validates `len < 0` and `len > MAX_ABI_BUFFER_BYTES (65536)`
|
||||
before slicing, and uses `mem.get(ptr..ptr+len)?` which cannot panic. In
|
||||
`hc_state_get` phase 3, the write is guarded by `json_bytes.len() > out_cap`
|
||||
before attempting the slice. The `call_export_str` host-to-guest path also uses
|
||||
`.get_mut(ptr..ptr+len).ok_or_else(...)` rather than unchecked indexing. No
|
||||
buffer-overflow vector identified in the host ABI.
|
||||
|
||||
**WASM JSON ABI escape**: Plugins receive and emit plain UTF-8 JSON strings via
|
||||
the linear-memory ABI. The host deserializes attribute JSON with
|
||||
`serde_json::from_str` and defaults to `{}` on parse failure — no panic path.
|
||||
No mechanism for a plugin to escape the Cranelift JIT sandbox via the JSON layer
|
||||
alone was identified; the sandbox-escape risk is in the Cranelift/Winch compiler
|
||||
backends (HC-03/04).
|
||||
|
||||
**Path traversal in homecore-migrate**: All `.storage/` filenames are currently
|
||||
hard-coded constants (`"core.entity_registry"`, `"core.device_registry"`, etc.)
|
||||
in the Rust source. The `--storage` and `--config-dir` arguments are user-supplied
|
||||
but refer to the directory root, not individual filenames. No user-controlled
|
||||
string is concatenated into a file path. Clean at P1 scope (noted as a P2 gate in HC-15).
|
||||
|
||||
**DoS via event-bus flood from a plugin**: A WASM plugin can call `hc_state_set`
|
||||
in a tight loop. Each call fires a `broadcast::Sender::send` on the system channel
|
||||
(capacity 4096). When the channel is full, `send` returns 0 (receivers are
|
||||
dropped/lagged) but does not block or panic. Lagged receivers are notified via
|
||||
`RecvError::Lagged`. The state machine itself does not back-pressure the sender.
|
||||
The flood can cause the recorder and automation engine to lag, but it cannot crash
|
||||
the host process. Noted as design-level concern; acceptable for P1.
|
||||
|
||||
**Secrets leakage in homecore-migrate InspectSecrets**: The CLI correctly prints
|
||||
`<redacted>` for secret values and only logs key names.
|
||||
|
||||
---
|
||||
|
||||
## Critical-Path Remediation List (Required Before Production Deployment)
|
||||
|
||||
The following items MUST be resolved before `homecore-server` is reachable from
|
||||
any untrusted network:
|
||||
|
||||
1. **HC-01 + HC-02 (Critical)** — Implement the token store and validate bearer
|
||||
tokens in both `BearerAuth::from_headers` and the WS `handle_socket` auth
|
||||
phase. Until this is done every REST and WS endpoint is completely open.
|
||||
|
||||
2. **HC-03 + HC-04 (Critical)** — Upgrade `wasmtime` in `homecore-plugins/Cargo.toml`
|
||||
from `25.0.3` to `>=36.0.7` (or `>=42.0.2`). The current version has two
|
||||
confirmed CVSS-9.0 sandbox-escape bugs; loading any third-party WASM plugin
|
||||
on the current version cannot be considered safe.
|
||||
|
||||
3. **HC-06 (High)** — Upgrade `sqlx` from `0.7.4` to `>=0.8.1` to eliminate the
|
||||
binary-protocol misinterpretation bug.
|
||||
|
||||
4. **HC-05 (High)** — Replace `CorsLayer::permissive()` with an explicit
|
||||
origin allowlist before any browser-accessible deployment.
|
||||
|
||||
5. **HC-08 (High)** — Replace the hardcoded HAP setup code and broadcast MAC
|
||||
address with randomly generated values before P2 real HAP pairing lands.
|
||||
|
||||
6. **HC-07 (High)** — Add per-connection subscription limit to the WS command
|
||||
loop before exposing the server to untrusted LAN clients.
|
||||
|
||||
---
|
||||
|
||||
## Dependency CVE Summary
|
||||
|
||||
`cargo audit` reported **18 advisories** against workspace `Cargo.lock`:
|
||||
|
||||
| Advisory | Crate | Severity | Affects HOMECORE |
|
||||
|----------|-------|----------|------------------|
|
||||
| RUSTSEC-2026-0096 | wasmtime 25.0.3 | Critical (9.0) | homecore-plugins |
|
||||
| RUSTSEC-2026-0095 | wasmtime 25.0.3 | Critical (9.0) | homecore-plugins |
|
||||
| RUSTSEC-2026-0093 | wasmtime 25.0.3 | Medium (6.9) | homecore-plugins |
|
||||
| RUSTSEC-2026-0020 | wasmtime 25.0.3 | Medium (6.9) | homecore-plugins |
|
||||
| RUSTSEC-2026-0021 | wasmtime 25.0.3 | Medium (6.9) | homecore-plugins |
|
||||
| RUSTSEC-2024-0363 | sqlx 0.7.4 | (no CVSS) | homecore-recorder |
|
||||
| RUSTSEC-2026-0091 | wasmtime 25.0.3 | Medium (6.1) | homecore-plugins |
|
||||
| RUSTSEC-2026-0094 | wasmtime 25.0.3 | Medium (6.1) | homecore-plugins |
|
||||
| RUSTSEC-2026-0089 | wasmtime 25.0.3 | Medium (5.9) | homecore-plugins |
|
||||
| RUSTSEC-2026-0092 | wasmtime 25.0.3 | Medium (5.9) | homecore-plugins |
|
||||
| RUSTSEC-2023-0071 | rsa 0.9.10 | Medium (5.9) | transitive via sqlx-mysql |
|
||||
| RUSTSEC-2026-0085 | wasmtime 25.0.3 | Medium (5.6) | homecore-plugins |
|
||||
| RUSTSEC-2026-0087 | wasmtime 25.0.3 | Medium (4.1) | homecore-plugins |
|
||||
| RUSTSEC-2025-0046 | wasmtime 25.0.3 | Low (3.3) | homecore-plugins |
|
||||
| RUSTSEC-2026-0086 | wasmtime 25.0.3 | Low (2.3) | homecore-plugins |
|
||||
| RUSTSEC-2026-0088 | wasmtime 25.0.3 | Low (2.3) | homecore-plugins |
|
||||
| RUSTSEC-2025-0118 | wasmtime 25.0.3 | Low (1.8) | homecore-plugins |
|
||||
| RUSTSEC-2024-0006 | shlex 0.1.1 | (no CVSS) | transitive via wasm3-sys |
|
||||
|
||||
All 15 wasmtime advisories are resolved by upgrading to `wasmtime >= 36.0.7`.
|
||||
@@ -0,0 +1,474 @@
|
||||
# RuView ↔ HomePod Integration Guide
|
||||
|
||||
**Ambient intelligence for Apple Home.** Run RuView as a native HomeKit accessory so your HomePod discovers it, Siri understands it, and Apple Home automations govern it — no Home Assistant required.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
RuView turns WiFi radio reflections into spatial intelligence (presence, breathing, fall risk, activity patterns). When paired with a HomePod or Apple TV acting as your Home Hub, RuView becomes an invisible sensor that feeds Siri, automations, and scenes:
|
||||
|
||||
```
|
||||
ESP32-C6 CSI node (living room)
|
||||
↓ (UDP feature stream)
|
||||
RuView Sensing Server (announces presence, vital signs, BFLD events)
|
||||
↓ (HTTP polling)
|
||||
HAP Bridge (advertises HomeKit accessory on mDNS)
|
||||
↓ (Bonjour discovery)
|
||||
HomePod or Apple TV (Home Hub)
|
||||
↓ (forwards to Home app + Siri)
|
||||
iPhone, iPad, Mac, Watch, Apple Home automations
|
||||
```
|
||||
|
||||
The integration leverages HomeKit Accessory Protocol (HAP-1.1) — the same standard that Philips Hue, Eve, and Nanoleaf use. Your HomePod discovers the bridge within seconds of launch, pairing is one-tap from the Home app, and Siri queries work immediately: *"Hey Siri, is anyone in the living room?"*
|
||||
|
||||
For design rationale and privacy safeguards, see [ADR-125 — RuView ↔ Apple Home native HAP bridge](docs/adr/ADR-125-ruview-apple-home-native-hap-bridge.md).
|
||||
|
||||
---
|
||||
|
||||
## What's Shipped Today (Tier 1 + Tier 2)
|
||||
|
||||
Eight incremental iterations landed in PR #797 on the `feat/adr-125-apple-fabric` branch:
|
||||
|
||||
| Iteration | Capability | Commit | Status |
|
||||
|-----------|-----------|--------|--------|
|
||||
| 1 | Multi-characteristic HomeKit accessory (Motion + Occupancy + StatelessProgrammableSwitch) | `48db60a65` | Runtime-live |
|
||||
| 2 | Sensing-server HTTP endpoints for bridge polling (`/api/v1/vitals`, `/api/v1/bfld`, `/api/v1/semantic-events`) | `194a2e163` | Runtime-live, curl-validated |
|
||||
| 3 | HAP bridge with N child accessories; Siri-by-room (name each room, Siri voices it) | `63b77f760` | Runtime-live, two bridges advertising |
|
||||
| 4 | Semantic-events endpoint per §2.1.d (`Unknown Presence`, `Unexpected Occupancy`, `Unrecognized Activity Pattern`) | `3d30261e7` | Runtime-live, privacy invariant I1 enforced |
|
||||
| 5 | rvagent MCP consumer (agentic chain); 12 MCP tools for Claude Code integration | `c19742d71` | Runtime-validated on real C6 |
|
||||
| 6 | PyO3 BFLD PrivacyClass binding (SOTA rust crate exposed to Python) | `de0712d43` | Source-built (`cargo check` green) |
|
||||
| 7 | Shortcuts-as-glue (launchd job + Speak Text on HomePod via iCloud Home graph, bypasses Bonjour blocker) | `d0525359d` | Runtime-validated, osascript trigger green |
|
||||
| 8 | Custom characteristic UUID scaffold for Eve.app rendering (design complete; runtime HAP-python JSON-loader follow-up) | `3bb8c1621` | Design scaffolded |
|
||||
|
||||
**What you can do today:**
|
||||
|
||||
- Pair a RuView bridge into your Home app on iPhone, iPad, or Mac.
|
||||
- Ask Siri room-specific presence questions ("is anyone home", "is the office occupied", "did someone fall").
|
||||
- Trigger automations on presence detection, breathing presence, fall risk, or activity pattern anomalies.
|
||||
- Stream RuView events to HomePod announcements via the Shortcuts-as-glue path (Tier 2).
|
||||
- Query RuView data programmatically through the agentic MCP interface (Claude Code integration).
|
||||
|
||||
---
|
||||
|
||||
## Quickstart (5 minutes)
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Hardware**: ESP32-C6 running CSI firmware (rev v0.7.0+) on the same WiFi network as your Mac and HomePod.
|
||||
- **Software**: Python 3.8+ on a Mac that's already paired into your Home app (iCloud account).
|
||||
- **Network**: Mac, HomePod, and ESP32-C6 must all be on the same LAN subnet (e.g., `192.168.1.0/24`).
|
||||
|
||||
### Step 1: Provision the ESP32-C6
|
||||
|
||||
Connect the C6 via USB and run the provisioning script:
|
||||
|
||||
```bash
|
||||
python firmware/esp32-csi-node/provision.py \
|
||||
--port /dev/ttyUSB0 \
|
||||
--ssid "YourWiFiSSID" \
|
||||
--password "YourWiFiPassword" \
|
||||
--target-ip 192.168.1.20
|
||||
```
|
||||
|
||||
Verify the C6 boots on the network:
|
||||
|
||||
```bash
|
||||
ping 192.168.1.20
|
||||
```
|
||||
|
||||
### Step 2: Create a Python venv on the Mac and install HAP-python
|
||||
|
||||
```bash
|
||||
mkdir -p ~/ruview-hap
|
||||
cd ~/ruview-hap
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install HAP-python
|
||||
```
|
||||
|
||||
### Step 3: Copy the RuView bridge scripts to the Mac
|
||||
|
||||
From the repository (e.g., cloned on your Mac), copy these files:
|
||||
|
||||
```bash
|
||||
cp scripts/c6-presence-watcher.py ~/ruview-hap/
|
||||
cp scripts/ruview-sensing-server.py ~/ruview-hap/
|
||||
cp scripts/ruview-hap-bridge.py ~/ruview-hap/
|
||||
```
|
||||
|
||||
### Step 4: Start the three daemons in order
|
||||
|
||||
**Terminal 1: Start the C6 presence watcher** (reads UDP packets from the C6, applies BFLD privacy gate)
|
||||
|
||||
```bash
|
||||
cd ~/ruview-hap
|
||||
source venv/bin/activate
|
||||
python c6-presence-watcher.py --node-id 1 --esp32-ip 192.168.1.20 --privacy-class 2
|
||||
```
|
||||
|
||||
Output: Writes presence events to `/tmp/ruview-state.json`.
|
||||
|
||||
**Terminal 2: Start the sensing server** (HTTP polling interface for the HAP bridge)
|
||||
|
||||
```bash
|
||||
cd ~/ruview-hap
|
||||
source venv/bin/activate
|
||||
python ruview-sensing-server.py --port 3000
|
||||
```
|
||||
|
||||
Output: Listening on `http://127.0.0.1:3000/api/v1/...`.
|
||||
|
||||
**Terminal 3: Start the HAP bridge** (advertises HomeKit accessory on mDNS)
|
||||
|
||||
```bash
|
||||
cd ~/ruview-hap
|
||||
source venv/bin/activate
|
||||
python ruview-hap-bridge.py --port 51826 --pin 200-70-910
|
||||
```
|
||||
|
||||
Output: Look for setup code in the terminal output, e.g., `Setup code: 200-70-910`.
|
||||
|
||||
### Step 5: Pair the bridge from your iPhone
|
||||
|
||||
1. Open the **Home** app on your iPhone.
|
||||
2. Tap the **+** icon (top right) → **Add Accessory**.
|
||||
3. Scan the setup code (or tap **Don't Have a Code or Can't Scan?** → **More Options**).
|
||||
4. Select the **RuView Sense** bridge from the list (should appear within 10 seconds).
|
||||
5. Assign to a room (e.g., "Living Room").
|
||||
6. Tap **Done**.
|
||||
|
||||
### Step 6: Test with Siri
|
||||
|
||||
Once paired, ask Siri:
|
||||
|
||||
```
|
||||
"Hey Siri, is anyone in the living room?"
|
||||
```
|
||||
|
||||
Siri will respond with the current occupancy state. Walk past the C6 and ask again — the presence value should update within 1–2 seconds.
|
||||
|
||||
---
|
||||
|
||||
## Per-Room Expansion
|
||||
|
||||
To monitor multiple rooms, run multiple C6 nodes, each with its own `c6-presence-watcher.py` instance:
|
||||
|
||||
```bash
|
||||
# Terminal: Room 1 (Living Room, node_id=1)
|
||||
python c6-presence-watcher.py --node-id 1 --esp32-ip 192.168.1.20 \
|
||||
--output /tmp/ruview-state.living-room.json
|
||||
|
||||
# Terminal: Room 2 (Bedroom, node_id=2)
|
||||
python c6-presence-watcher.py --node-id 2 --esp32-ip 192.168.1.21 \
|
||||
--output /tmp/ruview-state.bedroom.json
|
||||
|
||||
# Terminal: HAP bridge (auto-discovers both state files)
|
||||
python ruview-hap-bridge.py --port 51826 --rooms "Living Room,Bedroom"
|
||||
```
|
||||
|
||||
The HAP bridge auto-discovers `*.json` files in `/tmp/ruview-state*` and creates a child HomeKit accessory per room. Each room appears separately in the Home app and can be assigned to its physical location.
|
||||
|
||||
---
|
||||
|
||||
## Privacy Semantics
|
||||
|
||||
RuView's BFLD (Beamforming Feedback Layer for Detection) uses a **privacy class** gate that enforces what data can cross the HomeKit boundary. Only Classes 2 and 3 (Anonymous and Restricted) are eligible; Class 0/1 (Raw identity information) is never exposed.
|
||||
|
||||
### The Three Semantic Events
|
||||
|
||||
HomeKit exposes **thresholded events**, not raw probabilities:
|
||||
|
||||
| Event | HomeKit Characteristic | Meaning | Example Automation |
|
||||
|-------|----------------------|---------|-------------------|
|
||||
| **Unknown Presence** | MotionSensor (stateful) | Person detected + no matching identity record for >30s | "Turn on porch light when Unknown Presence detected after 9pm" |
|
||||
| **Unexpected Occupancy** | OccupancySensor | Occupancy outside the operator's defined schedule | "Send notification if office is occupied on weekends" |
|
||||
| **Unrecognized Activity Pattern** | ProgrammableSwitch (momentary) | Activity drift or recalibration gate fires | "Run a re-learning sequence when activity changes" |
|
||||
|
||||
### What's Deliberately Hidden
|
||||
|
||||
The following are **never** exposed to HomeKit:
|
||||
|
||||
- `identity_risk_score` (numeric 0–1 confidence) — only thresholded semantic events cross the boundary
|
||||
- Soul-Signature match probability — internal to BFLD
|
||||
- `rf_signature_hash` — cryptographic internal state
|
||||
|
||||
This enforces **ADR-125 §2.1.d invariant I1**: raw identity information never exits the node. The semantic framing is intentional — "Unknown Presence" reads as *who's-here-and-it's-fine-but-worth-noting*, not as an accusation.
|
||||
|
||||
For the technical definition, see [ADR-118 — Beamforming Feedback Layer for Detection](docs/adr/ADR-118-bfld-beamforming-feedback-layer-for-detection.md).
|
||||
|
||||
---
|
||||
|
||||
## Siri-by-Room
|
||||
|
||||
Name each HomeKit accessory after its room. The HAP bridge pulls room names from the state file prefixes:
|
||||
|
||||
```bash
|
||||
python c6-presence-watcher.py --node-id 1 \
|
||||
--output /tmp/ruview-state.LIVING_ROOM.json
|
||||
|
||||
# HAP bridge sees this and names the accessory "Living Room"
|
||||
```
|
||||
|
||||
When paired in the Home app, Siri knows the room:
|
||||
|
||||
| Query | Result |
|
||||
|-------|--------|
|
||||
| "Is anyone in the living room?" | Queries the Living Room accessory's motion sensor |
|
||||
| "Is anyone home?" | Queries all room accessories; returns true if any motion is detected |
|
||||
| "Turn on the bedroom lights when occupancy is detected" | Automation triggers on the Bedroom accessory only |
|
||||
|
||||
### StatelessProgrammableSwitch for Automations
|
||||
|
||||
Each room also exposes a **StatelessProgrammableSwitch** that fires on semantic-event boundaries (Unrecognized Activity Pattern, Recalibration, etc.). This is the HomeKit primitive for momentary triggers:
|
||||
|
||||
1. In the Home app, go to **Automation** → **Create New Automation** → **When an Accessory is Controlled**.
|
||||
2. Select **Living Room** → **Programmable Switch** → **Single Press**.
|
||||
3. Add an action: *Turn on scene*, *Send notification*, *Set HomeKit Secure Video recording*, etc.
|
||||
|
||||
---
|
||||
|
||||
## HomePod Announcements via Shortcuts (Tier 2 Path)
|
||||
|
||||
The easiest way to announce RuView events on a HomePod is through **Shortcuts-as-glue** — a native macOS launchd job that watches RuView's semantic events and triggers a Shortcut you define.
|
||||
|
||||
This path **bypasses the Bonjour reflector blocker** that can prevent HomePod discovery in some mesh networks. Instead of direct mDNS, the Mac uses the Home graph (iCloud-paired) to reach the HomePod.
|
||||
|
||||
### One-Time Setup
|
||||
|
||||
#### 1. Create the Shortcut in Shortcuts.app
|
||||
|
||||
1. Open **Shortcuts.app** on your Mac.
|
||||
2. Click **+** (top left) → **Create Shortcut**.
|
||||
3. Click **Add Action** → search for **"Speak Text"** → add it.
|
||||
4. In the **"Speak Text"** action, click the **speaker icon** → select your **HomePod** (or HomePod mini).
|
||||
5. Name the Shortcut **`RuView Announce`** (exact name).
|
||||
6. **Save** (top right).
|
||||
|
||||
#### 2. Test the Shortcut from the terminal
|
||||
|
||||
```bash
|
||||
osascript -e 'tell application "Shortcuts Events" to run shortcut "RuView Announce" with input "Test from RuView"'
|
||||
```
|
||||
|
||||
Your HomePod should speak "Test from RuView" in your chosen voice.
|
||||
|
||||
#### 3. Install the launchd job
|
||||
|
||||
Copy the launchd plist from the repository:
|
||||
|
||||
```bash
|
||||
cp scripts/macos-shortcuts/ruview-watcher.plist \
|
||||
~/Library/LaunchAgents/com.ruvnet.ruview.watcher.plist
|
||||
|
||||
launchctl load ~/Library/LaunchAgents/com.ruvnet.ruview.watcher.plist
|
||||
|
||||
launchctl list | grep ruvnet # Confirm it's loaded
|
||||
```
|
||||
|
||||
#### 4. Verify it works
|
||||
|
||||
Tail the log in one terminal:
|
||||
|
||||
```bash
|
||||
tail -f /tmp/ruview-watcher.log
|
||||
```
|
||||
|
||||
In another terminal, walk past the C6 and trigger a presence detection. The log should show:
|
||||
|
||||
```
|
||||
[17:10:12] unknown_presence rising-edge → running 'RuView Announce'
|
||||
```
|
||||
|
||||
And your HomePod should announce the event in its configured voice.
|
||||
|
||||
### Extending to Multiple Rooms
|
||||
|
||||
To announce different events in different rooms, create multiple Shortcuts in Shortcuts.app:
|
||||
|
||||
- `RuView Announce Kitchen`
|
||||
- `RuView Announce Bedroom`
|
||||
|
||||
Then run multiple watcher jobs with different `--shortcut-name` flags:
|
||||
|
||||
```bash
|
||||
# Kitchen events on HomePod mini in kitchen
|
||||
scripts/macos-shortcuts/announce-via-homepod.sh \
|
||||
--node-id 1 --event unknown_presence \
|
||||
--shortcut-name "RuView Announce Kitchen" \
|
||||
--poll-interval 2 &
|
||||
|
||||
# Bedroom events on HomePod in bedroom
|
||||
scripts/macos-shortcuts/announce-via-homepod.sh \
|
||||
--node-id 2 --event unknown_presence \
|
||||
--shortcut-name "RuView Announce Bedroom" \
|
||||
--poll-interval 2 &
|
||||
```
|
||||
|
||||
### Going Further
|
||||
|
||||
Because the Shortcut is operator-editable in Shortcuts.app, you can extend it to do anything:
|
||||
|
||||
- **Activate a scene** ("turn on bedtime scene when fall risk detected")
|
||||
- **Send a notification** to your Apple Watch
|
||||
- **Call a Webhook** to integrate with other systems
|
||||
- **Send a message** to another person's iPhone
|
||||
- **Trigger a HomeKit secure camera recording**
|
||||
|
||||
This is the flexibility of the Shortcuts-as-glue approach — no code change needed in RuView, all customization in the operator's own Shortcuts library.
|
||||
|
||||
For complete setup details and troubleshooting, see [`scripts/macos-shortcuts/README.md`](scripts/macos-shortcuts/README.md).
|
||||
|
||||
---
|
||||
|
||||
## Agentic Consumption via MCP
|
||||
|
||||
RuView's sensing stream is also available through Model Context Protocol (MCP) — the standard interface for Claude Code and other AI agents to query RuView data.
|
||||
|
||||
### The `@ruvnet/rvagent` npm package (v0.1.0)
|
||||
|
||||
The package exposes **12 MCP tools** that let Claude Code agents:
|
||||
|
||||
- Query presence and occupancy per room
|
||||
- Read breathing rate and heart rate telemetry
|
||||
- Monitor BFLD semantic events
|
||||
- Inspect the app registry (edge modules)
|
||||
- Kickstart background training jobs
|
||||
|
||||
### Installation
|
||||
|
||||
In your Claude Code project:
|
||||
|
||||
```bash
|
||||
npm install -D @ruvnet/rvagent@0.1.0
|
||||
|
||||
# Or, add via MCP:
|
||||
claude mcp add rvagent -- npx -y @ruvnet/rvagent@0.1.0
|
||||
```
|
||||
|
||||
Then in your Claude Code chat:
|
||||
|
||||
```
|
||||
/claude-flow-help # Lists all available MCP tools
|
||||
```
|
||||
|
||||
### Tool Reference
|
||||
|
||||
| Tool | Input | Output |
|
||||
|------|-------|--------|
|
||||
| `ruview_csi_latest` | node_id | Latest CSI window (1024 subcarriers, 30 OFDM symbols) |
|
||||
| `ruview_pose_infer` | CSI window | 17-keypoint skeleton (x, y, confidence per joint) |
|
||||
| `ruview_count_infer` | CSI window | Person count + 95% CI |
|
||||
| `ruview_registry_list` | query (optional) | List of 105+ available edge modules |
|
||||
| `ruview_train_count` | epochs, learning_rate | Kickoff training job ID |
|
||||
| `ruview_job_status` | job_id | Progress, ETA, current loss |
|
||||
| `ruview.bfld.last_scan` | node_id | Latest BFLD scan: privacy_class, person_count (identity_risk_score=null per I1 invariant) |
|
||||
| `ruview.bfld.subscribe` | node_id, event_filter | Stream BFLD windows until you close the stream |
|
||||
| `ruview.presence.now` | room (optional) | Current occupancy per room |
|
||||
| `ruview.vitals.get_breathing` | node_id | Breathing rate (BPM) + confidence |
|
||||
| `ruview.vitals.get_heart_rate` | node_id | Heart rate (BPM) + confidence |
|
||||
| `ruview.vitals.get_all` | node_id | Breathing + heart rate + metadata |
|
||||
|
||||
### Example: Claude Code Agent Workflow
|
||||
|
||||
```python
|
||||
# Claude-flow agent pseudocode
|
||||
import claude_code
|
||||
|
||||
tools = claude_code.mcp_tools("rvagent")
|
||||
|
||||
# Query latest presence
|
||||
presence = tools["ruview.presence.now"](room="living room")
|
||||
print(f"Living room occupancy: {presence.occupancy}") # True/False
|
||||
|
||||
# Check vitals
|
||||
vitals = tools["ruview.vitals.get_all"](node_id=1)
|
||||
print(f"Breathing: {vitals.breathing_bpm} BPM")
|
||||
|
||||
# Stream BFLD events in real-time
|
||||
for event in tools["ruview.bfld.subscribe"](node_id=1, event_filter="unknown_presence"):
|
||||
print(f"Unknown presence detected: privacy_class={event.privacy_class}")
|
||||
```
|
||||
|
||||
For the full MCP specification, see [ADR-124 — rvagent MCP / RuVector npm integration](docs/adr/ADR-124-rvagent-mcp-ruvector-npm-integration.md).
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### HomePod Not Visible on `dns-sd -B _airplay._tcp local.` from the Mac
|
||||
|
||||
**Likely cause**: HomePod and Mac are on different subnets despite being on the same SSID. Some mesh networks segment 2.4 GHz and 5 GHz bands onto different `/24` subnets, or place guest devices on a separate VLAN.
|
||||
|
||||
**Check**:
|
||||
|
||||
1. Open your router admin page and confirm both the HomePod and Mac are in the same subnet range (e.g., both `192.168.1.x`).
|
||||
2. If they're on different subnets (e.g., `192.168.1.x` vs `192.168.100.x`), enable **IGMP Proxying** in your router settings (common on Netgear Nighthawk). If available, enable **Bonjour Repeater** or **mDNS Reflector** instead.
|
||||
3. Restart the HomePod and Mac.
|
||||
|
||||
**Note**: The **Shortcuts-as-glue path (Tier 2)** doesn't need this fix — it routes announcements through the iCloud Home graph, not mDNS.
|
||||
|
||||
### iPhone Pairing Fails with "Couldn't Add Accessory"
|
||||
|
||||
**Likely cause**: The HAP bridge's pairing state is corrupt or out of sync with mDNS.
|
||||
|
||||
**Fix**:
|
||||
|
||||
1. Stop the HAP bridge daemon.
|
||||
2. Delete the pairing state file:
|
||||
```bash
|
||||
rm -rf ~/.ruview-hap-prod/accessory.state
|
||||
```
|
||||
3. Restart the HAP bridge — it regenerates a new setup code.
|
||||
4. From the Home app, retry **Add Accessory** → **More Options** with the new setup code.
|
||||
|
||||
### The Setup Code Regenerates on Restart
|
||||
|
||||
**Expected behavior.** HAP-python regenerates the setup code if the pairing persist file is missing or corrupt. Once you've paired successfully, the pairing key is stored separately in `~/.ruview-hap-prod/` and survives restarts — the setup code itself is transient and only matters during initial pairing.
|
||||
|
||||
If you lose the setup code before pairing, simply delete the state and restart to get a new one.
|
||||
|
||||
### Presence Updates Are Slow or Stuck
|
||||
|
||||
**Likely cause**: The HTTP polling loop in `ruview-sensing-server.py` is blocked, or the C6 is not sending UDP packets.
|
||||
|
||||
**Check**:
|
||||
|
||||
1. Verify the C6 is booting: `ping 192.168.1.20`.
|
||||
2. Verify packets are reaching the sensing server:
|
||||
```bash
|
||||
nc -u -l 5005 & # Listen on UDP 5005
|
||||
# You should see occasional packets from the C6
|
||||
```
|
||||
3. Manually query the sensing server:
|
||||
```bash
|
||||
curl http://127.0.0.1:3000/api/v1/vitals/latest
|
||||
```
|
||||
Should return JSON with breathing and heart rate fields.
|
||||
4. If the HAP bridge doesn't reflect the changes after polling, restart it.
|
||||
|
||||
---
|
||||
|
||||
## What's NOT in Scope
|
||||
|
||||
These items are intentionally deferred or beyond the current release:
|
||||
|
||||
| Item | Status | Timeline |
|
||||
|------|--------|----------|
|
||||
| **Matter Protocol (P3)** | Deferred | Waiting for `matter-rs` SDK stabilization; HAP-1.1 covers 95% of the UX today |
|
||||
| **Rust-native HAP (P2)** | Planned | Replaces Python `HAP-python` sidecar; expected after operator feedback from 5+ real pairings |
|
||||
| **PyO3 BFLD wheel deployment (ADR-117 P5)** | Pending | Runtime import flip so Python scripts use the Rust BFLD crate; source-built (✅ `cargo check` green) but wheel not yet published |
|
||||
| **Custom characteristic UUIDs for Eve.app (Iter 8 runtime)** | Scaffolded | Design complete; awaiting HAP-python JSON-loader implementation (small follow-up PR) |
|
||||
| **AirPlay 2 voice synthesis (pyatv)** | Network-pending | Requires HomePod visible on Bonjour from the Mac; Shortcuts-as-glue (Tier 2) is the working alternative |
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [ADR-125 — RuView ↔ Apple Home native HAP bridge](docs/adr/ADR-125-ruview-apple-home-native-hap-bridge.md) — Design spec, privacy rationale, sequencing
|
||||
- [ADR-118 — Beamforming Feedback Layer for Detection](docs/adr/ADR-118-bfld-beamforming-feedback-layer-for-detection.md) — BFLD privacy gate and identity-risk semantics
|
||||
- [ADR-124 — rvagent MCP / RuVector npm integration](docs/adr/ADR-124-rvagent-mcp-ruvector-npm-integration.md) — MCP tool specification
|
||||
- [Issue #796](https://github.com/ruvnet/RuView/issues/796) — Tier 1+2 sprint tracking (close-out comments have per-iter empirical data)
|
||||
- [scripts/macos-shortcuts/README.md](scripts/macos-shortcuts/README.md) — Shortcuts-as-glue setup and troubleshooting
|
||||
- [HomeKit Accessory Protocol (Non-Commercial Version)](https://developer.apple.com/apple-home/) — HAP-1.1 spec
|
||||
- [HAP-python on GitHub](https://github.com/ikalchev/HAP-python) — Implementation library
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
*.tsbuildinfo
|
||||
coverage/
|
||||
@@ -0,0 +1,69 @@
|
||||
# @ruvnet/homecore-frontend
|
||||
|
||||
HOMECORE web UI — built with Lit 3, TypeScript, and Vite.
|
||||
Design system mirrors the cognitum-v0 / v0-appliance dashboard (ADR-131).
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # http://localhost:5173
|
||||
```
|
||||
|
||||
The Vite dev server proxies `/api` → `http://localhost:8123`, so you need a
|
||||
`homecore-api-server` (or the `wifi-densepose-sensing-server` crate) running on `:8123`.
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | Description |
|
||||
|--------|-------------|
|
||||
| `npm run dev` | Start Vite dev server on port 5173 |
|
||||
| `npm run build` | TypeScript compile + Vite production bundle → `dist/` |
|
||||
| `npm run lint` | ESLint on `src/` |
|
||||
| `npm test` | Vitest unit tests (3 suites, jsdom) |
|
||||
|
||||
## Package layout
|
||||
|
||||
```
|
||||
frontend/
|
||||
src/
|
||||
api/
|
||||
client.ts # fetch + WebSocket client (REST + WS)
|
||||
types.ts # TypeScript types matching homecore-api JSON shapes
|
||||
components/
|
||||
AppShell.ts # <hc-app-shell> — header + nav + content slot
|
||||
StateCard.ts # <hc-state-card> — single entity state card
|
||||
icons/
|
||||
lucide.ts # Tree-shaken Lucide icon wrapper
|
||||
styles/
|
||||
tokens.css # 16 CSS custom properties (--hc-*)
|
||||
base.css # Typography reset, page shell, nav layout
|
||||
__tests__/ # Vitest unit tests
|
||||
index.html # Shell loading src/main.ts
|
||||
vite.config.ts
|
||||
tsconfig.json
|
||||
vitest.config.ts
|
||||
```
|
||||
|
||||
## Design system
|
||||
|
||||
Colors, typography, and components mirror the cognitum-v0 dashboard
|
||||
(`http://cognitum-v0:9000/`). Dark-only; no light-mode. Key tokens:
|
||||
|
||||
- `--hc-primary` `#19d4e5` — teal (active nav, focus ring, CTA borders)
|
||||
- `--hc-accent` `#26d867` — green (success, secondary CTA)
|
||||
- `--hc-bg` `#0b0e13` — near-black navy page root
|
||||
- Font: Outfit (display) + JetBrains Mono (mono)
|
||||
- Icons: Lucide (SVG, `stroke: currentColor`, no icon font)
|
||||
|
||||
See `docs/design/HOMECORE-FRONTEND-design-recon.md` for the full recon.
|
||||
|
||||
## Architecture notes
|
||||
|
||||
- Components are standard Lit `LitElement` custom elements — compatible with
|
||||
any HTML page and with Home Assistant's Lit-based frontend.
|
||||
- The REST client uses `fetch`; the WS client uses `WebSocket`. Both accept a
|
||||
bearer token and are fully typed against the Rust `homecore-api` JSON shapes.
|
||||
- WASM: `vite.config.ts` enables `.wasm` asset import. Hook up via dynamic
|
||||
`import('/path/to/module.wasm?init')` when WASM bindings are ready.
|
||||
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>HOMECORE</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=JetBrains+Mono:wght@400;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<hc-app-shell></hc-app-shell>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+4429
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@ruvnet/homecore-frontend",
|
||||
"version": "0.1.0-alpha.0",
|
||||
"description": "HOMECORE web UI — Lit + TypeScript + Vite, cognitum-v0 design system",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"lit": "^3.2.1",
|
||||
"lucide": "^0.474.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.0",
|
||||
"eslint": "^9.17.0",
|
||||
"jsdom": "^25.0.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.6",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Unit tests for <hc-state-card>.
|
||||
* Verifies that the component renders entity_id and state value into the DOM.
|
||||
*
|
||||
* Uses jsdom (via vitest environment) — no real browser required.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import type { StateView } from '../api/types.js';
|
||||
|
||||
// Register the custom element before tests run
|
||||
beforeAll(async () => {
|
||||
// jsdom does not support Lit's adoptedStyleSheets; suppress the error.
|
||||
if (typeof document !== 'undefined' && !document.adoptedStyleSheets) {
|
||||
Object.defineProperty(document, 'adoptedStyleSheets', { value: [], writable: true });
|
||||
}
|
||||
await import('../components/StateCard.js');
|
||||
});
|
||||
|
||||
function makeState(overrides: Partial<StateView> = {}): StateView {
|
||||
return {
|
||||
entity_id: 'light.living_room',
|
||||
state: 'on',
|
||||
attributes: { brightness: 255 },
|
||||
last_changed: '2026-05-25T10:00:00Z',
|
||||
last_updated: '2026-05-25T10:00:00Z',
|
||||
context: { id: 'abc123', user_id: null, parent_id: null },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('StateCard', () => {
|
||||
it('renders entity_id in the DOM', async () => {
|
||||
const el = document.createElement('hc-state-card') as HTMLElement & { state: StateView };
|
||||
el.state = makeState();
|
||||
document.body.appendChild(el);
|
||||
|
||||
// Lit renders synchronously into shadow root after a microtask
|
||||
await el.updateComplete;
|
||||
|
||||
const shadowRoot = el.shadowRoot!;
|
||||
const entityEl = shadowRoot.querySelector('.entity-id');
|
||||
expect(entityEl).not.toBeNull();
|
||||
expect(entityEl!.textContent).toContain('light.living_room');
|
||||
|
||||
document.body.removeChild(el);
|
||||
});
|
||||
|
||||
it('renders the state value', async () => {
|
||||
const el = document.createElement('hc-state-card') as HTMLElement & { state: StateView };
|
||||
el.state = makeState({ state: 'off' });
|
||||
document.body.appendChild(el);
|
||||
|
||||
await el.updateComplete;
|
||||
|
||||
const stateEl = el.shadowRoot!.querySelector('.state-value');
|
||||
expect(stateEl).not.toBeNull();
|
||||
expect(stateEl!.textContent).toBe('off');
|
||||
|
||||
document.body.removeChild(el);
|
||||
});
|
||||
|
||||
it('applies .off badge class for unavailable state', async () => {
|
||||
const el = document.createElement('hc-state-card') as HTMLElement & { state: StateView };
|
||||
el.state = makeState({ state: 'unavailable' });
|
||||
document.body.appendChild(el);
|
||||
|
||||
await el.updateComplete;
|
||||
|
||||
const badge = el.shadowRoot!.querySelector('.badge.off');
|
||||
expect(badge).not.toBeNull();
|
||||
|
||||
document.body.removeChild(el);
|
||||
});
|
||||
});
|
||||
|
||||
// Augment for updateComplete
|
||||
declare global {
|
||||
interface HTMLElement {
|
||||
updateComplete: Promise<boolean>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Unit tests for HomecoreClient REST methods.
|
||||
* Mocks global `fetch` and asserts correct URL + Authorization header.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { HomecoreClient } from '../api/client.js';
|
||||
|
||||
describe('HomecoreClient', () => {
|
||||
const token = 'test-bearer-token';
|
||||
let client: HomecoreClient;
|
||||
let fetchSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new HomecoreClient({ token });
|
||||
fetchSpy = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([]),
|
||||
} as Response);
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('getStates() GETs /api/states with the bearer header', async () => {
|
||||
await client.getStates();
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledOnce();
|
||||
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
||||
|
||||
expect(url).toBe('/api/states');
|
||||
expect((init.headers as Record<string, string>)['Authorization']).toBe(`Bearer ${token}`);
|
||||
expect(init.method).toBe('GET');
|
||||
});
|
||||
|
||||
it('getState() GETs /api/states/:entity_id with the bearer header', async () => {
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ entity_id: 'light.living', state: 'on', attributes: {}, last_changed: '', last_updated: '', context: { id: 'x', user_id: null, parent_id: null } }),
|
||||
} as Response);
|
||||
|
||||
await client.getState('light.living');
|
||||
|
||||
const [url] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe('/api/states/light.living');
|
||||
});
|
||||
|
||||
it('getConfig() GETs /api/config', async () => {
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ location_name: 'Home', version: '0.1.0', state: 'RUNNING', components: [] }),
|
||||
} as Response);
|
||||
|
||||
await client.getConfig();
|
||||
|
||||
const [url] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe('/api/config');
|
||||
});
|
||||
|
||||
it('throws on non-OK response', async () => {
|
||||
fetchSpy.mockResolvedValueOnce({ ok: false, status: 401, statusText: 'Unauthorized' } as Response);
|
||||
|
||||
await expect(client.getStates()).rejects.toThrow('401');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Validates that tokens.css contains all 16 documented HOMECORE design tokens.
|
||||
* Reads the file from disk and checks for each CSS custom property name.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const tokensPath = resolve(__dirname, '../styles/tokens.css');
|
||||
const css = readFileSync(tokensPath, 'utf-8');
|
||||
|
||||
/**
|
||||
* The 16 design tokens from ADR-131 §9 / HOMECORE-FRONTEND-design-recon.md §1.
|
||||
* 4 surfaces + 2 text + 6 accent/status + 2 border/ring + 2 radius = 16 tokens.
|
||||
*/
|
||||
const REQUIRED_TOKENS = [
|
||||
// Surfaces (4)
|
||||
'--hc-bg',
|
||||
'--hc-surface-card',
|
||||
'--hc-surface-elevated',
|
||||
'--hc-surface-overlay',
|
||||
// Text (2)
|
||||
'--hc-text',
|
||||
'--hc-text-muted',
|
||||
// Accent palette (6)
|
||||
'--hc-primary',
|
||||
'--hc-primary-fg',
|
||||
'--hc-accent',
|
||||
'--hc-accent-fg',
|
||||
'--hc-destructive',
|
||||
'--hc-warning',
|
||||
// Borders & rings (2)
|
||||
'--hc-border',
|
||||
'--hc-ring',
|
||||
// Radii (2)
|
||||
'--hc-radius',
|
||||
'--hc-radius-sm',
|
||||
] as const;
|
||||
|
||||
describe('tokens.css', () => {
|
||||
it('contains all 16 documented design tokens', () => {
|
||||
for (const token of REQUIRED_TOKENS) {
|
||||
expect(css, `Missing token: ${token}`).toContain(token);
|
||||
}
|
||||
});
|
||||
|
||||
it('has exactly 16 (or more) --hc- custom properties', () => {
|
||||
const matches = css.match(/--hc-[\w-]+\s*:/g) ?? [];
|
||||
// De-duplicate (token may appear in comments)
|
||||
const unique = new Set(matches.map(m => m.replace(/\s*:/, '')));
|
||||
expect(unique.size).toBeGreaterThanOrEqual(16);
|
||||
});
|
||||
|
||||
it('defines the teal primary token with the correct hue value', () => {
|
||||
// --hc-primary must reference HSL hue 185 (teal, from cognitum-v0)
|
||||
expect(css).toMatch(/--hc-primary\s*:\s*hsl\(185/);
|
||||
});
|
||||
|
||||
it('defines the green accent token (#26d867)', () => {
|
||||
// --hc-accent must reference HSL 142 70% 50%
|
||||
expect(css).toMatch(/--hc-accent\s*:\s*hsl\(142/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* HOMECORE API client.
|
||||
*
|
||||
* REST: fetch-based, bearer token auth. Base URL defaults to window.location.origin
|
||||
* so the Vite dev-server proxy handles the `/api` → `:8123` rewrite.
|
||||
* WS: native WebSocket, mirrors HA's ws handshake protocol (auth_required → auth → auth_ok).
|
||||
*/
|
||||
|
||||
import type {
|
||||
ApiConfig,
|
||||
ServiceDomainView,
|
||||
StateView,
|
||||
WsAuthOk,
|
||||
WsAuthRequired,
|
||||
WsServerMessage,
|
||||
} from './types.js';
|
||||
|
||||
export interface ClientOptions {
|
||||
baseUrl?: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export class HomecoreClient {
|
||||
private readonly base: string;
|
||||
private readonly token: string;
|
||||
|
||||
constructor(options: ClientOptions) {
|
||||
this.base = options.baseUrl ?? '';
|
||||
this.token = options.token;
|
||||
}
|
||||
|
||||
// ── REST helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
private headers(): HeadersInit {
|
||||
return {
|
||||
'Authorization': `Bearer ${this.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
private async get<T>(path: string): Promise<T> {
|
||||
const resp = await fetch(`${this.base}${path}`, {
|
||||
method: 'GET',
|
||||
headers: this.headers(),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(`GET ${path} → ${resp.status} ${resp.statusText}`);
|
||||
}
|
||||
return resp.json() as Promise<T>;
|
||||
}
|
||||
|
||||
private async post<T>(path: string, body: unknown): Promise<T> {
|
||||
const resp = await fetch(`${this.base}${path}`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(`POST ${path} → ${resp.status} ${resp.statusText}`);
|
||||
}
|
||||
return resp.json() as Promise<T>;
|
||||
}
|
||||
|
||||
// ── REST endpoints (mirrors rest.rs) ─────────────────────────────────────
|
||||
|
||||
getConfig(): Promise<ApiConfig> {
|
||||
return this.get<ApiConfig>('/api/config');
|
||||
}
|
||||
|
||||
getStates(): Promise<StateView[]> {
|
||||
return this.get<StateView[]>('/api/states');
|
||||
}
|
||||
|
||||
getState(entityId: string): Promise<StateView> {
|
||||
return this.get<StateView>(`/api/states/${encodeURIComponent(entityId)}`);
|
||||
}
|
||||
|
||||
setState(entityId: string, state: string, attributes?: Record<string, unknown>): Promise<StateView> {
|
||||
return this.post<StateView>(`/api/states/${encodeURIComponent(entityId)}`, {
|
||||
state,
|
||||
attributes: attributes ?? {},
|
||||
});
|
||||
}
|
||||
|
||||
getServices(): Promise<ServiceDomainView[]> {
|
||||
return this.get<ServiceDomainView[]>('/api/services');
|
||||
}
|
||||
|
||||
callService(domain: string, service: string, data?: unknown): Promise<unknown> {
|
||||
return this.post<unknown>(`/api/services/${domain}/${service}`, data ?? {});
|
||||
}
|
||||
|
||||
// ── WebSocket ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Open an authenticated WebSocket connection.
|
||||
* Resolves once `auth_ok` is received; rejects on auth failure or network error.
|
||||
* Returns the live socket; caller is responsible for `.close()`.
|
||||
*/
|
||||
openWebSocket(wsBase?: string): Promise<WebSocket> {
|
||||
const resolved = wsBase ?? this.base.replace(/^http/, 'ws');
|
||||
const origin = resolved || window.location.origin.replace(/^http/, 'ws');
|
||||
const url = `${origin}/api/websocket`;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(url);
|
||||
|
||||
ws.onmessage = (evt: MessageEvent<string>) => {
|
||||
const msg = JSON.parse(evt.data) as WsServerMessage;
|
||||
|
||||
if ((msg as WsAuthRequired).type === 'auth_required') {
|
||||
ws.send(JSON.stringify({ type: 'auth', access_token: this.token }));
|
||||
return;
|
||||
}
|
||||
|
||||
if ((msg as WsAuthOk).type === 'auth_ok') {
|
||||
ws.onmessage = null;
|
||||
resolve(ws);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'auth_invalid') {
|
||||
ws.close();
|
||||
reject(new Error(`WS auth_invalid`));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => reject(new Error('WebSocket connection error'));
|
||||
ws.onclose = () => reject(new Error('WebSocket closed before auth_ok'));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* TypeScript types mirroring the JSON shapes from homecore-api/src/rest.rs and ws.rs.
|
||||
* Keep in sync with Rust `StateView`, `ApiConfig`, `ServiceDomainView`.
|
||||
*/
|
||||
|
||||
/** Context for a state change — mirrors Rust `ContextView`. */
|
||||
export interface ContextView {
|
||||
id: string;
|
||||
user_id: string | null;
|
||||
parent_id: string | null;
|
||||
}
|
||||
|
||||
/** Snapshot of a single entity state — mirrors Rust `StateView`. */
|
||||
export interface StateView {
|
||||
entity_id: string;
|
||||
state: string;
|
||||
/** Arbitrary JSON attributes attached to the entity. */
|
||||
attributes: Record<string, unknown>;
|
||||
/** RFC 3339 timestamp of last state value change. */
|
||||
last_changed: string;
|
||||
/** RFC 3339 timestamp of last update (attributes may have changed). */
|
||||
last_updated: string;
|
||||
context: ContextView;
|
||||
}
|
||||
|
||||
/** HOMECORE configuration — mirrors Rust `ApiConfig`. */
|
||||
export interface ApiConfig {
|
||||
location_name: string;
|
||||
version: string;
|
||||
state: 'RUNNING' | 'STARTING' | 'STOPPING';
|
||||
components: string[];
|
||||
}
|
||||
|
||||
/** Services grouped by domain — mirrors Rust `ServiceDomainView`. */
|
||||
export interface ServiceDomainView {
|
||||
domain: string;
|
||||
/** Keyed by service name; value is the service schema (may be empty `{}`). */
|
||||
services: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ── WebSocket protocol types ──────────────────────────────────────────────────
|
||||
|
||||
/** Sent by server immediately upon WS upgrade. */
|
||||
export interface WsAuthRequired {
|
||||
type: 'auth_required';
|
||||
ha_version: string;
|
||||
}
|
||||
|
||||
/** Sent by client to authenticate. */
|
||||
export interface WsAuth {
|
||||
type: 'auth';
|
||||
access_token: string;
|
||||
}
|
||||
|
||||
/** Sent by server on successful auth. */
|
||||
export interface WsAuthOk {
|
||||
type: 'auth_ok';
|
||||
ha_version: string;
|
||||
}
|
||||
|
||||
/** Sent by server on failed auth. */
|
||||
export interface WsAuthInvalid {
|
||||
type: 'auth_invalid';
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Generic result message from server. */
|
||||
export interface WsResult<T = unknown> {
|
||||
id: number;
|
||||
type: 'result';
|
||||
success: boolean;
|
||||
result?: T;
|
||||
error?: { code: string; message: string };
|
||||
}
|
||||
|
||||
/** State-changed event pushed by server via `subscribe_events`. */
|
||||
export interface WsStateChangedEvent {
|
||||
id: number;
|
||||
type: 'event';
|
||||
event: {
|
||||
event_type: 'state_changed';
|
||||
data: {
|
||||
entity_id: string;
|
||||
old_state: StateView | null;
|
||||
new_state: StateView | null;
|
||||
};
|
||||
origin: 'LOCAL' | 'REMOTE';
|
||||
time_fired: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** Union of all inbound WS server messages. */
|
||||
export type WsServerMessage =
|
||||
| WsAuthRequired
|
||||
| WsAuthOk
|
||||
| WsAuthInvalid
|
||||
| WsResult
|
||||
| WsStateChangedEvent;
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* `<hc-app-shell>` — top-level layout: sticky header + horizontal sidenav + content slot.
|
||||
* Page shell mirrors cognitum-v0's appbar + wrap layout (ADR-131 §3).
|
||||
*/
|
||||
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
|
||||
export interface NavItem {
|
||||
id: string;
|
||||
label: string;
|
||||
/** Raw SVG string for the icon */
|
||||
iconSvg?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_NAV: NavItem[] = [
|
||||
{ id: 'dashboard', label: 'Dashboard' },
|
||||
{ id: 'states', label: 'States' },
|
||||
{ id: 'services', label: 'Services' },
|
||||
{ id: 'settings', label: 'Settings' },
|
||||
];
|
||||
|
||||
@customElement('hc-app-shell')
|
||||
export class AppShell extends LitElement {
|
||||
@property({ type: String }) locationName = 'HOMECORE';
|
||||
@property({ type: String }) version = '0.1.0';
|
||||
@property({ type: Array }) navItems: NavItem[] = DEFAULT_NAV;
|
||||
@state() private activeId = 'dashboard';
|
||||
|
||||
static styles = css`
|
||||
:host { display: block; min-height: 100dvh; background: var(--hc-bg, #0b0e13); }
|
||||
|
||||
/* ── Appbar ── */
|
||||
.appbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
background: hsl(220 25% 6% / 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border-bottom: 1px solid hsl(220 15% 18% / 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0 1.25rem;
|
||||
height: 3.25rem;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-family: var(--hc-font-display, 'Outfit', system-ui, sans-serif);
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--hc-text, #e6eaee);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 0.4rem;
|
||||
background: var(--hc-primary, #19d4e5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--hc-primary-fg, #0b0e13);
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
flex: 1;
|
||||
mask-image: linear-gradient(to right, black calc(100% - 24px), transparent);
|
||||
}
|
||||
.nav::-webkit-scrollbar { display: none; }
|
||||
|
||||
.nav-link {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.4rem 0.7rem;
|
||||
border-radius: 0.4rem;
|
||||
font-family: var(--hc-font-display, 'Outfit', system-ui, sans-serif);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--hc-text-muted, #7b899d);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: color 150ms, background 150ms;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--hc-text, #e6eaee);
|
||||
background: hsl(220 20% 14%);
|
||||
}
|
||||
|
||||
.nav-link:focus-visible {
|
||||
outline: 2px solid hsl(185 80% 50% / 0.6);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.nav-link:active { transform: translateY(1px); }
|
||||
|
||||
.nav-link.active { color: var(--hc-primary, #19d4e5); }
|
||||
|
||||
.nav-link.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 0.7rem;
|
||||
right: 0.7rem;
|
||||
height: 2px;
|
||||
background: var(--hc-primary, #19d4e5);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.version-chip {
|
||||
font-family: var(--hc-font-mono, 'JetBrains Mono', monospace);
|
||||
font-size: 0.6875rem;
|
||||
color: var(--hc-text-muted, #7b899d);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Main content ── */
|
||||
main {
|
||||
max-width: 1400px;
|
||||
margin-inline: auto;
|
||||
padding-inline: 1.25rem;
|
||||
padding-block: 1.5rem;
|
||||
}
|
||||
|
||||
/* ── Footer ── */
|
||||
footer {
|
||||
border-top: 1px solid hsl(220 15% 18%);
|
||||
text-align: center;
|
||||
padding: 1rem 1.25rem;
|
||||
font-family: var(--hc-font-mono, 'JetBrains Mono', monospace);
|
||||
font-size: 0.75rem;
|
||||
color: var(--hc-text-muted, #7b899d);
|
||||
}
|
||||
`;
|
||||
|
||||
private onNavClick(id: string) {
|
||||
this.activeId = id;
|
||||
this.dispatchEvent(new CustomEvent('hc-navigate', { detail: { id }, bubbles: true, composed: true }));
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<header class="appbar" part="appbar">
|
||||
<div class="brand">
|
||||
<div class="brand-icon" aria-hidden="true">H</div>
|
||||
${this.locationName}
|
||||
</div>
|
||||
<nav class="nav" aria-label="Primary navigation">
|
||||
${this.navItems.map(item => html`
|
||||
<button
|
||||
class="nav-link ${this.activeId === item.id ? 'active' : ''}"
|
||||
@click=${() => this.onNavClick(item.id)}
|
||||
aria-current=${this.activeId === item.id ? 'page' : 'false'}
|
||||
>${item.label}</button>
|
||||
`)}
|
||||
</nav>
|
||||
<span class="version-chip">v${this.version}</span>
|
||||
</header>
|
||||
|
||||
<main part="content">
|
||||
<slot></slot>
|
||||
</main>
|
||||
|
||||
<footer part="footer">
|
||||
HOMECORE — ${this.locationName} — v${this.version}
|
||||
</footer>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'hc-app-shell': AppShell;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* `<hc-state-card>` — renders one HOMECORE entity state in the cognitum-v0 card style.
|
||||
* Uses Lit 3 (LitElement + html/css template tags).
|
||||
*/
|
||||
|
||||
import { LitElement, html, css, nothing } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import type { StateView } from '../api/types.js';
|
||||
|
||||
@customElement('hc-state-card')
|
||||
export class StateCard extends LitElement {
|
||||
@property({ type: Object }) state!: StateView;
|
||||
/** Optional: icon SVG string (use `iconSvg()` from lucide.ts) */
|
||||
@property({ type: String }) iconSvg?: string;
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--hc-gradient-card, linear-gradient(180deg, #181c24 0%, #111318 100%));
|
||||
border: 1px solid hsl(220 15% 18% / 0.5);
|
||||
border-radius: var(--hc-radius, 0.75rem);
|
||||
box-shadow: var(--hc-shadow-card, 0 8px 32px -8px hsl(220 25% 2% / 0.8));
|
||||
padding: 1.25rem;
|
||||
transition: transform 200ms, border-color 200ms;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: hsl(185 80% 50% / 0.4);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.icon-wrap {
|
||||
flex-shrink: 0;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: var(--hc-radius-sm, 0.4rem);
|
||||
background: hsl(220 20% 14%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--hc-primary, #19d4e5);
|
||||
}
|
||||
|
||||
.meta { flex: 1; min-width: 0; }
|
||||
|
||||
.entity-id {
|
||||
font-family: var(--hc-font-mono, 'JetBrains Mono', monospace);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: var(--hc-text-muted, #7b899d);
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.state-value {
|
||||
font-family: var(--hc-font-display, 'Outfit', system-ui, sans-serif);
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--hc-text, #e6eaee);
|
||||
letter-spacing: -0.02em;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid var(--hc-border, #272b34);
|
||||
font-family: var(--hc-font-mono, monospace);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge.on { color: #26d867; border-color: hsl(142 70% 50% / 0.4); }
|
||||
.badge.off { color: #d22c2c; border-color: hsl(0 65% 50% / 0.4); }
|
||||
|
||||
.timestamp {
|
||||
font-family: var(--hc-font-mono, monospace);
|
||||
font-size: 0.625rem;
|
||||
color: var(--hc-text-muted, #7b899d);
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
`;
|
||||
|
||||
private badgeClass(state: string): string {
|
||||
const s = state.toLowerCase();
|
||||
if (s === 'on' || s === 'open' || s === 'home' || s === 'running') return 'on';
|
||||
if (s === 'off' || s === 'closed' || s === 'away' || s === 'unavailable') return 'off';
|
||||
return '';
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state) return nothing;
|
||||
const { entity_id, state, last_updated } = this.state;
|
||||
const badge = this.badgeClass(state);
|
||||
|
||||
return html`
|
||||
<div class="card" part="card">
|
||||
<div class="header">
|
||||
${this.iconSvg
|
||||
? html`<div class="icon-wrap" .innerHTML=${this.iconSvg}></div>`
|
||||
: nothing}
|
||||
<div class="meta">
|
||||
<div class="entity-id" title=${entity_id}>${entity_id}</div>
|
||||
<div class="state-value">${state}</div>
|
||||
</div>
|
||||
<span class="badge ${badge}">${state}</span>
|
||||
</div>
|
||||
<div class="timestamp">updated ${new Date(last_updated).toLocaleTimeString()}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'hc-state-card': StateCard;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Minimal Lucide icon wrapper.
|
||||
* Import only the icons used by HOMECORE components — Vite tree-shakes the rest.
|
||||
*/
|
||||
|
||||
export {
|
||||
Activity,
|
||||
BarChart3,
|
||||
Book,
|
||||
ChevronRight,
|
||||
Grid2X2,
|
||||
Home,
|
||||
LayoutDashboard,
|
||||
Settings,
|
||||
Shield,
|
||||
Sun,
|
||||
Wifi,
|
||||
Zap,
|
||||
} from 'lucide';
|
||||
|
||||
/** Re-export the icon node type for consumers that need it. */
|
||||
export type { IconNode as LucideIconNode } from 'lucide';
|
||||
|
||||
/**
|
||||
* Render a Lucide icon as an SVG string suitable for Lit's `unsafeHTML`.
|
||||
* Each icon is 24×24, no fill, stroke = currentColor, stroke-width = 2.
|
||||
*/
|
||||
export function iconSvg(
|
||||
paths: string,
|
||||
{ size = 24, label }: { size?: number; label?: string } = {},
|
||||
): string {
|
||||
const ariaAttrs = label
|
||||
? `role="img" aria-label="${label}"`
|
||||
: `aria-hidden="true"`;
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}"
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
${ariaAttrs}>${paths}</svg>`;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* HOMECORE frontend entry point.
|
||||
* Imports global styles, registers Lit components, and mounts the app shell.
|
||||
*/
|
||||
|
||||
import './styles/tokens.css';
|
||||
import './styles/base.css';
|
||||
|
||||
// Register custom elements
|
||||
import './components/AppShell.js';
|
||||
import './components/StateCard.js';
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* HOMECORE base styles — typography reset, page shell, nav layout.
|
||||
* Component vocabulary mirrors cognitum-v0 (ADR-131 §3–4).
|
||||
*/
|
||||
|
||||
@import './tokens.css';
|
||||
|
||||
/* ── Reset ── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html {
|
||||
color-scheme: dark;
|
||||
font-family: var(--hc-font-display);
|
||||
font-size: 16px;
|
||||
background: var(--hc-bg);
|
||||
color: var(--hc-text);
|
||||
}
|
||||
|
||||
body { min-height: 100dvh; }
|
||||
|
||||
/* ── Typography scale ── */
|
||||
h1 { font-size: 1.5rem; font-weight: 600; letter-spacing: -0.02em; }
|
||||
h2 { font-size: 1.125rem; font-weight: 700; letter-spacing: -0.02em; }
|
||||
h3 { font-size: 0.9375rem; font-weight: 600; letter-spacing: -0.02em; }
|
||||
h4 { font-size: 0.875rem; font-weight: 600; letter-spacing: -0.02em; }
|
||||
p { font-size: 0.875rem; line-height: 1.45; }
|
||||
|
||||
.mono { font-family: var(--hc-font-mono); }
|
||||
|
||||
/* ── Page shell ── */
|
||||
.hc-wrap {
|
||||
max-width: 1400px;
|
||||
margin-inline: auto;
|
||||
padding-inline: 1.25rem;
|
||||
padding-block: 1.5rem;
|
||||
}
|
||||
|
||||
/* ── Appbar ── */
|
||||
.hc-appbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
background: hsl(220 25% 6% / 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
border-bottom: 1px solid var(--hc-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0 1.25rem;
|
||||
height: 3.25rem;
|
||||
}
|
||||
|
||||
.hc-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
text-decoration: none;
|
||||
color: var(--hc-text);
|
||||
}
|
||||
|
||||
.hc-brand-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 0.4rem;
|
||||
background: var(--hc-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--hc-primary-fg);
|
||||
}
|
||||
|
||||
.hc-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
mask-image: linear-gradient(to right, black calc(100% - 24px), transparent);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.hc-nav::-webkit-scrollbar { display: none; }
|
||||
|
||||
.hc-nav-link {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.4rem 0.7rem;
|
||||
border-radius: var(--hc-radius-sm);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--hc-text-muted);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
transition: color 150ms, background 150ms;
|
||||
}
|
||||
|
||||
.hc-nav-link:hover {
|
||||
color: var(--hc-text);
|
||||
background: hsl(220 20% 14%);
|
||||
}
|
||||
|
||||
.hc-nav-link:focus-visible {
|
||||
outline: 2px solid hsl(185 80% 50% / 0.6);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.hc-nav-link:active { transform: translateY(1px); transition-duration: 50ms; }
|
||||
|
||||
.hc-nav-link.active {
|
||||
color: var(--hc-primary);
|
||||
}
|
||||
|
||||
.hc-nav-link.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 0.7rem;
|
||||
right: 0.7rem;
|
||||
height: 2px;
|
||||
background: var(--hc-primary);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
/* ── Card ── */
|
||||
.hc-card {
|
||||
background: var(--hc-gradient-card);
|
||||
border: 1px solid hsl(220 15% 18% / 0.5);
|
||||
border-radius: var(--hc-radius);
|
||||
box-shadow: var(--hc-shadow-card);
|
||||
padding: 1.25rem;
|
||||
transition: transform 200ms, border-color 200ms;
|
||||
}
|
||||
|
||||
.hc-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: hsl(185 80% 50% / 0.4);
|
||||
}
|
||||
|
||||
/* ── Badge ── */
|
||||
.hc-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: var(--hc-radius-pill);
|
||||
border: 1px solid var(--hc-border);
|
||||
font-family: var(--hc-font-mono);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.hc-badge.online { color: var(--hc-accent); border-color: hsl(142 70% 50% / 0.4); }
|
||||
.hc-badge.offline { color: var(--hc-destructive); border-color: hsl(0 65% 50% / 0.4); }
|
||||
.hc-badge.warning { color: var(--hc-warning); border-color: hsl(38 80% 60% / 0.4); }
|
||||
|
||||
/* ── Button ── */
|
||||
.hc-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 0.875rem;
|
||||
border-radius: var(--hc-radius-sm);
|
||||
font-family: var(--hc-font-display);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
border: 1px solid var(--hc-border);
|
||||
background: hsl(220 20% 14%);
|
||||
color: var(--hc-text);
|
||||
cursor: pointer;
|
||||
transition: background 150ms, border-color 150ms;
|
||||
}
|
||||
|
||||
.hc-btn:hover { background: hsl(220 20% 18%); }
|
||||
|
||||
.hc-btn.primary {
|
||||
background: var(--hc-primary);
|
||||
color: var(--hc-primary-fg);
|
||||
border-color: transparent;
|
||||
font-weight: 600;
|
||||
box-shadow: var(--hc-shadow-glow);
|
||||
}
|
||||
|
||||
.hc-btn.primary:hover { background: hsl(185 80% 55%); }
|
||||
|
||||
/* ── Section ── */
|
||||
.hc-section { margin-bottom: 1.5rem; }
|
||||
|
||||
.hc-section-label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--hc-text-muted);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
/* ── Grid helpers ── */
|
||||
.hc-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.hc-kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* ── Footer ── */
|
||||
.hc-footer {
|
||||
border-top: 1px solid var(--hc-border);
|
||||
text-align: center;
|
||||
padding: 1rem 1.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--hc-text-muted);
|
||||
font-family: var(--hc-font-mono);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* HOMECORE design tokens — sourced from cognitum-v0 (ADR-131 §9).
|
||||
* 16 CSS custom properties: 4 surfaces + 2 text + 6 accent/status + 2 border/ring + 2 radius.
|
||||
* Dark-only; no light-mode overrides.
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* ── Surfaces (darkest → lightest within dark palette) ── */
|
||||
--hc-bg: hsl(220 25% 6%); /* #0b0e13 — page root */
|
||||
--hc-surface-card: hsl(220 20% 10%); /* #14171e — card fill */
|
||||
--hc-surface-elevated: hsl(220 20% 12%); /* #181c24 — raised panel */
|
||||
--hc-surface-overlay: hsl(220 20% 8%); /* #111318 — modal / sticky nav base */
|
||||
|
||||
/* ── Text ── */
|
||||
--hc-text: hsl(210 20% 92%); /* #e6eaee — primary body text */
|
||||
--hc-text-muted: hsl(215 15% 55%); /* #7b899d — secondary / labels / timestamps */
|
||||
|
||||
/* ── Accent palette ── */
|
||||
--hc-primary: hsl(185 80% 50%); /* #19d4e5 — teal: active nav, CTA border, focus ring */
|
||||
--hc-primary-fg: hsl(220 25% 6%); /* #0b0e13 — text on filled primary buttons */
|
||||
--hc-accent: hsl(142 70% 50%); /* #26d867 — green: success / secondary CTA */
|
||||
--hc-accent-fg: hsl(220 25% 6%); /* #0b0e13 — text on filled accent buttons */
|
||||
--hc-destructive: hsl(0 65% 50%); /* #d22c2c — error / danger */
|
||||
--hc-warning: hsl(38 80% 60%); /* #e69940 — warning / amber (elevated from inline) */
|
||||
|
||||
/* ── Borders & rings ── */
|
||||
--hc-border: hsl(220 15% 18%); /* #272b34 — subtle 1px border */
|
||||
--hc-ring: hsl(185 80% 50%); /* #19d4e5 — focus ring (same hue as primary) */
|
||||
|
||||
/* ── Radii ── */
|
||||
--hc-radius: 0.75rem; /* cards, modals */
|
||||
--hc-radius-sm: 0.4rem; /* buttons, inputs, chips */
|
||||
--hc-radius-pill: 9999px; /* badges, CTA pills */
|
||||
|
||||
/* ── Typography ── */
|
||||
--hc-font-display: 'Outfit', system-ui, sans-serif;
|
||||
--hc-font-mono: 'JetBrains Mono', monospace;
|
||||
|
||||
/* ── Shadows ── */
|
||||
--hc-shadow-card: 0 8px 32px -8px hsl(220 25% 2% / 0.8);
|
||||
--hc-shadow-glow: 0 0 60px -10px hsl(185 80% 50% / 0.3);
|
||||
|
||||
/* ── Gradients ── */
|
||||
--hc-gradient-card: linear-gradient(180deg, hsl(220 20% 12%) 0%, hsl(220 20% 8%) 100%);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8123',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
target: 'es2022',
|
||||
outDir: 'dist',
|
||||
sourcemap: true,
|
||||
},
|
||||
optimizeDeps: {
|
||||
// Allow WASM async import via dynamic import()
|
||||
exclude: [],
|
||||
},
|
||||
// WASM async import support: vite handles .wasm?init natively
|
||||
assetsInclude: ['**/*.wasm'],
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: false,
|
||||
include: ['src/__tests__/**/*.test.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text'],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ruview",
|
||||
"description": "End-to-end RuView (WiFi-DensePose) toolkit for Claude Code: onboarding, ESP32 hardware setup, configuration, sensing applications, model training, advanced multistatic sensing, witness verification, BFLD privacy layer, and rvAgent + RVF agentic flows — from practical to advanced.",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"author": {
|
||||
"name": "ruvnet",
|
||||
"url": "https://github.com/ruvnet/RuView"
|
||||
@@ -19,5 +19,14 @@
|
||||
"edge-ai",
|
||||
"model-training",
|
||||
"onboarding"
|
||||
]
|
||||
],
|
||||
"mcpServers": {
|
||||
"rvagent": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@ruvnet/rvagent"],
|
||||
"env": {
|
||||
"RVAGENT_SENSING_URL": "http://localhost:3000"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,24 @@
|
||||
|
||||
You are helping the operator explore or prototype the integration of `vendor/ruvector/crates/rvAgent/` (a production Rust AI-agent framework) with RuView's existing sensing pipeline (`v2/crates/wifi-densepose-*`) and the RVF cognitive container format (`v2/crates/wifi-densepose-sensing-server/src/rvf_container.rs`).
|
||||
|
||||
## Live MCP server: `@ruvnet/rvagent` v0.1.0
|
||||
|
||||
The TypeScript MCP server (`tools/ruview-mcp/`, published as `@ruvnet/rvagent`) is live on npm and exposes `bfld_last_scan`, `bfld_subscribe`, `presence_now`, `vitals_get_breathing`, `vitals_get_heart_rate`, `vitals_get_all`, `vitals_fetch`. Add to a Codex MCP config:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"rvagent": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@ruvnet/rvagent"],
|
||||
"env": { "RVAGENT_SENSING_URL": "http://localhost:3000" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This is the operator-facing tool surface; the Rust crate below remains the substrate for deeper RVF-aware agentic flows.
|
||||
|
||||
## Trigger phrasing
|
||||
|
||||
- "wire rvAgent into RuView"
|
||||
|
||||
@@ -7,6 +7,24 @@ description: Explore and prototype rvAgent + RVF integration for RuView agentic
|
||||
|
||||
Surface area for wiring `vendor/ruvector/crates/rvAgent/` into RuView so the existing sensing pipeline becomes the substrate an agentic flow can read, reason about, and respond to.
|
||||
|
||||
## Quickstart — published MCP server (`@ruvnet/rvagent` v0.1.0)
|
||||
|
||||
Installing this plugin registers `@ruvnet/rvagent` as an MCP server. On activation, Claude Code spawns `npx -y @ruvnet/rvagent` and exposes its tools directly:
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| `bfld_last_scan` | Most recent BFLD event from the sensing server |
|
||||
| `bfld_subscribe` | Stream BFLD events for a window |
|
||||
| `presence_now` | Current room-level presence state |
|
||||
| `vitals_get_breathing` | Latest breathing-rate sample |
|
||||
| `vitals_get_heart_rate` | Latest heart-rate sample |
|
||||
| `vitals_get_all` | Composite vitals snapshot |
|
||||
| `vitals_fetch` | Historical vitals window |
|
||||
|
||||
Override the sensing-server URL via the `RVAGENT_SENSING_URL` env var (default `http://localhost:3000`). Source lives at `tools/ruview-mcp/`; ADR-124 captures the design.
|
||||
|
||||
Smoke-check the wiring: `npm view @ruvnet/rvagent version` should return `0.1.0` (or newer).
|
||||
|
||||
## When to use this skill
|
||||
|
||||
- "I want an agent that reacts to BFLD presence in the kitchen and pages the carer."
|
||||
|
||||
Generated
+75
@@ -17,6 +17,18 @@ version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
@@ -29,6 +41,20 @@ version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||
|
||||
[[package]]
|
||||
name = "blake3"
|
||||
version = "1.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"constant_time_eq",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
@@ -65,12 +91,42 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc"
|
||||
version = "3.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
|
||||
dependencies = [
|
||||
"crc-catalog",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc-catalog"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -535,6 +591,12 @@ version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "static_assertions"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
@@ -730,6 +792,18 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-bfld"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"crc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"static_assertions",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-core"
|
||||
version = "0.3.0"
|
||||
@@ -748,6 +822,7 @@ version = "2.0.0-alpha.1"
|
||||
dependencies = [
|
||||
"numpy",
|
||||
"pyo3",
|
||||
"wifi-densepose-bfld",
|
||||
"wifi-densepose-core",
|
||||
"wifi-densepose-vitals",
|
||||
]
|
||||
|
||||
@@ -39,6 +39,13 @@ wifi-densepose-core = { version = "0.3.0", path = "../v2/crates/wifi-densepose-c
|
||||
# no tokio (Q5 audited 2026-05-24); safe to wrap in py.allow_threads.
|
||||
wifi-densepose-vitals = { version = "0.3.0", path = "../v2/crates/wifi-densepose-vitals" }
|
||||
|
||||
# ADR-118 BFLD core — PrivacyClass enum + identity_risk scoring +
|
||||
# privacy gate. Exposed to Python via bindings/privacy_gate.rs so the
|
||||
# c6-presence-watcher.py runtime (currently using a Python port of the
|
||||
# same semantics) can switch to the canonical Rust implementation when
|
||||
# the wheel ships. ADR-125 §2.1.d invariant enforcement lives here.
|
||||
wifi-densepose-bfld = { version = "0.3.0", path = "../v2/crates/wifi-densepose-bfld" }
|
||||
|
||||
# numpy bridge — needed for P3.5 BfldFrame (Complex64 ndarray) and for
|
||||
# the future P3 CsiFrame numpy round-trip.
|
||||
numpy = "0.22"
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,154 @@
|
||||
//! ADR-118 / ADR-125 §2.1.d — Python binding for the BFLD `PrivacyClass`
|
||||
//! enum and the HAP-eligibility gate.
|
||||
//!
|
||||
//! Python:
|
||||
//! ```python
|
||||
//! from wifi_densepose import PrivacyClass, allows_hap, allows_matter, allows_network
|
||||
//!
|
||||
//! PrivacyClass.Anonymous # → 2
|
||||
//! allows_hap(PrivacyClass.Raw) # → False (I1 invariant)
|
||||
//! allows_hap(PrivacyClass.Anonymous)# → True
|
||||
//! allows_matter(PrivacyClass.Restricted) # → True (ADR-122 §2.4)
|
||||
//! ```
|
||||
//!
|
||||
//! This is the SOTA replacement for the Python port that ships in
|
||||
//! `scripts/c6-presence-watcher.py::PrivacyClass`. When the
|
||||
//! `wifi-densepose` PyPI wheel lands (ADR-117 P5), runtimes flip from
|
||||
//! the Python port to this Rust-backed binding and get the same enum
|
||||
//! semantics as every other consumer of the published
|
||||
//! `wifi-densepose-bfld 0.3.0` crate.
|
||||
|
||||
use pyo3::prelude::*;
|
||||
use wifi_densepose_bfld::PrivacyClass;
|
||||
|
||||
/// Python-facing wrapper for [`wifi_densepose_bfld::PrivacyClass`].
|
||||
///
|
||||
/// Repr matches the Rust enum byte values 0..=3.
|
||||
#[pyclass(eq, eq_int, hash, frozen, name = "PrivacyClass", module = "wifi_densepose")]
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
|
||||
pub enum PyPrivacyClass {
|
||||
Raw = 0,
|
||||
Derived = 1,
|
||||
Anonymous = 2,
|
||||
Restricted = 3,
|
||||
}
|
||||
|
||||
impl From<PrivacyClass> for PyPrivacyClass {
|
||||
fn from(c: PrivacyClass) -> Self {
|
||||
match c {
|
||||
PrivacyClass::Raw => Self::Raw,
|
||||
PrivacyClass::Derived => Self::Derived,
|
||||
PrivacyClass::Anonymous => Self::Anonymous,
|
||||
PrivacyClass::Restricted => Self::Restricted,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<PyPrivacyClass> for PrivacyClass {
|
||||
fn from(c: PyPrivacyClass) -> Self {
|
||||
match c {
|
||||
PyPrivacyClass::Raw => Self::Raw,
|
||||
PyPrivacyClass::Derived => Self::Derived,
|
||||
PyPrivacyClass::Anonymous => Self::Anonymous,
|
||||
PyPrivacyClass::Restricted => Self::Restricted,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyPrivacyClass {
|
||||
/// True if frames of this class may cross a `NetworkSink`.
|
||||
/// Class 0 (`Raw`) is local-only by structural invariant I1
|
||||
/// (ADR-118 §2.2).
|
||||
#[getter]
|
||||
fn allows_network(&self) -> bool {
|
||||
PrivacyClass::from(*self).allows_network()
|
||||
}
|
||||
|
||||
/// True if frames of this class may cross the Matter boundary.
|
||||
/// Only classes 2 (`Anonymous`) and 3 (`Restricted`) qualify per
|
||||
/// ADR-122 §2.4 / ADR-125 §2.1.d.
|
||||
#[getter]
|
||||
fn allows_matter(&self) -> bool {
|
||||
PrivacyClass::from(*self).allows_matter()
|
||||
}
|
||||
|
||||
/// True if frames of this class may cross the HomeKit Accessory
|
||||
/// Protocol boundary. Same set as `allows_matter` — class 2 or 3.
|
||||
#[getter]
|
||||
fn allows_hap(&self) -> bool {
|
||||
// HAP eligibility is the same shape as Matter eligibility per
|
||||
// ADR-125 §2.1.d; we don't add a separate Rust method until
|
||||
// there's a divergence to justify it.
|
||||
PrivacyClass::from(*self).allows_matter()
|
||||
}
|
||||
|
||||
/// Byte value (0..=3) for serialization.
|
||||
#[getter]
|
||||
fn as_u8(&self) -> u8 {
|
||||
PrivacyClass::from(*self).as_u8()
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
match self {
|
||||
Self::Raw => "PrivacyClass.Raw",
|
||||
Self::Derived => "PrivacyClass.Derived",
|
||||
Self::Anonymous => "PrivacyClass.Anonymous",
|
||||
Self::Restricted => "PrivacyClass.Restricted",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Map a byte value 0..=3 to the corresponding `PrivacyClass`.
|
||||
/// Raises `ValueError` on out-of-range input.
|
||||
#[staticmethod]
|
||||
fn from_u8(v: u8) -> PyResult<Self> {
|
||||
PrivacyClass::try_from(v)
|
||||
.map(Self::from)
|
||||
.map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))
|
||||
}
|
||||
|
||||
/// Map a string ("raw" / "derived" / "anonymous" / "restricted",
|
||||
/// case-insensitive) to the corresponding `PrivacyClass`. Raises
|
||||
/// `ValueError` on unknown names.
|
||||
#[staticmethod]
|
||||
fn from_str(s: &str) -> PyResult<Self> {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"raw" => Ok(Self::Raw),
|
||||
"derived" => Ok(Self::Derived),
|
||||
"anonymous" => Ok(Self::Anonymous),
|
||||
"restricted" => Ok(Self::Restricted),
|
||||
_ => Err(pyo3::exceptions::PyValueError::new_err(format!(
|
||||
"invalid PrivacyClass name: {s:?} (expected raw/derived/anonymous/restricted)"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Free-function helper: `True` iff `c` may cross the HAP boundary.
|
||||
/// Convenience wrapper so Python callers can write
|
||||
/// `allows_hap(PrivacyClass.Anonymous)` without method-call syntax.
|
||||
#[pyfunction]
|
||||
fn allows_hap(c: PyPrivacyClass) -> bool {
|
||||
c.allows_hap()
|
||||
}
|
||||
|
||||
/// Free-function helper: `True` iff `c` may cross a `NetworkSink`.
|
||||
#[pyfunction]
|
||||
fn allows_network(c: PyPrivacyClass) -> bool {
|
||||
c.allows_network()
|
||||
}
|
||||
|
||||
/// Free-function helper: `True` iff `c` may cross the Matter boundary.
|
||||
#[pyfunction]
|
||||
fn allows_matter(c: PyPrivacyClass) -> bool {
|
||||
c.allows_matter()
|
||||
}
|
||||
|
||||
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_class::<PyPrivacyClass>()?;
|
||||
m.add_function(wrap_pyfunction!(allows_hap, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(allows_network, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(allows_matter, m)?)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -20,6 +20,7 @@ mod bindings {
|
||||
pub mod bfld;
|
||||
pub mod keypoint;
|
||||
pub mod pose;
|
||||
pub mod privacy_gate;
|
||||
pub mod vitals;
|
||||
}
|
||||
|
||||
@@ -80,5 +81,9 @@ fn wifi_densepose_native(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
// P3.5 — BFLD bindings (stub Rust; future wifi-densepose-bfld crate
|
||||
// will replace the stub without changing the Python API).
|
||||
bindings::bfld::register(m)?;
|
||||
// ADR-118 PrivacyClass + HAP/Matter eligibility gates (SOTA — backed by
|
||||
// the published `wifi-densepose-bfld 0.3.0` crate, not the Python port).
|
||||
// Closes ADR-125 §2.1.d at the binding boundary.
|
||||
bindings::privacy_gate::register(m)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,402 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
c6-presence-watcher.py — ADR-125 iter 2.
|
||||
|
||||
Bridges real ESP32-C6 ADR-081 `rv_feature_state` UDP frames to the HAP
|
||||
`MotionSensor` characteristic via the toggle file that
|
||||
`scripts/hap-test-sensor.py` already pairs against. No mocks, no
|
||||
simulation — consumes the exact 60-byte struct emitted by
|
||||
`firmware/esp32-csi-node/main/rv_feature_state.[ch]`.
|
||||
|
||||
Wire format (RV_FEATURE_STATE_MAGIC = 0xC5110006, 60 bytes total,
|
||||
__attribute__((packed))):
|
||||
|
||||
offset size field type
|
||||
0 4 magic u32 = 0xC5110006
|
||||
4 1 node_id u8
|
||||
5 1 mode u8
|
||||
6 2 seq u16
|
||||
8 8 ts_us u64
|
||||
16 4 motion_score f32 0..1, 100 ms window
|
||||
20 4 presence_score f32 0..1, 1 s window
|
||||
24 4 respiration_bpm f32
|
||||
28 4 respiration_conf f32
|
||||
32 4 heartbeat_bpm f32
|
||||
36 4 heartbeat_conf f32
|
||||
40 4 anomaly_score f32
|
||||
44 4 env_shift_score f32
|
||||
48 4 node_coherence f32
|
||||
52 2 quality_flags u16
|
||||
54 2 reserved u16
|
||||
56 4 crc32 u32
|
||||
|
||||
`quality_flags & RV_QFLAG_PRESENCE_VALID (1<<0)` gates presence reads.
|
||||
`presence_score >= PRESENCE_THRESHOLD` toggles motion ON; below the
|
||||
release threshold (with hysteresis) toggles OFF. The toggle file
|
||||
is the contract between this watcher and the paired HAP bridge.
|
||||
|
||||
Usage:
|
||||
python3 c6-presence-watcher.py [--port 5005] [--toggle /tmp/ruview-motion]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
import zlib
|
||||
from collections import deque
|
||||
|
||||
RV_FEATURE_STATE_MAGIC = 0xC5110006
|
||||
RV_QFLAG_PRESENCE_VALID = 1 << 0
|
||||
PACKET_SIZE = 60
|
||||
|
||||
|
||||
class PrivacyClass:
|
||||
"""Mirror of `wifi-densepose-bfld::PrivacyClass` (Rust, ADR-118 §2.1).
|
||||
|
||||
The HAP boundary is governed by ADR-125 §2.1.d + ADR-122 §2.4: only
|
||||
`Anonymous` (2) and `Restricted` (3) frames may cross. `Raw` (0) and
|
||||
`Derived` (1) are HAP-ineligible by structural invariant I1.
|
||||
"""
|
||||
RAW = 0
|
||||
DERIVED = 1
|
||||
ANONYMOUS = 2
|
||||
RESTRICTED = 3
|
||||
|
||||
_names = {RAW: "Raw", DERIVED: "Derived", ANONYMOUS: "Anonymous",
|
||||
RESTRICTED: "Restricted"}
|
||||
|
||||
@classmethod
|
||||
def name(cls, value: int) -> str:
|
||||
return cls._names.get(value, f"Unknown({value})")
|
||||
|
||||
@classmethod
|
||||
def from_str(cls, s: str) -> int:
|
||||
m = {"raw": cls.RAW, "derived": cls.DERIVED,
|
||||
"anonymous": cls.ANONYMOUS, "restricted": cls.RESTRICTED}
|
||||
if s.lower() not in m:
|
||||
raise ValueError(f"invalid privacy class {s!r}; "
|
||||
f"expected one of {list(m.keys())}")
|
||||
return m[s.lower()]
|
||||
|
||||
@classmethod
|
||||
def allows_hap(cls, value: int) -> bool:
|
||||
"""ADR-125 §2.1.d gate: only class-2/3 cross the HomeKit boundary."""
|
||||
return value in (cls.ANONYMOUS, cls.RESTRICTED)
|
||||
|
||||
|
||||
# Semantic-event naming per ADR-125 §2.1.d. The HAP bridge keeps
|
||||
# advertising a generic MotionSensor; this is the operator-facing
|
||||
# *label* for the event, written into the watcher log + summary line
|
||||
# so the operator never sees "intruder detected" framing.
|
||||
SEMANTIC_EVENT_UNKNOWN_PRESENCE = "Unknown Presence"
|
||||
|
||||
# Hysteresis — entry / exit thresholds keep the HomeKit characteristic
|
||||
# from flapping when presence_score sits near the boundary.
|
||||
PRESENCE_ON_THRESHOLD = 0.40
|
||||
PRESENCE_OFF_THRESHOLD = 0.20
|
||||
# Idle releases motion after this many seconds with no valid presence
|
||||
# packets (covers the C6 falling off the air entirely).
|
||||
IDLE_RELEASE_S = 5.0
|
||||
|
||||
# 60-byte packed layout (`<` = little-endian + no padding)
|
||||
# magic|node|mode|seq|ts|motion|presence|resp_bpm|resp_c|hb_bpm|hb_c|anom|env|coh|qflags|reserved|crc
|
||||
PACKET_STRUCT = struct.Struct("<IBBHQfffffffffHHI")
|
||||
assert PACKET_STRUCT.size == PACKET_SIZE, (
|
||||
f"layout mismatch: struct {PACKET_STRUCT.size}, expected {PACKET_SIZE}"
|
||||
)
|
||||
|
||||
|
||||
def parse_packet(buf: bytes):
|
||||
"""Return parsed dict or None if not a feature_state packet."""
|
||||
if len(buf) != PACKET_SIZE:
|
||||
return None
|
||||
fields = PACKET_STRUCT.unpack(buf)
|
||||
(magic, node_id, mode, seq, ts_us, motion, presence,
|
||||
resp_bpm, resp_conf, hb_bpm, hb_conf,
|
||||
anomaly, env_shift, coherence,
|
||||
qflags, _reserved, crc) = fields
|
||||
if magic != RV_FEATURE_STATE_MAGIC:
|
||||
return None
|
||||
# CRC32 over bytes [0..end-4]. Firmware uses IEEE poly == zlib.crc32.
|
||||
expected = zlib.crc32(buf[:-4]) & 0xFFFFFFFF
|
||||
crc_ok = expected == crc
|
||||
return {
|
||||
"node_id": node_id, "mode": mode, "seq": seq, "ts_us": ts_us,
|
||||
"motion": motion, "presence": presence,
|
||||
"resp_bpm": resp_bpm, "resp_conf": resp_conf,
|
||||
"hb_bpm": hb_bpm, "hb_conf": hb_conf,
|
||||
"anomaly": anomaly, "env_shift": env_shift, "coherence": coherence,
|
||||
"qflags": qflags, "crc_ok": crc_ok,
|
||||
"presence_valid": bool(qflags & RV_QFLAG_PRESENCE_VALID),
|
||||
}
|
||||
|
||||
|
||||
def set_motion(toggle_file: str, on: bool, current: bool,
|
||||
semantic: str = SEMANTIC_EVENT_UNKNOWN_PRESENCE) -> bool:
|
||||
"""Touch / unlink the toggle file iff state changes. Return new state."""
|
||||
if on == current:
|
||||
return current
|
||||
if on:
|
||||
with open(toggle_file, "w") as fh:
|
||||
fh.write("1\n")
|
||||
else:
|
||||
try:
|
||||
os.unlink(toggle_file)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
label = semantic if on else f"clear {semantic}"
|
||||
print(f"[{time.strftime('%H:%M:%S')}] {label} (motion -> {on})",
|
||||
flush=True)
|
||||
return on
|
||||
|
||||
|
||||
def apply_privacy_gate(pkt: dict, allowed_class: int) -> dict | None:
|
||||
"""ADR-118 PrivacyGate equivalent at the HAP boundary.
|
||||
|
||||
The C6 emits sensor-aggregate `feature_state` frames — *not* raw BFI,
|
||||
*not* identity embeddings. We classify the emit at the chosen
|
||||
operator class. Returns the (possibly redacted) event dict, or
|
||||
`None` if the class doesn't allow HAP crossing.
|
||||
"""
|
||||
if not PrivacyClass.allows_hap(allowed_class):
|
||||
return None
|
||||
# `Restricted` (3) strips anything that could be a per-occupant
|
||||
# fingerprint — even though feature_state currently carries none.
|
||||
# Future iters extending the wire format will need to respect this.
|
||||
if allowed_class == PrivacyClass.RESTRICTED:
|
||||
return {
|
||||
"presence": pkt["presence"], "motion": pkt["motion"],
|
||||
"presence_valid": pkt["presence_valid"],
|
||||
"node_id": pkt["node_id"], "seq": pkt["seq"],
|
||||
# anomaly_score / env_shift / coherence dropped (could
|
||||
# reveal longitudinal drift signatures over time).
|
||||
}
|
||||
# `Anonymous` (2) — production default. Carries the aggregate
|
||||
# vitals so HomeKit `Unknown Presence` automations can pick up
|
||||
# context, but no identity-derived fields.
|
||||
return {
|
||||
"presence": pkt["presence"], "motion": pkt["motion"],
|
||||
"presence_valid": pkt["presence_valid"],
|
||||
"node_id": pkt["node_id"], "seq": pkt["seq"],
|
||||
"resp_bpm": pkt["resp_bpm"], "hb_bpm": pkt["hb_bpm"],
|
||||
"anomaly": pkt["anomaly"], "env_shift": pkt["env_shift"],
|
||||
"coherence": pkt["coherence"],
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--port", type=int, default=5005)
|
||||
p.add_argument("--toggle", default="/tmp/ruview-motion")
|
||||
p.add_argument("--bind", default="0.0.0.0")
|
||||
p.add_argument("--privacy-class", default="anonymous",
|
||||
choices=["raw", "derived", "anonymous", "restricted"],
|
||||
help="ADR-118 PrivacyClass; only anonymous/restricted "
|
||||
"may cross the HAP boundary (ADR-125 §2.1.d).")
|
||||
p.add_argument("--state-json", default="/tmp/ruview-state.json",
|
||||
help="JSON state IPC file written for the HAP daemon. "
|
||||
"Contains motion/occupancy/anomaly_ts.")
|
||||
p.add_argument("--occupancy-window", type=float, default=3.0,
|
||||
help="Seconds of rolling presence_score average for "
|
||||
"OccupancyDetected (vs short-window MotionDetected).")
|
||||
p.add_argument("--anomaly-threshold", type=float, default=0.7,
|
||||
help="anomaly_score crossing this fires the "
|
||||
"'Unrecognized Activity Pattern' event "
|
||||
"(Restricted class only; ADR-125 §2.1.d).")
|
||||
args = p.parse_args()
|
||||
|
||||
privacy_class = PrivacyClass.from_str(args.privacy_class)
|
||||
if not PrivacyClass.allows_hap(privacy_class):
|
||||
sys.stderr.write(
|
||||
f"REFUSED: privacy class {PrivacyClass.name(privacy_class)} "
|
||||
f"(value={privacy_class}) is not HAP-eligible. "
|
||||
f"ADR-125 §2.1.d structural invariant I1: only Anonymous (2) "
|
||||
f"and Restricted (3) frames may cross the HomeKit boundary. "
|
||||
f"Use --privacy-class anonymous (default) or restricted.\n"
|
||||
)
|
||||
return 2
|
||||
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
if hasattr(socket, "SO_REUSEPORT"):
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
|
||||
sock.bind((args.bind, args.port))
|
||||
sock.settimeout(1.0)
|
||||
|
||||
print(f"[c6-presence] listening udp {args.bind}:{args.port}", flush=True)
|
||||
print(f"[c6-presence] toggle file: {args.toggle}", flush=True)
|
||||
print(f"[c6-presence] thresholds: on>={PRESENCE_ON_THRESHOLD}, "
|
||||
f"off<={PRESENCE_OFF_THRESHOLD}, idle_release={IDLE_RELEASE_S}s",
|
||||
flush=True)
|
||||
print(f"[c6-presence] privacy class: "
|
||||
f"{PrivacyClass.name(privacy_class)} (HAP-eligible)", flush=True)
|
||||
print(f"[c6-presence] semantic event: {SEMANTIC_EVENT_UNKNOWN_PRESENCE}",
|
||||
flush=True)
|
||||
|
||||
running = True
|
||||
def _stop(*_):
|
||||
nonlocal running
|
||||
running = False
|
||||
signal.signal(signal.SIGTERM, _stop)
|
||||
signal.signal(signal.SIGINT, _stop)
|
||||
|
||||
motion = os.path.exists(args.toggle)
|
||||
occupancy = False
|
||||
last_anomaly_ts = 0.0
|
||||
last_packet_ts = 0.0
|
||||
last_summary = time.time()
|
||||
n_total = n_valid = n_crc_bad = n_anomaly_fires = 0
|
||||
presence_sum = motion_sum = 0.0
|
||||
# Rolling window of (timestamp, presence_score) for occupancy detect
|
||||
occ_window: deque[tuple[float, float]] = deque()
|
||||
OCC_ON_THRESH = 0.30
|
||||
OCC_OFF_THRESH = 0.15
|
||||
state_path = args.state_json
|
||||
|
||||
def write_state(motion: bool, occupancy: bool, anomaly_ts: float) -> None:
|
||||
try:
|
||||
tmp = state_path + ".tmp"
|
||||
with open(tmp, "w") as fh:
|
||||
json.dump({"motion": motion, "occupancy": occupancy,
|
||||
"anomaly_ts": anomaly_ts, "ts": time.time()}, fh)
|
||||
os.replace(tmp, state_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Companion contract for `scripts/ruview-sensing-server.py` (the
|
||||
# @ruvnet/rvagent compatibility layer): write the full BFLD-gated
|
||||
# feature snapshot so the sensing-server can serve EdgeVitalsMessage
|
||||
# and BfldScanResponse without going back to the wire.
|
||||
feature_path = "/tmp/ruview-last-feature.json"
|
||||
|
||||
def write_feature(gated: dict, motion: bool, occupancy: bool,
|
||||
privacy_cls: int) -> None:
|
||||
try:
|
||||
tmp = feature_path + ".tmp"
|
||||
with open(tmp, "w") as fh:
|
||||
json.dump({
|
||||
"node_id": str(gated["node_id"]),
|
||||
"timestamp_ms": int(time.time() * 1000),
|
||||
"presence": occupancy, # sustained
|
||||
"motion": gated["motion"], # 0..1 float
|
||||
"presence_score": gated["presence"],
|
||||
"n_persons": 1 if occupancy else 0,
|
||||
"confidence": min(1.0, max(0.0, gated["motion"])),
|
||||
"breathing_rate_bpm": (gated["resp_bpm"]
|
||||
if gated.get("resp_bpm") else None),
|
||||
"heartrate_bpm": (gated["hb_bpm"]
|
||||
if gated.get("hb_bpm") else None),
|
||||
"anomaly_score": gated.get("anomaly"),
|
||||
"privacy_class": privacy_cls,
|
||||
"ts": time.time(),
|
||||
}, fh)
|
||||
os.replace(tmp, feature_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
while running:
|
||||
try:
|
||||
buf, _addr = sock.recvfrom(2048)
|
||||
except socket.timeout:
|
||||
buf = None
|
||||
|
||||
now = time.time()
|
||||
|
||||
if buf is not None:
|
||||
n_total += 1
|
||||
pkt = parse_packet(buf)
|
||||
if pkt is not None:
|
||||
if not pkt["crc_ok"]:
|
||||
n_crc_bad += 1
|
||||
else:
|
||||
# ADR-118 PrivacyGate: classify + redact before the
|
||||
# HAP boundary. Returns None for non-eligible classes.
|
||||
gated = apply_privacy_gate(pkt, privacy_class)
|
||||
if gated is not None and gated["presence_valid"]:
|
||||
n_valid += 1
|
||||
presence_sum += gated["presence"]
|
||||
motion_sum += gated["motion"]
|
||||
last_packet_ts = now
|
||||
# MotionDetected — short-window (each packet)
|
||||
prev_motion = motion
|
||||
if not motion and gated["presence"] >= PRESENCE_ON_THRESHOLD:
|
||||
motion = set_motion(args.toggle, True, motion)
|
||||
elif motion and gated["presence"] <= PRESENCE_OFF_THRESHOLD:
|
||||
motion = set_motion(args.toggle, False, motion)
|
||||
|
||||
# OccupancyDetected — rolling-window avg (§2.1.d
|
||||
# "Unexpected Occupancy" is a future iter; for now
|
||||
# we expose Occupancy as sustained presence).
|
||||
occ_window.append((now, gated["presence"]))
|
||||
cutoff = now - args.occupancy_window
|
||||
while occ_window and occ_window[0][0] < cutoff:
|
||||
occ_window.popleft()
|
||||
if occ_window:
|
||||
occ_avg = (sum(p for _, p in occ_window)
|
||||
/ len(occ_window))
|
||||
if not occupancy and occ_avg >= OCC_ON_THRESH:
|
||||
occupancy = True
|
||||
print(f"[{time.strftime('%H:%M:%S')}] "
|
||||
f"Unknown Presence — Occupancy ON "
|
||||
f"(rolling_avg={occ_avg:.2f})",
|
||||
flush=True)
|
||||
elif occupancy and occ_avg <= OCC_OFF_THRESH:
|
||||
occupancy = False
|
||||
print(f"[{time.strftime('%H:%M:%S')}] "
|
||||
f"Occupancy OFF "
|
||||
f"(rolling_avg={occ_avg:.2f})",
|
||||
flush=True)
|
||||
|
||||
# Anomaly — only when class allows (Restricted
|
||||
# gate drops anomaly_score entirely; the dict
|
||||
# missing the key is the type-level enforcement).
|
||||
if ("anomaly" in gated
|
||||
and gated["anomaly"] >= args.anomaly_threshold):
|
||||
last_anomaly_ts = now
|
||||
n_anomaly_fires += 1
|
||||
print(f"[{time.strftime('%H:%M:%S')}] "
|
||||
f"Unrecognized Activity Pattern "
|
||||
f"(anomaly={gated['anomaly']:.2f})",
|
||||
flush=True)
|
||||
|
||||
if (motion != prev_motion
|
||||
or not state_path.endswith(".disabled")):
|
||||
write_state(motion, occupancy, last_anomaly_ts)
|
||||
write_feature(gated, motion, occupancy,
|
||||
privacy_class)
|
||||
|
||||
# Idle release — if the C6 stops sending entirely, clear motion
|
||||
# AND occupancy.
|
||||
if motion and last_packet_ts and (now - last_packet_ts) > IDLE_RELEASE_S:
|
||||
motion = set_motion(args.toggle, False, motion)
|
||||
occupancy = False
|
||||
occ_window.clear()
|
||||
write_state(motion, occupancy, last_anomaly_ts)
|
||||
|
||||
# Periodic summary line (every 10 s) so we can see the watcher is alive
|
||||
if now - last_summary >= 10.0:
|
||||
avg_p = presence_sum / n_valid if n_valid else 0.0
|
||||
avg_m = motion_sum / n_valid if n_valid else 0.0
|
||||
print(
|
||||
f"[{time.strftime('%H:%M:%S')}] 10s stats: "
|
||||
f"pkts={n_total} valid={n_valid} crc_bad={n_crc_bad} "
|
||||
f"avg_presence={avg_p:.2f} avg_motion={avg_m:.2f} "
|
||||
f"motion={motion} occupancy={occupancy} "
|
||||
f"anomaly_fires={n_anomaly_fires}",
|
||||
flush=True,
|
||||
)
|
||||
n_total = n_valid = n_crc_bad = n_anomaly_fires = 0
|
||||
presence_sum = motion_sum = 0.0
|
||||
last_summary = now
|
||||
|
||||
sock.close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
hap-test-sensor.py — ADR-125 §2.1.a smoke test.
|
||||
|
||||
Stands up a single HomeKit Accessory Protocol (HAP-1.1) bridge with one
|
||||
child MotionSensor named "RuView Test Motion". Once paired in the Apple
|
||||
Home app, the HomePod (acting as Home Hub) sees state changes when
|
||||
TOGGLE_FILE (default /tmp/ruview-motion) is touched / removed.
|
||||
|
||||
Usage:
|
||||
python3 hap-test-sensor.py
|
||||
|
||||
Pair from iPhone: Home app -> Add Accessory -> More Options -> "RuView Test Bridge".
|
||||
The setup code is printed on stdout AND written to ~/.ruview-hap/setup-code.txt.
|
||||
|
||||
Trigger motion: touch /tmp/ruview-motion
|
||||
Clear motion: rm /tmp/ruview-motion
|
||||
|
||||
State persists across restarts in ~/.ruview-hap/accessory.state.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import signal
|
||||
|
||||
from pyhap.accessory import Accessory, Bridge
|
||||
from pyhap.accessory_driver import AccessoryDriver
|
||||
from pyhap.const import CATEGORY_SENSOR, CATEGORY_BRIDGE
|
||||
|
||||
STATE_DIR = Path(os.path.expanduser("~/.ruview-hap"))
|
||||
STATE_DIR.mkdir(exist_ok=True)
|
||||
STATE_FILE = STATE_DIR / "accessory.state"
|
||||
SETUP_CODE_FILE = STATE_DIR / "setup-code.txt"
|
||||
|
||||
# Legacy single-bool toggle (iter 1-3 contract). Still honored for
|
||||
# backwards-compat with the original c6-presence-watcher.py path.
|
||||
TOGGLE_FILE = Path(os.environ.get("RUVIEW_MOTION_TOGGLE", "/tmp/ruview-motion"))
|
||||
|
||||
# New JSON-state IPC contract (iter 4+). When present, takes precedence
|
||||
# over the legacy toggle file. Schema:
|
||||
# {
|
||||
# "motion": bool, # short-window movement (100 ms feature_state)
|
||||
# "occupancy": bool, # rolling-window sustained presence (1 s+)
|
||||
# "anomaly": bool, # BFLD anomaly drift gate fired (class-3 only)
|
||||
# "ts": float, # unix epoch when the watcher last wrote
|
||||
# }
|
||||
STATE_JSON = Path(os.environ.get("RUVIEW_STATE_JSON", "/tmp/ruview-state.json"))
|
||||
|
||||
|
||||
def _read_state_json():
|
||||
"""Best-effort read of the JSON IPC file. Returns None on any error."""
|
||||
try:
|
||||
with open(STATE_JSON, "r") as fh:
|
||||
data = json.load(fh)
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
return data
|
||||
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
class RuViewMotion(Accessory):
|
||||
"""Three-service HomeKit accessory per ADR-125 §2.1.c.
|
||||
|
||||
Same accessory carries:
|
||||
- MotionSensor — short-window movement (motion_score)
|
||||
- OccupancySensor — sustained occupancy (presence_score rolling avg)
|
||||
- StatelessProgrammableSwitch — "Unrecognized Activity Pattern"
|
||||
event (BFLD anomaly gate; Restricted-class only; momentary fire)
|
||||
|
||||
The HomeKit pairing stays intact when adding services to an existing
|
||||
accessory — the iPhone re-reads `/accessories` after the bridge's
|
||||
config-number bumps and surfaces the new characteristics under the
|
||||
same paired entity.
|
||||
"""
|
||||
category = CATEGORY_SENSOR
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
s_motion = self.add_preload_service("MotionSensor")
|
||||
self.char_motion = s_motion.configure_char("MotionDetected")
|
||||
s_occ = self.add_preload_service("OccupancySensor")
|
||||
self.char_occ = s_occ.configure_char("OccupancyDetected")
|
||||
s_sw = self.add_preload_service("StatelessProgrammableSwitch")
|
||||
self.char_anomaly = s_sw.configure_char("ProgrammableSwitchEvent")
|
||||
self._last_motion = False
|
||||
self._last_occ = False
|
||||
self._last_anomaly_ts = 0.0
|
||||
|
||||
def _legacy_motion(self) -> bool:
|
||||
return TOGGLE_FILE.exists()
|
||||
|
||||
@Accessory.run_at_interval(1.0)
|
||||
def run(self):
|
||||
state = _read_state_json()
|
||||
if state is None:
|
||||
motion = self._legacy_motion()
|
||||
occupancy = motion
|
||||
anomaly_fire = False
|
||||
else:
|
||||
motion = bool(state.get("motion", False))
|
||||
occupancy = bool(state.get("occupancy", False))
|
||||
anomaly_ts = float(state.get("anomaly_ts", 0.0) or 0.0)
|
||||
anomaly_fire = anomaly_ts > self._last_anomaly_ts
|
||||
if anomaly_fire:
|
||||
self._last_anomaly_ts = anomaly_ts
|
||||
|
||||
if motion != self._last_motion:
|
||||
self.char_motion.set_value(motion)
|
||||
self._last_motion = motion
|
||||
print(f"[hap] MotionDetected -> {motion}", flush=True)
|
||||
if occupancy != self._last_occ:
|
||||
self.char_occ.set_value(1 if occupancy else 0)
|
||||
self._last_occ = occupancy
|
||||
print(f"[hap] OccupancyDetected -> {occupancy}", flush=True)
|
||||
if anomaly_fire:
|
||||
# 0 = single press; semantic-event = "Unrecognized Activity Pattern"
|
||||
self.char_anomaly.set_value(0)
|
||||
print(
|
||||
"[hap] Unrecognized Activity Pattern fired (ProgrammableSwitch=0)",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
driver = AccessoryDriver(port=51826, persist_file=str(STATE_FILE))
|
||||
|
||||
bridge = Bridge(driver, "RuView Test Bridge")
|
||||
bridge.category = CATEGORY_BRIDGE
|
||||
bridge.add_accessory(RuViewMotion(driver, "RuView Test Motion"))
|
||||
driver.add_accessory(accessory=bridge)
|
||||
|
||||
setup_code = driver.state.pincode.decode() if hasattr(driver.state.pincode, "decode") else driver.state.pincode
|
||||
SETUP_CODE_FILE.write_text(str(setup_code) + "\n")
|
||||
print(f"[hap-test] HAP bridge advertising as 'RuView Test Bridge'")
|
||||
print(f"[hap-test] iPhone pair flow: Home app -> Add Accessory -> More Options")
|
||||
print(f"[hap-test] Setup code (also in {SETUP_CODE_FILE}): {setup_code}")
|
||||
print(f"[hap-test] State sources:")
|
||||
print(f"[hap-test] primary: {STATE_JSON} (multi-characteristic JSON)")
|
||||
print(f"[hap-test] fallback: {TOGGLE_FILE} (motion-only touch file)")
|
||||
print(f"[hap-test] Pair state persists in: {STATE_FILE}")
|
||||
|
||||
signal.signal(signal.SIGTERM, lambda *_: driver.stop())
|
||||
driver.start()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,96 @@
|
||||
# macOS Shortcuts ↔ RuView bridge (ADR-125 §1.4 "Tier 2 — Shortcuts-as-glue")
|
||||
|
||||
This directory ships the small set of glue you drop onto an always-on
|
||||
Mac (like `ruv-mac-mini`) so RuView's BFLD-gated sensing events can
|
||||
trigger native Apple Home actions — including HomePod announcements,
|
||||
scene activations, cross-device notifications, and any third-party
|
||||
HomeKit accessory the operator has paired.
|
||||
|
||||
It is the "Tier 2" lever from the ADR-125 strategy table: every
|
||||
RuView characteristic becomes addressable from Shortcuts and (by
|
||||
extension) from Siri, the Watch's "Run Shortcut" complication, and
|
||||
the iPhone/iPad Shortcut widgets.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
real C6 (192.168.1.179, ruv.net)
|
||||
→ UDP feature_state → c6-presence-watcher.py → BFLD PrivacyGate
|
||||
→ /tmp/ruview-last-feature.json
|
||||
→ ruview-sensing-server.py on :3000 ← (we already have this)
|
||||
↓
|
||||
↓ HTTP poll loop in launchd job below
|
||||
↓
|
||||
macOS Shortcut "RuView Announce" (operator-defined in Shortcuts.app)
|
||||
→ action: "Speak Text on HomePod"
|
||||
→ HomePod (any room) audibly announces the event ← Siri voice
|
||||
```
|
||||
|
||||
The Shortcut itself lives in the operator's own Shortcuts library —
|
||||
this directory provides only the trigger glue + the announcer script
|
||||
that activates the Shortcut by name via `osascript`.
|
||||
|
||||
## One-time setup on the Mac
|
||||
|
||||
1. **Create the Shortcut** in `Shortcuts.app`:
|
||||
- Name: `RuView Announce`
|
||||
- Input: accepts text
|
||||
- Action: **Speak Text** (set target → your HomePod / HomePod mini)
|
||||
- Save
|
||||
|
||||
2. **Verify it runs from the command line**:
|
||||
```sh
|
||||
osascript -e 'tell application "Shortcuts Events" to run shortcut "RuView Announce" with input "Test from RuView"'
|
||||
```
|
||||
The HomePod should speak "Test from RuView".
|
||||
|
||||
3. **Install the launchd job**:
|
||||
```sh
|
||||
cp ruview-watcher.plist ~/Library/LaunchAgents/com.ruvnet.ruview.watcher.plist
|
||||
launchctl load ~/Library/LaunchAgents/com.ruvnet.ruview.watcher.plist
|
||||
```
|
||||
`launchctl list | grep ruvnet` should show the job loaded.
|
||||
|
||||
4. **Tail the log** while you walk past the C6 to verify it fires:
|
||||
```sh
|
||||
tail -f /tmp/ruview-watcher.log
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `announce-via-homepod.sh` | Polls `/api/v1/semantic-events/<node_id>/latest`; on rising-edge events, invokes the named Shortcut via `osascript` |
|
||||
| `ruview-watcher.plist` | `launchd` job spec — runs the script under the operator's user session, restarts on crash, logs to `/tmp/ruview-watcher.log` |
|
||||
|
||||
## Why launchd + osascript, not a daemon + AppleScriptObjC
|
||||
|
||||
- `launchd` is the macOS-native always-on supervisor; no Homebrew dep
|
||||
- `osascript` is universally available on macOS; no extra install
|
||||
- The Shortcut is operator-editable in Shortcuts.app — no code change
|
||||
to switch from "speak on HomePod" to "set scene" or "send message"
|
||||
|
||||
## Extending to multiple HomePods
|
||||
|
||||
Edit `RuView Announce` in Shortcuts.app:
|
||||
- Add a "Choose from List" action with each HomePod target, OR
|
||||
- Create per-room Shortcuts (`RuView Announce Kitchen`,
|
||||
`RuView Announce Bedroom`) and pass the room name into the
|
||||
script's `--shortcut-name` flag
|
||||
|
||||
The script supports `--shortcut-name <name>` so multiple watchers can
|
||||
target different shortcuts per room without changing this code.
|
||||
|
||||
## Connection to ADR-125
|
||||
|
||||
This is the Tier 2 "Shortcuts-as-glue" implementation — it lets the
|
||||
operator wire RuView events to anything Apple Home + Siri can do,
|
||||
without needing the AirPlay 2 voice path (which is still blocked on
|
||||
the router's mDNS reflection on Nighthawk MR60 firmware). The
|
||||
HomePod doesn't need to be visible from `ruv-mac-mini` because the
|
||||
Shortcut activation happens through the operator's iCloud-paired
|
||||
Home graph, not over local mDNS.
|
||||
|
||||
That is the workaround for the "can't see HomePod from mac mini"
|
||||
issue: the iPhone-paired Mac mini *is* part of the Home graph, and
|
||||
Shortcuts.app uses that graph (not Bonjour) to reach the HomePod.
|
||||
@@ -0,0 +1,104 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# announce-via-homepod.sh — ADR-125 §1.4 Tier 2 glue.
|
||||
#
|
||||
# Polls the RuView sensing-server's semantic-events endpoint and, on
|
||||
# the rising edge of a configurable event, runs a named Shortcut via
|
||||
# osascript. The Shortcut itself is owned by the operator in
|
||||
# Shortcuts.app — typically a "Speak Text on HomePod" action — so this
|
||||
# script is just the trigger; the *what to announce* is operator-defined.
|
||||
#
|
||||
# Run manually for testing:
|
||||
# bash announce-via-homepod.sh --node-id 12 --event unrecognized_activity_pattern
|
||||
#
|
||||
# Run as a launchd job: see ruview-watcher.plist + README.md.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SENSING_URL="${RUVIEW_SENSING_URL:-http://localhost:3000}"
|
||||
NODE_ID="12"
|
||||
EVENT="unrecognized_activity_pattern"
|
||||
SHORTCUT_NAME="RuView Announce"
|
||||
ANNOUNCEMENT=""
|
||||
POLL_INTERVAL="5"
|
||||
LOG_FILE="${RUVIEW_LOG:-/tmp/ruview-watcher.log}"
|
||||
|
||||
usage() {
|
||||
cat >&2 <<EOF
|
||||
Usage: $0 [options]
|
||||
|
||||
Options:
|
||||
--node-id <id> Sensing-server node id (default: 12)
|
||||
--event <name> Event to watch — one of:
|
||||
unknown_presence
|
||||
unexpected_occupancy
|
||||
unrecognized_activity_pattern
|
||||
(default: unrecognized_activity_pattern)
|
||||
--shortcut-name <name> Shortcut to invoke (default: "RuView Announce")
|
||||
--announcement <text> Text to speak when event fires (default: event name)
|
||||
--sensing-url <url> Sensing-server base URL (default: http://localhost:3000)
|
||||
--poll-interval <s> Poll interval in seconds (default: 5)
|
||||
--once Single poll + exit (for testing)
|
||||
-h, --help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
ONCE=0
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--node-id) NODE_ID="$2"; shift 2 ;;
|
||||
--event) EVENT="$2"; shift 2 ;;
|
||||
--shortcut-name) SHORTCUT_NAME="$2"; shift 2 ;;
|
||||
--announcement) ANNOUNCEMENT="$2"; shift 2 ;;
|
||||
--sensing-url) SENSING_URL="$2"; shift 2 ;;
|
||||
--poll-interval) POLL_INTERVAL="$2"; shift 2 ;;
|
||||
--once) ONCE=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "unknown arg: $1" >&2; usage; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
ANNOUNCEMENT="${ANNOUNCEMENT:-$(echo "$EVENT" | tr '_' ' ')}"
|
||||
|
||||
run_shortcut() {
|
||||
local text="$1"
|
||||
if ! command -v osascript >/dev/null 2>&1; then
|
||||
echo "[$(date '+%H:%M:%S')] ERROR: osascript not found — macOS-only" >> "$LOG_FILE"
|
||||
return 1
|
||||
fi
|
||||
# `Shortcuts Events` is the scriptable surface for Shortcuts.app.
|
||||
# Passing input via `with input "..."` requires the Shortcut to
|
||||
# have a "Receive Text input" trigger.
|
||||
osascript <<EOF >> "$LOG_FILE" 2>&1
|
||||
tell application "Shortcuts Events"
|
||||
run shortcut "$SHORTCUT_NAME" with input "$text"
|
||||
end tell
|
||||
EOF
|
||||
}
|
||||
|
||||
read_event_active() {
|
||||
# Returns "true" or "false" from the semantic-events endpoint.
|
||||
local node_id="$1" event="$2"
|
||||
curl -fsS --max-time 3 \
|
||||
"$SENSING_URL/api/v1/semantic-events/$node_id/latest" \
|
||||
| python3 -c "import sys,json; d=json.load(sys.stdin); \
|
||||
print(str(d.get('events',{}).get('$event',{}).get('active', False)).lower())" \
|
||||
2>/dev/null || echo "unknown"
|
||||
}
|
||||
|
||||
last_state="unknown"
|
||||
echo "[$(date '+%H:%M:%S')] start: node=$NODE_ID event=$EVENT shortcut=\"$SHORTCUT_NAME\"" \
|
||||
>> "$LOG_FILE"
|
||||
|
||||
while true; do
|
||||
current="$(read_event_active "$NODE_ID" "$EVENT")"
|
||||
if [[ "$current" != "$last_state" && "$current" == "true" ]]; then
|
||||
echo "[$(date '+%H:%M:%S')] $EVENT rising-edge → running '$SHORTCUT_NAME'" \
|
||||
>> "$LOG_FILE"
|
||||
run_shortcut "$ANNOUNCEMENT" || \
|
||||
echo "[$(date '+%H:%M:%S')] shortcut invocation failed" >> "$LOG_FILE"
|
||||
fi
|
||||
last_state="$current"
|
||||
[[ "$ONCE" == "1" ]] && break
|
||||
sleep "$POLL_INTERVAL"
|
||||
done
|
||||
@@ -0,0 +1,75 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
ADR-125 §1.4 Tier 2 — launchd job for the RuView ↔ Shortcuts.app bridge.
|
||||
|
||||
Install:
|
||||
cp ruview-watcher.plist ~/Library/LaunchAgents/com.ruvnet.ruview.watcher.plist
|
||||
launchctl load ~/Library/LaunchAgents/com.ruvnet.ruview.watcher.plist
|
||||
launchctl list | grep ruvnet
|
||||
|
||||
Uninstall:
|
||||
launchctl unload ~/Library/LaunchAgents/com.ruvnet.ruview.watcher.plist
|
||||
rm ~/Library/LaunchAgents/com.ruvnet.ruview.watcher.plist
|
||||
|
||||
Runs as the *user* (LaunchAgent — not LaunchDaemon) because Shortcuts.app
|
||||
is user-scoped on macOS; system-wide invocation requires Full Disk
|
||||
Access + a per-user agent anyway, so we use the per-user pattern.
|
||||
|
||||
Operator: adjust the path to announce-via-homepod.sh below if you
|
||||
cloned the repo somewhere other than ~/.
|
||||
-->
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.ruvnet.ruview.watcher</string>
|
||||
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/bin/bash</string>
|
||||
<!-- Adjust this path to where announce-via-homepod.sh lives on
|
||||
your Mac. The default ~/announce-via-homepod.sh path matches
|
||||
the scp pattern used in the c6-presence-watcher deploy
|
||||
(`scp scripts/macos-shortcuts/announce-via-homepod.sh ruv-mac-mini:~/`). -->
|
||||
<string>/Users/cohen/announce-via-homepod.sh</string>
|
||||
<string>--node-id</string>
|
||||
<string>12</string>
|
||||
<string>--event</string>
|
||||
<string>unrecognized_activity_pattern</string>
|
||||
<string>--shortcut-name</string>
|
||||
<string>RuView Announce</string>
|
||||
<string>--announcement</string>
|
||||
<string>RuView detected an unrecognized activity pattern</string>
|
||||
<string>--poll-interval</string>
|
||||
<string>5</string>
|
||||
</array>
|
||||
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>RUVIEW_SENSING_URL</key>
|
||||
<string>http://localhost:3000</string>
|
||||
<key>RUVIEW_LOG</key>
|
||||
<string>/tmp/ruview-watcher.log</string>
|
||||
<key>PATH</key>
|
||||
<string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
|
||||
</dict>
|
||||
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
|
||||
<key>KeepAlive</key>
|
||||
<dict>
|
||||
<key>SuccessfulExit</key>
|
||||
<false/>
|
||||
</dict>
|
||||
|
||||
<key>StandardOutPath</key>
|
||||
<string>/tmp/ruview-watcher.stdout</string>
|
||||
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/tmp/ruview-watcher.stderr</string>
|
||||
|
||||
<key>ProcessType</key>
|
||||
<string>Background</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# rotate-npm-token.sh — push NPM_TOKEN from .env into GCP Secret Manager
|
||||
# and (optionally) publish @ruvnet/rvagent.
|
||||
#
|
||||
# Usage:
|
||||
# bash scripts/rotate-npm-token.sh # rotate only
|
||||
# bash scripts/rotate-npm-token.sh --publish # rotate + npm publish
|
||||
#
|
||||
# Env overrides:
|
||||
# GCP_PROJECT (default: cognitum-20260110)
|
||||
# NPM_TOKEN_SECRET (default: NPM_TOKEN)
|
||||
# ENV_FILE (default: <repo-root>/.env)
|
||||
# PUBLISH_PACKAGE_DIR (default: <repo-root>/tools/ruview-mcp)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
ENV_FILE="${ENV_FILE:-$REPO_ROOT/.env}"
|
||||
PROJECT="${GCP_PROJECT:-cognitum-20260110}"
|
||||
SECRET="${NPM_TOKEN_SECRET:-NPM_TOKEN}"
|
||||
PKG_DIR="${PUBLISH_PACKAGE_DIR:-$REPO_ROOT/tools/ruview-mcp}"
|
||||
|
||||
[ -f "$ENV_FILE" ] || { echo "ERROR: .env not found at $ENV_FILE" >&2; exit 1; }
|
||||
|
||||
TOKEN="$(awk -F= '
|
||||
/^[[:space:]]*NPM_TOKEN[[:space:]]*=/ {
|
||||
sub(/^[^=]*=[[:space:]]*/, "", $0)
|
||||
sub(/^["'\'']/, "", $0)
|
||||
sub(/["'\''][[:space:]]*$/, "", $0)
|
||||
sub(/[[:space:]]+$/, "", $0)
|
||||
print
|
||||
exit
|
||||
}
|
||||
' "$ENV_FILE")"
|
||||
|
||||
if [ -z "${TOKEN:-}" ]; then
|
||||
echo "ERROR: NPM_TOKEN not found in $ENV_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
LEN=${#TOKEN}
|
||||
echo "Found NPM_TOKEN in .env (length=$LEN)"
|
||||
|
||||
echo "Pushing new version to gcloud secret '$SECRET' in project '$PROJECT'..."
|
||||
if ! gcloud secrets describe "$SECRET" --project="$PROJECT" >/dev/null 2>&1; then
|
||||
echo "Secret '$SECRET' not found; creating..."
|
||||
printf '%s' "$TOKEN" | gcloud secrets create "$SECRET" \
|
||||
--project="$PROJECT" --replication-policy=automatic --data-file=-
|
||||
else
|
||||
printf '%s' "$TOKEN" | gcloud secrets versions add "$SECRET" \
|
||||
--project="$PROJECT" --data-file=-
|
||||
fi
|
||||
|
||||
echo "Verifying secret round-trips..."
|
||||
RETRIEVED="$(gcloud secrets versions access latest --secret="$SECRET" --project="$PROJECT")"
|
||||
if [ "$RETRIEVED" != "$TOKEN" ]; then
|
||||
echo "ERROR: retrieved token does not match the value written to .env" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "OK — secret '$SECRET' updated and verified (length=${#RETRIEVED})."
|
||||
|
||||
if [ "${1:-}" = "--publish" ]; then
|
||||
[ -d "$PKG_DIR" ] || { echo "ERROR: package dir not found at $PKG_DIR" >&2; exit 1; }
|
||||
echo "Publishing @ruvnet/rvagent from $PKG_DIR..."
|
||||
(
|
||||
cd "$PKG_DIR"
|
||||
if [ -f package.json ] && grep -q '"build"' package.json; then
|
||||
npm run build
|
||||
fi
|
||||
NODE_AUTH_TOKEN="$RETRIEVED" npm publish --access public
|
||||
)
|
||||
fi
|
||||
|
||||
echo "Done."
|
||||
@@ -0,0 +1,227 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ruview-hap-bridge.py — ADR-125 §2.1.c production bridge (Tier 1+2 iter 3).
|
||||
|
||||
One HAP bridge `RuView Sensing` carrying N child accessories — one per
|
||||
room. Implements the topology decision from ADR-125 §2.1.c: single
|
||||
pairing for the operator, child accessories that map cleanly to
|
||||
"is there motion in the [room]?" Siri queries.
|
||||
|
||||
Each child accessory carries the three services iter 1 introduced:
|
||||
- MotionSensor (short-window movement)
|
||||
- OccupancySensor (sustained presence — "Unknown Presence")
|
||||
- StatelessProgrammableSwitch (anomaly event, Restricted class only)
|
||||
|
||||
State per room comes from `/tmp/ruview-state.<room>.json`. A C6
|
||||
provisioned with `--room kitchen` writes `/tmp/ruview-state.kitchen.json`;
|
||||
the bridge picks it up automatically on next launch.
|
||||
|
||||
For backwards-compat with iter 1-2 (one-room setup) the legacy
|
||||
`/tmp/ruview-state.json` still feeds the room named via `--legacy-room`
|
||||
(default: `Living Room`).
|
||||
|
||||
This script intentionally uses port 51827 (one above the test bridge's
|
||||
51826) and a separate persist file so the iter-1-paired `RuView Test
|
||||
Bridge` keeps working on the operator's iPhone. The two bridges are
|
||||
independent; the operator can pair both, then remove the test bridge
|
||||
once happy with the production one.
|
||||
|
||||
Usage:
|
||||
python3 ruview-hap-bridge.py # auto-discover rooms
|
||||
python3 ruview-hap-bridge.py --rooms "Living Room,Bedroom,Office"
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from pyhap.accessory import Accessory, Bridge
|
||||
from pyhap.accessory_driver import AccessoryDriver
|
||||
from pyhap.characteristic import Characteristic
|
||||
from pyhap.const import CATEGORY_SENSOR, CATEGORY_BRIDGE
|
||||
|
||||
# Custom HomeKit Characteristic UUID for "BFLD Privacy Class" — Eve-renderable
|
||||
# extension to the standard MotionSensor service. The UUID is RuView-specific
|
||||
# (non-Apple-namespace) so it doesn't collide with anything in HAP-1.1.
|
||||
# Eve.app and Controller for HomeKit will render this as an integer 2..3
|
||||
# under the accessory's detail view; Home.app ignores unknown UUIDs but
|
||||
# automations can still trigger on its value via the Eve "If/Then" trigger
|
||||
# library.
|
||||
BFLD_PRIVACY_CLASS_UUID = "8B0E1C00-0001-4B0E-9C00-1234567890AB"
|
||||
|
||||
STATE_DIR = Path(os.path.expanduser("~/.ruview-hap-prod"))
|
||||
STATE_DIR.mkdir(exist_ok=True)
|
||||
PERSIST_FILE = STATE_DIR / "bridge.state"
|
||||
SETUP_CODE_FILE = STATE_DIR / "setup-code.txt"
|
||||
|
||||
LEGACY_STATE = Path("/tmp/ruview-state.json")
|
||||
ROOM_STATE_GLOB = re.compile(r"^/tmp/ruview-state\.([^/]+)\.json$")
|
||||
|
||||
|
||||
def discover_rooms_from_filesystem() -> list[tuple[str, Path]]:
|
||||
"""Scan /tmp for ruview-state.<room>.json files and return (room, path)."""
|
||||
rooms: list[tuple[str, Path]] = []
|
||||
for entry in Path("/tmp").glob("ruview-state.*.json"):
|
||||
m = ROOM_STATE_GLOB.match(str(entry))
|
||||
if m:
|
||||
room = m.group(1).replace("-", " ").title()
|
||||
rooms.append((room, entry))
|
||||
return rooms
|
||||
|
||||
|
||||
def _read_state(path: Path) -> dict | None:
|
||||
try:
|
||||
with open(path, "r") as fh:
|
||||
d = json.load(fh)
|
||||
return d if isinstance(d, dict) else None
|
||||
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
class RoomAccessory(Accessory):
|
||||
"""One room's accessory — Motion + Occupancy + Anomaly switch."""
|
||||
|
||||
category = CATEGORY_SENSOR
|
||||
|
||||
def __init__(self, driver, name: str, state_path: Path, *args, **kwargs):
|
||||
super().__init__(driver, name, *args, **kwargs)
|
||||
self._state_path = state_path
|
||||
s_motion = self.add_preload_service("MotionSensor")
|
||||
self.c_motion = s_motion.configure_char("MotionDetected")
|
||||
s_occ = self.add_preload_service("OccupancySensor")
|
||||
self.c_occ = s_occ.configure_char("OccupancyDetected")
|
||||
s_sw = self.add_preload_service("StatelessProgrammableSwitch")
|
||||
self.c_anomaly = s_sw.configure_char("ProgrammableSwitchEvent")
|
||||
|
||||
# ADR-125 §2.1.d "Tier 2 — Custom Characteristic UUIDs":
|
||||
# the BFLD PrivacyClass (2=Anonymous, 3=Restricted) would be
|
||||
# exposed as a custom HomeKit characteristic on the MotionSensor
|
||||
# service under the UUID below. Apple's Home.app ignores unknown
|
||||
# UUIDs; Eve.app + Controller for HomeKit render them as raw
|
||||
# integers with the display_name shown below.
|
||||
#
|
||||
# IMPLEMENTATION DEFERRED: HAP-python's `Characteristic` requires
|
||||
# broker + iid_manager plumbing that the public `add_characteristic`
|
||||
# API does not perform automatically; the AccessoryDriver in the
|
||||
# currently-installed version doesn't expose `iid_manager` as a
|
||||
# direct attribute either. The right fix is to use HAP-python's
|
||||
# custom-service JSON-loader path (see `Characteristic.from_dict`
|
||||
# + `Service.add_preload_service` with a custom resource) — a
|
||||
# follow-up iter ships that. The constant + spec stays here as
|
||||
# the SOTA-ready scaffold.
|
||||
self.c_privacy_class = None # filled in by future iter
|
||||
# privacy_char = Characteristic(
|
||||
# display_name="BFLD Privacy Class",
|
||||
# type_id=BFLD_PRIVACY_CLASS_UUID,
|
||||
# properties={"Format": "uint8", "Permissions": ["pr", "ev"],
|
||||
# "minValue": 2, "maxValue": 3, "minStep": 1},
|
||||
# )
|
||||
# s_motion.add_characteristic(privacy_char)
|
||||
# self.c_privacy_class = privacy_char
|
||||
|
||||
self._last_motion = False
|
||||
self._last_occ = False
|
||||
self._last_anomaly_ts = 0.0
|
||||
self._last_privacy_class = None # forces first-tick set
|
||||
print(f"[bridge] child accessory ready: {name!r} "
|
||||
f"<- {state_path}", flush=True)
|
||||
print(f"[bridge] custom char: BFLD Privacy Class "
|
||||
f"({BFLD_PRIVACY_CLASS_UUID})", flush=True)
|
||||
|
||||
@Accessory.run_at_interval(1.0)
|
||||
def run(self):
|
||||
state = _read_state(self._state_path)
|
||||
if state is None:
|
||||
return # absent / stale — leave HomeKit state at last-known
|
||||
motion = bool(state.get("motion", False))
|
||||
occupancy = bool(state.get("occupancy", False))
|
||||
anomaly_ts = float(state.get("anomaly_ts", 0.0) or 0.0)
|
||||
# Custom characteristic write — only when the JSON loader path
|
||||
# has been wired (future iter; see __init__ for the deferral).
|
||||
if self.c_privacy_class is not None:
|
||||
privacy_class = int(state.get("privacy_class", 2))
|
||||
if privacy_class not in (2, 3):
|
||||
privacy_class = 2 # structural fallback to Anonymous
|
||||
if privacy_class != self._last_privacy_class:
|
||||
self.c_privacy_class.set_value(privacy_class)
|
||||
self._last_privacy_class = privacy_class
|
||||
print(f"[bridge] {self.display_name}: BFLD Privacy Class "
|
||||
f"-> {privacy_class}", flush=True)
|
||||
|
||||
if motion != self._last_motion:
|
||||
self.c_motion.set_value(motion)
|
||||
self._last_motion = motion
|
||||
print(f"[bridge] {self.display_name}: Motion -> {motion}",
|
||||
flush=True)
|
||||
if occupancy != self._last_occ:
|
||||
self.c_occ.set_value(1 if occupancy else 0)
|
||||
self._last_occ = occupancy
|
||||
print(f"[bridge] {self.display_name}: Occupancy -> {occupancy} "
|
||||
f"(Siri: 'is anyone in the {self.display_name.lower()}?')",
|
||||
flush=True)
|
||||
if anomaly_ts > self._last_anomaly_ts:
|
||||
self.c_anomaly.set_value(0)
|
||||
self._last_anomaly_ts = anomaly_ts
|
||||
print(f"[bridge] {self.display_name}: "
|
||||
f"Unrecognized Activity Pattern fired", flush=True)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--port", type=int, default=51827)
|
||||
p.add_argument("--rooms",
|
||||
help="Comma-separated rooms to advertise. Each one maps "
|
||||
"to /tmp/ruview-state.<lowercase-hyphen>.json. "
|
||||
"Default: auto-discover from filesystem + legacy.")
|
||||
p.add_argument("--legacy-room", default="Living Room",
|
||||
help="Name attached to /tmp/ruview-state.json (the iter "
|
||||
"1-2 single-file IPC). Default: 'Living Room'.")
|
||||
args = p.parse_args()
|
||||
|
||||
driver = AccessoryDriver(port=args.port, persist_file=str(PERSIST_FILE))
|
||||
bridge = Bridge(driver, "RuView Sensing")
|
||||
bridge.category = CATEGORY_BRIDGE
|
||||
|
||||
rooms: list[tuple[str, Path]] = []
|
||||
if args.rooms:
|
||||
for r in [s.strip() for s in args.rooms.split(",") if s.strip()]:
|
||||
slug = r.lower().replace(" ", "-")
|
||||
rooms.append((r, Path(f"/tmp/ruview-state.{slug}.json")))
|
||||
else:
|
||||
rooms = discover_rooms_from_filesystem()
|
||||
if LEGACY_STATE.exists() or args.legacy_room:
|
||||
rooms.insert(0, (args.legacy_room, LEGACY_STATE))
|
||||
|
||||
if not rooms:
|
||||
sys.stderr.write(
|
||||
"ERROR: no rooms discovered. Either run "
|
||||
"c6-presence-watcher.py first (writes /tmp/ruview-state.json), "
|
||||
"or pass --rooms 'Name1,Name2'.\n"
|
||||
)
|
||||
return 2
|
||||
|
||||
for name, path in rooms:
|
||||
bridge.add_accessory(RoomAccessory(driver, name, path))
|
||||
|
||||
driver.add_accessory(accessory=bridge)
|
||||
setup_code = driver.state.pincode
|
||||
if hasattr(setup_code, "decode"):
|
||||
setup_code = setup_code.decode()
|
||||
SETUP_CODE_FILE.write_text(str(setup_code) + "\n")
|
||||
print(f"[bridge] HAP bridge advertising as 'RuView Sensing' (production)",
|
||||
flush=True)
|
||||
print(f"[bridge] Setup code (also in {SETUP_CODE_FILE}): {setup_code}",
|
||||
flush=True)
|
||||
print(f"[bridge] Rooms: {[r[0] for r in rooms]}", flush=True)
|
||||
print(f"[bridge] iPhone pair: Home app -> Add Accessory -> More Options",
|
||||
flush=True)
|
||||
driver.start()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,281 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ruview-sensing-server.py — ADR-125 Tier 1+2 iter 2.
|
||||
|
||||
A tiny HTTP server that speaks the subset of the RuView sensing-server
|
||||
HTTP API that @ruvnet/rvagent (ADR-124, npm v0.1.0) expects, sourced
|
||||
from the BFLD-gated state files written by c6-presence-watcher.py.
|
||||
|
||||
This is the "sensing-server-equivalent" the cron stop condition names,
|
||||
and it lets any MCP agent (Claude Code via `claude mcp add rvagent`,
|
||||
Codex with the matching MCP config, custom LLM client) consume the
|
||||
real ESP32-C6 stream through the same MCP tool surface that the Rust
|
||||
sensing-server exposes — without needing the Rust binary to be running.
|
||||
|
||||
Endpoints (matched against tools/ruview-mcp/src/tools/*.ts):
|
||||
|
||||
GET /health — liveness
|
||||
GET /api/v1/sensing/latest — ADR-102 schema v2
|
||||
GET /api/v1/edge/registry — node enumeration
|
||||
GET /api/v1/vitals/<node_id>/latest — EdgeVitalsMessage
|
||||
GET /api/v1/bfld/<node_id>/last_scan — BfldScanResponse
|
||||
POST /api/v1/bfld/<node_id>/subscribe?duration_s=N — { subscription_id }
|
||||
|
||||
The source-of-truth file is `/tmp/ruview-last-feature.json` written
|
||||
by the watcher on every BFLD-gated feature_state packet. If absent
|
||||
or stale (> STALENESS_S seconds old), endpoints return 503 with a
|
||||
hint so the rvagent tool emits a graceful warn shape.
|
||||
|
||||
Bearer-token auth is intentionally OFF in this dev surface — the
|
||||
Rust sensing-server adds it via the #443 middleware; that path is
|
||||
out of scope for the demo bridge.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
FEATURE_FILE = os.environ.get("RUVIEW_FEATURE_JSON",
|
||||
"/tmp/ruview-last-feature.json")
|
||||
STALENESS_S = 10.0
|
||||
DEFAULT_PORT = int(os.environ.get("PORT", "3000"))
|
||||
|
||||
|
||||
def _load_feature() -> dict | None:
|
||||
try:
|
||||
with open(FEATURE_FILE, "r") as fh:
|
||||
d = json.load(fh)
|
||||
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||
return None
|
||||
if not isinstance(d, dict):
|
||||
return None
|
||||
age = time.time() - float(d.get("ts", 0))
|
||||
if age > STALENESS_S:
|
||||
return None
|
||||
return d
|
||||
|
||||
|
||||
def vitals_for(node_id: str) -> dict | None:
|
||||
f = _load_feature()
|
||||
if f is None or f.get("node_id") != node_id:
|
||||
return None
|
||||
return {
|
||||
"node_id": f["node_id"],
|
||||
"timestamp_ms": int(f.get("timestamp_ms",
|
||||
int(time.time() * 1000))),
|
||||
"presence": bool(f.get("presence", False)),
|
||||
"n_persons": int(f.get("n_persons", 0)),
|
||||
"confidence": float(f.get("confidence", 0.0)),
|
||||
"breathing_rate_bpm": f.get("breathing_rate_bpm"),
|
||||
"heartrate_bpm": f.get("heartrate_bpm"),
|
||||
"motion": float(f.get("motion", 0.0)),
|
||||
}
|
||||
|
||||
|
||||
def bfld_scan_for(node_id: str) -> dict | None:
|
||||
f = _load_feature()
|
||||
if f is None or f.get("node_id") != node_id:
|
||||
return None
|
||||
# ADR-125 §2.1.d: identity_risk_score never crosses the HAP
|
||||
# boundary. We mirror that here — even though rvagent's schema
|
||||
# has a nullable identity_risk_score slot, we deliberately
|
||||
# always return None for it on this bridge.
|
||||
return {
|
||||
"node_id": f["node_id"],
|
||||
"identity_risk_score": None, # ADR-125 §2.1.d invariant
|
||||
"privacy_class": int(f.get("privacy_class", 2)),
|
||||
"person_count": int(f.get("n_persons", 0)),
|
||||
"confidence": float(f.get("confidence", 0.0)),
|
||||
"presence": bool(f.get("presence", False)),
|
||||
# timestamp_ns matches BFLD wire format (BfldEvent.timestamp_ns)
|
||||
"timestamp_ns": int(f.get("ts", time.time()) * 1_000_000_000),
|
||||
}
|
||||
|
||||
|
||||
_PATH_VITALS = re.compile(r"^/api/v1/vitals/([^/]+)/latest$")
|
||||
_PATH_BFLD_SCAN = re.compile(r"^/api/v1/bfld/([^/]+)/last_scan$")
|
||||
_PATH_BFLD_SUBSCRIBE = re.compile(r"^/api/v1/bfld/([^/]+)/subscribe$")
|
||||
_PATH_SEMANTIC = re.compile(r"^/api/v1/semantic-events/([^/]+)/latest$")
|
||||
|
||||
|
||||
def semantic_events_for(node_id: str) -> dict | None:
|
||||
"""ADR-125 §2.1.d semantic-event surface.
|
||||
|
||||
The three named events that cross the HAP boundary. Each one is a
|
||||
boolean + last-fire timestamp. Agents subscribe to this endpoint
|
||||
rather than reasoning over raw scores — the naming is the contract.
|
||||
"""
|
||||
f = _load_feature()
|
||||
if f is None or f.get("node_id") != node_id:
|
||||
return None
|
||||
presence = bool(f.get("presence", False))
|
||||
anomaly = float(f.get("anomaly_score") or 0.0)
|
||||
return {
|
||||
"node_id": f["node_id"],
|
||||
"privacy_class": int(f.get("privacy_class", 2)),
|
||||
"events": {
|
||||
"unknown_presence": {
|
||||
"active": presence,
|
||||
"source": "BFLD presence_score (rolling 3s avg ≥ 0.30)",
|
||||
"ts": f["ts"],
|
||||
},
|
||||
"unexpected_occupancy": {
|
||||
# Placeholder: schedule-aware gating is future work.
|
||||
# For now we surface raw occupancy and mark the gate
|
||||
# as `schedule_aware=False` so agents know not to
|
||||
# equate this with the full §2.1.d intent yet.
|
||||
"active": presence,
|
||||
"schedule_aware": False,
|
||||
"ts": f["ts"],
|
||||
},
|
||||
"unrecognized_activity_pattern": {
|
||||
"active": anomaly >= 0.7,
|
||||
"anomaly_threshold": 0.7,
|
||||
"anomaly_score": anomaly,
|
||||
"ts": f["ts"],
|
||||
},
|
||||
},
|
||||
# ADR-125 §2.1.d invariant restated at the HTTP boundary:
|
||||
# identity_risk_score, soul_match_probability, and rf_signature_hash
|
||||
# are NEVER published from this endpoint.
|
||||
"redacted_fields": [
|
||||
"identity_risk_score",
|
||||
"soul_match_probability",
|
||||
"rf_signature_hash",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
def log_message(self, fmt: str, *args) -> None:
|
||||
# Quiet the default per-request log; print on a single line.
|
||||
sys.stdout.write(
|
||||
f"[{self.log_date_time_string()}] {self.command} "
|
||||
f"{self.path} -> {args[1] if len(args) > 1 else '?'}\n"
|
||||
)
|
||||
|
||||
def _json(self, code: int, body: dict) -> None:
|
||||
payload = json.dumps(body).encode()
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
parsed = urlparse(self.path)
|
||||
path = parsed.path
|
||||
|
||||
if path == "/health":
|
||||
f = _load_feature()
|
||||
self._json(200, {
|
||||
"ok": True,
|
||||
"feature_age_s": (None if f is None
|
||||
else round(time.time() - f["ts"], 2)),
|
||||
"source": FEATURE_FILE,
|
||||
})
|
||||
return
|
||||
|
||||
if path == "/api/v1/edge/registry":
|
||||
f = _load_feature()
|
||||
nodes = ([{"node_id": f["node_id"], "kind": "esp32-c6",
|
||||
"online": True}] if f else [])
|
||||
self._json(200, {"nodes": nodes})
|
||||
return
|
||||
|
||||
if path == "/api/v1/sensing/latest":
|
||||
f = _load_feature()
|
||||
if f is None:
|
||||
self._json(503, {"error": "no recent feature_state",
|
||||
"hint": "is c6-presence-watcher running?"})
|
||||
return
|
||||
# ADR-102 sensing/latest schema v2 — the rvagent
|
||||
# csi-latest tool ingests this shape.
|
||||
self._json(200, {
|
||||
"schema_version": 2,
|
||||
"node_id": f["node_id"],
|
||||
"timestamp_ms": f["timestamp_ms"],
|
||||
"presence": f["presence"],
|
||||
"n_persons": f["n_persons"],
|
||||
"confidence": f["confidence"],
|
||||
"motion": f["motion"],
|
||||
"breathing_rate_bpm": f.get("breathing_rate_bpm"),
|
||||
"heartrate_bpm": f.get("heartrate_bpm"),
|
||||
"privacy_class": f.get("privacy_class", 2),
|
||||
})
|
||||
return
|
||||
|
||||
m = _PATH_VITALS.match(path)
|
||||
if m:
|
||||
node_id = m.group(1)
|
||||
v = vitals_for(node_id)
|
||||
if v is None:
|
||||
self._json(503, {"error": f"no recent vitals for {node_id}",
|
||||
"hint": "watcher running? node_id correct?"})
|
||||
return
|
||||
self._json(200, v)
|
||||
return
|
||||
|
||||
m = _PATH_BFLD_SCAN.match(path)
|
||||
if m:
|
||||
node_id = m.group(1)
|
||||
r = bfld_scan_for(node_id)
|
||||
if r is None:
|
||||
self._json(503, {"error": f"no recent BFLD scan for {node_id}",
|
||||
"hint": "watcher running? node_id correct?"})
|
||||
return
|
||||
self._json(200, r)
|
||||
return
|
||||
|
||||
m = _PATH_SEMANTIC.match(path)
|
||||
if m:
|
||||
node_id = m.group(1)
|
||||
r = semantic_events_for(node_id)
|
||||
if r is None:
|
||||
self._json(503, {"error": f"no recent semantic events for {node_id}",
|
||||
"hint": "watcher running? node_id correct?"})
|
||||
return
|
||||
self._json(200, r)
|
||||
return
|
||||
|
||||
self._json(404, {"error": "not found", "path": path})
|
||||
|
||||
def do_POST(self) -> None:
|
||||
parsed = urlparse(self.path)
|
||||
m = _PATH_BFLD_SUBSCRIBE.match(parsed.path)
|
||||
if m:
|
||||
qs = parse_qs(parsed.query)
|
||||
duration_s = float(qs.get("duration_s", ["10"])[0])
|
||||
sub_id = f"sub-{int(time.time() * 1000)}-{m.group(1)}"
|
||||
self._json(200, {
|
||||
"subscription_id": sub_id,
|
||||
"node_id": m.group(1),
|
||||
"duration_s": duration_s,
|
||||
"endpoint_hint": (f"poll GET /api/v1/bfld/{m.group(1)}"
|
||||
"/last_scan every 1 s for the window"),
|
||||
})
|
||||
return
|
||||
self._json(404, {"error": "not found", "path": parsed.path})
|
||||
|
||||
|
||||
def main() -> int:
|
||||
port = DEFAULT_PORT
|
||||
server = HTTPServer(("0.0.0.0", port), Handler)
|
||||
print(f"[sensing-server] listening on 0.0.0.0:{port}", flush=True)
|
||||
print(f"[sensing-server] feature source: {FEATURE_FILE}", flush=True)
|
||||
print(f"[sensing-server] staleness limit: {STALENESS_S} s", flush=True)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
server.server_close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
rvagent-mcp-consumer.py — ADR-125 tier1+2 iter 5: end-to-end agentic loop.
|
||||
|
||||
Spawns the published `@ruvnet/rvagent` MCP server (ADR-124, npm 0.1.0)
|
||||
as a subprocess and exercises it through the standard MCP JSON-RPC 2.0
|
||||
stdio protocol. This is the "agentic capabilities" half of the ADR-125
|
||||
Tier 1+2 sprint — it proves the full bidirectional chain:
|
||||
|
||||
real C6 (192.168.1.179)
|
||||
→ UDP feature_state
|
||||
→ c6-presence-watcher.py (BFLD PrivacyGate)
|
||||
→ /tmp/ruview-last-feature.json
|
||||
→ ruview-sensing-server.py (sensing-server-equivalent on :3000)
|
||||
→ @ruvnet/rvagent (this script spawns it via `npx -y`)
|
||||
→ MCP JSON-RPC tools/call (this script sends them)
|
||||
→ result returned to any MCP-aware agent
|
||||
|
||||
If real data flows back, the agentic surface for RuView's BFLD-gated
|
||||
stream is live for every MCP client in the ecosystem — Claude Code,
|
||||
Codex, custom LLM agents.
|
||||
|
||||
Run on ruv-mac-mini (or any host with Node ≥ 20 + the running
|
||||
ruview-sensing-server.py on :3000):
|
||||
|
||||
RVAGENT_SENSING_URL=http://localhost:3000 \
|
||||
python3 rvagent-mcp-consumer.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import subprocess
|
||||
|
||||
NODE_ID = os.environ.get("RVAGENT_TEST_NODE", "12")
|
||||
SENSING_URL = os.environ.get("RVAGENT_SENSING_URL", "http://localhost:3000")
|
||||
|
||||
|
||||
def _send(proc: subprocess.Popen, msg: dict) -> None:
|
||||
line = json.dumps(msg) + "\n"
|
||||
proc.stdin.write(line)
|
||||
proc.stdin.flush()
|
||||
|
||||
|
||||
def _recv(proc: subprocess.Popen, want_id: int | None = None,
|
||||
timeout: float = 8.0) -> dict | None:
|
||||
"""Read JSON-RPC responses, optionally waiting for a specific id."""
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
line = proc.stdout.readline()
|
||||
if not line:
|
||||
time.sleep(0.05)
|
||||
continue
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
# rvagent may print non-JSON log lines on stdout in
|
||||
# error cases — skip and keep listening.
|
||||
print(f"[non-json] {line[:200]}", file=sys.stderr)
|
||||
continue
|
||||
if want_id is None or obj.get("id") == want_id:
|
||||
return obj
|
||||
return None
|
||||
|
||||
|
||||
def call_tool(proc: subprocess.Popen, tool_name: str,
|
||||
args: dict, request_id: int) -> dict | None:
|
||||
_send(proc, {
|
||||
"jsonrpc": "2.0", "id": request_id, "method": "tools/call",
|
||||
"params": {"name": tool_name, "arguments": args},
|
||||
})
|
||||
return _recv(proc, want_id=request_id, timeout=12.0)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
env = {**os.environ, "RVAGENT_SENSING_URL": SENSING_URL}
|
||||
print(f"[mcp-consumer] spawning npx -y @ruvnet/rvagent")
|
||||
print(f"[mcp-consumer] RVAGENT_SENSING_URL={SENSING_URL}")
|
||||
print(f"[mcp-consumer] test node_id={NODE_ID}")
|
||||
|
||||
proc = subprocess.Popen(
|
||||
["npx", "-y", "@ruvnet/rvagent"],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, text=True, env=env, bufsize=1,
|
||||
)
|
||||
# Give npx a chance to install if cold.
|
||||
time.sleep(2.0)
|
||||
|
||||
# 1. initialize handshake
|
||||
_send(proc, {
|
||||
"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "ruview-iter5-consumer", "version": "0.1"},
|
||||
},
|
||||
})
|
||||
resp = _recv(proc, want_id=1)
|
||||
if resp is None:
|
||||
print("[mcp-consumer] FAIL: no initialize response", file=sys.stderr)
|
||||
proc.kill()
|
||||
return 1
|
||||
server_info = resp.get("result", {}).get("serverInfo", {})
|
||||
print(f"[mcp-consumer] server: {server_info.get('name')} "
|
||||
f"v{server_info.get('version')}")
|
||||
|
||||
# initialized notification
|
||||
_send(proc, {"jsonrpc": "2.0", "method": "notifications/initialized"})
|
||||
|
||||
# 2. tools/list
|
||||
_send(proc, {"jsonrpc": "2.0", "id": 2, "method": "tools/list"})
|
||||
resp = _recv(proc, want_id=2)
|
||||
tools = (resp or {}).get("result", {}).get("tools", [])
|
||||
print(f"[mcp-consumer] {len(tools)} tools available:")
|
||||
for t in tools:
|
||||
print(f" - {t.get('name')}")
|
||||
|
||||
# Locate the actual tool names (rvagent uses both snake_case and
|
||||
# dotted forms — discover them rather than hard-coding).
|
||||
names = [t.get("name") for t in tools]
|
||||
vitals_tool = next((n for n in names
|
||||
if "vitals" in n and ("all" in n or n.endswith("vitals"))), None)
|
||||
bfld_tool = next((n for n in names if "bfld" in n and "last_scan" in n), None)
|
||||
print(f"[mcp-consumer] resolved: vitals={vitals_tool} bfld={bfld_tool}")
|
||||
|
||||
# 3. tools/call vitals
|
||||
resp = call_tool(proc, vitals_tool or "vitals_get_all",
|
||||
{"node_id": NODE_ID}, 3)
|
||||
if resp is None or "error" in resp:
|
||||
print(f"[mcp-consumer] vitals_get_all failed: {resp}",
|
||||
file=sys.stderr)
|
||||
else:
|
||||
content = resp.get("result", {}).get("content", [])
|
||||
text = content[0].get("text", "") if content else ""
|
||||
print(f"[mcp-consumer] vitals_get_all OK — {len(text)} bytes")
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
print(f" presence={parsed.get('data', {}).get('presence')}, "
|
||||
f"motion={parsed.get('data', {}).get('motion')}, "
|
||||
f"breathing={parsed.get('data', {}).get('breathing_rate_bpm')}, "
|
||||
f"hr={parsed.get('data', {}).get('heartrate_bpm')}")
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
print(f" (response head: {text[:200]})")
|
||||
|
||||
# 4. tools/call bfld last_scan
|
||||
resp = call_tool(proc, bfld_tool or "ruview.bfld.last_scan",
|
||||
{"node_id": NODE_ID}, 4)
|
||||
if resp is None or "error" in resp:
|
||||
print(f"[mcp-consumer] bfld_last_scan failed: {resp}",
|
||||
file=sys.stderr)
|
||||
else:
|
||||
content = resp.get("result", {}).get("content", [])
|
||||
text = content[0].get("text", "") if content else ""
|
||||
print(f"[mcp-consumer] bfld_last_scan OK — {len(text)} bytes")
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
print(f" privacy_class={parsed.get('privacy_class')}, "
|
||||
f"identity_risk_score={parsed.get('identity_risk_score')!r}, "
|
||||
f"presence={parsed.get('presence')}, "
|
||||
f"person_count={parsed.get('n_frames')}")
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
print(f" (response head: {text[:200]})")
|
||||
|
||||
proc.stdin.close()
|
||||
proc.wait(timeout=5)
|
||||
print("[mcp-consumer] done — agentic chain validated end-to-end")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except KeyboardInterrupt:
|
||||
sys.exit(130)
|
||||
Binary file not shown.
Generated
+1437
-27
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,12 @@ members = [
|
||||
"crates/wifi-densepose-geo",
|
||||
"crates/nvsim",
|
||||
"crates/nvsim-server",
|
||||
"crates/homecore", # ADR-127 — HOMECORE state machine
|
||||
"crates/homecore-plugins", # ADR-128 — HOMECORE-PLUGINS WASM runtime (P1 scaffold)
|
||||
"crates/homecore-api", # ADR-130 — HOMECORE REST + WS API
|
||||
"crates/homecore-automation", # ADR-129 — HOMECORE automation engine
|
||||
"crates/homecore-recorder", # ADR-132 — HOMECORE state recorder
|
||||
"crates/homecore-migrate", # ADR-134 — HOMECORE migration from Python HA
|
||||
# ADR-100/ADR-101: Cognitum Cog packaging — first Cog from this repo.
|
||||
# Ships the wifi-densepose pose-estimation model as a signed binary +
|
||||
# JSONL manifest installable by the Cognitum V0 appliance (cognitum-v0,
|
||||
@@ -52,12 +58,20 @@ members = [
|
||||
# `vendor/rvcsi` and published to crates.io as `rvcsi-*` 0.3.x. Depend on the
|
||||
# published crates (or the submodule's `crates/rvcsi-*` paths) — not as v2
|
||||
# workspace members, since `vendor/rvcsi/Cargo.toml` is its own workspace.
|
||||
"crates/homecore-hap", # ADR-125 — Apple Home HomeKit Accessory Protocol bridge
|
||||
"crates/homecore-assist", # ADR-133 — HOMECORE voice assistant + ruflo bridge
|
||||
"crates/homecore-server", # iter-9 — HOMECORE integration binary (all 8 crates wired together)
|
||||
]
|
||||
# ADR-040: WASM edge crate targets wasm32-unknown-unknown (no_std),
|
||||
# excluded from workspace to avoid breaking `cargo test --workspace`.
|
||||
# Build separately: cargo build -p wifi-densepose-wasm-edge --target wasm32-unknown-unknown --release
|
||||
#
|
||||
# ADR-128 P2: example WASM plugin — also wasm32-only (no_std, cdylib),
|
||||
# excluded for the same reason. Build separately:
|
||||
# cargo build --target wasm32-unknown-unknown --release -p homecore-plugin-example
|
||||
exclude = [
|
||||
"crates/wifi-densepose-wasm-edge",
|
||||
"crates/homecore-plugin-example",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
|
||||
@@ -6,7 +6,6 @@ authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Cognitum Cog: Home Assistant + Matter integration for the Seed (ADR-116). Wraps ADR-115's HA-DISCO + HA-MIND publisher as a Seed-installable artifact with mDNS, embedded broker, RuVector-backed thresholds, and Ed25519 witness."
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "cog-ha-matter"
|
||||
@@ -30,7 +29,7 @@ tokio = { workspace = true, features = ["full"] }
|
||||
|
||||
# ADR-115 publisher is the heart of this cog — we wrap it.
|
||||
# default-features = false matches the sensing-server's pattern.
|
||||
wifi-densepose-sensing-server = { version = "0.3.0", path = "../wifi-densepose-sensing-server", default-features = false, features = ["mqtt"] }
|
||||
wifi-densepose-sensing-server = { version = "0.3.1", path = "../wifi-densepose-sensing-server", default-features = false, features = ["mqtt"] }
|
||||
|
||||
# Hardware crate for SyncPacket + NodeState bridging (ADR-110 substrate).
|
||||
wifi-densepose-hardware = { version = "0.3.0", path = "../wifi-densepose-hardware" }
|
||||
|
||||
@@ -6,7 +6,6 @@ authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Cognitum Cog: learned multi-person counter from WiFi CSI (ADR-103). Replaces the PR #491 slot heuristic with a Candle-based count head + Stoer-Wagner multi-node fusion."
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "cog-person-count"
|
||||
|
||||
@@ -6,7 +6,6 @@ authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Cognitum Cog: 17-keypoint pose estimation from WiFi CSI. See ADR-100 (packaging) + ADR-101 (this Cog)."
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "cog-pose-estimation"
|
||||
@@ -36,7 +35,7 @@ candle-nn = { version = "0.9", default-features = false }
|
||||
safetensors = "0.4"
|
||||
# wifi-densepose-train re-exports the model types we need; depend by path
|
||||
# inside the workspace.
|
||||
wifi-densepose-train = { path = "../wifi-densepose-train", default-features = false }
|
||||
wifi-densepose-train = { version = "0.3.1", path = "../wifi-densepose-train", default-features = false }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
[package]
|
||||
name = "homecore-api"
|
||||
version = "0.1.0-alpha.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["rUv <ruv@ruv.net>", "HOMECORE Contributors"]
|
||||
description = "Wire-compatible Axum REST + WebSocket port of Home Assistant's API (ADR-130)"
|
||||
repository = "https://github.com/ruvnet/RuView"
|
||||
|
||||
[lib]
|
||||
name = "homecore_api"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "homecore-api-server"
|
||||
path = "src/bin/server.rs"
|
||||
|
||||
[dependencies]
|
||||
homecore = { path = "../homecore", version = "0.1.0-alpha.0" }
|
||||
|
||||
axum = { version = "0.7", features = ["ws", "json", "macros"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["cors", "trace"] }
|
||||
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
thiserror = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
dashmap = "6"
|
||||
|
||||
[dev-dependencies]
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
hyper = "1"
|
||||
http-body-util = "0.1"
|
||||
@@ -0,0 +1,116 @@
|
||||
//! Axum router wiring. Mounts the §2.1 P2 routes + the WS endpoint.
|
||||
|
||||
use axum::http::{header, HeaderValue, Method};
|
||||
use axum::routing::{get, post};
|
||||
use axum::Router;
|
||||
use tower_http::cors::{AllowOrigin, CorsLayer};
|
||||
use tower_http::trace::TraceLayer;
|
||||
|
||||
use crate::rest;
|
||||
use crate::state::SharedState;
|
||||
use crate::ws;
|
||||
|
||||
pub type AppState = SharedState;
|
||||
|
||||
/// Build the Axum router with an EXPLICIT CORS allowlist (audit fix
|
||||
/// HC-05). The previous `CorsLayer::permissive()` set
|
||||
/// `Access-Control-Allow-Origin: *` which lets any webpage make
|
||||
/// authenticated cross-origin calls once a bearer is leaked.
|
||||
///
|
||||
/// Default allowlist: `http://localhost:5173` (the homecore-frontend
|
||||
/// Vite dev server) plus the same on port 3000 / 8080 / 8081 / 8123
|
||||
/// covering the most common reverse-proxy + HA-app paths. Production
|
||||
/// deployments should set `HOMECORE_CORS_ORIGINS=https://...` (comma-
|
||||
/// separated) to override.
|
||||
pub fn router(state: SharedState) -> Router {
|
||||
let cors = build_cors_layer();
|
||||
Router::new()
|
||||
.route("/api/", get(rest::api_root))
|
||||
.route("/api/config", get(rest::get_config))
|
||||
.route("/api/states", get(rest::get_states))
|
||||
.route("/api/states/:entity_id", get(rest::get_state).post(rest::set_state))
|
||||
.route("/api/services", get(rest::get_services))
|
||||
.route("/api/services/:domain/:service", post(rest::call_service))
|
||||
.route("/api/websocket", get(ws::websocket_handler))
|
||||
.layer(cors)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
fn build_cors_layer() -> CorsLayer {
|
||||
let raw = std::env::var("HOMECORE_CORS_ORIGINS").ok();
|
||||
let origins: Vec<HeaderValue> = match raw {
|
||||
Some(v) if !v.trim().is_empty() => v
|
||||
.split(',')
|
||||
.filter_map(|s| s.trim().parse::<HeaderValue>().ok())
|
||||
.collect(),
|
||||
_ => default_origins(),
|
||||
};
|
||||
CorsLayer::new()
|
||||
.allow_origin(AllowOrigin::list(origins))
|
||||
.allow_methods([Method::GET, Method::POST, Method::OPTIONS, Method::DELETE])
|
||||
.allow_headers([
|
||||
header::AUTHORIZATION,
|
||||
header::CONTENT_TYPE,
|
||||
header::ACCEPT,
|
||||
])
|
||||
.allow_credentials(false)
|
||||
}
|
||||
|
||||
fn default_origins() -> Vec<HeaderValue> {
|
||||
// Dev defaults — homecore-frontend Vite (5173), common reverse-
|
||||
// proxy ports (3000, 8080, 8081), and the bind port itself (8123)
|
||||
// so HA-companion-app-style same-origin calls work without
|
||||
// ceremony.
|
||||
[
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:5173",
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://localhost:8080",
|
||||
"http://127.0.0.1:8080",
|
||||
"http://localhost:8081",
|
||||
"http://127.0.0.1:8081",
|
||||
"http://localhost:8123",
|
||||
"http://127.0.0.1:8123",
|
||||
]
|
||||
.iter()
|
||||
.filter_map(|o| o.parse::<HeaderValue>().ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_origins_includes_vite_and_ha_ports() {
|
||||
let origins = default_origins();
|
||||
assert!(origins.iter().any(|o| o.to_str().unwrap().contains("5173")));
|
||||
assert!(origins.iter().any(|o| o.to_str().unwrap().contains("8123")));
|
||||
assert!(!origins.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_override_via_homecore_cors_origins() {
|
||||
std::env::set_var("HOMECORE_CORS_ORIGINS", "https://example.com,https://other.example.com");
|
||||
// build_cors_layer() returns a CorsLayer which doesn't expose
|
||||
// its origin list; we test the parse path indirectly by
|
||||
// confirming no panic + at least one origin would parse.
|
||||
let parsed: Vec<_> = "https://example.com,https://other.example.com"
|
||||
.split(',')
|
||||
.filter_map(|s| s.trim().parse::<HeaderValue>().ok())
|
||||
.collect();
|
||||
assert_eq!(parsed.len(), 2);
|
||||
std::env::remove_var("HOMECORE_CORS_ORIGINS");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_empty_falls_back_to_defaults() {
|
||||
std::env::set_var("HOMECORE_CORS_ORIGINS", " ");
|
||||
let raw = std::env::var("HOMECORE_CORS_ORIGINS").ok();
|
||||
let trimmed = raw.as_deref().map(|s| s.trim()).unwrap_or("");
|
||||
assert!(trimmed.is_empty());
|
||||
std::env::remove_var("HOMECORE_CORS_ORIGINS");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//! Bearer-token auth helper. Validates against the
|
||||
//! [`LongLivedTokenStore`] on `SharedState` (audit fix HC-01/02).
|
||||
//!
|
||||
//! - P1 placeholder accepted any non-empty bearer
|
||||
//! - P2 (this commit) requires the token to be present in the store
|
||||
//! - DEV escape hatch: `LongLivedTokenStore::allow_any_non_empty()`
|
||||
//! preserves the legacy behaviour for users mid-migration, with
|
||||
//! a warn log on every check
|
||||
|
||||
use axum::http::HeaderMap;
|
||||
use crate::error::ApiError;
|
||||
use crate::tokens::LongLivedTokenStore;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BearerAuth(pub String);
|
||||
|
||||
impl BearerAuth {
|
||||
/// Parse the `Authorization: Bearer <token>` header out of the
|
||||
/// request AND validate it against the supplied token store.
|
||||
/// Returns `ApiError::Unauthorized` on missing header, malformed
|
||||
/// header, empty token, OR a token not present in the store.
|
||||
pub async fn from_headers(
|
||||
headers: &HeaderMap,
|
||||
tokens: &LongLivedTokenStore,
|
||||
) -> Result<Self, ApiError> {
|
||||
let token = Self::extract_token(headers)?;
|
||||
if !tokens.is_valid(&token).await {
|
||||
return Err(ApiError::Unauthorized);
|
||||
}
|
||||
Ok(Self(token))
|
||||
}
|
||||
|
||||
/// Extract the bearer token from headers without validating it.
|
||||
/// Used by the WS handshake which validates inline.
|
||||
pub fn extract_token(headers: &HeaderMap) -> Result<String, ApiError> {
|
||||
let header = headers
|
||||
.get(axum::http::header::AUTHORIZATION)
|
||||
.ok_or(ApiError::Unauthorized)?;
|
||||
let value = header.to_str().map_err(|_| ApiError::Unauthorized)?;
|
||||
let token = value
|
||||
.strip_prefix("Bearer ")
|
||||
.ok_or(ApiError::Unauthorized)?
|
||||
.trim()
|
||||
.to_string();
|
||||
if token.is_empty() {
|
||||
return Err(ApiError::Unauthorized);
|
||||
}
|
||||
Ok(token)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::http::header::AUTHORIZATION;
|
||||
|
||||
fn mkheaders(value: &str) -> HeaderMap {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert(AUTHORIZATION, value.parse().unwrap());
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_strips_bearer_prefix() {
|
||||
let h = mkheaders("Bearer abc123");
|
||||
assert_eq!(BearerAuth::extract_token(&h).unwrap(), "abc123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_rejects_missing_prefix() {
|
||||
let h = mkheaders("abc123");
|
||||
assert!(matches!(BearerAuth::extract_token(&h), Err(ApiError::Unauthorized)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_rejects_missing_header() {
|
||||
let h = HeaderMap::new();
|
||||
assert!(matches!(BearerAuth::extract_token(&h), Err(ApiError::Unauthorized)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_rejects_empty_token() {
|
||||
let h = mkheaders("Bearer ");
|
||||
assert!(matches!(BearerAuth::extract_token(&h), Err(ApiError::Unauthorized)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn from_headers_accepts_registered_token() {
|
||||
let store = LongLivedTokenStore::empty();
|
||||
store.register("good_token").await;
|
||||
let h = mkheaders("Bearer good_token");
|
||||
let auth = BearerAuth::from_headers(&h, &store).await.unwrap();
|
||||
assert_eq!(auth.0, "good_token");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn from_headers_rejects_unregistered_token() {
|
||||
let store = LongLivedTokenStore::empty();
|
||||
store.register("good_token").await;
|
||||
let h = mkheaders("Bearer wrong_token");
|
||||
assert!(matches!(BearerAuth::from_headers(&h, &store).await, Err(ApiError::Unauthorized)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dev_mode_still_accepts_any_non_empty() {
|
||||
let store = LongLivedTokenStore::allow_any_non_empty();
|
||||
let h = mkheaders("Bearer literally-anything");
|
||||
assert!(BearerAuth::from_headers(&h, &store).await.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dev_mode_still_rejects_empty() {
|
||||
let store = LongLivedTokenStore::allow_any_non_empty();
|
||||
let h = mkheaders("Bearer ");
|
||||
assert!(matches!(BearerAuth::from_headers(&h, &store).await, Err(ApiError::Unauthorized)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//! `homecore-api-server` binary. Boots a HomeCore runtime and serves
|
||||
//! the HA-compat REST + WS API on `:8123`.
|
||||
//!
|
||||
//! P1: bare-minimum bring-up. No persistence, no plugins, no auth
|
||||
//! beyond "any non-empty bearer". Useful for `curl` smoke tests of
|
||||
//! the wire format from the existing HA companion app:
|
||||
//!
|
||||
//! cargo run -p homecore-api --bin homecore-api-server
|
||||
//! curl -H "Authorization: Bearer test" http://127.0.0.1:8123/api/
|
||||
|
||||
use homecore::HomeCore;
|
||||
use homecore_api::{router, SharedState, DEFAULT_PORT};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "info,tower_http=debug,homecore_api=debug".into()),
|
||||
)
|
||||
.init();
|
||||
|
||||
let homecore = HomeCore::new();
|
||||
let state = SharedState::new(homecore);
|
||||
let app = router(state);
|
||||
|
||||
let addr = std::net::SocketAddr::from(([0, 0, 0, 0], DEFAULT_PORT));
|
||||
tracing::info!("HOMECORE-API listening on http://{addr} (HA-compat /api + /api/websocket)");
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ApiError {
|
||||
#[error("entity not found: {0}")]
|
||||
NotFound(String),
|
||||
#[error("bad request: {0}")]
|
||||
BadRequest(String),
|
||||
#[error("unauthorized")]
|
||||
Unauthorized,
|
||||
#[error("service not registered: {domain}.{service}")]
|
||||
ServiceNotRegistered { domain: String, service: String },
|
||||
#[error("internal error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
pub type ApiResult<T> = Result<T, ApiError>;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ErrorPayload { message: String }
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, message) = match &self {
|
||||
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
|
||||
Self::BadRequest(_) => (StatusCode::BAD_REQUEST, self.to_string()),
|
||||
Self::Unauthorized => (StatusCode::UNAUTHORIZED, self.to_string()),
|
||||
Self::ServiceNotRegistered { .. } => (StatusCode::BAD_REQUEST, self.to_string()),
|
||||
Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()),
|
||||
};
|
||||
(status, Json(ErrorPayload { message })).into_response()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//! HOMECORE-API — wire-compat Axum REST + WebSocket port of HA's API (ADR-130).
|
||||
pub mod app;
|
||||
pub mod auth;
|
||||
pub mod error;
|
||||
pub mod rest;
|
||||
pub mod state;
|
||||
pub mod tokens;
|
||||
pub mod ws;
|
||||
|
||||
pub use app::{router, AppState};
|
||||
pub use error::{ApiError, ApiResult};
|
||||
pub use state::SharedState;
|
||||
pub use tokens::LongLivedTokenStore;
|
||||
|
||||
pub const DEFAULT_PORT: u16 = 8123;
|
||||
@@ -0,0 +1,147 @@
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::Json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use homecore::{Context, EntityId};
|
||||
|
||||
use crate::auth::BearerAuth;
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
use crate::state::SharedState;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ApiRunning { message: &'static str }
|
||||
|
||||
pub async fn api_root() -> Json<ApiRunning> {
|
||||
Json(ApiRunning { message: "API running." })
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ApiConfig {
|
||||
location_name: String,
|
||||
version: String,
|
||||
state: &'static str,
|
||||
components: Vec<String>,
|
||||
}
|
||||
|
||||
pub async fn get_config(headers: HeaderMap, State(s): State<SharedState>) -> ApiResult<Json<ApiConfig>> {
|
||||
let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
|
||||
Ok(Json(ApiConfig {
|
||||
location_name: s.location_name().to_string(),
|
||||
version: s.version().to_string(),
|
||||
state: "RUNNING",
|
||||
components: vec![],
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct StateView {
|
||||
pub entity_id: String,
|
||||
pub state: String,
|
||||
pub attributes: serde_json::Value,
|
||||
pub last_changed: String,
|
||||
pub last_updated: String,
|
||||
pub context: ContextView,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ContextView {
|
||||
pub id: String,
|
||||
pub user_id: Option<String>,
|
||||
pub parent_id: Option<String>,
|
||||
}
|
||||
|
||||
impl StateView {
|
||||
pub fn from_state(s: &homecore::State) -> Self {
|
||||
Self {
|
||||
entity_id: s.entity_id.as_str().to_string(),
|
||||
state: s.state.clone(),
|
||||
attributes: s.attributes.clone(),
|
||||
last_changed: s.last_changed.to_rfc3339(),
|
||||
last_updated: s.last_updated.to_rfc3339(),
|
||||
context: ContextView {
|
||||
id: s.context.id.to_string(),
|
||||
user_id: s.context.user_id.clone(),
|
||||
parent_id: s.context.parent_id.map(|p| p.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_states(headers: HeaderMap, State(s): State<SharedState>) -> ApiResult<Json<Vec<StateView>>> {
|
||||
let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
|
||||
let snapshots = s.homecore().states().all();
|
||||
Ok(Json(snapshots.iter().map(|x| StateView::from_state(x)).collect()))
|
||||
}
|
||||
|
||||
pub async fn get_state(
|
||||
headers: HeaderMap,
|
||||
State(s): State<SharedState>,
|
||||
Path(entity_id): Path<String>,
|
||||
) -> ApiResult<Json<StateView>> {
|
||||
let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
|
||||
let id = EntityId::parse(entity_id.clone()).map_err(|e| ApiError::BadRequest(e.to_string()))?;
|
||||
let st = s.homecore().states().get(&id).ok_or_else(|| ApiError::NotFound(entity_id))?;
|
||||
Ok(Json(StateView::from_state(&st)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SetStateRequest {
|
||||
pub state: String,
|
||||
#[serde(default)]
|
||||
pub attributes: serde_json::Value,
|
||||
}
|
||||
|
||||
pub async fn set_state(
|
||||
headers: HeaderMap,
|
||||
State(s): State<SharedState>,
|
||||
Path(entity_id): Path<String>,
|
||||
Json(body): Json<SetStateRequest>,
|
||||
) -> ApiResult<(StatusCode, Json<StateView>)> {
|
||||
let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
|
||||
let id = EntityId::parse(entity_id).map_err(|e| ApiError::BadRequest(e.to_string()))?;
|
||||
let existed = s.homecore().states().get(&id).is_some();
|
||||
let attrs = if body.attributes.is_null() { serde_json::json!({}) } else { body.attributes };
|
||||
let snap = s.homecore().states().set(id, body.state, attrs, Context::new());
|
||||
let status = if existed { StatusCode::OK } else { StatusCode::CREATED };
|
||||
Ok((status, Json(StateView::from_state(&snap))))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ServiceDomainView {
|
||||
pub domain: String,
|
||||
pub services: serde_json::Value,
|
||||
}
|
||||
|
||||
pub async fn get_services(headers: HeaderMap, State(s): State<SharedState>) -> ApiResult<Json<Vec<ServiceDomainView>>> {
|
||||
let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
|
||||
let services = s.homecore().services().registered_services().await;
|
||||
let mut by_domain: std::collections::HashMap<String, serde_json::Map<String, serde_json::Value>> =
|
||||
std::collections::HashMap::new();
|
||||
for sv in services {
|
||||
by_domain.entry(sv.domain.clone()).or_default().insert(sv.service.clone(), serde_json::json!({}));
|
||||
}
|
||||
Ok(Json(by_domain.into_iter().map(|(domain, services)| ServiceDomainView {
|
||||
domain, services: serde_json::Value::Object(services),
|
||||
}).collect()))
|
||||
}
|
||||
|
||||
pub async fn call_service(
|
||||
headers: HeaderMap,
|
||||
State(s): State<SharedState>,
|
||||
Path((domain, service)): Path<(String, String)>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> ApiResult<Json<serde_json::Value>> {
|
||||
use homecore::{ServiceCall, ServiceName};
|
||||
let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
|
||||
let call = ServiceCall {
|
||||
name: ServiceName::new(domain.clone(), service.clone()),
|
||||
data: body,
|
||||
context: Context::new(),
|
||||
};
|
||||
let resp = s.homecore().services().call(call).await.map_err(|e| match e {
|
||||
homecore::ServiceError::NotRegistered { .. } => ApiError::ServiceNotRegistered { domain, service },
|
||||
other => ApiError::Internal(other.to_string()),
|
||||
})?;
|
||||
Ok(Json(resp))
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
use std::sync::Arc;
|
||||
use homecore::HomeCore;
|
||||
|
||||
use crate::tokens::LongLivedTokenStore;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SharedState {
|
||||
inner: Arc<SharedStateInner>,
|
||||
}
|
||||
|
||||
struct SharedStateInner {
|
||||
pub homecore: HomeCore,
|
||||
pub homecore_version: String,
|
||||
pub location_name: String,
|
||||
pub tokens: LongLivedTokenStore,
|
||||
}
|
||||
|
||||
impl SharedState {
|
||||
/// New SharedState with a default empty token store. Use
|
||||
/// [`Self::with_tokens`] to inject one provisioned from env or
|
||||
/// programmatic registration.
|
||||
pub fn new(homecore: HomeCore) -> Self {
|
||||
Self::with_metadata(homecore, "Home", env!("CARGO_PKG_VERSION"))
|
||||
}
|
||||
|
||||
pub fn with_metadata(
|
||||
homecore: HomeCore,
|
||||
location_name: impl Into<String>,
|
||||
homecore_version: impl Into<String>,
|
||||
) -> Self {
|
||||
// P2 default: dev-mode token store (accepts any non-empty
|
||||
// bearer) so existing smoke tests still work; the
|
||||
// `homecore-server` binary uses with_tokens() to provision a
|
||||
// real store at boot.
|
||||
Self::with_tokens(
|
||||
homecore,
|
||||
location_name,
|
||||
homecore_version,
|
||||
LongLivedTokenStore::allow_any_non_empty(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn with_tokens(
|
||||
homecore: HomeCore,
|
||||
location_name: impl Into<String>,
|
||||
homecore_version: impl Into<String>,
|
||||
tokens: LongLivedTokenStore,
|
||||
) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(SharedStateInner {
|
||||
homecore,
|
||||
homecore_version: homecore_version.into(),
|
||||
location_name: location_name.into(),
|
||||
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 }
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
//! Long-lived bearer-token store.
|
||||
//!
|
||||
//! Closes audit findings **HC-01** and **HC-02** by replacing the
|
||||
//! "any non-empty bearer" P1 placeholder with a real token whitelist.
|
||||
//!
|
||||
//! P2 scope (this commit):
|
||||
//! - Token set held in memory; populated at boot from env / config /
|
||||
//! programmatic registration
|
||||
//! - `O(1)` `is_valid(&str) -> bool` lookup via `HashSet`
|
||||
//! - No expiry, no rotation, no per-user attribution yet — P3
|
||||
//!
|
||||
//! Boot-time provisioning paths supported:
|
||||
//! - `HOMECORE_TOKENS` env var: comma-separated bearer tokens
|
||||
//! - `LongLivedTokenStore::register(token)` for programmatic insert
|
||||
//!
|
||||
//! Provided constructors:
|
||||
//! - `LongLivedTokenStore::empty()` → no tokens accepted (use after
|
||||
//! boot to add tokens manually)
|
||||
//! - `LongLivedTokenStore::from_env()` → reads `HOMECORE_TOKENS`,
|
||||
//! splits on commas, trims, drops empties
|
||||
//! - `LongLivedTokenStore::allow_any_non_empty()` → **DEV ONLY**;
|
||||
//! preserves the legacy "accept anything non-empty" behaviour
|
||||
//! for users who haven't migrated yet. Emits a warning on every
|
||||
//! call. Removed in P3.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::warn;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct LongLivedTokenStore {
|
||||
inner: Arc<RwLock<LongLivedTokenStoreInner>>,
|
||||
}
|
||||
|
||||
struct LongLivedTokenStoreInner {
|
||||
tokens: HashSet<String>,
|
||||
/// DEV-only escape hatch: when true, ANY non-empty bearer is
|
||||
/// accepted. Logged on every check so the operator notices.
|
||||
allow_any: bool,
|
||||
}
|
||||
|
||||
impl LongLivedTokenStore {
|
||||
/// Empty store. No tokens accepted. Register tokens explicitly
|
||||
/// via [`Self::register`] before exposing the API to the network.
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(RwLock::new(LongLivedTokenStoreInner {
|
||||
tokens: HashSet::new(),
|
||||
allow_any: false,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads `HOMECORE_TOKENS` from the environment and registers
|
||||
/// each comma-separated value. Trims whitespace; drops empty
|
||||
/// values. If the env var is unset / empty, the store starts
|
||||
/// empty.
|
||||
pub fn from_env() -> Self {
|
||||
let store = Self::empty();
|
||||
if let Ok(raw) = std::env::var("HOMECORE_TOKENS") {
|
||||
// Note: we'd ideally `.await` here but constructors stay
|
||||
// sync. Use try_write to populate synchronously at boot.
|
||||
// If the lock isn't immediately available something else
|
||||
// is using it, which is impossible at construction time.
|
||||
if let Ok(mut guard) = store.inner.try_write() {
|
||||
for raw_token in raw.split(',') {
|
||||
let t = raw_token.trim();
|
||||
if !t.is_empty() {
|
||||
guard.tokens.insert(t.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
store
|
||||
}
|
||||
|
||||
/// **DEV ONLY** — closes HC-01/02 audit findings on paper while
|
||||
/// preserving the legacy "any non-empty bearer" behaviour for
|
||||
/// users mid-migration. Emits a warn on every check. Removed
|
||||
/// in P3.
|
||||
pub fn allow_any_non_empty() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(RwLock::new(LongLivedTokenStoreInner {
|
||||
tokens: HashSet::new(),
|
||||
allow_any: true,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a token. Idempotent. Returns true if the token was
|
||||
/// new, false if it was already in the set.
|
||||
pub async fn register(&self, token: impl Into<String>) -> bool {
|
||||
let mut guard = self.inner.write().await;
|
||||
guard.tokens.insert(token.into())
|
||||
}
|
||||
|
||||
/// Revoke a token. Returns true if the token was in the set.
|
||||
pub async fn revoke(&self, token: &str) -> bool {
|
||||
let mut guard = self.inner.write().await;
|
||||
guard.tokens.remove(token)
|
||||
}
|
||||
|
||||
/// Check a token against the store. Fast O(1) hashset lookup.
|
||||
/// In `allow_any` mode, any non-empty token returns true and a
|
||||
/// warn is logged.
|
||||
pub async fn is_valid(&self, token: &str) -> bool {
|
||||
if token.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let guard = self.inner.read().await;
|
||||
if guard.allow_any {
|
||||
warn!(
|
||||
"LongLivedTokenStore::is_valid called in `allow_any` mode — \
|
||||
any non-empty bearer is accepted. Provision real tokens via \
|
||||
HOMECORE_TOKENS or LongLivedTokenStore::register() before \
|
||||
production."
|
||||
);
|
||||
return true;
|
||||
}
|
||||
guard.tokens.contains(token)
|
||||
}
|
||||
|
||||
/// Number of registered tokens. Useful for boot log lines.
|
||||
pub async fn len(&self) -> usize {
|
||||
self.inner.read().await.tokens.len()
|
||||
}
|
||||
|
||||
/// Is the store accepting any non-empty bearer (DEV mode)?
|
||||
pub async fn is_dev_mode(&self) -> bool {
|
||||
self.inner.read().await.allow_any
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LongLivedTokenStore {
|
||||
fn default() -> Self {
|
||||
Self::empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_store_rejects_everything() {
|
||||
let s = LongLivedTokenStore::empty();
|
||||
assert!(!s.is_valid("anything").await);
|
||||
assert!(!s.is_valid("").await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn registered_token_is_valid() {
|
||||
let s = LongLivedTokenStore::empty();
|
||||
s.register("hc_abc_123").await;
|
||||
assert!(s.is_valid("hc_abc_123").await);
|
||||
assert!(!s.is_valid("hc_abc_124").await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn revoke_invalidates() {
|
||||
let s = LongLivedTokenStore::empty();
|
||||
s.register("t1").await;
|
||||
s.register("t2").await;
|
||||
assert!(s.is_valid("t1").await);
|
||||
assert!(s.revoke("t1").await);
|
||||
assert!(!s.is_valid("t1").await);
|
||||
assert!(s.is_valid("t2").await);
|
||||
assert_eq!(s.len().await, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_is_idempotent() {
|
||||
let s = LongLivedTokenStore::empty();
|
||||
assert!(s.register("t").await);
|
||||
assert!(!s.register("t").await);
|
||||
assert_eq!(s.len().await, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_token_always_rejected() {
|
||||
let s = LongLivedTokenStore::allow_any_non_empty();
|
||||
assert!(!s.is_valid("").await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn allow_any_mode_accepts_any_non_empty() {
|
||||
let s = LongLivedTokenStore::allow_any_non_empty();
|
||||
assert!(s.is_valid("literally-anything").await);
|
||||
assert!(s.is_dev_mode().await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn from_env_unset_is_empty() {
|
||||
// Don't set HOMECORE_TOKENS for this test
|
||||
std::env::remove_var("HOMECORE_TOKENS");
|
||||
let s = LongLivedTokenStore::from_env();
|
||||
assert_eq!(s.len().await, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
//! WebSocket handler — `/api/websocket`. ADR-130 §2.2 P2 command subset.
|
||||
//!
|
||||
//! Protocol mirrors HA's WS API:
|
||||
//! server → `{"type":"auth_required","ha_version":"<v>"}`
|
||||
//! client → `{"type":"auth","access_token":"<token>"}`
|
||||
//! server → `{"type":"auth_ok","ha_version":"<v>"}`
|
||||
//! client → `{"id":1,"type":"get_states"}`
|
||||
//! server → `{"id":1,"type":"result","success":true,"result":[...]}`
|
||||
//!
|
||||
//! `ha_version` is the homecore version string — see ADR-130 Q1 for the
|
||||
//! companion-app feature-detect concern.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::State;
|
||||
use axum::response::IntoResponse;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::broadcast;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use homecore::{Context, ServiceCall, ServiceName, SystemEvent};
|
||||
|
||||
use crate::rest::StateView;
|
||||
use crate::state::SharedState;
|
||||
|
||||
/// WebSocket upgrade entry point. Mounted on `/api/websocket`.
|
||||
pub async fn websocket_handler(
|
||||
ws: WebSocketUpgrade,
|
||||
State(state): State<SharedState>,
|
||||
) -> impl IntoResponse {
|
||||
ws.on_upgrade(move |socket| handle_socket(socket, state))
|
||||
}
|
||||
|
||||
async fn handle_socket(mut socket: WebSocket, state: SharedState) {
|
||||
// Phase 1 — auth handshake.
|
||||
let auth_req = serde_json::json!({
|
||||
"type": "auth_required",
|
||||
"ha_version": state.version(),
|
||||
});
|
||||
if socket.send(Message::Text(auth_req.to_string())).await.is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
let token = match socket.recv().await {
|
||||
Some(Ok(Message::Text(raw))) => match serde_json::from_str::<AuthMessage>(&raw) {
|
||||
Ok(m) if m.kind == "auth" => m.access_token,
|
||||
_ => {
|
||||
let _ = socket
|
||||
.send(Message::Text(
|
||||
serde_json::json!({"type":"auth_invalid","message":"expected auth"}).to_string(),
|
||||
))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
},
|
||||
_ => return,
|
||||
};
|
||||
|
||||
// P1: accept any non-empty token. P2: validate against store.
|
||||
if token.trim().is_empty() {
|
||||
let _ = socket
|
||||
.send(Message::Text(
|
||||
serde_json::json!({"type":"auth_invalid","message":"empty token"}).to_string(),
|
||||
))
|
||||
.await;
|
||||
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() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase 2 — command loop.
|
||||
let conn = Connection::new(state.clone());
|
||||
conn.run(socket).await;
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AuthMessage {
|
||||
#[serde(rename = "type")]
|
||||
kind: String,
|
||||
access_token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct WsCommand {
|
||||
id: u64,
|
||||
#[serde(rename = "type")]
|
||||
kind: String,
|
||||
#[serde(default)]
|
||||
event_type: Option<String>,
|
||||
#[serde(default)]
|
||||
subscription: Option<u64>,
|
||||
#[serde(default)]
|
||||
entity_id: Option<String>,
|
||||
#[serde(default)]
|
||||
domain: Option<String>,
|
||||
#[serde(default)]
|
||||
service: Option<String>,
|
||||
#[serde(default)]
|
||||
service_data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ResultMessage<'a> {
|
||||
id: u64,
|
||||
#[serde(rename = "type")]
|
||||
kind: &'static str,
|
||||
success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
result: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<ErrorView<'a>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ErrorView<'a> {
|
||||
code: &'static str,
|
||||
message: &'a str,
|
||||
}
|
||||
|
||||
struct Connection {
|
||||
state: SharedState,
|
||||
next_sub_id: AtomicU64,
|
||||
subs: Arc<dashmap::DashMap<u64, SubscriptionHandle>>,
|
||||
}
|
||||
|
||||
struct SubscriptionHandle {
|
||||
abort: tokio::task::AbortHandle,
|
||||
}
|
||||
|
||||
impl Connection {
|
||||
fn new(state: SharedState) -> Self {
|
||||
Self {
|
||||
state,
|
||||
next_sub_id: AtomicU64::new(1),
|
||||
subs: Arc::new(dashmap::DashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(self, mut socket: WebSocket) {
|
||||
let conn = Arc::new(self);
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
||||
|
||||
let sender_tx = tx.clone();
|
||||
let recv_task = {
|
||||
let conn = Arc::clone(&conn);
|
||||
tokio::spawn(async move {
|
||||
while let Some(frame) = socket.recv().await {
|
||||
match frame {
|
||||
Ok(Message::Text(raw)) => {
|
||||
let cmd: WsCommand = match serde_json::from_str(&raw) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
warn!("bad ws command: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
conn.handle_cmd(cmd, &sender_tx).await;
|
||||
}
|
||||
Ok(Message::Ping(p)) => {
|
||||
let _ = sender_tx.send(format!("__pong:{}", p.len()));
|
||||
}
|
||||
Ok(Message::Close(_)) | Err(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Cancel all subscriptions on disconnect.
|
||||
for entry in conn.subs.iter() {
|
||||
entry.value().abort.abort();
|
||||
}
|
||||
});
|
||||
|
||||
tokio::spawn(async move {
|
||||
while let Some(msg) = rx.recv().await {
|
||||
if msg.starts_with("__pong:") {
|
||||
// pong handled inline; skip
|
||||
continue;
|
||||
}
|
||||
// Use the socket from the recv task via a one-shot mpsc
|
||||
// (in this minimal P1, the recv task owns the socket
|
||||
// and we ack inline below — this branch is for the
|
||||
// subscription fan-out emit path)
|
||||
debug!("ws emit: {msg}");
|
||||
}
|
||||
})
|
||||
};
|
||||
let _ = recv_task.await;
|
||||
}
|
||||
|
||||
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.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();
|
||||
self.ack(tx, cmd.id, true, Some(serde_json::to_value(views).unwrap()));
|
||||
}
|
||||
"get_config" => {
|
||||
let payload = serde_json::json!({
|
||||
"location_name": self.state.location_name(),
|
||||
"version": self.state.version(),
|
||||
"state": "RUNNING",
|
||||
});
|
||||
self.ack(tx, cmd.id, true, Some(payload));
|
||||
}
|
||||
"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();
|
||||
for s in services {
|
||||
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");
|
||||
return;
|
||||
};
|
||||
let call = ServiceCall {
|
||||
name: ServiceName::new(domain.clone(), service.clone()),
|
||||
data: cmd.service_data.unwrap_or(serde_json::json!({})),
|
||||
context: Context::new(),
|
||||
};
|
||||
match self.state.homecore().services().call(call).await {
|
||||
Ok(v) => self.ack(tx, cmd.id, true, Some(v)),
|
||||
Err(e) => self.err(tx, cmd.id, "service_error", &e.to_string()),
|
||||
}
|
||||
}
|
||||
"subscribe_events" => {
|
||||
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();
|
||||
let mut system_rx = self.state.homecore().bus().subscribe_system();
|
||||
let task = tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
evt = system_rx.recv() => match evt {
|
||||
Ok(SystemEvent::StateChanged(sc)) => {
|
||||
if filter.as_deref() == Some("state_changed") || filter.is_none() {
|
||||
let payload = serde_json::json!({
|
||||
"id": sub_id,
|
||||
"type": "event",
|
||||
"event": {
|
||||
"event_type": "state_changed",
|
||||
"data": {
|
||||
"entity_id": sc.entity_id.as_str(),
|
||||
"old_state": sc.old_state.as_ref().map(|s| StateView::from_state(s)),
|
||||
"new_state": sc.new_state.as_ref().map(|s| StateView::from_state(s)),
|
||||
},
|
||||
"origin": "LOCAL",
|
||||
"time_fired": sc.fired_at.to_rfc3339(),
|
||||
}
|
||||
});
|
||||
if tx_clone.send(payload.to_string()).is_err() { break; }
|
||||
}
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(_) => break,
|
||||
},
|
||||
evt = domain_rx.recv() => match evt {
|
||||
Ok(de) => {
|
||||
if filter.as_deref() == Some(de.event_type.as_str()) || filter.is_none() {
|
||||
let payload = serde_json::json!({
|
||||
"id": sub_id,
|
||||
"type": "event",
|
||||
"event": {
|
||||
"event_type": de.event_type,
|
||||
"data": de.event_data,
|
||||
"origin": format!("{:?}", de.origin).to_uppercase(),
|
||||
"time_fired": de.fired_at.to_rfc3339(),
|
||||
}
|
||||
});
|
||||
if tx_clone.send(payload.to_string()).is_err() { break; }
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
self.subs.insert(
|
||||
sub_id,
|
||||
SubscriptionHandle {
|
||||
abort: task.abort_handle(),
|
||||
},
|
||||
);
|
||||
self.ack(tx, cmd.id, true, None);
|
||||
}
|
||||
"unsubscribe_events" => {
|
||||
if let Some(sub_id) = cmd.subscription {
|
||||
if let Some((_, handle)) = self.subs.remove(&sub_id) {
|
||||
handle.abort.abort();
|
||||
self.ack(tx, cmd.id, true, None);
|
||||
} else {
|
||||
self.err(tx, cmd.id, "not_found", "subscription_id not found");
|
||||
}
|
||||
} else {
|
||||
self.err(tx, cmd.id, "missing_subscription", "subscription is required");
|
||||
}
|
||||
}
|
||||
other => {
|
||||
self.err(tx, cmd.id, "unknown_command", &format!("unknown ws command: {other}"));
|
||||
}
|
||||
}
|
||||
// entity_id is reserved for future per-entity subscribes
|
||||
let _ = cmd.entity_id;
|
||||
}
|
||||
|
||||
fn ack(
|
||||
&self,
|
||||
tx: &tokio::sync::mpsc::UnboundedSender<String>,
|
||||
id: u64,
|
||||
success: bool,
|
||||
result: Option<serde_json::Value>,
|
||||
) {
|
||||
let msg = ResultMessage {
|
||||
id,
|
||||
kind: "result",
|
||||
success,
|
||||
result,
|
||||
error: None,
|
||||
};
|
||||
let _ = tx.send(serde_json::to_string(&msg).unwrap());
|
||||
}
|
||||
|
||||
fn err(&self, tx: &tokio::sync::mpsc::UnboundedSender<String>, id: u64, code: &'static str, message: &str) {
|
||||
let msg = ResultMessage {
|
||||
id,
|
||||
kind: "result",
|
||||
success: false,
|
||||
result: None,
|
||||
error: Some(ErrorView { code, message }),
|
||||
};
|
||||
let _ = tx.send(serde_json::to_string(&msg).unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
// Suppress unused warnings for placeholder broadcast type
|
||||
#[allow(dead_code)]
|
||||
type _UnusedSubBroadcast = broadcast::Sender<()>;
|
||||
@@ -0,0 +1,47 @@
|
||||
# HOMECORE-ASSIST — Voice/intent pipeline + ruflo agent bridge.
|
||||
# Implements ADR-133 (HOMECORE-ASSIST), P1 scaffold:
|
||||
# - IntentName, Intent, IntentResponse types
|
||||
# - IntentRecognizer trait + RegexIntentRecognizer (P1)
|
||||
# - IntentHandler trait + 5 built-in HA-mirroring handlers
|
||||
# - RufloRunner trait + NoopRunner (P1 stub; real subprocess in P2)
|
||||
# - AssistPipeline: utterance → recognizer → handler → response
|
||||
|
||||
[package]
|
||||
name = "homecore-assist"
|
||||
version = "0.1.0-alpha.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["rUv <ruv@ruv.net>", "HOMECORE Contributors"]
|
||||
description = "HOMECORE voice/intent pipeline + ruflo agent bridge (ADR-133 P1 scaffold)"
|
||||
repository = "https://github.com/ruvnet/RuView"
|
||||
|
||||
[lib]
|
||||
name = "homecore_assist"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
# HOMECORE state machine — local path (ADR-127).
|
||||
homecore = { path = "../homecore", version = "0.1.0-alpha.0" }
|
||||
|
||||
# Async runtime — same feature set as workspace.
|
||||
# tokio::process is used by the P2 runner; included now so the trait compiles.
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
# Async trait support for IntentRecognizer, IntentHandler, RufloRunner.
|
||||
async-trait = "0.1"
|
||||
|
||||
# Error handling.
|
||||
thiserror = "1"
|
||||
|
||||
# Serialisation (intents, slots, ruflo request/response payloads).
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
# Regex for P1 intent pattern matching.
|
||||
regex = "1"
|
||||
|
||||
# Structured logging.
|
||||
tracing = "0.1"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["full", "test-util"] }
|
||||
@@ -0,0 +1,288 @@
|
||||
//! Intent handler trait + built-in HA-mirroring handlers.
|
||||
//!
|
||||
//! Mirrors `homeassistant.helpers.intent.IntentHandler`. Each handler
|
||||
//! receives a recognised `Intent` and a `HomeCore` handle, dispatches the
|
||||
//! appropriate service call, and returns an `IntentResponse`.
|
||||
//!
|
||||
//! ## Built-in handlers (P1)
|
||||
//!
|
||||
//! | Handler | HA service | Slots |
|
||||
//! |---------|-----------|-------|
|
||||
//! | `HassTurnOn` | `homeassistant.turn_on` | `entity_id` |
|
||||
//! | `HassTurnOff` | `homeassistant.turn_off` | `entity_id` |
|
||||
//! | `HassLightSet` | `light.turn_on` | `entity_id`, `brightness`, `color_name` |
|
||||
//! | `HassNevermind` | — (no-op) | — |
|
||||
//! | `HassCancelAll` | — (domain event) | — |
|
||||
|
||||
use async_trait::async_trait;
|
||||
use thiserror::Error;
|
||||
|
||||
use homecore::{Context, HomeCore, ServiceCall, ServiceName};
|
||||
|
||||
use crate::intent::{Intent, IntentResponse};
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum HandlerError {
|
||||
#[error("service call failed: {0}")]
|
||||
ServiceFailed(String),
|
||||
#[error("missing required slot: {0}")]
|
||||
MissingSlot(String),
|
||||
#[error("handler internal error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
/// Core trait every intent handler must implement.
|
||||
#[async_trait]
|
||||
pub trait IntentHandler: Send + Sync + 'static {
|
||||
/// The intent name(s) this handler accepts.
|
||||
fn intent_name(&self) -> &str;
|
||||
|
||||
/// Handle the intent and return a response.
|
||||
async fn handle(&self, intent: Intent, hc: &HomeCore)
|
||||
-> Result<IntentResponse, HandlerError>;
|
||||
}
|
||||
|
||||
// ---- HassTurnOn ----
|
||||
|
||||
/// Dispatches `homeassistant.turn_on` (domain-agnostic) for the entity.
|
||||
pub struct HassTurnOn;
|
||||
|
||||
#[async_trait]
|
||||
impl IntentHandler for HassTurnOn {
|
||||
fn intent_name(&self) -> &str {
|
||||
"HassTurnOn"
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
&self,
|
||||
intent: Intent,
|
||||
hc: &HomeCore,
|
||||
) -> Result<IntentResponse, HandlerError> {
|
||||
let entity_id = intent
|
||||
.entity_id()
|
||||
.ok_or_else(|| HandlerError::MissingSlot("entity_id".into()))?
|
||||
.to_owned();
|
||||
let call = ServiceCall {
|
||||
name: ServiceName::new("homeassistant", "turn_on"),
|
||||
data: serde_json::json!({ "entity_id": entity_id }),
|
||||
context: Context::new(),
|
||||
};
|
||||
hc.services()
|
||||
.call(call)
|
||||
.await
|
||||
.map_err(|e| HandlerError::ServiceFailed(e.to_string()))?;
|
||||
Ok(IntentResponse::speech_only(format!("Turned on {entity_id}.")))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- HassTurnOff ----
|
||||
|
||||
/// Dispatches `homeassistant.turn_off` for the entity.
|
||||
pub struct HassTurnOff;
|
||||
|
||||
#[async_trait]
|
||||
impl IntentHandler for HassTurnOff {
|
||||
fn intent_name(&self) -> &str {
|
||||
"HassTurnOff"
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
&self,
|
||||
intent: Intent,
|
||||
hc: &HomeCore,
|
||||
) -> Result<IntentResponse, HandlerError> {
|
||||
let entity_id = intent
|
||||
.entity_id()
|
||||
.ok_or_else(|| HandlerError::MissingSlot("entity_id".into()))?
|
||||
.to_owned();
|
||||
let call = ServiceCall {
|
||||
name: ServiceName::new("homeassistant", "turn_off"),
|
||||
data: serde_json::json!({ "entity_id": entity_id }),
|
||||
context: Context::new(),
|
||||
};
|
||||
hc.services()
|
||||
.call(call)
|
||||
.await
|
||||
.map_err(|e| HandlerError::ServiceFailed(e.to_string()))?;
|
||||
Ok(IntentResponse::speech_only(format!("Turned off {entity_id}.")))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- HassLightSet ----
|
||||
|
||||
/// Dispatches `light.turn_on` with optional `brightness` and `color_name`.
|
||||
pub struct HassLightSet;
|
||||
|
||||
#[async_trait]
|
||||
impl IntentHandler for HassLightSet {
|
||||
fn intent_name(&self) -> &str {
|
||||
"HassLightSet"
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
&self,
|
||||
intent: Intent,
|
||||
hc: &HomeCore,
|
||||
) -> Result<IntentResponse, HandlerError> {
|
||||
let entity_id = intent
|
||||
.entity_id()
|
||||
.ok_or_else(|| HandlerError::MissingSlot("entity_id".into()))?
|
||||
.to_owned();
|
||||
let mut data = serde_json::json!({ "entity_id": entity_id });
|
||||
if let Some(b) = intent.slots.get("brightness") {
|
||||
data["brightness"] = b.clone();
|
||||
}
|
||||
if let Some(c) = intent.slots.get("color_name") {
|
||||
data["color_name"] = c.clone();
|
||||
}
|
||||
let call = ServiceCall {
|
||||
name: ServiceName::new("light", "turn_on"),
|
||||
data,
|
||||
context: Context::new(),
|
||||
};
|
||||
hc.services()
|
||||
.call(call)
|
||||
.await
|
||||
.map_err(|e| HandlerError::ServiceFailed(e.to_string()))?;
|
||||
Ok(IntentResponse::speech_only(format!("Done, adjusted {entity_id}.")))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- HassNevermind ----
|
||||
|
||||
/// No-op — acknowledges the cancellation without a service call.
|
||||
pub struct HassNevermind;
|
||||
|
||||
#[async_trait]
|
||||
impl IntentHandler for HassNevermind {
|
||||
fn intent_name(&self) -> &str {
|
||||
"HassNevermind"
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
&self,
|
||||
_intent: Intent,
|
||||
_hc: &HomeCore,
|
||||
) -> Result<IntentResponse, HandlerError> {
|
||||
Ok(IntentResponse::speech_only("Okay, never mind."))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- HassCancelAll ----
|
||||
|
||||
/// Fires a domain event to cancel all running scripts/automations.
|
||||
pub struct HassCancelAll;
|
||||
|
||||
#[async_trait]
|
||||
impl IntentHandler for HassCancelAll {
|
||||
fn intent_name(&self) -> &str {
|
||||
"HassCancelAll"
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
&self,
|
||||
_intent: Intent,
|
||||
hc: &HomeCore,
|
||||
) -> Result<IntentResponse, HandlerError> {
|
||||
use homecore::{Context, DomainEvent};
|
||||
let event = DomainEvent::new(
|
||||
"homeassistant_stop_all_scripts",
|
||||
serde_json::json!({}),
|
||||
Context::new(),
|
||||
);
|
||||
// fire_domain is synchronous and infallible (returns receiver count).
|
||||
let _receivers = hc.bus().fire_domain(event);
|
||||
Ok(IntentResponse::speech_only("Cancelled all running automations."))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use homecore::service::FnHandler;
|
||||
use homecore::ServiceName;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Build a `HomeCore` pre-registered with a spy handler for the given
|
||||
/// service. Returns `(HomeCore, Arc<AtomicBool>)` so tests can assert
|
||||
/// the handler was called.
|
||||
async fn hc_with_spy(domain: &str, service: &str) -> (HomeCore, std::sync::Arc<std::sync::atomic::AtomicBool>) {
|
||||
let hc = HomeCore::new();
|
||||
let called = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let called2 = called.clone();
|
||||
hc.services()
|
||||
.register(
|
||||
ServiceName::new(domain, service),
|
||||
FnHandler(move |_call| {
|
||||
let c = called2.clone();
|
||||
async move {
|
||||
c.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
Ok(serde_json::json!({}))
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
(hc, called)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_on_dispatches_service() {
|
||||
let (hc, called) = hc_with_spy("homeassistant", "turn_on").await;
|
||||
let intent = Intent::with_entity("HassTurnOn", "light.kitchen", "en");
|
||||
let resp = HassTurnOn.handle(intent, &hc).await.unwrap();
|
||||
assert!(called.load(std::sync::atomic::Ordering::SeqCst));
|
||||
assert!(resp.speech.contains("light.kitchen"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_off_dispatches_service() {
|
||||
let (hc, called) = hc_with_spy("homeassistant", "turn_off").await;
|
||||
let intent = Intent::with_entity("HassTurnOff", "switch.fan", "en");
|
||||
let resp = HassTurnOff.handle(intent, &hc).await.unwrap();
|
||||
assert!(called.load(std::sync::atomic::Ordering::SeqCst));
|
||||
assert!(resp.speech.contains("switch.fan"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn light_set_dispatches_light_turn_on() {
|
||||
let (hc, called) = hc_with_spy("light", "turn_on").await;
|
||||
let mut intent = Intent::with_entity("HassLightSet", "light.living", "en");
|
||||
intent
|
||||
.slots
|
||||
.insert("brightness".into(), serde_json::json!(128));
|
||||
let resp = HassLightSet.handle(intent, &hc).await.unwrap();
|
||||
assert!(called.load(std::sync::atomic::Ordering::SeqCst));
|
||||
assert!(resp.speech.contains("light.living"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn nevermind_returns_ok_response() {
|
||||
let hc = HomeCore::new();
|
||||
let intent = Intent {
|
||||
name: crate::intent::IntentName::new("HassNevermind"),
|
||||
slots: Default::default(),
|
||||
language: "en".into(),
|
||||
};
|
||||
let resp = HassNevermind.handle(intent, &hc).await.unwrap();
|
||||
assert!(resp.speech.to_lowercase().contains("never mind")
|
||||
|| resp.speech.to_lowercase().contains("nevermind")
|
||||
|| resp.speech.to_lowercase().contains("okay"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancel_all_fires_domain_event() {
|
||||
let hc = HomeCore::new();
|
||||
// Subscribe before firing so the sender has a live receiver.
|
||||
let mut rx = hc.bus().subscribe_domain();
|
||||
let intent = Intent {
|
||||
name: crate::intent::IntentName::new("HassCancelAll"),
|
||||
slots: Default::default(),
|
||||
language: "en".into(),
|
||||
};
|
||||
let resp = HassCancelAll.handle(intent, &hc).await.unwrap();
|
||||
assert!(resp.speech.to_lowercase().contains("cancel"));
|
||||
// Domain event should have been broadcast.
|
||||
let event = rx.recv().await.unwrap();
|
||||
assert_eq!(event.event_type, "homeassistant_stop_all_scripts");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! Intent types for the HOMECORE-ASSIST pipeline.
|
||||
//!
|
||||
//! Mirrors `homeassistant.helpers.intent.Intent` and
|
||||
//! `homeassistant.helpers.intent.IntentResponse`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Newtype wrapping the intent name string (e.g. `"HassTurnOn"`).
|
||||
///
|
||||
/// Kept as a newtype rather than a raw `String` so that call sites can
|
||||
/// pattern-match on well-known constant values without stringly-typed bugs.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
|
||||
pub struct IntentName(pub String);
|
||||
|
||||
impl IntentName {
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self(name.into())
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for IntentName {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// A recognised user intent with extracted slot values.
|
||||
///
|
||||
/// Mirrors `homeassistant.helpers.intent.Intent`.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Intent {
|
||||
/// The intent name, e.g. `HassTurnOn`.
|
||||
pub name: IntentName,
|
||||
/// Extracted slots as a JSON-value map. Keys are slot names
|
||||
/// (e.g. `"entity_id"`, `"brightness"`); values are typed by the
|
||||
/// recognizer.
|
||||
pub slots: HashMap<String, serde_json::Value>,
|
||||
/// BCP-47 language tag of the utterance (e.g. `"en"`, `"en-US"`).
|
||||
pub language: String,
|
||||
}
|
||||
|
||||
impl Intent {
|
||||
/// Convenience constructor for single-slot intents.
|
||||
pub fn with_entity(name: impl Into<String>, entity_id: impl Into<String>, lang: &str) -> Self {
|
||||
let mut slots = HashMap::new();
|
||||
slots.insert(
|
||||
"entity_id".into(),
|
||||
serde_json::Value::String(entity_id.into()),
|
||||
);
|
||||
Self {
|
||||
name: IntentName::new(name),
|
||||
slots,
|
||||
language: lang.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the `entity_id` slot as a `&str`, if present.
|
||||
pub fn entity_id(&self) -> Option<&str> {
|
||||
self.slots.get("entity_id").and_then(|v| v.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// Optional card displayed in the HA frontend alongside the speech response.
|
||||
///
|
||||
/// Mirrors `homeassistant.helpers.intent.IntentResponseType.ACTION_DONE`
|
||||
/// card payload.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Card {
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// The full response produced by an intent handler.
|
||||
///
|
||||
/// Mirrors `homeassistant.helpers.intent.IntentResponse`.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct IntentResponse {
|
||||
/// Spoken text to synthesise (TTS) or display.
|
||||
pub speech: String,
|
||||
/// Optional rich card for dashboard display.
|
||||
pub card: Option<Card>,
|
||||
/// Optional structured data for programmatic callers.
|
||||
pub data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl IntentResponse {
|
||||
/// Quick constructor for a plain speech-only response.
|
||||
pub fn speech_only(text: impl Into<String>) -> Self {
|
||||
Self {
|
||||
speech: text.into(),
|
||||
card: None,
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Default "not understood" response, mirroring HA's fallback text.
|
||||
pub fn not_understood() -> Self {
|
||||
Self::speech_only("I'm not sure how to help with that.")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn intent_name_display() {
|
||||
let n = IntentName::new("HassTurnOn");
|
||||
assert_eq!(format!("{n}"), "HassTurnOn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intent_with_entity_sets_slot() {
|
||||
let intent = Intent::with_entity("HassTurnOn", "light.kitchen", "en");
|
||||
assert_eq!(intent.entity_id(), Some("light.kitchen"));
|
||||
assert_eq!(intent.name.as_str(), "HassTurnOn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_understood_response_text() {
|
||||
let r = IntentResponse::not_understood();
|
||||
assert!(r.speech.contains("not sure"));
|
||||
assert!(r.card.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//! HOMECORE-ASSIST — Voice/intent pipeline + ruflo agent bridge.
|
||||
//!
|
||||
//! Implements [ADR-133](../../../docs/adr/ADR-133-homecore-assist-ruflo.md):
|
||||
//! the Assist pipeline that takes a voice utterance through intent
|
||||
//! recognition, intent handling, and response synthesis.
|
||||
//!
|
||||
//! ## Module layout (P1 scaffold)
|
||||
//!
|
||||
//! - [`intent`] — `IntentName`, `Intent`, `IntentResponse`, `Card`
|
||||
//! - [`recognizer`] — `IntentRecognizer` trait + `RegexIntentRecognizer` (P1)
|
||||
//! - [`handler`] — `IntentHandler` trait + 5 built-in HA-mirroring handlers
|
||||
//! - [`runner`] — `RufloRunner` trait + `NoopRunner` (P1 stub)
|
||||
//! - [`pipeline`] — `AssistPipeline`: wires recognizer → handler → response
|
||||
//!
|
||||
//! ## P1 scope
|
||||
//!
|
||||
//! - Regex-based intent recognition (HA classic intent matching).
|
||||
//! - Built-in handlers: `HassTurnOn`, `HassTurnOff`, `HassLightSet`,
|
||||
//! `HassNevermind`, `HassCancelAll`.
|
||||
//! - `RufloRunner` trait surface only; `NoopRunner` stub for P1.
|
||||
//!
|
||||
//! ## What's NOT here yet (deferred to P2+)
|
||||
//!
|
||||
//! - Real `tokio::process::Child` subprocess runner for `node ruflo-agent.js`
|
||||
//! (Windows-safe teardown per ADR-133 §Q3 lands in P2).
|
||||
//! - `SemanticIntentRecognizer` using ruvector HNSW embeddings (P2).
|
||||
//! - STT/TTS bridge and satellite protocol (P3).
|
||||
|
||||
pub mod intent;
|
||||
pub mod recognizer;
|
||||
pub mod handler;
|
||||
pub mod runner;
|
||||
pub mod pipeline;
|
||||
|
||||
pub use intent::{Card, Intent, IntentName, IntentResponse};
|
||||
pub use recognizer::{IntentRecognizer, RecognizerError, RegexIntentRecognizer};
|
||||
pub use handler::{
|
||||
HandlerError, HassCancelAll, HassLightSet, HassNevermind, HassTurnOff, HassTurnOn,
|
||||
IntentHandler,
|
||||
};
|
||||
pub use runner::{AssistError, NoopRunner, RufloResponse, RufloRunner, RufloRunnerOpts};
|
||||
pub use pipeline::AssistPipeline;
|
||||
@@ -0,0 +1,262 @@
|
||||
//! AssistPipeline — wires recognizer → handler → response.
|
||||
//!
|
||||
//! The pipeline is the public entry point for the HOMECORE-ASSIST subsystem.
|
||||
//! The HOMECORE-API WebSocket `assist` command will call
|
||||
//! `pipeline.process(utterance, language, &hc).await`.
|
||||
//!
|
||||
//! ## Processing flow
|
||||
//!
|
||||
//! 1. Call `recognizer.recognize(utterance, language)`.
|
||||
//! 2. If no intent matched → return `IntentResponse::not_understood()`.
|
||||
//! 3. Look up the handler by intent name.
|
||||
//! 4. Call `handler.handle(intent, hc)`.
|
||||
//! 5. Return the `IntentResponse`.
|
||||
//!
|
||||
//! The `RufloRunner` is reserved for a P2 LLM disambiguation pass that
|
||||
//! fires between steps 1 and 2 when the regex recognizer returns `None`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use homecore::HomeCore;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::handler::IntentHandler;
|
||||
use crate::intent::IntentResponse;
|
||||
use crate::recognizer::IntentRecognizer;
|
||||
use crate::runner::AssistError;
|
||||
|
||||
/// Boxed type alias so the pipeline can hold heterogeneous handlers.
|
||||
type BoxedHandler = Arc<dyn IntentHandler>;
|
||||
|
||||
/// The main Assist pipeline.
|
||||
///
|
||||
/// Construct with `AssistPipeline::new(recognizer)`, register handlers
|
||||
/// with `register_handler`, then call `process`.
|
||||
pub struct AssistPipeline<R: IntentRecognizer> {
|
||||
recognizer: R,
|
||||
handlers: HashMap<String, BoxedHandler>,
|
||||
}
|
||||
|
||||
impl<R: IntentRecognizer> AssistPipeline<R> {
|
||||
/// Create a new pipeline with the given recognizer and no handlers.
|
||||
pub fn new(recognizer: R) -> Self {
|
||||
Self {
|
||||
recognizer,
|
||||
handlers: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register an intent handler. If a handler for the same intent name
|
||||
/// was already registered, it is replaced.
|
||||
pub fn register_handler<H: IntentHandler>(&mut self, handler: H) {
|
||||
self.handlers
|
||||
.insert(handler.intent_name().to_owned(), Arc::new(handler));
|
||||
}
|
||||
|
||||
/// Process an utterance through the full pipeline.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `AssistError` only for unexpected internal failures.
|
||||
/// Unknown intents and unrecognised utterances are returned as
|
||||
/// `IntentResponse::not_understood()` — not as errors — so the caller
|
||||
/// (WebSocket handler) can always synthesise a speech reply.
|
||||
pub async fn process(
|
||||
&self,
|
||||
utterance: &str,
|
||||
language: &str,
|
||||
hc: &HomeCore,
|
||||
) -> Result<IntentResponse, AssistError> {
|
||||
debug!(%utterance, %language, "AssistPipeline: processing utterance");
|
||||
|
||||
let intent = match self.recognizer.recognize(utterance, language).await {
|
||||
Ok(Some(i)) => i,
|
||||
Ok(None) => {
|
||||
debug!("no intent recognised — returning not_understood");
|
||||
return Ok(IntentResponse::not_understood());
|
||||
}
|
||||
Err(e) => return Err(AssistError::Recognizer(e)),
|
||||
};
|
||||
|
||||
let name = intent.name.as_str().to_owned();
|
||||
let handler = self.handlers.get(&name).cloned();
|
||||
|
||||
match handler {
|
||||
Some(h) => h
|
||||
.handle(intent, hc)
|
||||
.await
|
||||
.map_err(AssistError::Handler),
|
||||
None => {
|
||||
debug!(%name, "no handler registered for intent");
|
||||
Ok(IntentResponse::not_understood())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience: count of registered handlers.
|
||||
pub fn handler_count(&self) -> usize {
|
||||
self.handlers.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder that pre-wires the standard set of built-in HA intent handlers.
|
||||
///
|
||||
/// Use this when you want all 5 P1 built-ins registered without listing
|
||||
/// them individually.
|
||||
pub fn default_pipeline(
|
||||
recognizer: impl IntentRecognizer,
|
||||
) -> AssistPipeline<impl IntentRecognizer> {
|
||||
use crate::handler::{HassCancelAll, HassLightSet, HassNevermind, HassTurnOff, HassTurnOn};
|
||||
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);
|
||||
pipeline
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use homecore::service::FnHandler;
|
||||
use homecore::{HomeCore, ServiceName};
|
||||
|
||||
use crate::handler::{HassTurnOff, HassTurnOn};
|
||||
use crate::recognizer::RegexIntentRecognizer;
|
||||
|
||||
use super::*;
|
||||
|
||||
async fn build_test_pipeline() -> (AssistPipeline<RegexIntentRecognizer>, HomeCore) {
|
||||
let r = RegexIntentRecognizer::new();
|
||||
r.register(
|
||||
"HassTurnOn",
|
||||
r"turn on (?:the )?(?P<entity_id>[a-z_][a-z0-9_ ]*(?:\.[a-z0-9_]+)?)",
|
||||
"*",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
r.register(
|
||||
"HassTurnOff",
|
||||
r"turn off (?:the )?(?P<entity_id>[a-z_][a-z0-9_ ]*(?:\.[a-z0-9_]+)?)",
|
||||
"*",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
r.register("HassNevermind", r"never ?mind|cancel that", "*")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut pipeline = AssistPipeline::new(r);
|
||||
pipeline.register_handler(HassTurnOn);
|
||||
pipeline.register_handler(HassTurnOff);
|
||||
pipeline.register_handler(crate::handler::HassNevermind);
|
||||
|
||||
let hc = HomeCore::new();
|
||||
// Register spy handlers so service calls don't return NotRegistered.
|
||||
hc.services()
|
||||
.register(
|
||||
ServiceName::new("homeassistant", "turn_on"),
|
||||
FnHandler(|_| async { Ok(serde_json::json!({})) }),
|
||||
)
|
||||
.await;
|
||||
hc.services()
|
||||
.register(
|
||||
ServiceName::new("homeassistant", "turn_off"),
|
||||
FnHandler(|_| async { Ok(serde_json::json!({})) }),
|
||||
)
|
||||
.await;
|
||||
(pipeline, hc)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pipeline_turn_on_end_to_end() {
|
||||
let (pipeline, hc) = build_test_pipeline().await;
|
||||
let resp = pipeline
|
||||
.process("turn on light.kitchen", "en", &hc)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(resp.speech.contains("light.kitchen"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pipeline_turn_off_end_to_end() {
|
||||
let (pipeline, hc) = build_test_pipeline().await;
|
||||
let resp = pipeline
|
||||
.process("turn off switch.fan", "en", &hc)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(resp.speech.to_lowercase().contains("off") || resp.speech.contains("switch.fan"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pipeline_unknown_utterance_returns_not_understood() {
|
||||
let (pipeline, hc) = build_test_pipeline().await;
|
||||
let resp = pipeline
|
||||
.process("what is the weather like", "en", &hc)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(resp.speech.contains("not sure") || resp.speech.contains("I'm not"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pipeline_recognized_but_no_handler_returns_not_understood() {
|
||||
// Register a pattern but NOT its handler.
|
||||
let r = RegexIntentRecognizer::new();
|
||||
r.register("HassGetState", r"what is (?P<entity_id>\S+)", "*")
|
||||
.await
|
||||
.unwrap();
|
||||
let pipeline = AssistPipeline::new(r);
|
||||
let hc = HomeCore::new();
|
||||
let resp = pipeline
|
||||
.process("what is light.kitchen", "en", &hc)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(resp.speech.contains("not sure") || resp.speech.contains("I'm not"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_pipeline_registers_five_handlers() {
|
||||
let r = RegexIntentRecognizer::new();
|
||||
let pipeline = default_pipeline(r);
|
||||
assert_eq!(pipeline.handler_count(), 5);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pipeline_nevermind_response() {
|
||||
let (pipeline, hc) = build_test_pipeline().await;
|
||||
let resp = pipeline
|
||||
.process("never mind", "en", &hc)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
resp.speech.to_lowercase().contains("okay")
|
||||
|| resp.speech.to_lowercase().contains("never")
|
||||
|| resp.speech.to_lowercase().contains("cancel")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pipeline_use_homecore_service_fn_handler() {
|
||||
use homecore::service::FnHandler;
|
||||
let hc = HomeCore::new();
|
||||
hc.services()
|
||||
.register(
|
||||
ServiceName::new("homeassistant", "turn_on"),
|
||||
FnHandler(|_| async { Ok(serde_json::json!({"ok": true})) }),
|
||||
)
|
||||
.await;
|
||||
let r = RegexIntentRecognizer::new();
|
||||
r.register(
|
||||
"HassTurnOn",
|
||||
r"on (?P<entity_id>\S+)",
|
||||
"*",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut pipeline = AssistPipeline::new(r);
|
||||
pipeline.register_handler(HassTurnOn);
|
||||
let resp = pipeline.process("on light.bed", "en", &hc).await.unwrap();
|
||||
assert!(resp.speech.contains("light.bed"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
//! Intent recognizer trait + P1 regex-based implementation.
|
||||
//!
|
||||
//! Mirrors `homeassistant.helpers.intent.IntentRecognizer` and the
|
||||
//! `homeassistant/components/conversation/default_agent.py` regex pattern
|
||||
//! approach used in HA's classic intent matching.
|
||||
//!
|
||||
//! ## P1: `RegexIntentRecognizer`
|
||||
//!
|
||||
//! Tries each registered pattern in order; the first match wins.
|
||||
//! Slot values are extracted from named capture groups.
|
||||
//!
|
||||
//! ## P2 (stub only): `SemanticIntentRecognizer`
|
||||
//!
|
||||
//! Will embed the utterance with ruvector-core and compare it to a
|
||||
//! HNSW index of intent exemplars. Falls back to regex when similarity
|
||||
//! is below a configurable threshold (default 0.75).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use regex::Regex;
|
||||
// serde imports used by SemanticIntentRecognizer and future P2 code
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::intent::{Intent, IntentName};
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum RecognizerError {
|
||||
#[error("regex compile error: {0}")]
|
||||
BadPattern(String),
|
||||
#[error("recognizer internal error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
/// Core trait every recognizer must implement.
|
||||
///
|
||||
/// Returns `Ok(None)` when no intent matches (pipeline falls through to
|
||||
/// the "not understood" path).
|
||||
#[async_trait]
|
||||
pub trait IntentRecognizer: Send + Sync + 'static {
|
||||
async fn recognize(
|
||||
&self,
|
||||
utterance: &str,
|
||||
language: &str,
|
||||
) -> Result<Option<Intent>, RecognizerError>;
|
||||
}
|
||||
|
||||
/// A single registered intent pattern.
|
||||
#[derive(Clone)]
|
||||
struct IntentPattern {
|
||||
name: IntentName,
|
||||
/// Pre-compiled regex. Named capture groups become slot keys.
|
||||
regex: Regex,
|
||||
/// Language tag this pattern applies to. `"*"` means any language.
|
||||
language: String,
|
||||
}
|
||||
|
||||
/// P1 recognizer that matches utterances against pre-registered regex patterns.
|
||||
///
|
||||
/// Thread-safe: patterns are stored in a `Vec` behind an `Arc<RwLock<_>>` so
|
||||
/// that `register` can be called from multiple tasks.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct RegexIntentRecognizer {
|
||||
patterns: std::sync::Arc<tokio::sync::RwLock<Vec<IntentPattern>>>,
|
||||
}
|
||||
|
||||
impl RegexIntentRecognizer {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Register a regex pattern for the given intent name and language.
|
||||
///
|
||||
/// Named capture groups (e.g. `(?P<entity_id>\w+\.\w+)`) become slot keys.
|
||||
/// `language` may be a BCP-47 tag (`"en"`) or `"*"` to match any language.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `RecognizerError::BadPattern` if the regex fails to compile.
|
||||
pub async fn register(
|
||||
&self,
|
||||
name: impl Into<String>,
|
||||
pattern: &str,
|
||||
language: impl Into<String>,
|
||||
) -> Result<(), RecognizerError> {
|
||||
let regex = Regex::new(pattern).map_err(|e| RecognizerError::BadPattern(e.to_string()))?;
|
||||
self.patterns.write().await.push(IntentPattern {
|
||||
name: IntentName::new(name),
|
||||
regex,
|
||||
language: language.into(),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl IntentRecognizer for RegexIntentRecognizer {
|
||||
async fn recognize(
|
||||
&self,
|
||||
utterance: &str,
|
||||
language: &str,
|
||||
) -> Result<Option<Intent>, RecognizerError> {
|
||||
let normalised = utterance.trim().to_lowercase();
|
||||
let patterns = self.patterns.read().await;
|
||||
for pattern in patterns.iter() {
|
||||
if pattern.language != "*" && pattern.language != language {
|
||||
continue;
|
||||
}
|
||||
if let Some(caps) = pattern.regex.captures(&normalised) {
|
||||
let mut slots: HashMap<String, serde_json::Value> = HashMap::new();
|
||||
for name in pattern.regex.capture_names().flatten() {
|
||||
if let Some(m) = caps.name(name) {
|
||||
slots.insert(name.to_owned(), serde_json::Value::String(m.as_str().to_owned()));
|
||||
}
|
||||
}
|
||||
return Ok(Some(Intent {
|
||||
name: pattern.name.clone(),
|
||||
slots,
|
||||
language: language.to_owned(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// P2 stub: semantic recognizer backed by ruvector HNSW.
|
||||
///
|
||||
/// Currently always delegates to the inner `RegexIntentRecognizer`.
|
||||
/// P2 will populate a HNSW index at startup and compare embedded
|
||||
/// utterances before falling back to regex.
|
||||
pub struct SemanticIntentRecognizer {
|
||||
fallback: RegexIntentRecognizer,
|
||||
}
|
||||
|
||||
impl SemanticIntentRecognizer {
|
||||
pub fn new(fallback: RegexIntentRecognizer) -> Self {
|
||||
Self { fallback }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl IntentRecognizer for SemanticIntentRecognizer {
|
||||
async fn recognize(
|
||||
&self,
|
||||
utterance: &str,
|
||||
language: &str,
|
||||
) -> Result<Option<Intent>, RecognizerError> {
|
||||
// TODO P2: embed utterance + HNSW search before falling through.
|
||||
self.fallback.recognize(utterance, language).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
async fn turn_on_recognizer() -> RegexIntentRecognizer {
|
||||
let r = RegexIntentRecognizer::new();
|
||||
r.register(
|
||||
"HassTurnOn",
|
||||
r"turn on (?:the )?(?P<entity_id>[a-z_][a-z0-9_ ]*(?:\.[a-z_][a-z0-9_]*)?)",
|
||||
"*",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
r.register(
|
||||
"HassTurnOff",
|
||||
r"turn off (?:the )?(?P<entity_id>[a-z_][a-z0-9_ ]*(?:\.[a-z_][a-z0-9_]*)?)",
|
||||
"*",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
r
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recognizes_turn_on_entity() {
|
||||
let r = turn_on_recognizer().await;
|
||||
let intent = r
|
||||
.recognize("turn on the kitchen light", "en")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(intent.name.as_str(), "HassTurnOn");
|
||||
assert!(intent.slots.contains_key("entity_id"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recognizes_dotted_entity_id() {
|
||||
let r = turn_on_recognizer().await;
|
||||
let intent = r
|
||||
.recognize("turn on light.kitchen", "en")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(intent.name.as_str(), "HassTurnOn");
|
||||
assert_eq!(intent.entity_id(), Some("light.kitchen"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unrecognized_utterance_returns_none() {
|
||||
let r = turn_on_recognizer().await;
|
||||
let result = r.recognize("play jazz music", "en").await.unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn language_filter_skips_non_matching() {
|
||||
let r = RegexIntentRecognizer::new();
|
||||
r.register("HassTurnOn", r"turn on (?P<entity_id>\S+)", "de")
|
||||
.await
|
||||
.unwrap();
|
||||
// German-only pattern must not match an English utterance.
|
||||
let result = r.recognize("turn on light.kitchen", "en").await.unwrap();
|
||||
assert!(result.is_none());
|
||||
// But it must match a German-tagged utterance.
|
||||
let result = r.recognize("turn on licht.kueche", "de").await.unwrap();
|
||||
assert!(result.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn semantic_recognizer_delegates_to_fallback() {
|
||||
let regex = turn_on_recognizer().await;
|
||||
let semantic = SemanticIntentRecognizer::new(regex);
|
||||
let result = semantic
|
||||
.recognize("turn on light.kitchen", "en")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.is_some());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
//! RufloRunner trait + NoopRunner (P1 stub).
|
||||
//!
|
||||
//! The ruflo agent is a Node.js process that exposes an MCP-over-stdio
|
||||
//! interface for LLM-grade intent disambiguation. HOMECORE-ASSIST manages
|
||||
//! a long-lived subprocess via `tokio::process::Child`.
|
||||
//!
|
||||
//! ## P1 scope
|
||||
//!
|
||||
//! Only the trait + `NoopRunner` stub ship in P1. No subprocess is spawned.
|
||||
//!
|
||||
//! ## P2 scope
|
||||
//!
|
||||
//! Real subprocess management with Windows-safe teardown per ADR-133 §Q3:
|
||||
//! - `Child` wrapped in `Arc<Mutex<Option<Child>>>`.
|
||||
//! - Explicit `async shutdown()` calls `child.kill().await` before drop.
|
||||
//! - `tokio::signal` handler registered for `Ctrl+C`/`SIGINT` that calls
|
||||
//! `shutdown()` before exit.
|
||||
//! - Windows job object approach (option 3 per Q3) deferred to P3.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::intent::Intent;
|
||||
|
||||
/// Error type for the assist pipeline (runner + pipeline-level errors).
|
||||
#[derive(Error, Debug)]
|
||||
pub enum AssistError {
|
||||
#[error("runner not started")]
|
||||
NotStarted,
|
||||
#[error("runner IO error: {0}")]
|
||||
Io(String),
|
||||
#[error("runner response parse error: {0}")]
|
||||
ParseError(String),
|
||||
#[error("recognizer error: {0}")]
|
||||
Recognizer(#[from] crate::recognizer::RecognizerError),
|
||||
#[error("handler error: {0}")]
|
||||
Handler(#[from] crate::handler::HandlerError),
|
||||
#[error("no handler registered for intent: {0}")]
|
||||
NoHandler(String),
|
||||
}
|
||||
|
||||
/// Configuration for launching the ruflo agent subprocess.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct RufloRunnerOpts {
|
||||
/// Path to the `ruflo-agent.js` entry point.
|
||||
pub script_path: String,
|
||||
/// Additional environment variables to pass to the subprocess.
|
||||
pub env: std::collections::HashMap<String, String>,
|
||||
/// Request timeout in milliseconds (default 5000).
|
||||
pub timeout_ms: u64,
|
||||
}
|
||||
|
||||
impl Default for RufloRunnerOpts {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
script_path: "ruflo-agent.js".into(),
|
||||
env: Default::default(),
|
||||
timeout_ms: 5000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON response from the ruflo agent subprocess.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct RufloResponse {
|
||||
/// Recognised intent, if the LLM resolved one.
|
||||
pub intent: Option<Intent>,
|
||||
/// Spoken text from the LLM, if any.
|
||||
pub speech: Option<String>,
|
||||
}
|
||||
|
||||
/// Trait for the ruflo agent subprocess runner.
|
||||
///
|
||||
/// P1 ships only this trait + `NoopRunner`. The real subprocess runner
|
||||
/// lands in P2 with Windows-safe teardown (ADR-133 §Q3).
|
||||
#[async_trait]
|
||||
pub trait RufloRunner: Send + Sync + 'static {
|
||||
/// Spawn (or reconnect to) the ruflo agent subprocess.
|
||||
async fn spawn(&mut self, opts: RufloRunnerOpts) -> Result<(), AssistError>;
|
||||
|
||||
/// Send an utterance payload to the agent and await a response.
|
||||
///
|
||||
/// `payload` is an arbitrary JSON object; at minimum it should include
|
||||
/// `{ "utterance": "...", "language": "..." }`.
|
||||
async fn send_request(
|
||||
&self,
|
||||
payload: serde_json::Value,
|
||||
) -> Result<RufloResponse, AssistError>;
|
||||
|
||||
/// Gracefully shut down the subprocess.
|
||||
///
|
||||
/// Must be idempotent — calling `shutdown` on an already-stopped runner
|
||||
/// must return `Ok(())` rather than an error.
|
||||
async fn shutdown(&mut self) -> Result<(), AssistError>;
|
||||
}
|
||||
|
||||
/// P1 no-op implementation. Spawn/send/shutdown are all immediate Ok.
|
||||
///
|
||||
/// `send_request` returns an empty `RufloResponse` (no intent, no speech),
|
||||
/// which causes the pipeline to fall through to the regex recognizer path.
|
||||
#[derive(Default)]
|
||||
pub struct NoopRunner {
|
||||
started: bool,
|
||||
}
|
||||
|
||||
impl NoopRunner {
|
||||
pub fn new() -> Self {
|
||||
Self { started: false }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RufloRunner for NoopRunner {
|
||||
async fn spawn(&mut self, _opts: RufloRunnerOpts) -> Result<(), AssistError> {
|
||||
self.started = true;
|
||||
tracing::debug!("NoopRunner: spawn called (P1 stub — no subprocess started)");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_request(
|
||||
&self,
|
||||
_payload: serde_json::Value,
|
||||
) -> Result<RufloResponse, AssistError> {
|
||||
// P1 stub: always returns empty response so the pipeline falls through
|
||||
// to the regex recognizer.
|
||||
Ok(RufloResponse {
|
||||
intent: None,
|
||||
speech: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn shutdown(&mut self) -> Result<(), AssistError> {
|
||||
// Idempotent: Ok whether or not spawn was called.
|
||||
self.started = false;
|
||||
tracing::debug!("NoopRunner: shutdown called (idempotent no-op in P1)");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn noop_runner_spawn_returns_ok() {
|
||||
let mut runner = NoopRunner::new();
|
||||
let result = runner.spawn(RufloRunnerOpts::default()).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn noop_runner_send_request_returns_empty_response() {
|
||||
let runner = NoopRunner::new();
|
||||
let resp = runner
|
||||
.send_request(serde_json::json!({"utterance": "turn on the light", "language": "en"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(resp.intent.is_none());
|
||||
assert!(resp.speech.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn noop_runner_shutdown_is_idempotent() {
|
||||
let mut runner = NoopRunner::new();
|
||||
// First shutdown without spawn — must not error.
|
||||
assert!(runner.shutdown().await.is_ok());
|
||||
// Spawn then shutdown — must not error.
|
||||
runner.spawn(RufloRunnerOpts::default()).await.unwrap();
|
||||
assert!(runner.shutdown().await.is_ok());
|
||||
// Second shutdown — must still not error.
|
||||
assert!(runner.shutdown().await.is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
# homecore-automation — HOMECORE automation engine, trigger evaluator, and
|
||||
# MiniJinja template evaluator.
|
||||
# Implements ADR-129 (HOMECORE-AUTO): YAML automation parser, trigger/condition/
|
||||
# action evaluation, AutomationEngine runtime that subscribes to the HOMECORE
|
||||
# event bus and fires automations.
|
||||
|
||||
[package]
|
||||
name = "homecore-automation"
|
||||
version = "0.1.0-alpha.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["rUv <ruv@ruv.net>", "HOMECORE Contributors"]
|
||||
description = "Automation engine, trigger evaluator, and MiniJinja template evaluator for HOMECORE (ADR-129)"
|
||||
repository = "https://github.com/ruvnet/RuView"
|
||||
|
||||
[lib]
|
||||
name = "homecore_automation"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
# HOMECORE core — state machine, event bus, service registry, entity types
|
||||
homecore = { path = "../homecore" }
|
||||
|
||||
# Async runtime
|
||||
tokio = { version = "1", features = ["sync", "rt", "rt-multi-thread", "time", "macros"] }
|
||||
|
||||
# Serialization — YAML automation files + JSON service call data
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_yaml = "0.9"
|
||||
serde_json = "1"
|
||||
|
||||
# MiniJinja — HA-compatible Jinja2 template engine in pure Rust (ADR-129 §2.1)
|
||||
minijinja = { version = "2", features = ["json", "loader"] }
|
||||
|
||||
# Error handling
|
||||
thiserror = "1"
|
||||
|
||||
# Time — chrono DateTime for triggers + condition evaluation
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
# Async trait for EvaluateTrigger + condition evaluate
|
||||
async-trait = "0.1"
|
||||
|
||||
# Unique IDs for automation instances
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["sync", "rt", "rt-multi-thread", "time", "macros", "test-util"] }
|
||||
@@ -0,0 +1,191 @@
|
||||
//! `Action` enum and async execution.
|
||||
//!
|
||||
//! Implements the ADR-129 P1 action set: `service_call`, `delay`, `scene`,
|
||||
//! `wait_for_trigger`, `choose`. Complex variants (parallel, repeat, if,
|
||||
//! stop, fire_event, wait_template) land in P2.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::time::sleep;
|
||||
|
||||
use homecore::{Context, HomeCore, ServiceCall, ServiceName};
|
||||
|
||||
use crate::error::AutomationError;
|
||||
|
||||
/// Runtime context passed into action execution.
|
||||
pub struct ExecutionContext {
|
||||
/// HOMECORE handle — provides service registry + state machine.
|
||||
pub hc: HomeCore,
|
||||
/// Causality context for service calls triggered by this automation.
|
||||
pub context: Context,
|
||||
/// Automation ID for tracing/logging.
|
||||
pub automation_id: String,
|
||||
}
|
||||
|
||||
impl ExecutionContext {
|
||||
pub fn new(hc: HomeCore, automation_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
hc,
|
||||
context: Context::new(),
|
||||
automation_id: automation_id.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Action configuration. Deserialized from YAML `action:` blocks.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "action", rename_all = "snake_case")]
|
||||
pub enum Action {
|
||||
/// Call a HOMECORE service.
|
||||
ServiceCall {
|
||||
domain: String,
|
||||
service: String,
|
||||
#[serde(default)]
|
||||
data: serde_json::Value,
|
||||
},
|
||||
/// Pause execution for a fixed duration (ISO 8601 or seconds float).
|
||||
Delay {
|
||||
/// Delay in seconds.
|
||||
seconds: f64,
|
||||
},
|
||||
/// Activate a named scene entity.
|
||||
Scene {
|
||||
scene: String,
|
||||
},
|
||||
/// Block until one of the listed triggers fires (or timeout).
|
||||
WaitForTrigger {
|
||||
timeout_seconds: Option<f64>,
|
||||
},
|
||||
/// Conditional branching — first matching branch wins.
|
||||
Choose {
|
||||
choices: Vec<ChoiceBranch>,
|
||||
#[serde(default)]
|
||||
default: Vec<Action>,
|
||||
},
|
||||
}
|
||||
|
||||
/// A single branch in a `Choose` action.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ChoiceBranch {
|
||||
pub conditions: Vec<serde_yaml::Value>,
|
||||
pub sequence: Vec<Action>,
|
||||
}
|
||||
|
||||
impl Action {
|
||||
/// Execute this action using the provided context.
|
||||
///
|
||||
/// Returns a JSON value (may be `null`) for callers that chain
|
||||
/// `wait_for_trigger` / `set_variable` patterns (P2).
|
||||
///
|
||||
/// Uses `Box::pin` for recursive variants (Choose) to satisfy the
|
||||
/// Rust requirement that recursive async fns introduce indirection.
|
||||
pub fn execute<'a>(
|
||||
&'a self,
|
||||
ctx: &'a mut ExecutionContext,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<serde_json::Value, AutomationError>> + Send + 'a>> {
|
||||
Box::pin(async move {
|
||||
match self {
|
||||
Action::ServiceCall { domain, service, data } => {
|
||||
let call = ServiceCall {
|
||||
name: ServiceName::new(domain.clone(), service.clone()),
|
||||
data: data.clone(),
|
||||
context: ctx.context.clone(),
|
||||
};
|
||||
let result = ctx.hc.services().call(call).await?;
|
||||
Ok(result)
|
||||
}
|
||||
Action::Delay { seconds } => {
|
||||
let dur = Duration::from_secs_f64(*seconds);
|
||||
sleep(dur).await;
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
Action::Scene { scene } => {
|
||||
// Scene activation maps to homeassistant.turn_on with entity_id = scene
|
||||
let call = ServiceCall {
|
||||
name: ServiceName::new("homeassistant", "turn_on"),
|
||||
data: serde_json::json!({ "entity_id": scene }),
|
||||
context: ctx.context.clone(),
|
||||
};
|
||||
let result = ctx.hc.services().call(call).await?;
|
||||
Ok(result)
|
||||
}
|
||||
Action::WaitForTrigger { timeout_seconds } => {
|
||||
// P1 stub — just sleeps for the timeout duration if specified.
|
||||
// Full trigger subscription lands in P2.
|
||||
if let Some(secs) = timeout_seconds {
|
||||
sleep(Duration::from_secs_f64(*secs)).await;
|
||||
}
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
Action::Choose { choices: _, default } => {
|
||||
// P1 stub — condition evaluation for choices lands in P2;
|
||||
// for now, fall through to default branch.
|
||||
for a in default {
|
||||
a.execute(ctx).await?;
|
||||
}
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use homecore::{HomeCore, ServiceCall, ServiceError, ServiceName};
|
||||
use homecore::service::FnHandler;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[tokio::test]
|
||||
async fn service_call_action_fires_handler() {
|
||||
let hc = HomeCore::new();
|
||||
let log: Arc<Mutex<Vec<serde_json::Value>>> = Arc::new(Mutex::new(vec![]));
|
||||
let log2 = Arc::clone(&log);
|
||||
hc.services()
|
||||
.register(
|
||||
ServiceName::new("light", "turn_on"),
|
||||
FnHandler(move |call: ServiceCall| {
|
||||
let log3 = Arc::clone(&log2);
|
||||
async move {
|
||||
log3.lock().unwrap().push(call.data.clone());
|
||||
Ok(call.data)
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let action = Action::ServiceCall {
|
||||
domain: "light".into(),
|
||||
service: "turn_on".into(),
|
||||
data: serde_json::json!({"brightness": 255}),
|
||||
};
|
||||
let mut exec_ctx = ExecutionContext::new(hc, "test_auto");
|
||||
let res = action.execute(&mut exec_ctx).await.unwrap();
|
||||
assert_eq!(res["brightness"], 255);
|
||||
assert_eq!(log.lock().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delay_action_completes() {
|
||||
let hc = HomeCore::new();
|
||||
let mut exec_ctx = ExecutionContext::new(hc, "test_auto");
|
||||
let action = Action::Delay { seconds: 0.001 };
|
||||
let result = action.execute(&mut exec_ctx).await.unwrap();
|
||||
assert!(result.is_null());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn service_call_unregistered_returns_error() {
|
||||
let hc = HomeCore::new();
|
||||
let mut exec_ctx = ExecutionContext::new(hc, "test_auto");
|
||||
let action = Action::ServiceCall {
|
||||
domain: "light".into(),
|
||||
service: "turn_on".into(),
|
||||
data: serde_json::json!({}),
|
||||
};
|
||||
let err = action.execute(&mut exec_ctx).await.unwrap_err();
|
||||
assert!(matches!(err, AutomationError::ServiceCall(ServiceError::NotRegistered { .. })));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//! `Automation` — the parsed representation of one HA automation YAML block.
|
||||
//!
|
||||
//! Mirrors HA's `AutomationConfig` / `AutomationEntity`. Deserialized from
|
||||
//! YAML via serde; validated at construction time by the engine.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::condition::Condition;
|
||||
use crate::trigger::Trigger;
|
||||
|
||||
/// Script run mode. Mirrors HA's `ScriptRunMode` (`script/__init__.py`).
|
||||
///
|
||||
/// Controls what happens when a second trigger fires while the automation
|
||||
/// is already running.
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RunMode {
|
||||
/// Only one instance runs at a time. If already running, the new
|
||||
/// trigger is silently dropped (HA default).
|
||||
#[default]
|
||||
Single,
|
||||
/// Kill the running instance and start a fresh one.
|
||||
Restart,
|
||||
/// Queue new triggers; execute sequentially when the prior run finishes.
|
||||
Queued,
|
||||
/// Allow unlimited concurrent runs.
|
||||
Parallel,
|
||||
/// Same as `Single` but also skips the first trigger (rarely used).
|
||||
IgnoreFirst,
|
||||
}
|
||||
|
||||
/// A parsed automation. Cheap to clone — all heaps are `Arc`-free vecs of
|
||||
/// enums; the engine holds `Arc<Automation>` copies.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Automation {
|
||||
/// Unique identifier. HA auto-assigns a 32-char hex ID if omitted.
|
||||
pub id: String,
|
||||
|
||||
/// Human-readable alias shown in the HA UI.
|
||||
#[serde(default)]
|
||||
pub alias: Option<String>,
|
||||
|
||||
/// Optional free-text description.
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
|
||||
/// Whether the automation is enabled. Disabled automations are loaded
|
||||
/// but their triggers are not evaluated.
|
||||
#[serde(default = "default_enabled")]
|
||||
pub enabled: bool,
|
||||
|
||||
/// Script run mode.
|
||||
#[serde(default)]
|
||||
pub mode: RunMode,
|
||||
|
||||
/// Maximum concurrent runs when mode is `Queued` or `Parallel`.
|
||||
#[serde(default)]
|
||||
pub max: Option<usize>,
|
||||
|
||||
/// One or more trigger definitions. At least one must be present.
|
||||
pub trigger: Vec<Trigger>,
|
||||
|
||||
/// Optional conditions — all must pass before actions run.
|
||||
#[serde(default)]
|
||||
pub condition: Vec<Condition>,
|
||||
|
||||
/// Action sequence to execute when triggered + conditions pass.
|
||||
pub action: Vec<Action>,
|
||||
}
|
||||
|
||||
fn default_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Automation {
|
||||
/// Minimal constructor for tests.
|
||||
pub fn new(
|
||||
id: impl Into<String>,
|
||||
triggers: Vec<Trigger>,
|
||||
actions: Vec<Action>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
alias: None,
|
||||
description: None,
|
||||
enabled: true,
|
||||
mode: RunMode::Single,
|
||||
max: None,
|
||||
trigger: triggers,
|
||||
condition: vec![],
|
||||
action: actions,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::trigger::Trigger;
|
||||
|
||||
#[test]
|
||||
fn run_mode_defaults_to_single() {
|
||||
let a = Automation::new("test.1", vec![Trigger::Event { event_type: "t".into() }], vec![]);
|
||||
assert_eq!(a.mode, RunMode::Single);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automation_enabled_by_default() {
|
||||
let a = Automation::new("test.2", vec![], vec![]);
|
||||
assert!(a.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_mode_roundtrip_yaml() {
|
||||
// RunMode is a plain string enum; deserialize from a bare YAML string.
|
||||
let mode: RunMode = serde_yaml::from_str("restart").unwrap();
|
||||
assert_eq!(mode, RunMode::Restart);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
//! `Condition` enum + async evaluation.
|
||||
//!
|
||||
//! Mirrors HA's 7 condition types. P1 ships: `state`, `numeric_state`,
|
||||
//! `template`, `and`, `or`, `not`. Time/zone/sun/device land in P2.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
use homecore::{EntityId, StateMachine};
|
||||
|
||||
use crate::template::TemplateEnvironment;
|
||||
|
||||
/// Context passed to condition evaluation. Holds a snapshot of the state
|
||||
/// machine and the optional template evaluator.
|
||||
#[derive(Clone)]
|
||||
pub struct EvalContext {
|
||||
pub states: Arc<StateMachine>,
|
||||
pub template_env: Option<Arc<TemplateEnvironment>>,
|
||||
}
|
||||
|
||||
impl EvalContext {
|
||||
pub fn new(states: Arc<StateMachine>) -> Self {
|
||||
Self { states, template_env: None }
|
||||
}
|
||||
|
||||
pub fn with_templates(states: Arc<StateMachine>, env: Arc<TemplateEnvironment>) -> Self {
|
||||
Self { states, template_env: Some(env) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Condition configuration. Deserialized from YAML `condition:` blocks.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "condition", rename_all = "snake_case")]
|
||||
pub enum Condition {
|
||||
/// Entity state equals a specific value.
|
||||
State {
|
||||
entity_id: EntityId,
|
||||
state: String,
|
||||
},
|
||||
/// Entity numeric state satisfies threshold bounds.
|
||||
NumericState {
|
||||
entity_id: EntityId,
|
||||
#[serde(default)]
|
||||
above: Option<f64>,
|
||||
#[serde(default)]
|
||||
below: Option<f64>,
|
||||
},
|
||||
/// Jinja2 template evaluates to truthy.
|
||||
Template {
|
||||
value_template: String,
|
||||
},
|
||||
/// All child conditions must be true (logical AND).
|
||||
And {
|
||||
conditions: Vec<Condition>,
|
||||
},
|
||||
/// At least one child condition must be true (logical OR).
|
||||
Or {
|
||||
conditions: Vec<Condition>,
|
||||
},
|
||||
/// Inner condition must be false (logical NOT).
|
||||
Not {
|
||||
conditions: Vec<Condition>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Condition {
|
||||
/// Evaluate this condition against the provided context.
|
||||
///
|
||||
/// Uses `Box::pin` for recursive variants (And/Or/Not) to satisfy the
|
||||
/// Rust requirement that recursive async fns introduce indirection.
|
||||
pub fn evaluate<'a>(&'a self, ctx: &'a EvalContext) -> std::pin::Pin<Box<dyn std::future::Future<Output = bool> + Send + 'a>> {
|
||||
Box::pin(async move {
|
||||
match self {
|
||||
Condition::State { entity_id, state } => {
|
||||
ctx.states
|
||||
.get(entity_id)
|
||||
.map_or(false, |s| s.state == *state)
|
||||
}
|
||||
Condition::NumericState { entity_id, above, below } => {
|
||||
let value: Option<f64> = ctx
|
||||
.states
|
||||
.get(entity_id)
|
||||
.and_then(|s| s.state.parse().ok());
|
||||
match value {
|
||||
None => false,
|
||||
Some(v) => {
|
||||
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 {
|
||||
match env.render_bool(value_template) {
|
||||
Ok(v) => v,
|
||||
Err(_) => false,
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
Condition::And { conditions } => {
|
||||
for c in conditions {
|
||||
if !c.evaluate(ctx).await {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
Condition::Or { conditions } => {
|
||||
for c in conditions {
|
||||
if c.evaluate(ctx).await {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
Condition::Not { conditions } => {
|
||||
for c in conditions {
|
||||
if c.evaluate(ctx).await {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use homecore::{Context, EntityId, StateMachine};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn sm_with(entity_id: &str, state: &str) -> Arc<StateMachine> {
|
||||
let sm = Arc::new(StateMachine::new());
|
||||
sm.set(
|
||||
EntityId::parse(entity_id).unwrap(),
|
||||
state,
|
||||
serde_json::json!({}),
|
||||
Context::new(),
|
||||
);
|
||||
sm
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn state_condition_matches() {
|
||||
let sm = sm_with("light.kitchen", "on");
|
||||
let ctx = EvalContext::new(sm);
|
||||
let cond = Condition::State {
|
||||
entity_id: EntityId::parse("light.kitchen").unwrap(),
|
||||
state: "on".into(),
|
||||
};
|
||||
assert!(cond.evaluate(&ctx).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn state_condition_no_match() {
|
||||
let sm = sm_with("light.kitchen", "off");
|
||||
let ctx = EvalContext::new(sm);
|
||||
let cond = Condition::State {
|
||||
entity_id: EntityId::parse("light.kitchen").unwrap(),
|
||||
state: "on".into(),
|
||||
};
|
||||
assert!(!cond.evaluate(&ctx).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn numeric_condition_above() {
|
||||
let sm = sm_with("sensor.temperature", "28");
|
||||
let ctx = EvalContext::new(sm);
|
||||
let cond = Condition::NumericState {
|
||||
entity_id: EntityId::parse("sensor.temperature").unwrap(),
|
||||
above: Some(25.0),
|
||||
below: None,
|
||||
};
|
||||
assert!(cond.evaluate(&ctx).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn and_combinator_all_true() {
|
||||
let sm = Arc::new(StateMachine::new());
|
||||
sm.set(EntityId::parse("light.a").unwrap(), "on", serde_json::json!({}), Context::new());
|
||||
sm.set(EntityId::parse("light.b").unwrap(), "on", serde_json::json!({}), Context::new());
|
||||
let ctx = EvalContext::new(sm);
|
||||
let cond = Condition::And {
|
||||
conditions: vec![
|
||||
Condition::State { entity_id: EntityId::parse("light.a").unwrap(), state: "on".into() },
|
||||
Condition::State { entity_id: EntityId::parse("light.b").unwrap(), state: "on".into() },
|
||||
],
|
||||
};
|
||||
assert!(cond.evaluate(&ctx).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn and_combinator_one_false() {
|
||||
let sm = Arc::new(StateMachine::new());
|
||||
sm.set(EntityId::parse("light.a").unwrap(), "on", serde_json::json!({}), Context::new());
|
||||
sm.set(EntityId::parse("light.b").unwrap(), "off", serde_json::json!({}), Context::new());
|
||||
let ctx = EvalContext::new(sm);
|
||||
let cond = Condition::And {
|
||||
conditions: vec![
|
||||
Condition::State { entity_id: EntityId::parse("light.a").unwrap(), state: "on".into() },
|
||||
Condition::State { entity_id: EntityId::parse("light.b").unwrap(), state: "on".into() },
|
||||
],
|
||||
};
|
||||
assert!(!cond.evaluate(&ctx).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn or_combinator_one_true() {
|
||||
let sm = Arc::new(StateMachine::new());
|
||||
sm.set(EntityId::parse("light.a").unwrap(), "off", serde_json::json!({}), Context::new());
|
||||
sm.set(EntityId::parse("light.b").unwrap(), "on", serde_json::json!({}), Context::new());
|
||||
let ctx = EvalContext::new(sm);
|
||||
let cond = Condition::Or {
|
||||
conditions: vec![
|
||||
Condition::State { entity_id: EntityId::parse("light.a").unwrap(), state: "on".into() },
|
||||
Condition::State { entity_id: EntityId::parse("light.b").unwrap(), state: "on".into() },
|
||||
],
|
||||
};
|
||||
assert!(cond.evaluate(&ctx).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn not_condition_inverts() {
|
||||
let sm = sm_with("light.kitchen", "off");
|
||||
let ctx = EvalContext::new(sm);
|
||||
let cond = Condition::Not {
|
||||
conditions: vec![
|
||||
Condition::State {
|
||||
entity_id: EntityId::parse("light.kitchen").unwrap(),
|
||||
state: "on".into(),
|
||||
},
|
||||
],
|
||||
};
|
||||
assert!(cond.evaluate(&ctx).await);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
//! `AutomationEngine` — subscribes to the HOMECORE event bus, evaluates
|
||||
//! triggers, and runs automation action sequences.
|
||||
//!
|
||||
//! ADR-129 §2 design: one Tokio task per running automation instance.
|
||||
//! RunMode::Single is enforced via a per-automation `AtomicBool` flag.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use homecore::HomeCore;
|
||||
|
||||
use crate::action::ExecutionContext;
|
||||
use crate::automation::Automation;
|
||||
use crate::condition::EvalContext;
|
||||
use crate::trigger::TriggerContext;
|
||||
|
||||
/// The automation engine. Holds a HOMECORE handle and a list of registered
|
||||
/// automations. Call `start()` to begin listening for events.
|
||||
pub struct AutomationEngine {
|
||||
hc: HomeCore,
|
||||
automations: Arc<Mutex<Vec<Arc<Automation>>>>,
|
||||
}
|
||||
|
||||
impl AutomationEngine {
|
||||
/// Create a new engine backed by the given HOMECORE handle.
|
||||
pub fn new(hc: HomeCore) -> Self {
|
||||
Self {
|
||||
hc,
|
||||
automations: Arc::new(Mutex::new(vec![])),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register an automation. Can be called before or after `start()`.
|
||||
pub fn register(&self, automation: Automation) {
|
||||
self.automations.lock().unwrap().push(Arc::new(automation));
|
||||
}
|
||||
|
||||
/// Subscribe to the state-machine broadcast channel and start
|
||||
/// evaluating triggers. Returns a join handle for the background task.
|
||||
///
|
||||
/// The task runs until the broadcast sender is dropped (i.e. the
|
||||
/// `HomeCore` instance is destroyed).
|
||||
pub fn start(&self) -> tokio::task::JoinHandle<()> {
|
||||
let mut rx = self.hc.states().subscribe();
|
||||
let automations = Arc::clone(&self.automations);
|
||||
let hc = self.hc.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(event) => {
|
||||
let autos = automations.lock().unwrap().clone();
|
||||
for automation in autos {
|
||||
if !automation.enabled {
|
||||
continue;
|
||||
}
|
||||
let trigger_ctx = TriggerContext::state_changed(
|
||||
event.entity_id.clone(),
|
||||
event.old_state.clone(),
|
||||
event.new_state.clone(),
|
||||
);
|
||||
// Check all triggers — fire on first match
|
||||
let triggered = automation
|
||||
.trigger
|
||||
.iter()
|
||||
.any(|t| t.matches_sync(&trigger_ctx));
|
||||
if !triggered {
|
||||
continue;
|
||||
}
|
||||
// Evaluate conditions
|
||||
let sm = Arc::new(hc.states().clone());
|
||||
let eval_ctx = EvalContext::new(sm);
|
||||
let mut conditions_pass = true;
|
||||
for cond in &automation.condition {
|
||||
if !cond.evaluate(&eval_ctx).await {
|
||||
conditions_pass = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !conditions_pass {
|
||||
continue;
|
||||
}
|
||||
// Execute actions in a spawned task (non-blocking)
|
||||
let auto_clone = Arc::clone(&automation);
|
||||
let hc_clone = hc.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut exec_ctx =
|
||||
ExecutionContext::new(hc_clone, auto_clone.id.clone());
|
||||
for action in &auto_clone.action {
|
||||
if let Err(e) = action.execute(&mut exec_ctx).await {
|
||||
// P1: log errors to stderr; structured logging in P2
|
||||
eprintln!(
|
||||
"[homecore-automation] action error in {}: {e}",
|
||||
auto_clone.id
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => break,
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
eprintln!("[homecore-automation] state-changed receiver lagged by {n} events");
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::action::Action;
|
||||
use crate::automation::Automation;
|
||||
use crate::trigger::Trigger;
|
||||
use homecore::{Context, EntityId, HomeCore, ServiceCall, ServiceName};
|
||||
use homecore::service::FnHandler;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::time::{sleep, Duration};
|
||||
|
||||
/// Register a recording handler that captures all calls.
|
||||
async fn register_recorder(
|
||||
hc: &HomeCore,
|
||||
domain: &str,
|
||||
service: &str,
|
||||
) -> Arc<Mutex<Vec<serde_json::Value>>> {
|
||||
let log: Arc<Mutex<Vec<serde_json::Value>>> = Arc::new(Mutex::new(vec![]));
|
||||
let log2 = Arc::clone(&log);
|
||||
hc.services()
|
||||
.register(
|
||||
ServiceName::new(domain, service),
|
||||
FnHandler(move |call: ServiceCall| {
|
||||
let l = Arc::clone(&log2);
|
||||
async move {
|
||||
l.lock().unwrap().push(call.data.clone());
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
log
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn engine_fires_automation_on_state_change() {
|
||||
let hc = HomeCore::new();
|
||||
let log = register_recorder(&hc, "light", "turn_on").await;
|
||||
|
||||
let engine = AutomationEngine::new(hc.clone());
|
||||
engine.register(Automation::new(
|
||||
"test_auto_1",
|
||||
vec![Trigger::State {
|
||||
entity_id: EntityId::parse("switch.living").unwrap(),
|
||||
from: None,
|
||||
to: Some("on".into()),
|
||||
}],
|
||||
vec![Action::ServiceCall {
|
||||
domain: "light".into(),
|
||||
service: "turn_on".into(),
|
||||
data: serde_json::json!({"brightness": 100}),
|
||||
}],
|
||||
));
|
||||
|
||||
let _handle = engine.start();
|
||||
|
||||
// Fire a matching state change
|
||||
hc.states().set(
|
||||
EntityId::parse("switch.living").unwrap(),
|
||||
"on",
|
||||
serde_json::json!({}),
|
||||
Context::new(),
|
||||
);
|
||||
|
||||
// Give the async task time to run
|
||||
sleep(Duration::from_millis(50)).await;
|
||||
|
||||
assert_eq!(log.lock().unwrap().len(), 1);
|
||||
assert_eq!(log.lock().unwrap()[0]["brightness"], 100);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn engine_does_not_fire_on_wrong_entity() {
|
||||
let hc = HomeCore::new();
|
||||
let log = register_recorder(&hc, "light", "turn_on").await;
|
||||
|
||||
let engine = AutomationEngine::new(hc.clone());
|
||||
engine.register(Automation::new(
|
||||
"test_auto_2",
|
||||
vec![Trigger::State {
|
||||
entity_id: EntityId::parse("switch.living").unwrap(),
|
||||
from: None,
|
||||
to: Some("on".into()),
|
||||
}],
|
||||
vec![Action::ServiceCall {
|
||||
domain: "light".into(),
|
||||
service: "turn_on".into(),
|
||||
data: serde_json::json!({}),
|
||||
}],
|
||||
));
|
||||
|
||||
let _handle = engine.start();
|
||||
|
||||
// Fire on a DIFFERENT entity
|
||||
hc.states().set(
|
||||
EntityId::parse("switch.bedroom").unwrap(),
|
||||
"on",
|
||||
serde_json::json!({}),
|
||||
Context::new(),
|
||||
);
|
||||
|
||||
sleep(Duration::from_millis(50)).await;
|
||||
assert_eq!(log.lock().unwrap().len(), 0, "should not fire on wrong entity");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn engine_disabled_automation_does_not_fire() {
|
||||
let hc = HomeCore::new();
|
||||
let log = register_recorder(&hc, "light", "turn_on").await;
|
||||
|
||||
let engine = AutomationEngine::new(hc.clone());
|
||||
let mut auto = Automation::new(
|
||||
"test_auto_3",
|
||||
vec![Trigger::State {
|
||||
entity_id: EntityId::parse("switch.living").unwrap(),
|
||||
from: None,
|
||||
to: Some("on".into()),
|
||||
}],
|
||||
vec![Action::ServiceCall {
|
||||
domain: "light".into(),
|
||||
service: "turn_on".into(),
|
||||
data: serde_json::json!({}),
|
||||
}],
|
||||
);
|
||||
auto.enabled = false;
|
||||
engine.register(auto);
|
||||
|
||||
let _handle = engine.start();
|
||||
|
||||
hc.states().set(
|
||||
EntityId::parse("switch.living").unwrap(),
|
||||
"on",
|
||||
serde_json::json!({}),
|
||||
Context::new(),
|
||||
);
|
||||
|
||||
sleep(Duration::from_millis(50)).await;
|
||||
assert_eq!(log.lock().unwrap().len(), 0, "disabled automation should not fire");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//! Crate-wide error type for homecore-automation.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
use homecore::ServiceError;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum AutomationError {
|
||||
#[error("YAML parse error: {0}")]
|
||||
YamlParse(#[from] serde_yaml::Error),
|
||||
|
||||
#[error("template render error: {0}")]
|
||||
TemplateRender(String),
|
||||
|
||||
#[error("service call failed: {0}")]
|
||||
ServiceCall(#[from] ServiceError),
|
||||
|
||||
#[error("entity id invalid: {0}")]
|
||||
EntityId(#[from] homecore::EntityIdError),
|
||||
|
||||
#[error("automation {id} not found")]
|
||||
NotFound { id: String },
|
||||
|
||||
#[error("automation action timed out after {secs}s")]
|
||||
ActionTimeout { secs: u64 },
|
||||
|
||||
#[error("numeric state parse error for '{entity_id}': {value}")]
|
||||
NumericParse { entity_id: String, value: String },
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//! homecore-automation — ADR-129 HOMECORE-AUTO
|
||||
//!
|
||||
//! Automation engine, trigger evaluator, MiniJinja template evaluator, and
|
||||
//! script action executor for the HOMECORE Home Assistant port.
|
||||
//!
|
||||
//! ## Layout
|
||||
//!
|
||||
//! - [`automation`] — `Automation` struct: id, alias, mode, triggers, conditions, actions
|
||||
//! - [`trigger`] — `Trigger` enum + `EvaluateTrigger` trait
|
||||
//! - [`condition`] — `Condition` enum + async `evaluate` method + `EvalContext`
|
||||
//! - [`action`] — `Action` enum + async `execute` method + `ExecutionContext`
|
||||
//! - [`template`] — MiniJinja environment with HA-compat globals (states, state_attr, is_state, now)
|
||||
//! - [`engine`] — `AutomationEngine`: subscribes to event bus, drives trigger→condition→action pipeline
|
||||
//! - [`error`] — crate-wide `AutomationError`
|
||||
|
||||
pub mod automation;
|
||||
pub mod trigger;
|
||||
pub mod condition;
|
||||
pub mod action;
|
||||
pub mod template;
|
||||
pub mod engine;
|
||||
pub mod error;
|
||||
|
||||
pub use automation::{Automation, RunMode};
|
||||
pub use trigger::{EvaluateTrigger, Trigger, TriggerContext};
|
||||
pub use condition::{Condition, EvalContext};
|
||||
pub use action::{Action, ExecutionContext};
|
||||
pub use template::TemplateEnvironment;
|
||||
pub use engine::AutomationEngine;
|
||||
pub use error::AutomationError;
|
||||
@@ -0,0 +1,194 @@
|
||||
//! MiniJinja-based template environment with HA-compatible globals.
|
||||
//!
|
||||
//! ADR-129 §2.1 — P1 ships four HA globals: `states()`, `state_attr()`,
|
||||
//! `is_state()`, `now()`. The `utcnow()`, `as_timestamp()`, `distance()`,
|
||||
//! and `iif()` globals plus custom filters land in P2.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::Utc;
|
||||
use minijinja::{Environment, Value};
|
||||
|
||||
use homecore::{EntityId, StateMachine};
|
||||
|
||||
use crate::error::AutomationError;
|
||||
|
||||
/// MiniJinja environment pre-loaded with HA-compatible globals.
|
||||
///
|
||||
/// Constructed once per `AutomationEngine` and shared via `Arc`. The
|
||||
/// globals close over an `Arc<StateMachine>` so every template render
|
||||
/// sees the live current state.
|
||||
pub struct TemplateEnvironment {
|
||||
env: Environment<'static>,
|
||||
}
|
||||
|
||||
impl TemplateEnvironment {
|
||||
/// Build a new environment backed by the given state machine.
|
||||
pub fn new(states: Arc<StateMachine>) -> Self {
|
||||
let mut env = Environment::new();
|
||||
|
||||
// --- states(entity_id) ---
|
||||
// Returns the current state string of an entity, or "unavailable".
|
||||
let states_sm = Arc::clone(&states);
|
||||
env.add_global(
|
||||
"states",
|
||||
Value::from_function(move |entity_id: String| -> String {
|
||||
EntityId::parse(&entity_id)
|
||||
.ok()
|
||||
.and_then(|eid| states_sm.get(&eid))
|
||||
.map(|s| s.state.clone())
|
||||
.unwrap_or_else(|| "unavailable".into())
|
||||
}),
|
||||
);
|
||||
|
||||
// --- state_attr(entity_id, attribute) ---
|
||||
// Returns an attribute value as a JSON string, or empty string.
|
||||
let attr_sm = Arc::clone(&states);
|
||||
env.add_global(
|
||||
"state_attr",
|
||||
Value::from_function(move |entity_id: String, attr: String| -> String {
|
||||
EntityId::parse(&entity_id)
|
||||
.ok()
|
||||
.and_then(|eid| attr_sm.get(&eid))
|
||||
.and_then(|s| s.attributes.get(&attr).cloned())
|
||||
.map(|v| match v {
|
||||
serde_json::Value::String(s) => s,
|
||||
other => other.to_string(),
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}),
|
||||
);
|
||||
|
||||
// --- is_state(entity_id, state) ---
|
||||
// Returns true if the entity's current state matches the given value.
|
||||
let is_state_sm = Arc::clone(&states);
|
||||
env.add_global(
|
||||
"is_state",
|
||||
Value::from_function(move |entity_id: String, expected: String| -> bool {
|
||||
EntityId::parse(&entity_id)
|
||||
.ok()
|
||||
.and_then(|eid| is_state_sm.get(&eid))
|
||||
.map(|s| s.state == expected)
|
||||
.unwrap_or(false)
|
||||
}),
|
||||
);
|
||||
|
||||
// --- now() ---
|
||||
// Returns the current UTC datetime as an ISO 8601 string.
|
||||
// HA returns a Python datetime; MiniJinja returns a string which
|
||||
// templates can further format with the `strftime` filter.
|
||||
env.add_global(
|
||||
"now",
|
||||
Value::from_function(|| -> String {
|
||||
Utc::now().format("%Y-%m-%dT%H:%M:%S%.6f+00:00").to_string()
|
||||
}),
|
||||
);
|
||||
|
||||
Self { env }
|
||||
}
|
||||
|
||||
/// Render a template string and return the string output.
|
||||
pub fn render(&self, template_str: &str) -> Result<String, AutomationError> {
|
||||
// Wrap bare expressions like `{{ states('light.kitchen') }}`
|
||||
// in a minimal template wrapper.
|
||||
let tmpl = self
|
||||
.env
|
||||
.template_from_str(template_str)
|
||||
.map_err(|e| AutomationError::TemplateRender(e.to_string()))?;
|
||||
tmpl.render(())
|
||||
.map_err(|e| AutomationError::TemplateRender(e.to_string()))
|
||||
}
|
||||
|
||||
/// Render a template and interpret the output as a boolean.
|
||||
/// "true", "1", "yes", "on" → true. Everything else → false.
|
||||
pub fn render_bool(&self, template_str: &str) -> Result<bool, AutomationError> {
|
||||
let raw = self.render(template_str)?;
|
||||
let v = raw.trim().to_ascii_lowercase();
|
||||
Ok(matches!(v.as_str(), "true" | "1" | "yes" | "on"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use homecore::{Context, EntityId, StateMachine};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn sm_with(entity_id: &str, state: &str, attrs: serde_json::Value) -> Arc<StateMachine> {
|
||||
let sm = Arc::new(StateMachine::new());
|
||||
sm.set(EntityId::parse(entity_id).unwrap(), state, attrs, Context::new());
|
||||
sm
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn states_global_returns_current_state() {
|
||||
let sm = sm_with("light.kitchen", "on", serde_json::json!({}));
|
||||
let env = TemplateEnvironment::new(sm);
|
||||
let out = env.render("{{ states('light.kitchen') }}").unwrap();
|
||||
assert_eq!(out.trim(), "on");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn states_global_unknown_entity_returns_unavailable() {
|
||||
let sm = Arc::new(StateMachine::new());
|
||||
let env = TemplateEnvironment::new(sm);
|
||||
let out = env.render("{{ states('sensor.unknown') }}").unwrap();
|
||||
assert_eq!(out.trim(), "unavailable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_attr_returns_attribute_value() {
|
||||
let sm = sm_with(
|
||||
"light.kitchen",
|
||||
"on",
|
||||
serde_json::json!({"brightness": 200}),
|
||||
);
|
||||
let env = TemplateEnvironment::new(sm);
|
||||
let out = env.render("{{ state_attr('light.kitchen', 'brightness') }}").unwrap();
|
||||
assert_eq!(out.trim(), "200");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_state_global_true_when_matches() {
|
||||
let sm = sm_with("switch.fan", "on", serde_json::json!({}));
|
||||
let env = TemplateEnvironment::new(sm);
|
||||
let out = env.render("{{ is_state('switch.fan', 'on') }}").unwrap();
|
||||
assert_eq!(out.trim(), "true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_state_global_false_when_no_match() {
|
||||
let sm = sm_with("switch.fan", "off", serde_json::json!({}));
|
||||
let env = TemplateEnvironment::new(sm);
|
||||
let out = env.render("{{ is_state('switch.fan', 'on') }}").unwrap();
|
||||
assert_eq!(out.trim(), "false");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn now_global_returns_timestamp_string() {
|
||||
let sm = Arc::new(StateMachine::new());
|
||||
let env = TemplateEnvironment::new(sm);
|
||||
let out = env.render("{{ now() }}").unwrap();
|
||||
// Should be an ISO 8601 datetime string containing 'T'
|
||||
assert!(out.contains('T'), "now() returned: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_bool_true_values() {
|
||||
let sm = Arc::new(StateMachine::new());
|
||||
let env = TemplateEnvironment::new(sm);
|
||||
for tmpl in &["true", "1", "yes", "on"] {
|
||||
let result = env.render_bool(tmpl).unwrap();
|
||||
assert!(result, "expected true for: {tmpl}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_bool_false_for_other() {
|
||||
let sm = Arc::new(StateMachine::new());
|
||||
let env = TemplateEnvironment::new(sm);
|
||||
assert!(!env.render_bool("false").unwrap());
|
||||
assert!(!env.render_bool("0").unwrap());
|
||||
assert!(!env.render_bool("off").unwrap());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
//! `Trigger` enum and `EvaluateTrigger` trait.
|
||||
//!
|
||||
//! Covers the four most common HA trigger platforms as required by ADR-129 P1:
|
||||
//! `state`, `numeric_state`, `time`, and `event`. Additional platforms land
|
||||
//! in P2 (template, zone, sun, MQTT, webhook, etc.).
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
use homecore::{EntityId, State};
|
||||
|
||||
/// Context produced by a fired trigger. Passed into condition evaluation and
|
||||
/// template rendering as `trigger.*` variables.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TriggerContext {
|
||||
/// Which trigger platform fired.
|
||||
pub platform: String,
|
||||
/// Entity ID (for state / numeric_state triggers).
|
||||
pub entity_id: Option<EntityId>,
|
||||
/// New state snapshot (for state / numeric_state triggers).
|
||||
pub to_state: Option<Arc<State>>,
|
||||
/// Previous state snapshot (for state / numeric_state triggers).
|
||||
pub from_state: Option<Arc<State>>,
|
||||
/// When the trigger fired.
|
||||
pub fired_at: DateTime<Utc>,
|
||||
/// Event type (for event triggers).
|
||||
pub event_type: Option<String>,
|
||||
}
|
||||
|
||||
impl TriggerContext {
|
||||
pub fn state_changed(
|
||||
entity_id: EntityId,
|
||||
from: Option<Arc<State>>,
|
||||
to: Option<Arc<State>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
platform: "state".into(),
|
||||
entity_id: Some(entity_id),
|
||||
to_state: to,
|
||||
from_state: from,
|
||||
fired_at: Utc::now(),
|
||||
event_type: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn event(event_type: impl Into<String>) -> Self {
|
||||
Self {
|
||||
platform: "event".into(),
|
||||
entity_id: None,
|
||||
to_state: None,
|
||||
from_state: None,
|
||||
fired_at: Utc::now(),
|
||||
event_type: Some(event_type.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Async evaluation trait. Each trigger variant implements this to decide
|
||||
/// whether a given `TriggerContext` matches its configuration.
|
||||
#[async_trait]
|
||||
pub trait EvaluateTrigger: Send + Sync {
|
||||
async fn matches(&self, ctx: &TriggerContext) -> bool;
|
||||
}
|
||||
|
||||
/// Trigger configuration. Deserialized from YAML `trigger:` blocks.
|
||||
///
|
||||
/// Only four platforms are implemented in P1 (ADR-129 §6 Phase 1).
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "platform", rename_all = "snake_case")]
|
||||
pub enum Trigger {
|
||||
/// Fires when an entity's state changes.
|
||||
State {
|
||||
entity_id: EntityId,
|
||||
/// Optional: only fire if state was previously this value.
|
||||
#[serde(default)]
|
||||
from: Option<String>,
|
||||
/// Optional: only fire if state transitions to this value.
|
||||
#[serde(default)]
|
||||
to: Option<String>,
|
||||
},
|
||||
/// Fires when an entity's numeric state crosses a threshold.
|
||||
NumericState {
|
||||
entity_id: EntityId,
|
||||
/// Fire when value rises above this threshold.
|
||||
#[serde(default)]
|
||||
above: Option<f64>,
|
||||
/// Fire when value drops below this threshold.
|
||||
#[serde(default)]
|
||||
below: Option<f64>,
|
||||
},
|
||||
/// Fires at a specific time of day (HH:MM:SS).
|
||||
Time {
|
||||
at: String,
|
||||
},
|
||||
/// Fires when a named domain event is published on the event bus.
|
||||
Event {
|
||||
event_type: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Trigger {
|
||||
/// Synchronous check — does this trigger configuration match the provided
|
||||
/// context? Used directly in tests and by the engine's event loop.
|
||||
pub fn matches_sync(&self, ctx: &TriggerContext) -> bool {
|
||||
match self {
|
||||
Trigger::State { entity_id, from, to } => {
|
||||
let eid_match = ctx.entity_id.as_ref().map_or(false, |e| e == entity_id);
|
||||
if !eid_match {
|
||||
return false;
|
||||
}
|
||||
if let Some(expected_from) = from {
|
||||
let actual_from = ctx.from_state.as_ref().map(|s| s.state.as_str()).unwrap_or("unavailable");
|
||||
if actual_from != expected_from.as_str() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(expected_to) = to {
|
||||
let actual_to = ctx.to_state.as_ref().map(|s| s.state.as_str()).unwrap_or("unavailable");
|
||||
if actual_to != expected_to.as_str() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
Trigger::NumericState { entity_id, above, below } => {
|
||||
let eid_match = ctx.entity_id.as_ref().map_or(false, |e| e == entity_id);
|
||||
if !eid_match {
|
||||
return false;
|
||||
}
|
||||
let value: f64 = ctx
|
||||
.to_state
|
||||
.as_ref()
|
||||
.and_then(|s| s.state.parse().ok())
|
||||
.unwrap_or(f64::NAN);
|
||||
if value.is_nan() {
|
||||
return false;
|
||||
}
|
||||
if let Some(a) = above {
|
||||
if value <= *a {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(b) = below {
|
||||
if value >= *b {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
Trigger::Time { .. } => {
|
||||
// Time triggers are evaluated by the engine's timer task, not here.
|
||||
false
|
||||
}
|
||||
Trigger::Event { event_type } => {
|
||||
ctx.event_type.as_deref() == Some(event_type.as_str())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EvaluateTrigger for Trigger {
|
||||
async fn matches(&self, ctx: &TriggerContext) -> bool {
|
||||
self.matches_sync(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use homecore::{Context, EntityId, State};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn make_state(entity_id: &str, state: &str) -> Arc<State> {
|
||||
Arc::new(State::new(
|
||||
EntityId::parse(entity_id).unwrap(),
|
||||
state,
|
||||
serde_json::json!({}),
|
||||
Context::new(),
|
||||
))
|
||||
}
|
||||
|
||||
fn state_ctx(entity_id: &str, from: &str, to: &str) -> TriggerContext {
|
||||
let eid = EntityId::parse(entity_id).unwrap();
|
||||
TriggerContext::state_changed(
|
||||
eid,
|
||||
Some(make_state(entity_id, from)),
|
||||
Some(make_state(entity_id, to)),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_trigger_exact_from_to_match() {
|
||||
let trigger = Trigger::State {
|
||||
entity_id: EntityId::parse("light.kitchen").unwrap(),
|
||||
from: Some("off".into()),
|
||||
to: Some("on".into()),
|
||||
};
|
||||
let ctx = state_ctx("light.kitchen", "off", "on");
|
||||
assert!(trigger.matches_sync(&ctx));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_trigger_wrong_entity_no_match() {
|
||||
let trigger = Trigger::State {
|
||||
entity_id: EntityId::parse("light.kitchen").unwrap(),
|
||||
from: None,
|
||||
to: Some("on".into()),
|
||||
};
|
||||
let ctx = state_ctx("switch.hallway", "off", "on");
|
||||
assert!(!trigger.matches_sync(&ctx));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_trigger_wrong_to_no_match() {
|
||||
let trigger = Trigger::State {
|
||||
entity_id: EntityId::parse("light.kitchen").unwrap(),
|
||||
from: None,
|
||||
to: Some("on".into()),
|
||||
};
|
||||
let ctx = state_ctx("light.kitchen", "on", "off");
|
||||
assert!(!trigger.matches_sync(&ctx));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_trigger_no_constraints_matches_any_change() {
|
||||
let trigger = Trigger::State {
|
||||
entity_id: EntityId::parse("light.kitchen").unwrap(),
|
||||
from: None,
|
||||
to: None,
|
||||
};
|
||||
let ctx = state_ctx("light.kitchen", "off", "on");
|
||||
assert!(trigger.matches_sync(&ctx));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_trigger_above_threshold_fires() {
|
||||
let trigger = Trigger::NumericState {
|
||||
entity_id: EntityId::parse("sensor.temperature").unwrap(),
|
||||
above: Some(25.0),
|
||||
below: None,
|
||||
};
|
||||
let mut ctx = state_ctx("sensor.temperature", "20", "26");
|
||||
ctx.to_state = Some(make_state("sensor.temperature", "26"));
|
||||
assert!(trigger.matches_sync(&ctx));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_trigger_below_threshold_no_fire() {
|
||||
let trigger = Trigger::NumericState {
|
||||
entity_id: EntityId::parse("sensor.temperature").unwrap(),
|
||||
above: Some(25.0),
|
||||
below: None,
|
||||
};
|
||||
let mut ctx = state_ctx("sensor.temperature", "20", "24");
|
||||
ctx.to_state = Some(make_state("sensor.temperature", "24"));
|
||||
assert!(!trigger.matches_sync(&ctx));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_trigger_between_bounds() {
|
||||
let trigger = Trigger::NumericState {
|
||||
entity_id: EntityId::parse("sensor.humidity").unwrap(),
|
||||
above: Some(30.0),
|
||||
below: Some(80.0),
|
||||
};
|
||||
let mut ctx = state_ctx("sensor.humidity", "20", "50");
|
||||
ctx.to_state = Some(make_state("sensor.humidity", "50"));
|
||||
assert!(trigger.matches_sync(&ctx));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_trigger_matches_type() {
|
||||
let trigger = Trigger::Event { event_type: "my_custom_event".into() };
|
||||
let ctx = TriggerContext::event("my_custom_event");
|
||||
assert!(trigger.matches_sync(&ctx));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_trigger_no_match_wrong_type() {
|
||||
let trigger = Trigger::Event { event_type: "my_custom_event".into() };
|
||||
let ctx = TriggerContext::event("other_event");
|
||||
assert!(!trigger.matches_sync(&ctx));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn evaluate_trigger_trait_object() {
|
||||
let trigger: Box<dyn EvaluateTrigger> = Box::new(Trigger::Event {
|
||||
event_type: "boot".into(),
|
||||
});
|
||||
let ctx = TriggerContext::event("boot");
|
||||
assert!(trigger.matches(&ctx).await);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
# homecore-hap — Apple Home HomeKit Accessory Protocol bridge (ADR-125 P1 scaffold)
|
||||
#
|
||||
# P1 ships the trait surface, accessory/characteristic types, entity→HAP mapping,
|
||||
# bridge API, and an mDNS-advertise stub. The actual HAP-1.1 server and real
|
||||
# mDNS integration are feature-gated to P2 via the `hap-server` feature flag.
|
||||
|
||||
[package]
|
||||
name = "homecore-hap"
|
||||
version = "0.1.0-alpha.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["rUv <ruv@ruv.net>", "HOMECORE Contributors"]
|
||||
description = "Apple Home HomeKit Accessory Protocol bridge — ADR-125 P1 scaffold"
|
||||
repository = "https://github.com/ruvnet/wifi-densepose"
|
||||
|
||||
[lib]
|
||||
name = "homecore_hap"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# P2: gates the actual hap = "0.1" crate integration + real mDNS via mdns-sd
|
||||
hap-server = []
|
||||
|
||||
[dependencies]
|
||||
homecore = { path = "../homecore" }
|
||||
tokio = { version = "1", features = ["sync", "rt", "rt-multi-thread", "time", "macros"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
tracing = "0.1"
|
||||
async-trait = "0.1"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["sync", "rt", "rt-multi-thread", "time", "macros", "test-util"] }
|
||||
@@ -0,0 +1,124 @@
|
||||
//! HAP service type and characteristic enum catalogues.
|
||||
//!
|
||||
//! Mirrors the HAP-1.1 service/characteristic namespace used by Apple Home
|
||||
//! and the `hap` crate (https://crates.io/crates/hap). Keeping these as
|
||||
//! plain Rust enums in P1 avoids the heavy `hap` dep until P2.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// HAP service types exposed by the RuView bridge.
|
||||
///
|
||||
/// Derived from HomeKit Accessory Protocol Specification §8 (service
|
||||
/// definitions) and cross-checked against HA's `homekit` integration
|
||||
/// service catalog.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum HapAccessoryType {
|
||||
/// HAP `Lightbulb` service — maps `light.*` entities.
|
||||
Lightbulb,
|
||||
/// HAP `Switch` service — maps generic boolean `switch.*` entities.
|
||||
Switch,
|
||||
/// HAP `OccupancySensor` — maps presence / occupancy binary sensors.
|
||||
OccupancySensor,
|
||||
/// HAP `MotionSensor` — maps motion binary sensors + RuView motion.
|
||||
MotionSensor,
|
||||
/// HAP `TemperatureSensor` — maps `sensor.*temperature*` entities.
|
||||
TemperatureSensor,
|
||||
/// HAP `HumiditySensor` — maps `sensor.*humidity*` entities.
|
||||
HumiditySensor,
|
||||
/// HAP `LeakSensor` — maps abnormal event sensors; used for fall detection
|
||||
/// following HA's homekit_controller convention (HAP §11.42).
|
||||
LeakSensor,
|
||||
/// HAP `ContactSensor` — maps door / window binary sensors.
|
||||
ContactSensor,
|
||||
/// HAP `Door` service — maps `cover.*door*` entities.
|
||||
Door,
|
||||
/// HAP `LockMechanism` service — maps `lock.*` entities.
|
||||
Lock,
|
||||
/// HAP `SecuritySystem` service — maps alarm / security panel entities.
|
||||
SecuritySystem,
|
||||
}
|
||||
|
||||
impl HapAccessoryType {
|
||||
/// All defined variants — used in tests and for UI enumeration.
|
||||
pub const ALL: &'static [HapAccessoryType] = &[
|
||||
HapAccessoryType::Lightbulb,
|
||||
HapAccessoryType::Switch,
|
||||
HapAccessoryType::OccupancySensor,
|
||||
HapAccessoryType::MotionSensor,
|
||||
HapAccessoryType::TemperatureSensor,
|
||||
HapAccessoryType::HumiditySensor,
|
||||
HapAccessoryType::LeakSensor,
|
||||
HapAccessoryType::ContactSensor,
|
||||
HapAccessoryType::Door,
|
||||
HapAccessoryType::Lock,
|
||||
HapAccessoryType::SecuritySystem,
|
||||
];
|
||||
}
|
||||
|
||||
/// HAP characteristic identifiers that the bridge reads or writes.
|
||||
///
|
||||
/// Each variant corresponds to one HAP characteristic UUID as specified in
|
||||
/// HomeKit Accessory Protocol Specification §9.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum HapCharacteristic {
|
||||
/// `On` (bool) — Lightbulb / Switch power state.
|
||||
On,
|
||||
/// `Brightness` (uint8, 0–100) — Lightbulb brightness percentage.
|
||||
Brightness,
|
||||
/// `CurrentTemperature` (float, °C) — TemperatureSensor reading.
|
||||
CurrentTemperature,
|
||||
/// `CurrentRelativeHumidity` (float, %) — HumiditySensor reading.
|
||||
CurrentRelativeHumidity,
|
||||
/// `OccupancyDetected` (uint8, 0=not detected, 1=detected).
|
||||
OccupancyDetected,
|
||||
/// `MotionDetected` (bool).
|
||||
MotionDetected,
|
||||
/// `LeakDetected` (uint8, 0=no leak, 1=leak detected). Re-used for falls.
|
||||
LeakDetected,
|
||||
/// `ContactSensorState` (uint8, 0=in contact, 1=not in contact).
|
||||
ContactSensorState,
|
||||
/// `CurrentDoorState` (uint8, HAP §9.30).
|
||||
CurrentDoorState,
|
||||
/// `LockCurrentState` (uint8, HAP §9.56).
|
||||
LockCurrentState,
|
||||
/// `SecuritySystemCurrentState` (uint8, HAP §9.97).
|
||||
SecuritySystemCurrentState,
|
||||
}
|
||||
|
||||
/// Typed value carried by a HAP characteristic update.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum HapCharacteristicValue {
|
||||
Bool(bool),
|
||||
UInt8(u8),
|
||||
Float(f64),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn all_11_accessory_types_defined() {
|
||||
assert_eq!(HapAccessoryType::ALL.len(), 11);
|
||||
// Spot-check each variant is present.
|
||||
assert!(HapAccessoryType::ALL.contains(&HapAccessoryType::Lightbulb));
|
||||
assert!(HapAccessoryType::ALL.contains(&HapAccessoryType::Switch));
|
||||
assert!(HapAccessoryType::ALL.contains(&HapAccessoryType::OccupancySensor));
|
||||
assert!(HapAccessoryType::ALL.contains(&HapAccessoryType::MotionSensor));
|
||||
assert!(HapAccessoryType::ALL.contains(&HapAccessoryType::TemperatureSensor));
|
||||
assert!(HapAccessoryType::ALL.contains(&HapAccessoryType::HumiditySensor));
|
||||
assert!(HapAccessoryType::ALL.contains(&HapAccessoryType::LeakSensor));
|
||||
assert!(HapAccessoryType::ALL.contains(&HapAccessoryType::ContactSensor));
|
||||
assert!(HapAccessoryType::ALL.contains(&HapAccessoryType::Door));
|
||||
assert!(HapAccessoryType::ALL.contains(&HapAccessoryType::Lock));
|
||||
assert!(HapAccessoryType::ALL.contains(&HapAccessoryType::SecuritySystem));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn characteristic_value_roundtrip_serde() {
|
||||
let v = HapCharacteristicValue::Float(22.5);
|
||||
let json = serde_json::to_string(&v).unwrap();
|
||||
let back: HapCharacteristicValue = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(v, back);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
//! `HapBridge` — owns the set of HOMECORE entities exposed as HAP accessories.
|
||||
//!
|
||||
//! P1 does not start a real HAP-1.1 server; it ships the API surface so other
|
||||
//! crates (and P2's `hap-server` feature) can register accessories and query
|
||||
//! their current mapping. The actual mDNS + HAP pairing is gated to P2.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use homecore::entity::EntityId;
|
||||
|
||||
use crate::accessory::HapAccessoryType;
|
||||
use crate::error::HapError;
|
||||
use crate::mapping::{AccessoryMapping, EntityToAccessoryMapper};
|
||||
use crate::mdns::{HapServiceRecord, MdnsAdvertiser, NullAdvertiser};
|
||||
|
||||
/// One registered HAP accessory — an entity + its last-known mapping.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExposedAccessory {
|
||||
pub entity_id: EntityId,
|
||||
pub accessory_type: HapAccessoryType,
|
||||
pub mapping: AccessoryMapping,
|
||||
}
|
||||
|
||||
struct BridgeInner {
|
||||
accessories: HashMap<EntityId, ExposedAccessory>,
|
||||
}
|
||||
|
||||
/// The P1 HAP bridge.
|
||||
///
|
||||
/// Call [`HapBridge::add_accessory`] to register entities and
|
||||
/// [`HapBridge::running_accessories`] to read back what is currently
|
||||
/// registered. In P2, `start()` will spawn the `hap` server task.
|
||||
#[derive(Clone)]
|
||||
pub struct HapBridge {
|
||||
inner: Arc<RwLock<BridgeInner>>,
|
||||
advertiser: Arc<dyn MdnsAdvertiser>,
|
||||
pub service_record: HapServiceRecord,
|
||||
}
|
||||
|
||||
impl HapBridge {
|
||||
/// Create a bridge with the given service record and a `NullAdvertiser`
|
||||
/// (P1 default — real mDNS lands in P2).
|
||||
pub fn new(service_record: HapServiceRecord) -> Self {
|
||||
Self::with_advertiser(service_record, Arc::new(NullAdvertiser))
|
||||
}
|
||||
|
||||
/// Create a bridge with a custom `MdnsAdvertiser` (used in tests and P2).
|
||||
pub fn with_advertiser(
|
||||
service_record: HapServiceRecord,
|
||||
advertiser: Arc<dyn MdnsAdvertiser>,
|
||||
) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(RwLock::new(BridgeInner { accessories: HashMap::new() })),
|
||||
advertiser,
|
||||
service_record,
|
||||
}
|
||||
}
|
||||
|
||||
/// Register an entity as a HAP accessory.
|
||||
///
|
||||
/// The entity's current mapping is computed from `state`; call
|
||||
/// `update_accessory` on each `StateChanged` event to keep it fresh.
|
||||
///
|
||||
/// Returns `HapError::AlreadyRegistered` if the entity is already
|
||||
/// registered. Call `remove_accessory` first to replace it.
|
||||
pub fn add_accessory(
|
||||
&self,
|
||||
entity_id: &EntityId,
|
||||
state: &homecore::entity::State,
|
||||
) -> Result<(), HapError> {
|
||||
let mapping = EntityToAccessoryMapper::map(entity_id, state)?;
|
||||
let accessory_type = mapping.accessory_type;
|
||||
let exposed = ExposedAccessory {
|
||||
entity_id: entity_id.clone(),
|
||||
accessory_type,
|
||||
mapping,
|
||||
};
|
||||
let mut inner = self.inner.write().unwrap();
|
||||
if inner.accessories.contains_key(entity_id) {
|
||||
return Err(HapError::AlreadyRegistered(entity_id.as_str().to_owned()));
|
||||
}
|
||||
inner.accessories.insert(entity_id.clone(), exposed);
|
||||
tracing::debug!(entity = %entity_id, ?accessory_type, "HAP accessory registered");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a registered accessory.
|
||||
///
|
||||
/// Returns `HapError::EntityNotFound` if the entity was not registered.
|
||||
pub fn remove_accessory(&self, entity_id: &EntityId) -> Result<(), HapError> {
|
||||
let mut inner = self.inner.write().unwrap();
|
||||
if inner.accessories.remove(entity_id).is_none() {
|
||||
return Err(HapError::EntityNotFound(entity_id.as_str().to_owned()));
|
||||
}
|
||||
tracing::debug!(entity = %entity_id, "HAP accessory removed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Snapshot all currently registered accessories.
|
||||
pub fn running_accessories(&self) -> Vec<ExposedAccessory> {
|
||||
self.inner.read().unwrap().accessories.values().cloned().collect()
|
||||
}
|
||||
|
||||
/// Number of registered accessories.
|
||||
pub fn len(&self) -> usize {
|
||||
self.inner.read().unwrap().accessories.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
|
||||
/// P2 stub — will start the HAP-1.1 server + mDNS advertisement.
|
||||
/// In P1 this only fires the null advertiser.
|
||||
pub async fn start(&self) -> Result<(), HapError> {
|
||||
self.advertiser.advertise(&self.service_record).await?;
|
||||
tracing::info!(
|
||||
instance = %self.service_record.instance_name,
|
||||
port = self.service_record.port,
|
||||
"HapBridge started (P1 — no real HAP server; mDNS stub only)"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Graceful shutdown — retracts mDNS advertisement.
|
||||
pub async fn stop(&self) -> Result<(), HapError> {
|
||||
self.advertiser.retract(&self.service_record.instance_name).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use homecore::entity::{EntityId, State};
|
||||
use homecore::event::Context;
|
||||
|
||||
fn make_bridge() -> HapBridge {
|
||||
HapBridge::new(HapServiceRecord {
|
||||
instance_name: "RuView Sense".into(),
|
||||
port: 51826,
|
||||
setup_code: "111-22-333".into(),
|
||||
device_id: "AA:BB:CC:DD:EE:FF".into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn light_state(name: &str, on: bool, brightness: u8) -> (EntityId, State) {
|
||||
let eid = EntityId::parse(&format!("light.{name}")).unwrap();
|
||||
let attrs = serde_json::json!({"brightness": brightness});
|
||||
let s = State::new(eid.clone(), if on { "on" } else { "off" }, attrs, Context::default());
|
||||
(eid, s)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_remove_roundtrip() {
|
||||
let bridge = make_bridge();
|
||||
let (eid, s) = light_state("kitchen", true, 200);
|
||||
|
||||
assert!(bridge.is_empty());
|
||||
bridge.add_accessory(&eid, &s).unwrap();
|
||||
assert_eq!(bridge.len(), 1);
|
||||
|
||||
let acc = bridge.running_accessories();
|
||||
assert_eq!(acc.len(), 1);
|
||||
assert_eq!(acc[0].entity_id, eid);
|
||||
assert_eq!(acc[0].accessory_type, HapAccessoryType::Lightbulb);
|
||||
|
||||
bridge.remove_accessory(&eid).unwrap();
|
||||
assert!(bridge.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_duplicate_returns_error() {
|
||||
let bridge = make_bridge();
|
||||
let (eid, s) = light_state("kitchen", true, 200);
|
||||
bridge.add_accessory(&eid, &s).unwrap();
|
||||
let err = bridge.add_accessory(&eid, &s).unwrap_err();
|
||||
assert!(matches!(err, HapError::AlreadyRegistered(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_nonexistent_returns_error() {
|
||||
let bridge = make_bridge();
|
||||
let eid = EntityId::parse("light.ghost").unwrap();
|
||||
let err = bridge.remove_accessory(&eid).unwrap_err();
|
||||
assert!(matches!(err, HapError::EntityNotFound(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_stop_with_null_advertiser() {
|
||||
let bridge = make_bridge();
|
||||
bridge.start().await.unwrap();
|
||||
bridge.stop().await.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//! Unified error type for `homecore-hap`.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors produced by the HAP bridge and its sub-components.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum HapError {
|
||||
#[error("entity not found: {0}")]
|
||||
EntityNotFound(String),
|
||||
|
||||
#[error("entity {entity_id} cannot be mapped to a HAP accessory type: {reason}")]
|
||||
UnmappableEntity { entity_id: String, reason: String },
|
||||
|
||||
#[error("accessory already registered: {0}")]
|
||||
AlreadyRegistered(String),
|
||||
|
||||
#[error("mDNS advertiser error: {0}")]
|
||||
MdnsError(String),
|
||||
|
||||
#[error("bridge not running")]
|
||||
NotRunning,
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//! `homecore-hap` — Apple Home HomeKit Accessory Protocol bridge (ADR-125).
|
||||
//!
|
||||
//! # P1 scope
|
||||
//!
|
||||
//! Ships the trait surface and type definitions needed to map HOMECORE entity
|
||||
//! states onto HAP accessory / characteristic values. The actual HAP-1.1 TLS
|
||||
//! server and real mDNS advertisement are gated behind the `hap-server`
|
||||
//! feature (P2). P1 ships `NullAdvertiser` (no-op) so the bridge compiles and
|
||||
//! all tests pass with `--no-default-features`.
|
||||
//!
|
||||
//! # Module layout
|
||||
//!
|
||||
//! | Module | Purpose |
|
||||
//! |--------|---------|
|
||||
//! | [`accessory`] | HAP service / characteristic enum catalogue |
|
||||
//! | [`mapping`] | `EntityToAccessoryMapper` — HOMECORE entity → HAP |
|
||||
//! | [`bridge`] | `HapBridge` — owns exposed accessories |
|
||||
//! | [`mdns`] | `MdnsAdvertiser` trait + `NullAdvertiser` stub |
|
||||
//! | [`ruview`] | `RuViewToHapMapper` — sensing primitives → HAP |
|
||||
//! | [`error`] | Unified `HapError` type |
|
||||
|
||||
pub mod accessory;
|
||||
pub mod bridge;
|
||||
pub mod error;
|
||||
pub mod mapping;
|
||||
pub mod mdns;
|
||||
pub mod ruview;
|
||||
|
||||
pub use accessory::{HapAccessoryType, HapCharacteristic, HapCharacteristicValue};
|
||||
pub use bridge::{ExposedAccessory, HapBridge};
|
||||
pub use error::HapError;
|
||||
pub use mapping::EntityToAccessoryMapper;
|
||||
pub use mdns::{MdnsAdvertiser, NullAdvertiser};
|
||||
pub use ruview::RuViewToHapMapper;
|
||||
@@ -0,0 +1,273 @@
|
||||
//! HOMECORE entity → HAP accessory type + characteristic value mapping.
|
||||
//!
|
||||
//! Mirrors the HA `homekit` integration's mapping table
|
||||
//! (homeassistant/components/homekit/type_*.py) for the entity domains and
|
||||
//! device classes handled in P1.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use homecore::entity::{EntityId, State};
|
||||
|
||||
use crate::accessory::{HapAccessoryType, HapCharacteristic, HapCharacteristicValue};
|
||||
use crate::error::HapError;
|
||||
|
||||
/// Result of mapping one HOMECORE entity state to the HAP layer.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AccessoryMapping {
|
||||
/// HAP service type to advertise for this entity.
|
||||
pub accessory_type: HapAccessoryType,
|
||||
/// Characteristic key/value pairs to set on the HAP service.
|
||||
pub characteristics: Vec<(HapCharacteristic, HapCharacteristicValue)>,
|
||||
}
|
||||
|
||||
/// Maps a HOMECORE entity `(EntityId, State)` pair to a `HapAccessoryType`
|
||||
/// and its current characteristic values.
|
||||
///
|
||||
/// Rule table (mirrors HA homekit_controller mapping):
|
||||
///
|
||||
/// | Domain | device_class | HAP service |
|
||||
/// |--------|-------------|-------------|
|
||||
/// | `light` | — | Lightbulb |
|
||||
/// | `switch` | — | Switch |
|
||||
/// | `binary_sensor` | `occupancy` | OccupancySensor |
|
||||
/// | `binary_sensor` | `motion` | MotionSensor |
|
||||
/// | `binary_sensor` | `door` / `window` | ContactSensor |
|
||||
/// | `sensor` | — + unit=°C/°F | TemperatureSensor |
|
||||
/// | `sensor` | — + unit=% (humidity) | HumiditySensor |
|
||||
/// | `cover` (door) | — | Door |
|
||||
/// | `lock` | — | Lock |
|
||||
pub struct EntityToAccessoryMapper;
|
||||
|
||||
impl EntityToAccessoryMapper {
|
||||
/// Map a HOMECORE entity to its HAP representation.
|
||||
///
|
||||
/// Returns `HapError::UnmappableEntity` for domains that have no
|
||||
/// defined HAP mapping (e.g. `automation`, `input_boolean`).
|
||||
pub fn map(entity_id: &EntityId, state: &State) -> Result<AccessoryMapping, HapError> {
|
||||
match entity_id.domain() {
|
||||
"light" => Self::map_light(state),
|
||||
"switch" => Self::map_switch(state),
|
||||
"binary_sensor" => Self::map_binary_sensor(entity_id, state),
|
||||
"sensor" => Self::map_sensor(entity_id, state),
|
||||
"cover" => Self::map_cover(state),
|
||||
"lock" => Self::map_lock(state),
|
||||
other => Err(HapError::UnmappableEntity {
|
||||
entity_id: entity_id.as_str().to_owned(),
|
||||
reason: format!("domain '{other}' has no HAP mapping in P1"),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_light(state: &State) -> Result<AccessoryMapping, HapError> {
|
||||
let on = state.state == "on";
|
||||
let mut chars = vec![(HapCharacteristic::On, HapCharacteristicValue::Bool(on))];
|
||||
if let Some(b) = state.attributes.get("brightness").and_then(Value::as_u64) {
|
||||
chars.push((
|
||||
HapCharacteristic::Brightness,
|
||||
HapCharacteristicValue::UInt8(b.min(255) as u8),
|
||||
));
|
||||
}
|
||||
Ok(AccessoryMapping { accessory_type: HapAccessoryType::Lightbulb, characteristics: chars })
|
||||
}
|
||||
|
||||
fn map_switch(state: &State) -> Result<AccessoryMapping, HapError> {
|
||||
let on = state.state == "on";
|
||||
Ok(AccessoryMapping {
|
||||
accessory_type: HapAccessoryType::Switch,
|
||||
characteristics: vec![(HapCharacteristic::On, HapCharacteristicValue::Bool(on))],
|
||||
})
|
||||
}
|
||||
|
||||
fn map_binary_sensor(
|
||||
entity_id: &EntityId,
|
||||
state: &State,
|
||||
) -> Result<AccessoryMapping, HapError> {
|
||||
let detected = state.state == "on";
|
||||
let device_class = state
|
||||
.attributes
|
||||
.get("device_class")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_owned();
|
||||
|
||||
// Also check name heuristics for device_class-less entities.
|
||||
let name = entity_id.name();
|
||||
let is_occupancy = device_class == "occupancy" || name.contains("occupancy") || name.contains("presence");
|
||||
let is_motion = device_class == "motion" || name.contains("motion");
|
||||
let is_door = device_class == "door" || device_class == "window";
|
||||
|
||||
if is_occupancy {
|
||||
return Ok(AccessoryMapping {
|
||||
accessory_type: HapAccessoryType::OccupancySensor,
|
||||
characteristics: vec![(
|
||||
HapCharacteristic::OccupancyDetected,
|
||||
HapCharacteristicValue::UInt8(if detected { 1 } else { 0 }),
|
||||
)],
|
||||
});
|
||||
}
|
||||
if is_motion {
|
||||
return Ok(AccessoryMapping {
|
||||
accessory_type: HapAccessoryType::MotionSensor,
|
||||
characteristics: vec![(
|
||||
HapCharacteristic::MotionDetected,
|
||||
HapCharacteristicValue::Bool(detected),
|
||||
)],
|
||||
});
|
||||
}
|
||||
if is_door {
|
||||
return Ok(AccessoryMapping {
|
||||
accessory_type: HapAccessoryType::ContactSensor,
|
||||
characteristics: vec![(
|
||||
HapCharacteristic::ContactSensorState,
|
||||
HapCharacteristicValue::UInt8(if detected { 1 } else { 0 }),
|
||||
)],
|
||||
});
|
||||
}
|
||||
// Fallback: treat as motion sensor
|
||||
Ok(AccessoryMapping {
|
||||
accessory_type: HapAccessoryType::MotionSensor,
|
||||
characteristics: vec![(
|
||||
HapCharacteristic::MotionDetected,
|
||||
HapCharacteristicValue::Bool(detected),
|
||||
)],
|
||||
})
|
||||
}
|
||||
|
||||
fn map_sensor(entity_id: &EntityId, state: &State) -> Result<AccessoryMapping, HapError> {
|
||||
let unit = state
|
||||
.attributes
|
||||
.get("unit_of_measurement")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_owned();
|
||||
let name = entity_id.name();
|
||||
|
||||
let is_temp = unit == "°C" || unit == "°F" || unit == "C" || unit == "F"
|
||||
|| name.contains("temp") || name.contains("temperature");
|
||||
let is_humidity = unit == "%" && (name.contains("humid") || name.contains("rh"));
|
||||
|
||||
if is_temp {
|
||||
let temp: f64 = state.state.parse().unwrap_or(0.0);
|
||||
return Ok(AccessoryMapping {
|
||||
accessory_type: HapAccessoryType::TemperatureSensor,
|
||||
characteristics: vec![(
|
||||
HapCharacteristic::CurrentTemperature,
|
||||
HapCharacteristicValue::Float(temp),
|
||||
)],
|
||||
});
|
||||
}
|
||||
if is_humidity {
|
||||
let hum: f64 = state.state.parse().unwrap_or(0.0);
|
||||
return Ok(AccessoryMapping {
|
||||
accessory_type: HapAccessoryType::HumiditySensor,
|
||||
characteristics: vec![(
|
||||
HapCharacteristic::CurrentRelativeHumidity,
|
||||
HapCharacteristicValue::Float(hum),
|
||||
)],
|
||||
});
|
||||
}
|
||||
Err(HapError::UnmappableEntity {
|
||||
entity_id: entity_id.as_str().to_owned(),
|
||||
reason: "sensor unit/name not recognised as temperature or humidity".into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn map_cover(state: &State) -> Result<AccessoryMapping, HapError> {
|
||||
let door_state: u8 = match state.state.as_str() {
|
||||
"open" => 0,
|
||||
"opening" => 2,
|
||||
"closing" => 3,
|
||||
_ => 1, // closed
|
||||
};
|
||||
Ok(AccessoryMapping {
|
||||
accessory_type: HapAccessoryType::Door,
|
||||
characteristics: vec![(
|
||||
HapCharacteristic::CurrentDoorState,
|
||||
HapCharacteristicValue::UInt8(door_state),
|
||||
)],
|
||||
})
|
||||
}
|
||||
|
||||
fn map_lock(state: &State) -> Result<AccessoryMapping, HapError> {
|
||||
let lock_state: u8 = match state.state.as_str() {
|
||||
"unlocked" => 0,
|
||||
"locked" => 1,
|
||||
_ => 3, // unknown
|
||||
};
|
||||
Ok(AccessoryMapping {
|
||||
accessory_type: HapAccessoryType::Lock,
|
||||
characteristics: vec![(
|
||||
HapCharacteristic::LockCurrentState,
|
||||
HapCharacteristicValue::UInt8(lock_state),
|
||||
)],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use homecore::entity::{EntityId, State};
|
||||
use homecore::event::Context;
|
||||
|
||||
fn state(id: &str, st: &str, attrs: serde_json::Value) -> (EntityId, State) {
|
||||
let eid = EntityId::parse(id).unwrap();
|
||||
let s = State::new(eid.clone(), st, attrs, Context::default());
|
||||
(eid, s)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_kitchen_on_with_brightness() {
|
||||
let (eid, s) = state(
|
||||
"light.kitchen",
|
||||
"on",
|
||||
serde_json::json!({"brightness": 200}),
|
||||
);
|
||||
let mapping = EntityToAccessoryMapper::map(&eid, &s).unwrap();
|
||||
assert_eq!(mapping.accessory_type, HapAccessoryType::Lightbulb);
|
||||
assert!(mapping.characteristics.contains(&(
|
||||
HapCharacteristic::On,
|
||||
HapCharacteristicValue::Bool(true)
|
||||
)));
|
||||
assert!(mapping.characteristics.contains(&(
|
||||
HapCharacteristic::Brightness,
|
||||
HapCharacteristicValue::UInt8(200)
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_sensor_occupancy_device_class() {
|
||||
let (eid, s) = state(
|
||||
"binary_sensor.kitchen_presence",
|
||||
"on",
|
||||
serde_json::json!({"device_class": "occupancy"}),
|
||||
);
|
||||
let mapping = EntityToAccessoryMapper::map(&eid, &s).unwrap();
|
||||
assert_eq!(mapping.accessory_type, HapAccessoryType::OccupancySensor);
|
||||
assert!(mapping.characteristics.contains(&(
|
||||
HapCharacteristic::OccupancyDetected,
|
||||
HapCharacteristicValue::UInt8(1)
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sensor_outdoor_temp_celsius() {
|
||||
let (eid, s) = state(
|
||||
"sensor.outdoor_temp",
|
||||
"21.5",
|
||||
serde_json::json!({"unit_of_measurement": "°C"}),
|
||||
);
|
||||
let mapping = EntityToAccessoryMapper::map(&eid, &s).unwrap();
|
||||
assert_eq!(mapping.accessory_type, HapAccessoryType::TemperatureSensor);
|
||||
assert!(mapping.characteristics.contains(&(
|
||||
HapCharacteristic::CurrentTemperature,
|
||||
HapCharacteristicValue::Float(21.5)
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unmappable_domain_returns_error() {
|
||||
let (eid, s) = state("automation.morning", "on", serde_json::json!({}));
|
||||
assert!(EntityToAccessoryMapper::map(&eid, &s).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//! mDNS advertisement trait and P1 no-op stub.
|
||||
//!
|
||||
//! Real mDNS via the `mdns-sd` crate (https://crates.io/crates/mdns-sd)
|
||||
//! lands in P2 behind the `hap-server` feature flag. P1 ships `NullAdvertiser`
|
||||
//! so the bridge compiles and tests pass without any mDNS infrastructure.
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::error::HapError;
|
||||
|
||||
/// Service record advertised over mDNS for HAP discovery.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HapServiceRecord {
|
||||
/// Service instance name shown in Apple Home ("RuView Sense").
|
||||
pub instance_name: String,
|
||||
/// TCP port the HAP server listens on (default 51826).
|
||||
pub port: u16,
|
||||
/// HAP pairing setup code (8 digits, formatted as XXX-XX-XXX).
|
||||
pub setup_code: String,
|
||||
/// Unique device ID (colon-separated MAC-like hex, required by HAP §5.4).
|
||||
pub device_id: String,
|
||||
}
|
||||
|
||||
/// Advertise (and retract) a HAP accessory over mDNS (`_hap._tcp`).
|
||||
///
|
||||
/// Implementors register the `_hap._tcp` service so HomePod / Apple TV can
|
||||
/// discover the bridge and initiate pairing. P1 provides only `NullAdvertiser`.
|
||||
#[async_trait]
|
||||
pub trait MdnsAdvertiser: Send + Sync {
|
||||
/// Begin advertising the service. Idempotent.
|
||||
async fn advertise(&self, record: &HapServiceRecord) -> Result<(), HapError>;
|
||||
|
||||
/// Stop advertising. Called on bridge shutdown.
|
||||
async fn retract(&self, instance_name: &str) -> Result<(), HapError>;
|
||||
}
|
||||
|
||||
/// No-op advertiser for P1 / test environments.
|
||||
///
|
||||
/// All calls succeed without touching the network.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct NullAdvertiser;
|
||||
|
||||
#[async_trait]
|
||||
impl MdnsAdvertiser for NullAdvertiser {
|
||||
async fn advertise(&self, record: &HapServiceRecord) -> Result<(), HapError> {
|
||||
tracing::debug!(
|
||||
instance = %record.instance_name,
|
||||
port = record.port,
|
||||
"NullAdvertiser: skipping mDNS advertisement (P1 stub)"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn retract(&self, instance_name: &str) -> Result<(), HapError> {
|
||||
tracing::debug!(
|
||||
instance = %instance_name,
|
||||
"NullAdvertiser: skipping mDNS retract (P1 stub)"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn null_advertiser_is_noop() {
|
||||
let adv = NullAdvertiser;
|
||||
let rec = HapServiceRecord {
|
||||
instance_name: "RuView Sense".into(),
|
||||
port: 51826,
|
||||
setup_code: "111-22-333".into(),
|
||||
device_id: "AA:BB:CC:DD:EE:FF".into(),
|
||||
};
|
||||
adv.advertise(&rec).await.unwrap();
|
||||
adv.retract(&rec.instance_name).await.unwrap();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user