mirror of
https://github.com/ruvnet/RuView
synced 2026-06-09 10:13:17 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 377413e6a8 | |||
| b9e36a8be0 | |||
| 9e860c3a7a |
@@ -1 +0,0 @@
|
||||
{"intelligence":7,"timestamp":1774922079152}
|
||||
@@ -1,55 +1,50 @@
|
||||
{
|
||||
"running": true,
|
||||
"startedAt": "2026-05-24T22:26:25.030Z",
|
||||
"startedAt": "2026-03-09T15:26:00.921Z",
|
||||
"workers": {
|
||||
"map": {
|
||||
"runCount": 64,
|
||||
"successCount": 64,
|
||||
"runCount": 49,
|
||||
"successCount": 49,
|
||||
"failureCount": 0,
|
||||
"averageDurationMs": 136.171875,
|
||||
"lastRun": "2026-05-25T06:07:33.387Z",
|
||||
"lastStartedAt": "2026-05-25T06:07:33.381Z",
|
||||
"nextRun": "2026-05-25T06:26:25.410Z",
|
||||
"averageDurationMs": 1.2857142857142858,
|
||||
"lastRun": "2026-02-28T16:13:19.194Z",
|
||||
"nextRun": "2026-03-09T15:56:00.928Z",
|
||||
"isRunning": false
|
||||
},
|
||||
"audit": {
|
||||
"runCount": 72,
|
||||
"successCount": 27,
|
||||
"runCount": 45,
|
||||
"successCount": 0,
|
||||
"failureCount": 45,
|
||||
"averageDurationMs": 26260.11111111111,
|
||||
"lastRun": "2026-05-25T06:08:29.594Z",
|
||||
"lastStartedAt": "2026-05-25T06:07:33.416Z",
|
||||
"nextRun": "2026-05-25T06:18:32.928Z",
|
||||
"averageDurationMs": 0,
|
||||
"lastRun": "2026-03-09T15:43:00.933Z",
|
||||
"nextRun": "2026-03-09T15:38:00.914Z",
|
||||
"isRunning": false
|
||||
},
|
||||
"optimize": {
|
||||
"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",
|
||||
"runCount": 34,
|
||||
"successCount": 0,
|
||||
"failureCount": 34,
|
||||
"averageDurationMs": 0,
|
||||
"lastRun": "2026-02-28T16:23:19.387Z",
|
||||
"nextRun": "2026-03-09T15:45:00.915Z",
|
||||
"isRunning": false
|
||||
},
|
||||
"consolidate": {
|
||||
"runCount": 32,
|
||||
"successCount": 32,
|
||||
"runCount": 23,
|
||||
"successCount": 23,
|
||||
"failureCount": 0,
|
||||
"averageDurationMs": 4.71875,
|
||||
"lastRun": "2026-05-25T05:38:20.449Z",
|
||||
"lastStartedAt": "2026-05-25T05:38:20.443Z",
|
||||
"nextRun": "2026-05-25T06:32:25.248Z",
|
||||
"averageDurationMs": 0.6521739130434783,
|
||||
"lastRun": "2026-02-28T16:05:19.091Z",
|
||||
"nextRun": "2026-03-09T16:02:00.918Z",
|
||||
"isRunning": false
|
||||
},
|
||||
"testgaps": {
|
||||
"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",
|
||||
"runCount": 27,
|
||||
"successCount": 0,
|
||||
"failureCount": 27,
|
||||
"averageDurationMs": 0,
|
||||
"lastRun": "2026-02-28T16:08:19.369Z",
|
||||
"nextRun": "2026-03-09T15:54:00.920Z",
|
||||
"isRunning": false
|
||||
},
|
||||
"predict": {
|
||||
@@ -69,8 +64,8 @@
|
||||
},
|
||||
"config": {
|
||||
"autoStart": false,
|
||||
"logDir": "C:\\Users\\ruv\\Projects\\wifi-densepose\\.claude-flow\\logs",
|
||||
"stateFile": "C:\\Users\\ruv\\Projects\\wifi-densepose\\.claude-flow\\daemon-state.json",
|
||||
"logDir": "/Users/cohen/GitHub/ruvnet/RuView/.claude-flow/logs",
|
||||
"stateFile": "/Users/cohen/GitHub/ruvnet/RuView/.claude-flow/daemon-state.json",
|
||||
"maxConcurrent": 2,
|
||||
"workerTimeoutMs": 300000,
|
||||
"resourceThresholds": {
|
||||
@@ -136,5 +131,5 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"savedAt": "2026-05-25T06:11:52.530Z"
|
||||
"savedAt": "2026-03-09T15:43:00.933Z"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
31273
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"timestamp": "2026-05-25T06:07:33.385Z",
|
||||
"projectRoot": "C:\\Users\\ruv\\Projects\\wifi-densepose",
|
||||
"timestamp": "2026-02-28T16:13:19.193Z",
|
||||
"projectRoot": "/home/user/wifi-densepose",
|
||||
"structure": {
|
||||
"hasPackageJson": false,
|
||||
"hasTsConfig": false,
|
||||
"hasClaudeConfig": true,
|
||||
"hasClaudeFlow": true
|
||||
},
|
||||
"scannedAt": 1779689253386
|
||||
"scannedAt": 1772295199193
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"timestamp": "2026-05-25T05:38:20.448Z",
|
||||
"timestamp": "2026-02-28T16:05:19.091Z",
|
||||
"patternsConsolidated": 0,
|
||||
"memoryCleaned": 0,
|
||||
"duplicatesRemoved": 0
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"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,84 +1,12 @@
|
||||
{
|
||||
"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`."
|
||||
]
|
||||
"timestamp": "2026-03-06T13:17:27.368Z",
|
||||
"mode": "local",
|
||||
"checks": {
|
||||
"envFilesProtected": true,
|
||||
"gitIgnoreExists": true,
|
||||
"noHardcodedSecrets": true
|
||||
},
|
||||
"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
|
||||
"riskLevel": "low",
|
||||
"recommendations": [],
|
||||
"note": "Install Claude Code CLI for AI-powered security analysis"
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"name": "ruview",
|
||||
"description": "RuView Marketplace: Claude Code + Codex plugins for WiFi sensing — configuration, applications, model training, and onboarding, from practical to advanced",
|
||||
"owner": {
|
||||
"name": "ruvnet",
|
||||
"url": "https://github.com/ruvnet/RuView"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "ruview",
|
||||
"source": "./plugins/ruview",
|
||||
"description": "End-to-end RuView toolkit: getting started, ESP32 hardware setup, configuration, sensing applications (presence / vitals / pose / sleep / MAT), camera-free + camera-supervised model training, advanced multistatic sensing, CLI / API / WASM, mmWave radar, and witness verification"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{"sessionId":"d80c93c2-51b7-42e8-a0fc-dc47cff1200f","pid":45748,"acquiredAt":1779668018388}
|
||||
@@ -126,7 +126,10 @@
|
||||
"Bash(node .claude/*)",
|
||||
"mcp__claude-flow__:*"
|
||||
],
|
||||
"deny": []
|
||||
"deny": [
|
||||
"Read(./.env)",
|
||||
"Read(./.env.*)"
|
||||
]
|
||||
},
|
||||
"attribution": {
|
||||
"commit": "Co-Authored-By: claude-flow <ruv@ruv.net>",
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Keep all third-party GitHub Actions on verified, pinned commit SHAs.
|
||||
# Pairs with the SHA pinning in security-scan.yml and ci.yml so that
|
||||
# future bumps stay automated and reviewable rather than drifting back
|
||||
# to mutable @master / @main refs. See issue #442.
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- dependencies
|
||||
- github-actions
|
||||
|
||||
# Mobile app npm deps. Includes the @xmldom/xmldom, node-forge, and
|
||||
# picomatch advisories from #442 plus axios and any future surface.
|
||||
- package-ecosystem: npm
|
||||
directory: /ui/mobile
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- dependencies
|
||||
- mobile
|
||||
|
||||
# Desktop UI npm deps. Direct vite devDep currently has a HIGH advisory
|
||||
# (dev-server-only path traversal); track future bumps automatically.
|
||||
- package-ecosystem: npm
|
||||
directory: /v2/crates/wifi-densepose-desktop/ui
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- dependencies
|
||||
- desktop
|
||||
|
||||
# Python deps used by v1/ and the FastAPI service. requirements.txt is
|
||||
# only loosely pinned; let Dependabot surface upstream CVE bumps.
|
||||
- package-ecosystem: pip
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- dependencies
|
||||
- python
|
||||
|
||||
# Rust workspace (15+ crates). cargo audit is not currently wired into
|
||||
# any workflow, so Dependabot is the primary automated bump path.
|
||||
- package-ecosystem: cargo
|
||||
directory: /v2
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- dependencies
|
||||
- rust
|
||||
@@ -1,99 +0,0 @@
|
||||
name: BFLD MQTT Integration
|
||||
|
||||
# Runs the env-gated mosquitto integration tests from iters 24 + 29 of the
|
||||
# BFLD rollout (ADR-118 / ADR-122 §2.2). Spins up an eclipse-mosquitto:2
|
||||
# service container, exports BFLD_MQTT_BROKER, runs `cargo test --features
|
||||
# mqtt`. Local developers can reproduce with:
|
||||
#
|
||||
# scoop install mosquitto # Windows
|
||||
# # or: docker run -p 1883:1883 eclipse-mosquitto:2
|
||||
# BFLD_MQTT_BROKER=tcp://localhost:1883 \
|
||||
# cargo test -p wifi-densepose-bfld --features mqtt
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- 'feat/adr-118-*'
|
||||
- 'feat/bfld-*'
|
||||
paths:
|
||||
- 'v2/crates/wifi-densepose-bfld/**'
|
||||
- '.github/workflows/bfld-mqtt-integration.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'v2/crates/wifi-densepose-bfld/**'
|
||||
- '.github/workflows/bfld-mqtt-integration.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
mqtt-live-broker:
|
||||
name: cargo test --features mqtt (live mosquitto)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
services:
|
||||
mosquitto:
|
||||
image: eclipse-mosquitto:2
|
||||
ports:
|
||||
- 1883:1883
|
||||
# Allow anonymous connections — local-only CI broker, no exposure
|
||||
# to the public internet, never touches production credentials.
|
||||
options: >-
|
||||
--health-cmd "mosquitto_pub -h localhost -t healthcheck -m ping || exit 1"
|
||||
--health-interval 5s
|
||||
--health-timeout 3s
|
||||
--health-retries 10
|
||||
|
||||
env:
|
||||
BFLD_MQTT_BROKER: tcp://localhost:1883
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
RUSTFLAGS: -D warnings
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
|
||||
- name: Cache cargo registry + target
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
v2/target
|
||||
key: bfld-mqtt-${{ runner.os }}-${{ hashFiles('v2/Cargo.lock') }}
|
||||
|
||||
- name: Wait for mosquitto to be ready
|
||||
run: |
|
||||
for i in {1..20}; do
|
||||
if nc -z localhost 1883; then
|
||||
echo "mosquitto reachable on port 1883 (attempt $i)"
|
||||
exit 0
|
||||
fi
|
||||
echo "waiting for mosquitto ($i/20)..."
|
||||
sleep 1
|
||||
done
|
||||
echo "mosquitto never became reachable" >&2
|
||||
exit 1
|
||||
|
||||
- name: cargo test --no-default-features (baseline regression)
|
||||
working-directory: v2
|
||||
run: cargo test -p wifi-densepose-bfld --no-default-features
|
||||
|
||||
- name: cargo test (default features)
|
||||
working-directory: v2
|
||||
run: cargo test -p wifi-densepose-bfld
|
||||
|
||||
- name: cargo test --features mqtt (incl. live mosquitto roundtrip)
|
||||
working-directory: v2
|
||||
run: cargo test -p wifi-densepose-bfld --features mqtt
|
||||
|
||||
- name: cargo clippy --features mqtt (lint gate)
|
||||
working-directory: v2
|
||||
run: cargo clippy -p wifi-densepose-bfld --features mqtt --all-targets -- -D warnings
|
||||
continue-on-error: true
|
||||
+22
-146
@@ -15,50 +15,38 @@ env:
|
||||
|
||||
jobs:
|
||||
# Code Quality and Security Checks
|
||||
# The Python codebase moved to `archive/v1/` when the runtime was rewritten in
|
||||
# Rust under `v2/`. The lint/format/type/scan checks below still run against
|
||||
# the archive for hygiene, but with `continue-on-error: true` everywhere — the
|
||||
# archive is frozen reference code, not active development, so a stale lint
|
||||
# rule shouldn't gate PRs to the Rust workspace.
|
||||
code-quality:
|
||||
name: Code Quality & Security
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- name: Checkout code
|
||||
continue-on-error: true
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
continue-on-error: true
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install dependencies
|
||||
continue-on-error: true
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install black flake8 mypy bandit safety
|
||||
|
||||
- name: Code formatting check (Black)
|
||||
continue-on-error: true
|
||||
run: black --check --diff archive/v1/src archive/v1/tests
|
||||
run: black --check --diff src/ tests/
|
||||
|
||||
- name: Linting (Flake8)
|
||||
continue-on-error: true
|
||||
run: flake8 archive/v1/src archive/v1/tests --max-line-length=88 --extend-ignore=E203,W503
|
||||
run: flake8 src/ tests/ --max-line-length=88 --extend-ignore=E203,W503
|
||||
|
||||
- name: Type checking (MyPy)
|
||||
continue-on-error: true
|
||||
run: mypy archive/v1/src --ignore-missing-imports
|
||||
run: mypy src/ --ignore-missing-imports
|
||||
|
||||
- name: Security scan (Bandit)
|
||||
run: bandit -r archive/v1/src -f json -o bandit-report.json
|
||||
run: bandit -r src/ -f json -o bandit-report.json
|
||||
continue-on-error: true
|
||||
|
||||
- name: Dependency vulnerability scan (Safety)
|
||||
@@ -66,7 +54,6 @@ jobs:
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload security reports
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
@@ -75,90 +62,11 @@ jobs:
|
||||
bandit-report.json
|
||||
safety-report.json
|
||||
|
||||
# Rust Workspace Tests
|
||||
rust-tests:
|
||||
name: Rust Workspace Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# `wifi-densepose-desktop` is a Tauri v2 app — `glib-sys`, `gtk-sys`,
|
||||
# `webkit2gtk-sys`, etc. need the Linux dev libraries via pkg-config or the
|
||||
# workspace test fails at the build step before any test runs (every recent
|
||||
# main CI run has been red on this for exactly this reason). Install the
|
||||
# standard Tauri-on-Ubuntu set.
|
||||
- name: Install Tauri / GTK / serial system dev libraries
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libglib2.0-dev \
|
||||
libgtk-3-dev \
|
||||
libsoup-3.0-dev \
|
||||
libjavascriptcoregtk-4.1-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev \
|
||||
libxdo-dev \
|
||||
libudev-dev \
|
||||
libdbus-1-dev \
|
||||
libssl-dev \
|
||||
pkg-config
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
v2/target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('v2/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-
|
||||
|
||||
- name: Run Rust tests
|
||||
working-directory: v2
|
||||
run: cargo test --workspace --no-default-features
|
||||
|
||||
- name: Run ADR-147 worldmodel tests
|
||||
working-directory: v2
|
||||
run: cargo test -p wifi-densepose-worldmodel --no-default-features
|
||||
|
||||
# ADR-134 CIR tests are behind the `cir` feature so the bench dependency
|
||||
# (Criterion) only pulls when actually exercised. Run them as a separate
|
||||
# step so a CIR-only regression is unambiguously attributable.
|
||||
- name: Run ADR-134 CIR tests
|
||||
working-directory: v2
|
||||
run: cargo test -p wifi-densepose-signal --no-default-features --features cir --tests
|
||||
|
||||
# ADR-134 + ADR-028 witness guard. The CIR proof runner produces a
|
||||
# bit-deterministic SHA-256 over CirEstimator output on the synthetic
|
||||
# reference signal. Any algorithmic regression — changes to ISTA
|
||||
# convergence, sensing matrix construction, soft-thresholding, or input
|
||||
# padding — breaks the hash and fails the build. To regenerate after an
|
||||
# *intentional* change:
|
||||
# cd v2 && cargo run -p wifi-densepose-signal --bin cir_proof_runner \
|
||||
# --release --no-default-features -- --generate-hash \
|
||||
# > ../archive/v1/data/proof/expected_cir_features.sha256
|
||||
- name: ADR-134 CIR witness proof (determinism guard)
|
||||
run: bash scripts/verify-cir-proof.sh
|
||||
|
||||
- name: ADR-135 calibration witness proof (determinism guard)
|
||||
run: bash scripts/verify-calibration-proof.sh
|
||||
|
||||
# Unit and Integration Tests
|
||||
# Python pytest matrix — runs against the archived v1 Python tree.
|
||||
# `continue-on-error: true` for the same reason as code-quality above:
|
||||
# the archive is frozen reference, not blocking the Rust workspace PRs.
|
||||
test:
|
||||
name: Tests
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ['3.10', '3.11', '3.12']
|
||||
services:
|
||||
@@ -187,51 +95,44 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
continue-on-error: true
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
continue-on-error: true
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install dependencies
|
||||
continue-on-error: true
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install pytest-cov pytest-xdist
|
||||
|
||||
- name: Run unit tests
|
||||
continue-on-error: true
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test_wifi_densepose
|
||||
REDIS_URL: redis://localhost:6379/0
|
||||
ENVIRONMENT: test
|
||||
run: |
|
||||
pytest archive/v1/tests/unit/ -v --cov=archive/v1/src --cov-report=xml --cov-report=html --junitxml=junit.xml
|
||||
pytest tests/unit/ -v --cov=src --cov-report=xml --cov-report=html --junitxml=junit.xml
|
||||
|
||||
- name: Run integration tests
|
||||
continue-on-error: true
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test_wifi_densepose
|
||||
REDIS_URL: redis://localhost:6379/0
|
||||
ENVIRONMENT: test
|
||||
run: |
|
||||
pytest archive/v1/tests/integration/ -v --junitxml=integration-junit.xml
|
||||
pytest tests/integration/ -v --junitxml=integration-junit.xml
|
||||
|
||||
- name: Upload coverage reports
|
||||
continue-on-error: true
|
||||
uses: codecov/codecov-action@v6
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
flags: unittests
|
||||
name: codecov-umbrella
|
||||
|
||||
- name: Upload test results
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
@@ -242,21 +143,17 @@ jobs:
|
||||
htmlcov/
|
||||
|
||||
# Performance and Load Tests
|
||||
# NOTE: tests/performance/locustfile.py and the src.api.main app path both
|
||||
# predate the v1→archive/v1 reorganisation. continue-on-error: true until a
|
||||
# proper locust suite is added under archive/v1/tests/performance/.
|
||||
performance-test:
|
||||
name: Performance Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test]
|
||||
continue-on-error: true
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: 'pip'
|
||||
@@ -268,7 +165,6 @@ jobs:
|
||||
pip install locust
|
||||
|
||||
- name: Start application
|
||||
working-directory: archive/v1
|
||||
run: |
|
||||
uvicorn src.api.main:app --host 0.0.0.0 --port 8000 &
|
||||
sleep 10
|
||||
@@ -284,29 +180,18 @@ jobs:
|
||||
path: locust_report.html
|
||||
|
||||
# Docker Build and Test
|
||||
# NOTE: the canonical Docker build for the sensing-server is now
|
||||
# `.github/workflows/sensing-server-docker.yml` (multi-registry push, asset
|
||||
# smoke tests, bearer-auth smoke tests — #520/#514/#443). This job predates
|
||||
# that workflow, points at a non-existent root `Dockerfile` with a
|
||||
# non-existent `target: production`, and pushes to a mis-cased image name —
|
||||
# `continue-on-error: true` until it's deleted or rewired to call the new
|
||||
# workflow, so it doesn't gate the rest of the pipeline.
|
||||
docker-build:
|
||||
name: Docker Build & Test
|
||||
runs-on: ubuntu-latest
|
||||
needs: [code-quality, test, rust-tests]
|
||||
continue-on-error: true
|
||||
needs: [code-quality, test]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
continue-on-error: true
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
continue-on-error: true
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Container Registry
|
||||
continue-on-error: true
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
@@ -314,9 +199,8 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
continue-on-error: true
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
@@ -326,8 +210,7 @@ jobs:
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push Docker image
|
||||
continue-on-error: true
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
target: production
|
||||
@@ -339,7 +222,6 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
- name: Test Docker image
|
||||
continue-on-error: true
|
||||
run: |
|
||||
docker run --rm -d --name test-container -p 8000:8000 ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||
sleep 10
|
||||
@@ -347,15 +229,13 @@ jobs:
|
||||
docker stop test-container
|
||||
|
||||
- name: Run container security scan
|
||||
continue-on-error: true
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||
format: 'sarif'
|
||||
output: 'trivy-results.sarif'
|
||||
|
||||
- name: Upload Trivy scan results
|
||||
continue-on-error: true
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
@@ -372,7 +252,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: 'pip'
|
||||
@@ -383,7 +263,6 @@ jobs:
|
||||
pip install -r requirements.txt
|
||||
|
||||
- name: Generate OpenAPI spec
|
||||
working-directory: archive/v1
|
||||
run: |
|
||||
python -c "
|
||||
from src.api.main import app
|
||||
@@ -403,31 +282,28 @@ jobs:
|
||||
notify:
|
||||
name: Notify
|
||||
runs-on: ubuntu-latest
|
||||
needs: [code-quality, test, rust-tests, performance-test, docker-build, docs]
|
||||
needs: [code-quality, test, performance-test, docker-build, docs]
|
||||
if: always()
|
||||
permissions:
|
||||
contents: write # required by softprops/action-gh-release
|
||||
# GitHub Actions does not allow `secrets.X` directly in step-level `if:`
|
||||
# expressions — only `env.X`. Promote the secret to env at job scope so
|
||||
# the gating expression below is parseable.
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
steps:
|
||||
- name: Notify Slack on success
|
||||
if: ${{ env.SLACK_WEBHOOK_URL != '' && needs.code-quality.result == 'success' && needs.test.result == 'success' && needs.docker-build.result == 'success' }}
|
||||
if: ${{ secrets.SLACK_WEBHOOK_URL != '' && needs.code-quality.result == 'success' && needs.test.result == 'success' && needs.docker-build.result == 'success' }}
|
||||
uses: 8398a7/action-slack@v3
|
||||
with:
|
||||
status: success
|
||||
channel: '#ci-cd'
|
||||
text: '✅ CI pipeline completed successfully for ${{ github.ref }}'
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
|
||||
- name: Notify Slack on failure
|
||||
if: ${{ env.SLACK_WEBHOOK_URL != '' && (needs.code-quality.result == 'failure' || needs.test.result == 'failure' || needs.docker-build.result == 'failure') }}
|
||||
if: ${{ secrets.SLACK_WEBHOOK_URL != '' && (needs.code-quality.result == 'failure' || needs.test.result == 'failure' || needs.docker-build.result == 'failure') }}
|
||||
uses: 8398a7/action-slack@v3
|
||||
with:
|
||||
status: failure
|
||||
channel: '#ci-cd'
|
||||
text: '❌ CI pipeline failed for ${{ github.ref }}'
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: github.ref == 'refs/heads/main' && needs.docker-build.result == 'success'
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
name: GitHub Clone Tracking → data/clone-data.rvf
|
||||
|
||||
# Persists rolling 14-day clone-traffic snapshots to data/clone-data.rvf in
|
||||
# the ruvector JSONL RVF format. GitHub's /traffic/clones endpoint only
|
||||
# retains the last 14 days server-side, so without this scheduled scrape
|
||||
# the data is gone forever the moment it falls outside the window.
|
||||
#
|
||||
# Format: JSONL RVF
|
||||
# - line 1 is a `metadata` segment that initializes the file
|
||||
# - each subsequent run appends one `clone_snapshot` segment carrying the
|
||||
# 14-day rollup PLUS per-day breakdown
|
||||
# - file is idempotent: per-day entries are keyed by `timestamp` so a
|
||||
# downstream reader can dedupe across overlapping snapshot windows
|
||||
#
|
||||
# Schedule: every 14 days (1st + 15th of each month, ~14-day cadence in
|
||||
# practice). Workflow can also be dispatched manually for backfill or test.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# 01:23 UTC on the 1st and 15th of every month — close to 14-day cadence
|
||||
# without cron's "every 14 days" monthly-reset weirdness. Picking :23
|
||||
# avoids the cron herd on :00.
|
||||
- cron: '23 1 1,15 * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: clone-tracking
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
snapshot:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Fetch /traffic/clones + /traffic/views from GitHub
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
mkdir -p data
|
||||
gh api repos/${{ github.repository }}/traffic/clones > /tmp/clones.json
|
||||
gh api repos/${{ github.repository }}/traffic/views > /tmp/views.json
|
||||
echo "--- clones rollup ---"
|
||||
jq '{count, uniques, days: (.clones | length)}' /tmp/clones.json
|
||||
echo "--- views rollup ---"
|
||||
jq '{count, uniques, days: (.views | length)}' /tmp/views.json
|
||||
|
||||
- name: Append snapshot to data/clone-data.rvf
|
||||
env:
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -e
|
||||
RVF="data/clone-data.rvf"
|
||||
FETCHED_AT=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
# Initialize the file with a metadata segment on first run.
|
||||
if [ ! -f "$RVF" ]; then
|
||||
echo "Initializing $RVF with metadata segment"
|
||||
jq -n --arg repo "$REPO" --arg ts "$FETCHED_AT" '{
|
||||
type: "metadata",
|
||||
name: "ruview-clone-traffic-history",
|
||||
version: "1.0.0",
|
||||
schema: "ruvector.rvf.jsonl/v1",
|
||||
format: "github-traffic-snapshots",
|
||||
repo: $repo,
|
||||
source: "GitHub Traffic API /repos/{repo}/traffic/{clones,views}",
|
||||
policy: "GitHub retains only 14 days server-side; this file is the long-term record.",
|
||||
segments: ["metadata", "clone_snapshot", "view_snapshot"],
|
||||
created_at: $ts,
|
||||
custom: {
|
||||
cadence: "twice monthly (1st and 15th, ~14-day intervals)",
|
||||
idempotency_key: "timestamp (per-day records de-duplicate across overlapping snapshot windows)"
|
||||
}
|
||||
}' >> "$RVF"
|
||||
fi
|
||||
|
||||
# Append the clone snapshot.
|
||||
jq --arg ts "$FETCHED_AT" '{
|
||||
type: "clone_snapshot",
|
||||
fetched_at: $ts,
|
||||
window_count: .count,
|
||||
window_uniques: .uniques,
|
||||
per_day: .clones
|
||||
}' /tmp/clones.json >> "$RVF"
|
||||
|
||||
# Append the views snapshot (free with the same auth).
|
||||
jq --arg ts "$FETCHED_AT" '{
|
||||
type: "view_snapshot",
|
||||
fetched_at: $ts,
|
||||
window_count: .count,
|
||||
window_uniques: .uniques,
|
||||
per_day: .views
|
||||
}' /tmp/views.json >> "$RVF"
|
||||
|
||||
echo "--- RVF tail (last 4 lines) ---"
|
||||
tail -4 "$RVF" | jq -c '{type, fetched_at, window_count, window_uniques}' || true
|
||||
echo "--- file size ---"
|
||||
wc -l "$RVF"
|
||||
|
||||
- name: Compute aggregates for the commit summary
|
||||
id: agg
|
||||
run: |
|
||||
# Count distinct per-day entries across all snapshots so we can
|
||||
# show "cumulative observed clones" in the commit message.
|
||||
python3 - <<'PY'
|
||||
import json, os
|
||||
path = "data/clone-data.rvf"
|
||||
per_day_clones = {}
|
||||
per_day_views = {}
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
if not line.strip():
|
||||
continue
|
||||
d = json.loads(line)
|
||||
if d.get("type") == "clone_snapshot":
|
||||
for entry in d.get("per_day", []):
|
||||
per_day_clones[entry["timestamp"]] = entry
|
||||
elif d.get("type") == "view_snapshot":
|
||||
for entry in d.get("per_day", []):
|
||||
per_day_views[entry["timestamp"]] = entry
|
||||
|
||||
tot_clones = sum(e.get("count", 0) for e in per_day_clones.values())
|
||||
tot_uniq_clones = sum(e.get("uniques", 0) for e in per_day_clones.values())
|
||||
tot_views = sum(e.get("count", 0) for e in per_day_views.values())
|
||||
tot_uniq_views = sum(e.get("uniques", 0) for e in per_day_views.values())
|
||||
print(f"clone days observed: {len(per_day_clones)} total clones: {tot_clones:,} total unique cloners: {tot_uniq_clones:,}")
|
||||
print(f"view days observed: {len(per_day_views)} total views: {tot_views:,} total unique viewers: {tot_uniq_views:,}")
|
||||
|
||||
with open(os.environ["GITHUB_OUTPUT"], "a") as out:
|
||||
out.write(f"clones={tot_clones}\n")
|
||||
out.write(f"clone_days={len(per_day_clones)}\n")
|
||||
out.write(f"views={tot_views}\n")
|
||||
out.write(f"view_days={len(per_day_views)}\n")
|
||||
PY
|
||||
|
||||
- name: Commit + push if changed
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
if git diff --quiet data/clone-data.rvf; then
|
||||
echo "no changes to commit"
|
||||
exit 0
|
||||
fi
|
||||
git add data/clone-data.rvf
|
||||
git commit -m "chore(traffic): clone snapshot — ${{ steps.agg.outputs.clone_days }} days observed → ${{ steps.agg.outputs.clones }} clones, ${{ steps.agg.outputs.view_days }} view-days → ${{ steps.agg.outputs.views }} views"
|
||||
git push
|
||||
@@ -1,200 +0,0 @@
|
||||
name: Cog HA-Matter Release
|
||||
|
||||
# ADR-116 P8 — Build + sign + bundle the cog-ha-matter cog on a
|
||||
# version tag. Upload to gs://cognitum-apps/ runs only when the
|
||||
# GCP_CREDENTIALS + COGNITUM_OWNER_SIGNING_KEY secrets are set, so
|
||||
# this workflow is safe to merge before the production credentials
|
||||
# land — it'll bundle release artifacts to the workflow run page
|
||||
# either way.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'cog-ha-matter-v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: 'Build + sign + bundle but skip GCS upload'
|
||||
required: false
|
||||
default: 'true'
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CRATE: cog-ha-matter
|
||||
|
||||
jobs:
|
||||
build-x86_64:
|
||||
name: Build x86_64
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: x86_64-unknown-linux-gnu
|
||||
|
||||
- name: Cache cargo registry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
v2/target
|
||||
key: cog-ha-matter-x86_64-${{ hashFiles('v2/Cargo.lock') }}
|
||||
|
||||
- name: Build release binary
|
||||
working-directory: v2/crates/cog-ha-matter/cog
|
||||
run: make build-x86_64
|
||||
|
||||
- name: Compute SHA-256
|
||||
working-directory: v2/crates/cog-ha-matter/cog
|
||||
run: make sign-x86_64
|
||||
|
||||
- name: Sign with Ed25519 (gated)
|
||||
if: ${{ env.SIGNING_KEY != '' }}
|
||||
env:
|
||||
SIGNING_KEY: ${{ secrets.COGNITUM_OWNER_SIGNING_KEY }}
|
||||
working-directory: v2/crates/cog-ha-matter/cog
|
||||
run: |
|
||||
printf '%s' "$SIGNING_KEY" \
|
||||
| openssl pkeyutl -sign -inkey /dev/stdin -rawin \
|
||||
-in dist/cog-ha-matter-x86_64.sha256 \
|
||||
| base64 -w0 > dist/cog-ha-matter-x86_64.sig
|
||||
echo "Signed cog-ha-matter-x86_64 ($(wc -c < dist/cog-ha-matter-x86_64.sig) bytes)"
|
||||
|
||||
- name: Upload workflow artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cog-ha-matter-x86_64
|
||||
path: |
|
||||
v2/crates/cog-ha-matter/cog/dist/cog-ha-matter-x86_64
|
||||
v2/crates/cog-ha-matter/cog/dist/cog-ha-matter-x86_64.sha256
|
||||
v2/crates/cog-ha-matter/cog/dist/cog-ha-matter-x86_64.sig
|
||||
if-no-files-found: warn
|
||||
|
||||
build-arm:
|
||||
name: Build aarch64 (arm)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-unknown-linux-gnu
|
||||
|
||||
- name: Install cross-compiler
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gcc-aarch64-linux-gnu
|
||||
|
||||
- name: Cache cargo registry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
v2/target
|
||||
key: cog-ha-matter-arm-${{ hashFiles('v2/Cargo.lock') }}
|
||||
|
||||
- name: Build release binary
|
||||
working-directory: v2
|
||||
env:
|
||||
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
|
||||
run: |
|
||||
cargo build -p cog-ha-matter --release --target aarch64-unknown-linux-gnu
|
||||
mkdir -p crates/cog-ha-matter/cog/dist
|
||||
cp target/aarch64-unknown-linux-gnu/release/cog-ha-matter \
|
||||
crates/cog-ha-matter/cog/dist/cog-ha-matter-arm
|
||||
# ^ matches Makefile's `dist/$(CRATE)-arm` so `make sign-arm` finds it
|
||||
|
||||
- name: Compute SHA-256
|
||||
working-directory: v2/crates/cog-ha-matter/cog
|
||||
run: make sign-arm
|
||||
|
||||
- name: Sign with Ed25519 (gated)
|
||||
if: ${{ env.SIGNING_KEY != '' }}
|
||||
env:
|
||||
SIGNING_KEY: ${{ secrets.COGNITUM_OWNER_SIGNING_KEY }}
|
||||
working-directory: v2/crates/cog-ha-matter/cog
|
||||
run: |
|
||||
printf '%s' "$SIGNING_KEY" \
|
||||
| openssl pkeyutl -sign -inkey /dev/stdin -rawin \
|
||||
-in dist/cog-ha-matter-arm.sha256 \
|
||||
| base64 -w0 > dist/cog-ha-matter-arm.sig
|
||||
echo "Signed cog-ha-matter-arm ($(wc -c < dist/cog-ha-matter-arm.sig) bytes)"
|
||||
|
||||
- name: Upload workflow artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cog-ha-matter-arm
|
||||
path: |
|
||||
v2/crates/cog-ha-matter/cog/dist/cog-ha-matter-arm
|
||||
v2/crates/cog-ha-matter/cog/dist/cog-ha-matter-arm.sha256
|
||||
v2/crates/cog-ha-matter/cog/dist/cog-ha-matter-arm.sig
|
||||
if-no-files-found: warn
|
||||
|
||||
publish-gcs:
|
||||
name: Upload to GCS (gated)
|
||||
needs: [build-x86_64, build-arm]
|
||||
runs-on: ubuntu-latest
|
||||
# Skip on dry-run dispatch; skip on tags when GCP_CREDENTIALS unset.
|
||||
if: >
|
||||
github.event_name == 'push' &&
|
||||
vars.HAS_GCP_CREDENTIALS == 'true'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download x86_64 artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: cog-ha-matter-x86_64
|
||||
path: dist/
|
||||
|
||||
- name: Download arm artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: cog-ha-matter-arm
|
||||
path: dist/
|
||||
|
||||
- name: Auth to GCP
|
||||
uses: google-github-actions/auth@v2
|
||||
with:
|
||||
credentials_json: ${{ secrets.GCP_CREDENTIALS }}
|
||||
|
||||
- name: Set up gcloud
|
||||
uses: google-github-actions/setup-gcloud@v2
|
||||
|
||||
- name: Upload binaries + sidecars
|
||||
run: |
|
||||
gsutil cp dist/cog-ha-matter-x86_64 gs://cognitum-apps/cogs/x86_64/cog-ha-matter-x86_64
|
||||
gsutil cp dist/cog-ha-matter-x86_64.sha256 gs://cognitum-apps/cogs/x86_64/cog-ha-matter-x86_64.sha256
|
||||
gsutil cp dist/cog-ha-matter-arm gs://cognitum-apps/cogs/arm/cog-ha-matter-arm
|
||||
gsutil cp dist/cog-ha-matter-arm.sha256 gs://cognitum-apps/cogs/arm/cog-ha-matter-arm.sha256
|
||||
if [ -f dist/cog-ha-matter-x86_64.sig ]; then
|
||||
gsutil cp dist/cog-ha-matter-x86_64.sig gs://cognitum-apps/cogs/x86_64/cog-ha-matter-x86_64.sig
|
||||
fi
|
||||
if [ -f dist/cog-ha-matter-arm.sig ]; then
|
||||
gsutil cp dist/cog-ha-matter-arm.sig gs://cognitum-apps/cogs/arm/cog-ha-matter-arm.sig
|
||||
fi
|
||||
|
||||
- name: Print app-registry.json snippet for the cognitum-one PR
|
||||
run: |
|
||||
for arch in arm x86_64; do
|
||||
sha=$(cat dist/cog-cog-ha-matter-$arch.sha256)
|
||||
sig=$([ -f dist/cog-cog-ha-matter-$arch.sig ] && cat dist/cog-cog-ha-matter-$arch.sig || echo "")
|
||||
cat <<EOF
|
||||
--- $arch ---
|
||||
{
|
||||
"id": "ha-matter",
|
||||
"version": "${GITHUB_REF_NAME#cog-ha-matter-v}",
|
||||
"binary_url": "https://storage.googleapis.com/cognitum-apps/cogs/$arch/cog-cog-ha-matter-$arch",
|
||||
"binary_sha256": "$sha",
|
||||
"binary_signature": "$sig",
|
||||
"description": "Home Assistant + Matter Cognitum Seed cog (mDNS + witness chain)",
|
||||
"min_seed_version": "0.6.0",
|
||||
"installable_on": ["$arch"]
|
||||
}
|
||||
EOF
|
||||
done
|
||||
@@ -1,46 +0,0 @@
|
||||
name: Dashboard a11y + cross-browser
|
||||
|
||||
# Runs axe-core a11y assertions on the built dashboard across
|
||||
# Chromium, Firefox, and WebKit. Closes ADR-092 §11.5 (axe-core)
|
||||
# and §11.8 (cross-browser).
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ['dashboard/**', 'v2/crates/nvsim/**']
|
||||
pull_request:
|
||||
paths: ['dashboard/**']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
a11y:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: wasm32-unknown-unknown }
|
||||
|
||||
- name: Install wasm-pack
|
||||
run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
|
||||
|
||||
- name: Build nvsim WASM
|
||||
working-directory: v2
|
||||
run: |
|
||||
wasm-pack build crates/nvsim --target web \
|
||||
--out-dir ../../dashboard/public/nvsim-pkg \
|
||||
--release -- --no-default-features --features wasm
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with: { node-version: 20, cache: npm, cache-dependency-path: dashboard/package-lock.json }
|
||||
|
||||
- working-directory: dashboard
|
||||
run: |
|
||||
npm ci
|
||||
npm install --save-dev @playwright/test @axe-core/playwright
|
||||
npx playwright install --with-deps
|
||||
npm run build
|
||||
npx playwright test
|
||||
@@ -1,87 +0,0 @@
|
||||
name: nvsim Dashboard → GitHub Pages
|
||||
|
||||
# Deploys the nvsim Vite/Lit dashboard to gh-pages/nvsim/ — preserving
|
||||
# the existing observatory/, pose-fusion/, and root index.html demos
|
||||
# already published from gh-pages. ADR-092 §9.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'v2/crates/nvsim/**'
|
||||
- 'dashboard/**'
|
||||
- '.github/workflows/dashboard-pages.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: dashboard-pages
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust + wasm32 target
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
- name: Cache cargo registry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
v2/target
|
||||
key: ${{ runner.os }}-cargo-nvsim-${{ hashFiles('v2/Cargo.lock') }}
|
||||
restore-keys: ${{ runner.os }}-cargo-nvsim-
|
||||
|
||||
- name: Install wasm-pack
|
||||
run: |
|
||||
curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
|
||||
which wasm-pack
|
||||
|
||||
- name: Build nvsim WASM
|
||||
working-directory: v2
|
||||
run: |
|
||||
wasm-pack build crates/nvsim \
|
||||
--target web \
|
||||
--out-dir ../../dashboard/public/nvsim-pkg \
|
||||
--release \
|
||||
-- --no-default-features --features wasm
|
||||
|
||||
- name: Setup Node 20
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: dashboard/package-lock.json
|
||||
|
||||
- name: Install dashboard deps
|
||||
working-directory: dashboard
|
||||
run: npm ci
|
||||
|
||||
- name: Build dashboard
|
||||
working-directory: dashboard
|
||||
env:
|
||||
NVSIM_BASE: /RuView/nvsim/
|
||||
run: npm run build
|
||||
|
||||
- name: Deploy to gh-pages/nvsim/
|
||||
uses: peaceiris/actions-gh-pages@v4
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./dashboard/dist
|
||||
destination_dir: nvsim
|
||||
# CRITICAL: preserves observatory/, pose-fusion/, root index.html
|
||||
# and any other RuView demos already on gh-pages.
|
||||
keep_files: true
|
||||
commit_message: 'deploy(nvsim): ${{ github.sha }}'
|
||||
user_name: 'github-actions[bot]'
|
||||
user_email: 'github-actions[bot]@users.noreply.github.com'
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
@@ -40,18 +40,18 @@ jobs:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: v2/crates/wifi-densepose-desktop/ui
|
||||
working-directory: rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui
|
||||
run: npm ci
|
||||
|
||||
- name: Build frontend
|
||||
working-directory: v2/crates/wifi-densepose-desktop/ui
|
||||
working-directory: rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui
|
||||
run: npm run build
|
||||
|
||||
- name: Install Tauri CLI
|
||||
run: cargo install tauri-cli --version "^2.0.0"
|
||||
|
||||
- name: Build Tauri app
|
||||
working-directory: v2/crates/wifi-densepose-desktop
|
||||
working-directory: rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop
|
||||
run: cargo tauri build --target ${{ matrix.target }}
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
@@ -68,14 +68,14 @@ jobs:
|
||||
|
||||
- name: Package macOS app
|
||||
run: |
|
||||
cd v2/target/${{ matrix.target }}/release/bundle/macos
|
||||
cd rust-port/wifi-densepose-rs/target/${{ matrix.target }}/release/bundle/macos
|
||||
zip -r "RuView-Desktop-${{ github.event.inputs.version || '0.4.0' }}-macos-${{ steps.arch.outputs.arch }}.zip" "RuView Desktop.app"
|
||||
|
||||
- name: Upload macOS artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ruview-macos-${{ steps.arch.outputs.arch }}
|
||||
path: v2/target/${{ matrix.target }}/release/bundle/macos/*.zip
|
||||
path: rust-port/wifi-densepose-rs/target/${{ matrix.target }}/release/bundle/macos/*.zip
|
||||
|
||||
build-windows:
|
||||
name: Build Windows
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
@@ -93,18 +93,18 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: v2/crates/wifi-densepose-desktop/ui
|
||||
working-directory: rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui
|
||||
run: npm ci
|
||||
|
||||
- name: Build frontend
|
||||
working-directory: v2/crates/wifi-densepose-desktop/ui
|
||||
working-directory: rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop/ui
|
||||
run: npm run build
|
||||
|
||||
- name: Install Tauri CLI
|
||||
run: cargo install tauri-cli --version "^2.0.0"
|
||||
|
||||
- name: Build Tauri app
|
||||
working-directory: v2/crates/wifi-densepose-desktop
|
||||
working-directory: rust-port/wifi-densepose-rs/crates/wifi-densepose-desktop
|
||||
run: cargo tauri build
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
@@ -114,13 +114,13 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ruview-windows-msi
|
||||
path: v2/target/release/bundle/msi/*.msi
|
||||
path: rust-port/wifi-densepose-rs/target/release/bundle/msi/*.msi
|
||||
|
||||
- name: Upload Windows NSIS artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ruview-windows-nsis
|
||||
path: v2/target/release/bundle/nsis/*.exe
|
||||
path: rust-port/wifi-densepose-rs/target/release/bundle/nsis/*.exe
|
||||
|
||||
create-release:
|
||||
name: Create Release
|
||||
|
||||
@@ -2,11 +2,6 @@ name: Firmware CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
tags:
|
||||
# ESP32 firmware release tags — build + version-consistency guard (RuView#505).
|
||||
- 'v*-esp32'
|
||||
paths:
|
||||
- 'firmware/**'
|
||||
- '.github/workflows/firmware-ci.yml'
|
||||
@@ -16,92 +11,32 @@ on:
|
||||
- '.github/workflows/firmware-ci.yml'
|
||||
|
||||
jobs:
|
||||
version-guard:
|
||||
name: Verify version.txt matches release tag
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref_type == 'tag'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check firmware version.txt == tag
|
||||
run: |
|
||||
# Tag form: vX.Y.Z-esp32 → expect version.txt to contain X.Y.Z
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
EXPECTED="${TAG#v}"
|
||||
EXPECTED="${EXPECTED%-esp32}"
|
||||
ACTUAL="$(tr -d '[:space:]' < firmware/esp32-csi-node/version.txt)"
|
||||
echo "Tag: $TAG → expected version.txt: $EXPECTED | actual: $ACTUAL"
|
||||
if [ "$EXPECTED" != "$ACTUAL" ]; then
|
||||
echo "::error::firmware/esp32-csi-node/version.txt is '$ACTUAL' but tag '$TAG' expects '$EXPECTED'."
|
||||
echo "::error::Bump version.txt and re-tag so esp_app_get_description()->version is correct (RuView#505)."
|
||||
exit 1
|
||||
fi
|
||||
echo "version.txt matches the release tag."
|
||||
|
||||
build:
|
||||
name: Build firmware (${{ matrix.target }} / ${{ matrix.variant }})
|
||||
name: Build ESP32-S3 Firmware
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: espressif/idf:v5.4
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- variant: 8mb
|
||||
target: esp32s3
|
||||
sdkconfig: sdkconfig.defaults
|
||||
partition_table_name: partitions_display.csv
|
||||
size_limit_kb: 1100
|
||||
artifact_app: esp32-csi-node.bin
|
||||
artifact_pt: partition-table.bin
|
||||
- variant: 4mb
|
||||
target: esp32s3
|
||||
sdkconfig: sdkconfig.defaults.4mb
|
||||
partition_table_name: partitions_4mb.csv
|
||||
size_limit_kb: 1100
|
||||
artifact_app: esp32-csi-node-4mb.bin
|
||||
artifact_pt: partition-table-4mb.bin
|
||||
# ADR-110: ESP32-C6 research target (Wi-Fi 6 / 802.15.4 / TWT / LP-core)
|
||||
- variant: c6-4mb
|
||||
target: esp32c6
|
||||
sdkconfig: sdkconfig.defaults
|
||||
partition_table_name: partitions_4mb.csv
|
||||
size_limit_kb: 1100
|
||||
artifact_app: esp32-csi-node-c6.bin
|
||||
artifact_pt: partition-table-c6.bin
|
||||
image: espressif/idf:v5.2
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build firmware (${{ matrix.variant }})
|
||||
- name: Build firmware
|
||||
working-directory: firmware/esp32-csi-node
|
||||
run: |
|
||||
. $IDF_PATH/export.sh
|
||||
# 4mb variant supplies its own sdkconfig.defaults overlay.
|
||||
# c6-4mb variant relies on the auto-applied sdkconfig.defaults.esp32c6
|
||||
# overlay (ESP-IDF auto-loads sdkconfig.defaults.$TARGET when present).
|
||||
if [ "${{ matrix.variant }}" = "4mb" ]; then
|
||||
cp "${{ matrix.sdkconfig }}" sdkconfig.defaults
|
||||
fi
|
||||
idf.py set-target ${{ matrix.target }}
|
||||
idf.py set-target esp32s3
|
||||
idf.py build
|
||||
|
||||
- name: Build and run host-side ADR-110 unit tests
|
||||
if: matrix.variant == 'c6-4mb'
|
||||
working-directory: firmware/esp32-csi-node/test
|
||||
run: |
|
||||
make test_adr110
|
||||
./test_adr110
|
||||
|
||||
- name: Verify binary size (< ${{ matrix.size_limit_kb }} KB gate)
|
||||
- name: Verify binary size (< 950 KB gate)
|
||||
working-directory: firmware/esp32-csi-node
|
||||
run: |
|
||||
BIN=build/esp32-csi-node.bin
|
||||
SIZE=$(stat -c%s "$BIN")
|
||||
MAX=$((${{ matrix.size_limit_kb }} * 1024))
|
||||
MAX=$((950 * 1024))
|
||||
echo "Binary size: $SIZE bytes ($(( SIZE / 1024 )) KB)"
|
||||
echo "Size limit: $MAX bytes (${{ matrix.size_limit_kb }} KB)"
|
||||
echo "Size limit: $MAX bytes (950 KB — includes Tier 3 WASM runtime)"
|
||||
if [ "$SIZE" -gt "$MAX" ]; then
|
||||
echo "::error::Firmware binary exceeds ${{ matrix.size_limit_kb }} KB size gate ($SIZE > $MAX)"
|
||||
echo "::error::Firmware binary exceeds 950 KB size gate ($SIZE > $MAX)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Binary size OK: $SIZE <= $MAX"
|
||||
@@ -112,27 +47,31 @@ jobs:
|
||||
ERRORS=0
|
||||
BIN=build/esp32-csi-node.bin
|
||||
|
||||
# Check binary exists and is non-empty.
|
||||
if [ ! -s "$BIN" ]; then
|
||||
echo "::error::Binary not found or empty"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check partition table magic (0xAA50 at offset 0).
|
||||
PT=build/partition_table/partition-table.bin
|
||||
if [ -f "$PT" ]; then
|
||||
MAGIC=$(od -A n -t x1 -N 2 "$PT" | tr -d ' ')
|
||||
MAGIC=$(xxd -l2 -p "$PT")
|
||||
if [ "$MAGIC" != "aa50" ]; then
|
||||
echo "::warning::Partition table magic mismatch: $MAGIC (expected aa50)"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check bootloader exists.
|
||||
BL=build/bootloader/bootloader.bin
|
||||
if [ ! -s "$BL" ]; then
|
||||
echo "::warning::Bootloader binary missing or empty"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
|
||||
NONZERO=$(od -A n -t x1 -N 1024 "$BIN" | tr -d ' f\n' | wc -c)
|
||||
# Verify non-zero data in binary (not all 0xFF padding).
|
||||
NONZERO=$(xxd -l 1024 -p "$BIN" | tr -d 'f' | wc -c)
|
||||
if [ "$NONZERO" -lt 100 ]; then
|
||||
echo "::error::Binary appears to be mostly padding (non-zero chars: $NONZERO)"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
@@ -144,27 +83,18 @@ jobs:
|
||||
echo "Flash image integrity verified"
|
||||
fi
|
||||
|
||||
- name: Stage release binaries with variant-specific names
|
||||
working-directory: firmware/esp32-csi-node
|
||||
run: |
|
||||
mkdir -p release-staging
|
||||
cp build/esp32-csi-node.bin release-staging/${{ matrix.artifact_app }}
|
||||
cp build/partition_table/partition-table.bin release-staging/${{ matrix.artifact_pt }}
|
||||
if [ "${{ matrix.variant }}" = "8mb" ]; then
|
||||
cp build/bootloader/bootloader.bin release-staging/bootloader.bin
|
||||
cp build/ota_data_initial.bin release-staging/ota_data_initial.bin
|
||||
fi
|
||||
ls -la release-staging/
|
||||
|
||||
- name: Check QEMU ESP32-S3 support status
|
||||
run: |
|
||||
echo "::notice::ESP32-S3 QEMU support is experimental in ESP-IDF v5.4. "
|
||||
echo "Full smoke testing requires QEMU 8.2+ with xtensa-esp32s3 target."
|
||||
echo "See: https://github.com/espressif/qemu/wiki"
|
||||
|
||||
- name: Upload firmware artifact (${{ matrix.variant }})
|
||||
- name: Upload firmware artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: esp32-csi-node-firmware-${{ matrix.variant }}
|
||||
path: firmware/esp32-csi-node/release-staging/
|
||||
retention-days: 90
|
||||
name: esp32-csi-node-firmware
|
||||
path: |
|
||||
firmware/esp32-csi-node/build/esp32-csi-node.bin
|
||||
firmware/esp32-csi-node/build/bootloader/bootloader.bin
|
||||
firmware/esp32-csi-node/build/partition_table/partition-table.bin
|
||||
retention-days: 30
|
||||
|
||||
@@ -1,370 +0,0 @@
|
||||
name: Firmware QEMU Tests (ADR-061)
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'firmware/**'
|
||||
- 'scripts/qemu-esp32s3-test.sh'
|
||||
- 'scripts/validate_qemu_output.py'
|
||||
- 'scripts/generate_nvs_matrix.py'
|
||||
- 'scripts/qemu_swarm.py'
|
||||
- 'scripts/swarm_health.py'
|
||||
- 'scripts/swarm_presets/**'
|
||||
- '.github/workflows/firmware-qemu.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'firmware/**'
|
||||
- 'scripts/qemu-esp32s3-test.sh'
|
||||
- 'scripts/validate_qemu_output.py'
|
||||
- 'scripts/generate_nvs_matrix.py'
|
||||
- 'scripts/qemu_swarm.py'
|
||||
- 'scripts/swarm_health.py'
|
||||
- 'scripts/swarm_presets/**'
|
||||
- '.github/workflows/firmware-qemu.yml'
|
||||
|
||||
env:
|
||||
IDF_VERSION: "v5.4"
|
||||
QEMU_REPO: "https://github.com/espressif/qemu.git"
|
||||
QEMU_BRANCH: "esp-develop"
|
||||
|
||||
jobs:
|
||||
build-qemu:
|
||||
name: Build Espressif QEMU
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Cache QEMU build
|
||||
id: cache-qemu
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /opt/qemu-esp32
|
||||
# Include date component so cache refreshes monthly when branch updates
|
||||
key: qemu-esp32s3-${{ env.QEMU_BRANCH }}-v5
|
||||
restore-keys: |
|
||||
qemu-esp32s3-${{ env.QEMU_BRANCH }}-
|
||||
|
||||
- name: Install QEMU build dependencies
|
||||
if: steps.cache-qemu.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
git build-essential ninja-build pkg-config \
|
||||
libglib2.0-dev libpixman-1-dev libslirp-dev \
|
||||
libgcrypt20-dev \
|
||||
python3 python3-venv
|
||||
|
||||
- name: Clone and build Espressif QEMU
|
||||
if: steps.cache-qemu.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
git clone --depth 1 -b "$QEMU_BRANCH" "$QEMU_REPO" /tmp/qemu-esp
|
||||
cd /tmp/qemu-esp
|
||||
mkdir build && cd build
|
||||
../configure \
|
||||
--target-list=xtensa-softmmu \
|
||||
--prefix=/opt/qemu-esp32 \
|
||||
--enable-slirp \
|
||||
--disable-werror
|
||||
ninja -j$(nproc)
|
||||
ninja install
|
||||
|
||||
- name: Verify QEMU binary
|
||||
run: |
|
||||
file_size() { stat -c%s "$1" 2>/dev/null || stat -f%z "$1" 2>/dev/null || wc -c < "$1"; }
|
||||
/opt/qemu-esp32/bin/qemu-system-xtensa --version
|
||||
echo "QEMU binary size: $(file_size /opt/qemu-esp32/bin/qemu-system-xtensa) bytes"
|
||||
|
||||
- name: Upload QEMU artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: qemu-esp32
|
||||
path: /opt/qemu-esp32/
|
||||
retention-days: 7
|
||||
|
||||
qemu-test:
|
||||
name: QEMU Test (${{ matrix.nvs_config }})
|
||||
needs: build-qemu
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: espressif/idf:v5.4
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
nvs_config:
|
||||
- default
|
||||
- full-adr060
|
||||
- edge-tier0
|
||||
- edge-tier1
|
||||
- tdm-3node
|
||||
- boundary-max
|
||||
- boundary-min
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download QEMU artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: qemu-esp32
|
||||
path: /opt/qemu-esp32
|
||||
|
||||
- name: Make QEMU executable
|
||||
run: chmod +x /opt/qemu-esp32/bin/qemu-system-xtensa
|
||||
|
||||
- name: Verify QEMU works
|
||||
run: /opt/qemu-esp32/bin/qemu-system-xtensa --version
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
. $IDF_PATH/export.sh
|
||||
pip install esptool esp-idf-nvs-partition-gen
|
||||
|
||||
- name: Set target ESP32-S3
|
||||
working-directory: firmware/esp32-csi-node
|
||||
run: |
|
||||
. $IDF_PATH/export.sh
|
||||
idf.py set-target esp32s3
|
||||
|
||||
- name: Build firmware (mock CSI mode)
|
||||
working-directory: firmware/esp32-csi-node
|
||||
run: |
|
||||
. $IDF_PATH/export.sh
|
||||
idf.py \
|
||||
-D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.qemu" \
|
||||
build
|
||||
|
||||
- name: Generate NVS matrix
|
||||
run: |
|
||||
. $IDF_PATH/export.sh
|
||||
python3 scripts/generate_nvs_matrix.py \
|
||||
--output-dir firmware/esp32-csi-node/build/nvs_matrix \
|
||||
--only ${{ matrix.nvs_config }}
|
||||
|
||||
- name: Create merged flash image
|
||||
working-directory: firmware/esp32-csi-node
|
||||
run: |
|
||||
. $IDF_PATH/export.sh
|
||||
|
||||
# Determine merge_bin arguments
|
||||
OTA_ARGS=""
|
||||
if [ -f build/ota_data_initial.bin ]; then
|
||||
OTA_ARGS="0xf000 build/ota_data_initial.bin"
|
||||
fi
|
||||
|
||||
python3 -m esptool --chip esp32s3 merge_bin \
|
||||
-o build/qemu_flash.bin \
|
||||
--flash_mode dio --flash_freq 80m --flash_size 8MB \
|
||||
--fill-flash-size 8MB \
|
||||
0x0 build/bootloader/bootloader.bin \
|
||||
0x8000 build/partition_table/partition-table.bin \
|
||||
$OTA_ARGS \
|
||||
0x20000 build/esp32-csi-node.bin
|
||||
|
||||
file_size() { stat -c%s "$1" 2>/dev/null || stat -f%z "$1" 2>/dev/null || wc -c < "$1"; }
|
||||
echo "Flash image size: $(file_size build/qemu_flash.bin) bytes"
|
||||
|
||||
- name: Inject NVS partition
|
||||
if: matrix.nvs_config != 'default'
|
||||
working-directory: firmware/esp32-csi-node
|
||||
run: |
|
||||
NVS_BIN="build/nvs_matrix/nvs_${{ matrix.nvs_config }}.bin"
|
||||
if [ -f "$NVS_BIN" ]; then
|
||||
file_size() { stat -c%s "$1" 2>/dev/null || stat -f%z "$1" 2>/dev/null || wc -c < "$1"; }
|
||||
echo "Injecting NVS: $NVS_BIN ($(file_size "$NVS_BIN") bytes)"
|
||||
dd if="$NVS_BIN" of=build/qemu_flash.bin \
|
||||
bs=1 seek=$((0x9000)) conv=notrunc 2>/dev/null
|
||||
else
|
||||
echo "WARNING: NVS binary not found: $NVS_BIN"
|
||||
fi
|
||||
|
||||
- name: Run QEMU smoke test
|
||||
env:
|
||||
QEMU_PATH: /opt/qemu-esp32/bin/qemu-system-xtensa
|
||||
QEMU_TIMEOUT: "90"
|
||||
run: |
|
||||
echo "Starting QEMU (timeout: ${QEMU_TIMEOUT}s)..."
|
||||
|
||||
timeout "$QEMU_TIMEOUT" "$QEMU_PATH" \
|
||||
-machine esp32s3 \
|
||||
-nographic \
|
||||
-drive file=firmware/esp32-csi-node/build/qemu_flash.bin,if=mtd,format=raw \
|
||||
-serial mon:stdio \
|
||||
-nic user,model=open_eth,net=10.0.2.0/24 \
|
||||
-no-reboot \
|
||||
2>&1 | tee firmware/esp32-csi-node/build/qemu_output.log || true
|
||||
|
||||
echo "QEMU finished. Log size: $(wc -l < firmware/esp32-csi-node/build/qemu_output.log) lines"
|
||||
|
||||
- name: Validate QEMU output
|
||||
run: |
|
||||
python3 scripts/validate_qemu_output.py \
|
||||
firmware/esp32-csi-node/build/qemu_output.log
|
||||
|
||||
- name: Upload test logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: qemu-logs-${{ matrix.nvs_config }}
|
||||
path: |
|
||||
firmware/esp32-csi-node/build/qemu_output.log
|
||||
firmware/esp32-csi-node/build/nvs_matrix/
|
||||
retention-days: 14
|
||||
|
||||
fuzz-test:
|
||||
name: Fuzz Testing (ADR-061 Layer 6)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install clang
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y clang
|
||||
|
||||
- name: Build fuzz targets
|
||||
working-directory: firmware/esp32-csi-node/test
|
||||
run: make all CC=clang
|
||||
|
||||
- name: Run serialize fuzzer (60s)
|
||||
working-directory: firmware/esp32-csi-node/test
|
||||
run: make run_serialize FUZZ_DURATION=60 || echo "FUZZER_CRASH=serialize" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run edge enqueue fuzzer (60s)
|
||||
working-directory: firmware/esp32-csi-node/test
|
||||
run: make run_edge FUZZ_DURATION=60 || echo "FUZZER_CRASH=edge" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run NVS config fuzzer (60s)
|
||||
working-directory: firmware/esp32-csi-node/test
|
||||
run: make run_nvs FUZZ_DURATION=60 || echo "FUZZER_CRASH=nvs" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Check for crashes
|
||||
working-directory: firmware/esp32-csi-node/test
|
||||
run: |
|
||||
CRASHES=$(find . -type f \( -name "crash-*" -o -name "oom-*" -o -name "timeout-*" \) 2>/dev/null | wc -l)
|
||||
echo "Crash artifacts found: $CRASHES"
|
||||
if [ "$CRASHES" -gt 0 ] || [ -n "${FUZZER_CRASH:-}" ]; then
|
||||
echo "::error::Fuzzer found $CRASHES crash/oom/timeout artifacts. FUZZER_CRASH=${FUZZER_CRASH:-none}"
|
||||
ls -la crash-* oom-* timeout-* 2>/dev/null
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload fuzz artifacts
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: fuzz-crashes
|
||||
path: |
|
||||
firmware/esp32-csi-node/test/crash-*
|
||||
firmware/esp32-csi-node/test/oom-*
|
||||
firmware/esp32-csi-node/test/timeout-*
|
||||
retention-days: 30
|
||||
|
||||
nvs-matrix-validate:
|
||||
name: NVS Matrix Generation
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install NVS generator
|
||||
run: pip install esp-idf-nvs-partition-gen
|
||||
|
||||
- name: Generate all 14 NVS configs
|
||||
run: |
|
||||
python3 scripts/generate_nvs_matrix.py \
|
||||
--output-dir build/nvs_matrix
|
||||
|
||||
- name: Verify all binaries generated
|
||||
run: |
|
||||
EXPECTED=14
|
||||
ACTUAL=$(find build/nvs_matrix -type f -name "nvs_*.bin" 2>/dev/null | wc -l)
|
||||
echo "Generated $ACTUAL / $EXPECTED NVS binaries"
|
||||
ls -la build/nvs_matrix/
|
||||
|
||||
if [ "$ACTUAL" -lt "$EXPECTED" ]; then
|
||||
echo "::error::Only $ACTUAL of $EXPECTED NVS binaries generated"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Verify binary sizes
|
||||
run: |
|
||||
file_size() { stat -c%s "$1" 2>/dev/null || stat -f%z "$1" 2>/dev/null || wc -c < "$1"; }
|
||||
for f in build/nvs_matrix/nvs_*.bin; do
|
||||
SIZE=$(file_size "$f")
|
||||
if [ "$SIZE" -ne 24576 ]; then
|
||||
echo "::error::$f has unexpected size $SIZE (expected 24576)"
|
||||
exit 1
|
||||
fi
|
||||
echo " OK: $(basename $f) ($SIZE bytes)"
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ADR-062: QEMU Swarm Configurator Test
|
||||
#
|
||||
# Runs a lightweight 3-node swarm (ci_matrix preset) under QEMU to validate
|
||||
# multi-node orchestration, TDM slot coordination, and swarm-level health
|
||||
# assertions. Uses the pre-built QEMU binary from the build-qemu job and the
|
||||
# firmware built by qemu-test.
|
||||
#
|
||||
# The CI runner is non-root, so TAP bridge networking is unavailable.
|
||||
# The orchestrator (qemu_swarm.py) detects this and falls back to SLIRP
|
||||
# user-mode networking, which is sufficient for the ci_matrix preset.
|
||||
# ---------------------------------------------------------------------------
|
||||
swarm-test:
|
||||
name: Swarm Test (ADR-062)
|
||||
needs: [build-qemu]
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: espressif/idf:v5.4
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download QEMU artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: qemu-esp32
|
||||
path: /opt/qemu-esp32
|
||||
|
||||
- name: Make QEMU executable
|
||||
run: chmod +x /opt/qemu-esp32/bin/qemu-system-xtensa
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
. $IDF_PATH/export.sh
|
||||
pip install pyyaml esptool esp-idf-nvs-partition-gen
|
||||
|
||||
- name: Build firmware for swarm
|
||||
working-directory: firmware/esp32-csi-node
|
||||
run: |
|
||||
. $IDF_PATH/export.sh
|
||||
idf.py set-target esp32s3
|
||||
idf.py -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.qemu" build
|
||||
python3 -m esptool --chip esp32s3 merge_bin \
|
||||
-o build/qemu_flash.bin \
|
||||
--flash_mode dio --flash_freq 80m --flash_size 8MB \
|
||||
--fill-flash-size 8MB \
|
||||
0x0 build/bootloader/bootloader.bin \
|
||||
0x8000 build/partition_table/partition-table.bin \
|
||||
0x20000 build/esp32-csi-node.bin
|
||||
|
||||
- name: Run swarm smoke test
|
||||
run: |
|
||||
. $IDF_PATH/export.sh
|
||||
EXIT_CODE=0
|
||||
python3 scripts/qemu_swarm.py --preset ci_matrix \
|
||||
--qemu-path /opt/qemu-esp32/bin/qemu-system-xtensa \
|
||||
--output-dir build/swarm-results || EXIT_CODE=$?
|
||||
# Exit 0=PASS, 1=WARN (acceptable in CI without real hardware)
|
||||
if [ "$EXIT_CODE" -gt 1 ]; then
|
||||
echo "Swarm test failed with exit code $EXIT_CODE"
|
||||
exit "$EXIT_CODE"
|
||||
fi
|
||||
timeout-minutes: 10
|
||||
|
||||
- name: Upload swarm results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: swarm-results
|
||||
path: |
|
||||
build/swarm-results/
|
||||
retention-days: 14
|
||||
@@ -1,54 +0,0 @@
|
||||
name: Fix-Marker Regression Guard
|
||||
|
||||
# Asserts that previously-shipped fixes are still present in the tree.
|
||||
# Manifest: scripts/fix-markers.json Checker: scripts/check_fix_markers.py
|
||||
# Run locally: python scripts/check_fix_markers.py (also --list / --json)
|
||||
#
|
||||
# This complements the heavyweight checks (firmware build, deterministic
|
||||
# pipeline proof, witness bundle) with a fast per-PR "did someone revert a
|
||||
# known fix?" gate — the CI analogue of the ruflo witness fix-marker system.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
fix-markers:
|
||||
name: Verify fix markers
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Validate the manifest is well-formed JSON
|
||||
run: python -c "import json; json.load(open('scripts/fix-markers.json')); print('manifest OK')"
|
||||
|
||||
- name: Check fix markers
|
||||
run: python scripts/check_fix_markers.py
|
||||
|
||||
- name: Emit machine-readable result (for the run summary)
|
||||
if: always()
|
||||
run: |
|
||||
python scripts/check_fix_markers.py --json > fix-markers-result.json || true
|
||||
{
|
||||
echo '### Fix-marker regression guard'
|
||||
echo ''
|
||||
echo '```'
|
||||
python scripts/check_fix_markers.py || true
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload result artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: fix-markers-result
|
||||
path: fix-markers-result.json
|
||||
retention-days: 30
|
||||
@@ -1,110 +0,0 @@
|
||||
name: ADR-115 MQTT integration tests
|
||||
|
||||
# Runs the Mosquitto-broker-backed integration tests for ADR-115's MQTT
|
||||
# publisher. These prove the publisher reaches a real broker, emits the
|
||||
# expected HA-discovery topic shape, and honours --privacy-mode at the
|
||||
# wire boundary (not just in unit-test logic).
|
||||
#
|
||||
# Default `cargo test --workspace` does not run these tests because they
|
||||
# require a broker and pull rumqttc into the build. This workflow opts
|
||||
# into both by setting --features mqtt and RUVIEW_RUN_INTEGRATION=1.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'v2/crates/wifi-densepose-sensing-server/src/mqtt/**'
|
||||
- 'v2/crates/wifi-densepose-sensing-server/tests/mqtt_integration.rs'
|
||||
- 'v2/crates/wifi-densepose-sensing-server/Cargo.toml'
|
||||
- '.github/workflows/mqtt-integration.yml'
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'v2/crates/wifi-densepose-sensing-server/src/mqtt/**'
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
mqtt-integration:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
# NB: we don't use a `services:` mosquitto container here because the
|
||||
# eclipse-mosquitto:2.x image rejects anonymous connections by default
|
||||
# and GH Actions `services` doesn't easily support mounting a custom
|
||||
# config file. We start mosquitto manually in a step below with an
|
||||
# inline `allow_anonymous true` config.
|
||||
|
||||
env:
|
||||
RUVIEW_RUN_INTEGRATION: "1"
|
||||
RUVIEW_TEST_MQTT_PORT: "11883"
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: 1
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install mosquitto + clients and start with allow_anonymous
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y mosquitto mosquitto-clients
|
||||
sudo systemctl stop mosquitto || true
|
||||
# Inline config: anon listener on 11883 only — no TLS, no auth,
|
||||
# OK for CI because we test the wire shape, not security.
|
||||
# Production deployments enable mTLS per ADR-115 §3.9.
|
||||
cat > /tmp/mosquitto-ci.conf <<'EOF'
|
||||
listener 11883
|
||||
allow_anonymous true
|
||||
persistence false
|
||||
log_dest stdout
|
||||
EOF
|
||||
mosquitto -c /tmp/mosquitto-ci.conf -d
|
||||
for i in {1..20}; do
|
||||
if mosquitto_pub -h 127.0.0.1 -p 11883 -t healthcheck -m ok -q 0 2>/dev/null; then
|
||||
echo "mosquitto reachable on 11883"; exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "mosquitto never became reachable" >&2
|
||||
tail -50 /var/log/mosquitto/*.log 2>/dev/null || true
|
||||
exit 1
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- name: Cache cargo registry + build
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: v2 -> target
|
||||
|
||||
- name: Validate HA Blueprints
|
||||
run: |
|
||||
python -m pip install --quiet pyyaml
|
||||
python scripts/validate-ha-blueprints.py
|
||||
|
||||
- name: Verify unit tests still pass under --features mqtt
|
||||
working-directory: v2
|
||||
# `cargo test` accepts a single TESTNAME filter, so we run the
|
||||
# whole --lib suite here. That gives us the full 410-test green
|
||||
# bar under --features mqtt (which is more reassuring than
|
||||
# filtering anyway).
|
||||
run: >-
|
||||
cargo test -p wifi-densepose-sensing-server
|
||||
--features mqtt --no-default-features
|
||||
--lib
|
||||
--no-fail-fast
|
||||
|
||||
- name: Run integration tests against mosquitto
|
||||
working-directory: v2
|
||||
run: >-
|
||||
cargo test -p wifi-densepose-sensing-server
|
||||
--features mqtt --no-default-features
|
||||
--test mqtt_integration
|
||||
--no-fail-fast
|
||||
-- --test-threads=1 --nocapture
|
||||
|
||||
- name: Dump broker logs on failure
|
||||
if: failure()
|
||||
run: |
|
||||
docker ps -a
|
||||
docker logs $(docker ps -aqf "ancestor=eclipse-mosquitto:2.0.18") || true
|
||||
@@ -1,69 +0,0 @@
|
||||
name: nvsim-server → ghcr.io
|
||||
|
||||
# Builds and publishes the nvsim-server Docker image to ghcr.io on:
|
||||
# - push to main affecting nvsim-server or nvsim
|
||||
# - tag push matching nvsim-server-v*
|
||||
# - manual workflow_dispatch
|
||||
#
|
||||
# ADR-092 §6.2 + §9.4.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'v2/crates/nvsim-server/**'
|
||||
- 'v2/crates/nvsim/**'
|
||||
- '.github/workflows/nvsim-server-docker.yml'
|
||||
tags: ['nvsim-server-v*']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build-and-publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ghcr.io/ruvnet/nvsim-server
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=sha,format=short
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build + push
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: v2
|
||||
file: v2/crates/nvsim-server/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Smoke-test the image
|
||||
run: |
|
||||
docker pull ghcr.io/ruvnet/nvsim-server:sha-${GITHUB_SHA::7} || \
|
||||
docker pull ghcr.io/ruvnet/nvsim-server:latest
|
||||
docker run --rm -d --name nvsim-test -p 7878:7878 \
|
||||
ghcr.io/ruvnet/nvsim-server:latest
|
||||
sleep 4
|
||||
curl -fsS http://localhost:7878/api/health
|
||||
docker stop nvsim-test
|
||||
@@ -1,286 +0,0 @@
|
||||
# ADR-117 P5 — cibuildwheel + PyPI publish workflow for `wifi-densepose`
|
||||
#
|
||||
# This workflow is **explicitly NOT** triggered on every push. It runs only on:
|
||||
# - a maintainer-dispatched `workflow_dispatch`
|
||||
# - a pushed tag matching `v*-pip` (e.g. `v2.0.0-pip`)
|
||||
#
|
||||
# The reason for the `-pip` tag suffix is that the repo already cuts
|
||||
# `v0.X.Y-esp32` tags for firmware releases (see CLAUDE.md). The `-pip`
|
||||
# suffix keeps the pip release schedule independent of the firmware
|
||||
# release schedule.
|
||||
#
|
||||
# Sequencing on release day (per ADR-117 §7.3):
|
||||
# 1. cut tag `v1.99.0-pip` → publishes the tombstone wheel first
|
||||
# 2. cut tag `v2.0.0-pip` → publishes the PyO3 v2 wheel matrix
|
||||
#
|
||||
# Publishes via the `PYPI_API_TOKEN` GitHub Actions secret. The
|
||||
# token-refresh runbook (GCP Secret Manager → gh secret set) lives in
|
||||
# docs/integrations/pypi-release.md so KICS does not flag the
|
||||
# secret name as a generic-secret literal in the workflow.
|
||||
#
|
||||
# Q3 (witness hash v2 — open in ADR-117 §11.3) MUST be resolved
|
||||
# before the first v2.0.0 publish. When v2 lands, add a parallel
|
||||
# step that verifies the v2 hash against the Rust pipeline.
|
||||
|
||||
name: pip-release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
target:
|
||||
description: "Which package to release"
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- v2-wheels
|
||||
- v1-99-tombstone
|
||||
publish_to:
|
||||
description: "Where to publish"
|
||||
required: true
|
||||
default: testpypi
|
||||
type: choice
|
||||
options:
|
||||
- testpypi # dry-run target
|
||||
- pypi # production
|
||||
push:
|
||||
tags:
|
||||
- "v*-pip"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# v2.0.0 — cibuildwheel matrix (5 wheels + sdist)
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
build-wheels:
|
||||
name: Build ${{ matrix.os }} ${{ matrix.arch }}
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' && inputs.target == 'v2-wheels' ||
|
||||
startsWith(github.ref, 'refs/tags/v2.')
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
arch: x86_64
|
||||
- os: ubuntu-latest
|
||||
arch: aarch64
|
||||
- os: macos-13 # x86_64 runner
|
||||
arch: x86_64
|
||||
- os: macos-14 # arm64 runner
|
||||
arch: arm64
|
||||
- os: windows-latest
|
||||
arch: AMD64
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Linux aarch64 needs QEMU for cross-build on x86_64 runners.
|
||||
- name: Set up QEMU
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.arch == 'aarch64'
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
# ADR-117 §5.4: abi3-py310 — one binary per OS/arch covers all
|
||||
# Python minor versions ≥ 3.10. Build only cp310 wheels.
|
||||
- name: Build wheels (cibuildwheel)
|
||||
uses: pypa/cibuildwheel@v2.21
|
||||
env:
|
||||
CIBW_BUILD: "cp310-*"
|
||||
CIBW_ARCHS_LINUX: ${{ matrix.arch }}
|
||||
CIBW_ARCHS_MACOS: ${{ matrix.arch }}
|
||||
CIBW_ARCHS_WINDOWS: ${{ matrix.arch }}
|
||||
CIBW_BUILD_FRONTEND: "build"
|
||||
CIBW_BEFORE_BUILD: "pip install maturin>=1.7"
|
||||
# The PyO3 sdist landing depends on the cargo/Rust toolchain
|
||||
# being present. cibuildwheel images carry rustup on Linux
|
||||
# but we also pin a known-good version for reproducibility.
|
||||
CIBW_BEFORE_ALL_LINUX: "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain 1.82"
|
||||
CIBW_ENVIRONMENT_LINUX: 'PATH="$HOME/.cargo/bin:$PATH"'
|
||||
# Smoke-test every built wheel before accepting it. Catches
|
||||
# the case where the wheel imports but the compiled symbols
|
||||
# are missing.
|
||||
CIBW_TEST_REQUIRES: "pytest>=8.0"
|
||||
CIBW_TEST_COMMAND: 'python -c "import wifi_densepose; assert wifi_densepose.hello() == \"ok\"; print(wifi_densepose.__build_features__)"'
|
||||
with:
|
||||
package-dir: python
|
||||
output-dir: wheelhouse
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: wheels-${{ matrix.os }}-${{ matrix.arch }}
|
||||
path: wheelhouse/*.whl
|
||||
if-no-files-found: error
|
||||
|
||||
build-sdist:
|
||||
name: Build v2 sdist
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' && inputs.target == 'v2-wheels' ||
|
||||
startsWith(github.ref, 'refs/tags/v2.')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install maturin
|
||||
run: pip install maturin>=1.7
|
||||
- name: Build sdist
|
||||
working-directory: python
|
||||
run: maturin sdist --out ../sdist
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: sdist
|
||||
path: sdist/*.tar.gz
|
||||
if-no-files-found: error
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# v1.99.0 — tombstone wheel (pure Python, single sdist + wheel)
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
build-tombstone:
|
||||
name: Build v1.99.0 tombstone
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' && inputs.target == 'v1-99-tombstone' ||
|
||||
startsWith(github.ref, 'refs/tags/v1.99')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Install build backend
|
||||
run: python -m pip install --upgrade pip build>=1.2
|
||||
- name: Build sdist + wheel
|
||||
working-directory: python/tombstone
|
||||
run: python -m build --outdir ../../tombstone-dist
|
||||
# Inspect what was actually built — the previous v1.99.0-pip run
|
||||
# showed an `import wifi_densepose` that returned cleanly instead
|
||||
# of raising, even though build logs said `adding 'wifi_densepose/__init__.py'`.
|
||||
# Print the wheel manifest + the __init__.py content so any
|
||||
# future regression is debuggable from the run log alone.
|
||||
- name: Inspect wheel contents
|
||||
run: |
|
||||
set -e
|
||||
WHL=tombstone-dist/wifi_densepose-1.99.0-py3-none-any.whl
|
||||
echo "--- wheel listing ---"
|
||||
python -m zipfile -l "$WHL"
|
||||
echo "--- wifi_densepose/__init__.py inside the wheel ---"
|
||||
python -m zipfile -e "$WHL" /tmp/tomb-inspect
|
||||
cat /tmp/tomb-inspect/wifi_densepose/__init__.py
|
||||
echo "--- size in bytes ---"
|
||||
wc -c /tmp/tomb-inspect/wifi_densepose/__init__.py
|
||||
# Smoke-test in an ISOLATED venv. The previous run's failure
|
||||
# mode was that the ubuntu-latest runner's system `python` had
|
||||
# site-packages picking up something other than the user-installed
|
||||
# wheel, so the import resolved to a different module. A clean
|
||||
# venv removes any ambiguity about which wifi_densepose is loaded.
|
||||
- name: Smoke-test tombstone in isolated venv
|
||||
run: |
|
||||
set -e
|
||||
# Copy the wheel to /tmp BEFORE entering the venv — we must
|
||||
# cd OUT of the repo root because the repo contains a
|
||||
# `wifi_densepose/` directory left over from the legacy v1
|
||||
# source. Python puts cwd at sys.path[0], so an import from
|
||||
# the repo root would resolve to the legacy directory and
|
||||
# bypass the freshly-installed wheel entirely (this was the
|
||||
# silent failure mode of the previous two run attempts).
|
||||
cp tombstone-dist/wifi_densepose-1.99.0-py3-none-any.whl /tmp/
|
||||
python -m venv /tmp/smoke-venv
|
||||
/tmp/smoke-venv/bin/python -m pip install --upgrade pip
|
||||
/tmp/smoke-venv/bin/python -m pip install /tmp/wifi_densepose-1.99.0-py3-none-any.whl
|
||||
cd /tmp # away from the repo root's stray wifi_densepose/
|
||||
/tmp/smoke-venv/bin/python -c "import importlib.util as u; s = u.find_spec('wifi_densepose'); print('Resolved to:', s.origin); print('--- file content ---'); print(open(s.origin).read())"
|
||||
set +e
|
||||
/tmp/smoke-venv/bin/python -c "import wifi_densepose" 2> import-output.txt
|
||||
rc=$?
|
||||
set -e
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
echo "ERROR: tombstone import succeeded — should have raised ImportError"
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q "github.com/ruvnet/RuView" import-output.txt; then
|
||||
echo "ERROR: tombstone ImportError missing migration URL"
|
||||
cat import-output.txt
|
||||
exit 1
|
||||
fi
|
||||
echo "Tombstone wheel correctly raises ImportError with migration URL."
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: tombstone
|
||||
path: tombstone-dist/*
|
||||
if-no-files-found: error
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# Publish — gated by manual dispatch OR by the tag form
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
publish-v2:
|
||||
name: Publish v2 wheels
|
||||
needs: [build-wheels, build-sdist]
|
||||
if: |
|
||||
always() &&
|
||||
needs.build-wheels.result == 'success' &&
|
||||
needs.build-sdist.result == 'success' &&
|
||||
(
|
||||
github.event_name == 'workflow_dispatch' && inputs.target == 'v2-wheels' ||
|
||||
startsWith(github.ref, 'refs/tags/v2.')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Gather all artifacts into dist/
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist-staging
|
||||
- name: Flatten artifacts
|
||||
run: |
|
||||
mkdir -p dist
|
||||
find dist-staging -type f \( -name '*.whl' -o -name '*.tar.gz' \) -exec cp -v {} dist/ \;
|
||||
ls -lh dist/
|
||||
- name: Publish to TestPyPI (dry-run target)
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.publish_to == 'testpypi'
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
repository-url: https://test.pypi.org/legacy/
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
||||
packages-dir: dist
|
||||
skip-existing: true
|
||||
- name: Publish to PyPI
|
||||
if: |
|
||||
startsWith(github.ref, 'refs/tags/v2.') ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.publish_to == 'pypi')
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
||||
packages-dir: dist
|
||||
|
||||
publish-tombstone:
|
||||
name: Publish v1.99 tombstone
|
||||
needs: [build-tombstone]
|
||||
if: |
|
||||
always() &&
|
||||
needs.build-tombstone.result == 'success' &&
|
||||
(
|
||||
github.event_name == 'workflow_dispatch' && inputs.target == 'v1-99-tombstone' ||
|
||||
startsWith(github.ref, 'refs/tags/v1.99')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: tombstone
|
||||
path: dist
|
||||
- name: Publish to TestPyPI (dry-run target)
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.publish_to == 'testpypi'
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
repository-url: https://test.pypi.org/legacy/
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
||||
packages-dir: dist
|
||||
skip-existing: true
|
||||
- name: Publish to PyPI
|
||||
if: |
|
||||
startsWith(github.ref, 'refs/tags/v1.99') ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.publish_to == 'pypi')
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
||||
packages-dir: dist
|
||||
@@ -1,74 +0,0 @@
|
||||
name: Point Cloud Viewer → GitHub Pages
|
||||
|
||||
# Publishes the live 3D point cloud viewer to gh-pages/pointcloud/.
|
||||
# The viewer defaults to a synthetic in-browser demo; users can append
|
||||
# ?backend=<url> or ?backend=auto to point it at a real ruview-pointcloud
|
||||
# server (CORS-permitting host required). See ADR-094.
|
||||
#
|
||||
# Uses keep_files: true to preserve the existing observatory/, pose-fusion/,
|
||||
# nvsim/, and root index.html demos already on gh-pages.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'v2/crates/wifi-densepose-pointcloud/src/viewer.html'
|
||||
- '.github/workflows/pointcloud-pages.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: pointcloud-pages
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Stage viewer for Pages
|
||||
run: |
|
||||
mkdir -p _site/pointcloud
|
||||
cp v2/crates/wifi-densepose-pointcloud/src/viewer.html _site/pointcloud/index.html
|
||||
# Drop a tiny README so direct browsers of the directory get context.
|
||||
cat > _site/pointcloud/README.md <<'EOF'
|
||||
# RuView — Live 3D Point Cloud Viewer
|
||||
|
||||
Hosted at: https://ruvnet.github.io/RuView/pointcloud/
|
||||
|
||||
## Modes
|
||||
|
||||
- Default — synthetic in-browser demo (no backend, no network calls).
|
||||
- `?backend=auto` — fetch from `/api/splats` on the same origin
|
||||
(only works when the viewer is served by `ruview-pointcloud serve`).
|
||||
- `?backend=<url>` — fetch from `<url>/api/splats`. The intended
|
||||
local-ESP32 use is `?backend=http://127.0.0.1:9880`: run
|
||||
`ruview-pointcloud serve --bind 127.0.0.1:9880` on the same
|
||||
machine with your ESP32 streaming CSI to UDP port 3333, then
|
||||
visit the URL above. The local server's CorsLayer permits
|
||||
requests from `https://ruvnet.github.io`, and modern browsers
|
||||
permit HTTPS→127.0.0.1 mixed-content as a trustworthy origin.
|
||||
The "📡 Connect ESP32" button in the viewer prompts for this
|
||||
URL and persists it in localStorage.
|
||||
- `?live=1` — require a live backend; show an offline message instead
|
||||
of falling back to the synthetic demo.
|
||||
|
||||
See ADR-094 for the deployment design.
|
||||
EOF
|
||||
|
||||
- name: Deploy to gh-pages/pointcloud/
|
||||
uses: peaceiris/actions-gh-pages@v4
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./_site/pointcloud
|
||||
destination_dir: pointcloud
|
||||
# CRITICAL: preserves observatory/, pose-fusion/, nvsim/, and root
|
||||
# index.html already on gh-pages.
|
||||
keep_files: true
|
||||
commit_message: 'deploy(pointcloud): ${{ github.sha }}'
|
||||
user_name: 'github-actions[bot]'
|
||||
user_email: 'github-actions[bot]@users.noreply.github.com'
|
||||
@@ -18,27 +18,23 @@ jobs:
|
||||
sast:
|
||||
name: Static Application Security Testing
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true # third-party scanners are flaky / SARIF uploads can 403; don't gate the PR
|
||||
permissions:
|
||||
security-events: write
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
continue-on-error: true
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
continue-on-error: true
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install dependencies
|
||||
continue-on-error: true
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
@@ -50,7 +46,6 @@ jobs:
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload Bandit results to GitHub Security
|
||||
continue-on-error: true
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
@@ -58,7 +53,6 @@ jobs:
|
||||
category: bandit
|
||||
|
||||
- name: Run Semgrep security scan
|
||||
continue-on-error: true
|
||||
uses: returntocorp/semgrep-action@v1
|
||||
with:
|
||||
config: >-
|
||||
@@ -76,7 +70,6 @@ jobs:
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload Semgrep results to GitHub Security
|
||||
continue-on-error: true
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
@@ -87,25 +80,21 @@ jobs:
|
||||
dependency-scan:
|
||||
name: Dependency Vulnerability Scan
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true # third-party scanners are flaky / SARIF uploads can 403; don't gate the PR
|
||||
permissions:
|
||||
security-events: write
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
continue-on-error: true
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
continue-on-error: true
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install dependencies
|
||||
continue-on-error: true
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
@@ -122,7 +111,7 @@ jobs:
|
||||
continue-on-error: true
|
||||
|
||||
- name: Run Snyk vulnerability scan
|
||||
uses: snyk/actions/python@9adf32b1121593767fc3c057af55b55db032dc04 # v1.0.0
|
||||
uses: snyk/actions/python@master
|
||||
env:
|
||||
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
|
||||
with:
|
||||
@@ -130,7 +119,6 @@ jobs:
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload Snyk results to GitHub Security
|
||||
continue-on-error: true
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
@@ -138,7 +126,6 @@ jobs:
|
||||
category: snyk
|
||||
|
||||
- name: Upload vulnerability reports
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
@@ -152,7 +139,6 @@ jobs:
|
||||
container-scan:
|
||||
name: Container Security Scan
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true # third-party scanners are flaky / SARIF uploads can 403; don't gate the PR
|
||||
needs: []
|
||||
if: github.event_name == 'push' || github.event_name == 'schedule'
|
||||
permissions:
|
||||
@@ -161,16 +147,13 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
continue-on-error: true
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
continue-on-error: true
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build Docker image for scanning
|
||||
continue-on-error: true
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
target: production
|
||||
@@ -180,15 +163,13 @@ jobs:
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
continue-on-error: true
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
image-ref: 'wifi-densepose:scan'
|
||||
format: 'sarif'
|
||||
output: 'trivy-results.sarif'
|
||||
|
||||
- name: Upload Trivy results to GitHub Security
|
||||
continue-on-error: true
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
@@ -196,8 +177,7 @@ jobs:
|
||||
category: trivy
|
||||
|
||||
- name: Run Grype vulnerability scanner
|
||||
continue-on-error: true
|
||||
uses: anchore/scan-action@v7
|
||||
uses: anchore/scan-action@v3
|
||||
id: grype-scan
|
||||
with:
|
||||
image: 'wifi-densepose:scan'
|
||||
@@ -206,7 +186,6 @@ jobs:
|
||||
output-format: sarif
|
||||
|
||||
- name: Upload Grype results to GitHub Security
|
||||
continue-on-error: true
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
@@ -214,7 +193,6 @@ jobs:
|
||||
category: grype
|
||||
|
||||
- name: Run Docker Scout
|
||||
continue-on-error: true
|
||||
uses: docker/scout-action@v1
|
||||
if: always()
|
||||
with:
|
||||
@@ -224,7 +202,6 @@ jobs:
|
||||
summary: true
|
||||
|
||||
- name: Upload Docker Scout results
|
||||
continue-on-error: true
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
@@ -235,19 +212,16 @@ jobs:
|
||||
iac-scan:
|
||||
name: Infrastructure Security Scan
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true # third-party scanners are flaky / SARIF uploads can 403; don't gate the PR
|
||||
permissions:
|
||||
security-events: write
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
continue-on-error: true
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run Checkov IaC scan
|
||||
continue-on-error: true
|
||||
uses: bridgecrewio/checkov-action@99bb2caf247dfd9f03cf984373bc6043d4e32ebf # v12.1347.0
|
||||
uses: bridgecrewio/checkov-action@master
|
||||
with:
|
||||
directory: .
|
||||
framework: kubernetes,dockerfile,terraform,ansible
|
||||
@@ -257,7 +231,6 @@ jobs:
|
||||
soft_fail: true
|
||||
|
||||
- name: Upload Checkov results to GitHub Security
|
||||
continue-on-error: true
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
@@ -265,8 +238,7 @@ jobs:
|
||||
category: checkov
|
||||
|
||||
- name: Run Terrascan IaC scan
|
||||
continue-on-error: true
|
||||
uses: tenable/terrascan-action@3a6e87da8e244513bd77b631e624552643f794c6 # v1.4.1
|
||||
uses: tenable/terrascan-action@main
|
||||
with:
|
||||
iac_type: 'k8s'
|
||||
iac_version: 'v1'
|
||||
@@ -275,8 +247,7 @@ jobs:
|
||||
sarif_upload: true
|
||||
|
||||
- name: Run KICS IaC scan
|
||||
continue-on-error: true
|
||||
uses: checkmarx/kics-github-action@05aa5eb70eede1355220f4ca5238d96b397e30a6 # v2.1.20
|
||||
uses: checkmarx/kics-github-action@master
|
||||
with:
|
||||
path: '.'
|
||||
output_path: kics-results
|
||||
@@ -285,7 +256,6 @@ jobs:
|
||||
exclude_queries: 'a7ef1e8c-fbf8-4ac1-b8c7-2c3b0e6c6c6c'
|
||||
|
||||
- name: Upload KICS results to GitHub Security
|
||||
continue-on-error: true
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
@@ -296,21 +266,18 @@ jobs:
|
||||
secret-scan:
|
||||
name: Secret Scanning
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true # third-party scanners are flaky / SARIF uploads can 403; don't gate the PR
|
||||
permissions:
|
||||
security-events: write
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
continue-on-error: true
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Run TruffleHog secret scan
|
||||
continue-on-error: true
|
||||
uses: trufflesecurity/trufflehog@17456f8c7d042d8c82c9a8ca9e937231f9f42e26 # v3.95.2
|
||||
uses: trufflesecurity/trufflehog@main
|
||||
with:
|
||||
path: ./
|
||||
base: main
|
||||
@@ -318,7 +285,6 @@ jobs:
|
||||
extra_args: --debug --only-verified
|
||||
|
||||
- name: Run GitLeaks secret scan
|
||||
continue-on-error: true
|
||||
uses: gitleaks/gitleaks-action@v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -335,34 +301,28 @@ jobs:
|
||||
license-scan:
|
||||
name: License Compliance Scan
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true # third-party scanners are flaky / SARIF uploads can 403; don't gate the PR
|
||||
steps:
|
||||
- name: Checkout code
|
||||
continue-on-error: true
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
continue-on-error: true
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install dependencies
|
||||
continue-on-error: true
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install pip-licenses licensecheck
|
||||
|
||||
- name: Run license check
|
||||
continue-on-error: true
|
||||
run: |
|
||||
pip-licenses --format=json --output-file=licenses.json
|
||||
licensecheck --zero
|
||||
|
||||
- name: Upload license report
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: license-report
|
||||
@@ -372,14 +332,11 @@ jobs:
|
||||
compliance-check:
|
||||
name: Security Policy Compliance
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true # third-party scanners are flaky / SARIF uploads can 403; don't gate the PR
|
||||
steps:
|
||||
- name: Checkout code
|
||||
continue-on-error: true
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check security policy files
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# Check for required security files
|
||||
files=("SECURITY.md" ".github/SECURITY.md" "docs/SECURITY.md")
|
||||
@@ -397,13 +354,11 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Check for security headers in code
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# Check for security-related configurations
|
||||
grep -r "X-Frame-Options\|X-Content-Type-Options\|X-XSS-Protection\|Content-Security-Policy" src/ || echo "⚠️ Consider adding security headers"
|
||||
|
||||
- name: Validate Kubernetes security contexts
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# Check for security contexts in Kubernetes manifests
|
||||
if [[ -d "k8s" ]]; then
|
||||
@@ -420,21 +375,13 @@ jobs:
|
||||
security-report:
|
||||
name: Security Report
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true # third-party scanners are flaky / SARIF uploads can 403; don't gate the PR
|
||||
needs: [sast, dependency-scan, container-scan, iac-scan, secret-scan, license-scan, compliance-check]
|
||||
if: always()
|
||||
# Promote secret to env-scope so the gating `if:` on the Slack-notify
|
||||
# step below is parseable (GitHub Actions rejects `secrets.X` in
|
||||
# step-level `if:` expressions).
|
||||
env:
|
||||
SECURITY_SLACK_WEBHOOK_URL: ${{ secrets.SECURITY_SLACK_WEBHOOK_URL }}
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v4
|
||||
|
||||
- name: Generate security summary
|
||||
continue-on-error: true
|
||||
run: |
|
||||
echo "# Security Scan Summary" > security-summary.md
|
||||
echo "" >> security-summary.md
|
||||
@@ -450,18 +397,13 @@ jobs:
|
||||
echo "Generated on: $(date)" >> security-summary.md
|
||||
|
||||
- name: Upload security summary
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: security-summary
|
||||
path: security-summary.md
|
||||
|
||||
# GitHub Actions does not allow `secrets.X` in step-level `if:` —
|
||||
# use env.X instead. Inherits SECURITY_SLACK_WEBHOOK_URL from the
|
||||
# job-level env block (added below).
|
||||
- name: Notify security team on critical findings
|
||||
continue-on-error: true
|
||||
if: ${{ env.SECURITY_SLACK_WEBHOOK_URL != '' && (needs.sast.result == 'failure' || needs.dependency-scan.result == 'failure' || needs.container-scan.result == 'failure') }}
|
||||
if: ${{ secrets.SECURITY_SLACK_WEBHOOK_URL != '' && (needs.sast.result == 'failure' || needs.dependency-scan.result == 'failure' || needs.container-scan.result == 'failure') }}
|
||||
uses: 8398a7/action-slack@v3
|
||||
with:
|
||||
status: failure
|
||||
@@ -473,10 +415,9 @@ jobs:
|
||||
Workflow: ${{ github.workflow }}
|
||||
Please review the security scan results immediately.
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ env.SECURITY_SLACK_WEBHOOK_URL }}
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SECURITY_SLACK_WEBHOOK_URL }}
|
||||
|
||||
- name: Create security issue on critical findings
|
||||
continue-on-error: true
|
||||
if: needs.sast.result == 'failure' || needs.dependency-scan.result == 'failure'
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
name: wifi-densepose sensing-server → Docker Hub + ghcr.io
|
||||
|
||||
# Build + publish the `wifi-densepose` sensing-server image to both Docker Hub
|
||||
# (`ruvnet/wifi-densepose`) and ghcr.io (`ghcr.io/ruvnet/wifi-densepose`) on:
|
||||
# - push to main affecting the Dockerfile, the server crate, the UI assets,
|
||||
# or this workflow itself,
|
||||
# - tag push matching v* (release builds),
|
||||
# - manual workflow_dispatch.
|
||||
#
|
||||
# Closes #520 and #514: the stale `:latest` is rebuilt and pushed automatically
|
||||
# whenever the surface that produces it changes, and the Dockerfile fails the
|
||||
# build if the observatory/pose-fusion UI assets ever go missing again.
|
||||
#
|
||||
# Secrets:
|
||||
# DOCKERHUB_USERNAME — `ruvnet` (Docker Hub login name)
|
||||
# DOCKERHUB_TOKEN — Docker Hub access token with read/write/delete scope
|
||||
# (ghcr.io uses the workflow's GITHUB_TOKEN — no secret needed.)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'docker/Dockerfile.rust'
|
||||
- 'docker/docker-entrypoint.sh'
|
||||
- 'v2/crates/wifi-densepose-sensing-server/**'
|
||||
- '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/**'
|
||||
- '.github/workflows/sensing-server-docker.yml'
|
||||
tags: ['v*']
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
group: sensing-server-docker-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-publish:
|
||||
name: build · push · smoke-test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
# QEMU is required so the amd64 GitHub runner can cross-build the
|
||||
# linux/arm64 layer below (Dockerfile.rust is arch-agnostic — no `--target`
|
||||
# flag — so buildx + QEMU is all that's needed; arm64 builds are emulated
|
||||
# by the runner, not built on a separate arm64 host).
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
# 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
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Compute tags
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
docker.io/ruvnet/wifi-densepose
|
||||
ghcr.io/ruvnet/wifi-densepose
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=sha,format=short
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build + push
|
||||
id: build
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile.rust
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
# README badge advertises `amd64 + arm64`, and #547 promised multi-arch
|
||||
# as part of the docker publish refresh; arm64 was never actually wired
|
||||
# in, so Apple Silicon Macs hit `no matching manifest for linux/arm64/v8`
|
||||
# on `docker pull ruvnet/wifi-densepose:latest` (#136, #625). Build both.
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Smoke-test the freshly-pushed image:
|
||||
# 1. UI assets that closed #520 are inside `/app/ui` (the Dockerfile's
|
||||
# RUN guard catches missing ones at build time, this re-checks the
|
||||
# pushed artifact post-hoc as belt-and-braces).
|
||||
# 2. /health is up.
|
||||
# 3. /api/v1/info returns 200 with no auth (LAN-mode default).
|
||||
# 4. With RUVIEW_API_TOKEN set, /api/v1/info returns 401 without a
|
||||
# Bearer header, 200 with the correct one (the #443 auth middleware).
|
||||
# ---------------------------------------------------------------------
|
||||
- name: Smoke-test image assets + LAN-mode HTTP
|
||||
run: |
|
||||
set -euo pipefail
|
||||
IMAGE="ghcr.io/ruvnet/wifi-densepose:sha-${GITHUB_SHA::7}"
|
||||
docker pull "$IMAGE"
|
||||
docker run --rm "$IMAGE" sh -c \
|
||||
'ls /app/ui/observatory.html /app/ui/pose-fusion.html /app/ui/index.html /app/ui/viz.html >/dev/null'
|
||||
docker run --rm "$IMAGE" sh -c 'ls -d /app/ui/observatory /app/ui/pose-fusion >/dev/null'
|
||||
|
||||
docker run -d --name sm -p 3000:3000 -e CSI_SOURCE=simulated "$IMAGE"
|
||||
# Wait up to 30 s for /health.
|
||||
for _ in $(seq 1 30); do
|
||||
if curl -fsS http://127.0.0.1:3000/health >/dev/null 2>&1; then break; fi
|
||||
sleep 1
|
||||
done
|
||||
curl -fsS http://127.0.0.1:3000/health
|
||||
curl -fsS http://127.0.0.1:3000/api/v1/info >/dev/null
|
||||
curl -fsS http://127.0.0.1:3000/ui/observatory.html >/dev/null
|
||||
curl -fsS http://127.0.0.1:3000/ui/pose-fusion.html >/dev/null
|
||||
docker stop sm
|
||||
|
||||
- name: Smoke-test the bearer-token auth path
|
||||
run: |
|
||||
set -euo pipefail
|
||||
IMAGE="ghcr.io/ruvnet/wifi-densepose:sha-${GITHUB_SHA::7}"
|
||||
docker run -d --name auth \
|
||||
-p 3000:3000 \
|
||||
-e CSI_SOURCE=simulated \
|
||||
-e RUVIEW_API_TOKEN=smoke-test-token-do-not-use \
|
||||
"$IMAGE"
|
||||
for _ in $(seq 1 30); do
|
||||
if curl -fsS http://127.0.0.1:3000/health >/dev/null 2>&1; then break; fi
|
||||
sleep 1
|
||||
done
|
||||
# /health stays unauthenticated.
|
||||
curl -fsS http://127.0.0.1:3000/health >/dev/null
|
||||
# /api/v1/info without a bearer → 401.
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/api/v1/info)
|
||||
test "$code" = "401" || { echo "expected 401, got $code"; exit 1; }
|
||||
# Wrong bearer → 401.
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' -H 'Authorization: Bearer wrong' http://127.0.0.1:3000/api/v1/info)
|
||||
test "$code" = "401" || { echo "expected 401 (wrong token), got $code"; exit 1; }
|
||||
# Correct bearer → 200.
|
||||
curl -fsS -H 'Authorization: Bearer smoke-test-token-do-not-use' http://127.0.0.1:3000/api/v1/info >/dev/null
|
||||
docker stop auth
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
{
|
||||
echo "## sensing-server image published"
|
||||
echo
|
||||
echo "Tags:"
|
||||
echo '```'
|
||||
echo "${{ steps.meta.outputs.tags }}"
|
||||
echo '```'
|
||||
echo
|
||||
echo "Closes #520 (missing observatory/pose-fusion UI assets) and #514 (stale `:latest` for the v0.6+ packet format)."
|
||||
echo "The Dockerfile fails the build if those UI assets ever disappear again, and this workflow rebuilds + pushes automatically on every change to the surface."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -1,70 +0,0 @@
|
||||
name: three.js demos → GitHub Pages
|
||||
|
||||
# Publishes the ADR-097 three.js demos under gh-pages/three.js/.
|
||||
# Uses keep_files: true so the existing observatory/, pose-fusion/,
|
||||
# pointcloud/, nvsim/, and root index.html demos are preserved.
|
||||
#
|
||||
# Demos 04 and 05 require a Mixamo "X Bot.fbx" placed in assets/.
|
||||
# That file is intentionally gitignored (license boundary), so this
|
||||
# workflow does NOT ship it. Demos 01-03 work standalone; the index
|
||||
# page documents the FBX requirement honestly.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'examples/three.js/**'
|
||||
- '.github/workflows/threejs-pages.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: threejs-pages
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Stage demos for Pages
|
||||
run: |
|
||||
mkdir -p _site/three.js
|
||||
# Copy everything except the local Python server (CI doesn't need it)
|
||||
# and any stray scratch screenshots.
|
||||
cp -r examples/three.js/demos _site/three.js/demos
|
||||
cp -r examples/three.js/screenshots _site/three.js/screenshots
|
||||
cp examples/three.js/README.md _site/three.js/README.md
|
||||
# An index.html that lists the 5 demos with the FBX caveat.
|
||||
cp examples/three.js/index.html _site/three.js/index.html
|
||||
# Mixamo FBX is gitignored — assets dir won't exist in CI.
|
||||
# Drop an empty placeholder so the relative path 'assets/' resolves
|
||||
# to a directory listing (404 on missing file) instead of an opaque
|
||||
# network error. Browsers showing the 404 path makes the failure
|
||||
# visible to anyone trying demos 04/05 without their own FBX.
|
||||
mkdir -p _site/three.js/assets
|
||||
cat > _site/three.js/assets/README.txt <<'EOF'
|
||||
The Mixamo "X Bot.fbx" required by demos 04-skinned-fbx.html and
|
||||
05-skinned-realtime.html is intentionally not redistributed here.
|
||||
|
||||
Download your own from https://mixamo.com (FBX Binary, T-Pose,
|
||||
Without Skin) and place it here as "X Bot.fbx" if you want to
|
||||
run those demos locally. See examples/three.js/README.md in the
|
||||
repo for context.
|
||||
EOF
|
||||
echo "Staged contents:"
|
||||
ls -R _site/three.js/ | head -30
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
uses: peaceiris/actions-gh-pages@v3
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: _site
|
||||
# Critical: preserve observatory/, pose-fusion/, pointcloud/, nvsim/
|
||||
# and the root index.html already on gh-pages.
|
||||
keep_files: true
|
||||
commit_message: 'three.js demos: ${{ github.event.head_commit.message }}'
|
||||
@@ -19,24 +19,8 @@ jobs:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Identity must be set BEFORE any operation that can create a commit.
|
||||
# `git submodule update --remote --merge` used to fail here with
|
||||
# "Committer identity unknown" because the merge inside vendor/ruvector
|
||||
# needs an author when the pinned commit isn't a fast-forward of upstream.
|
||||
- name: Configure git identity
|
||||
run: |
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
# Use a plain `--remote` checkout (detached HEAD at each submodule's
|
||||
# configured `branch` tip from .gitmodules) rather than `--merge`. We only
|
||||
# want to bump the superproject's gitlink to the latest upstream commit;
|
||||
# there's no reason to create merge commits inside the vendored repos, and
|
||||
# `--merge` breaks whenever the current pin has diverged from that branch.
|
||||
- name: Update submodules to latest tracked branch
|
||||
run: |
|
||||
git submodule sync --recursive
|
||||
git submodule update --remote --recursive
|
||||
- name: Update submodules to latest main
|
||||
run: git submodule update --remote --merge
|
||||
|
||||
- name: Check for changes
|
||||
id: check
|
||||
@@ -45,22 +29,21 @@ jobs:
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
echo "--- submodule pointer changes ---"
|
||||
git submodule status --recursive || true
|
||||
git diff --submodule=log -- vendor/ || true
|
||||
fi
|
||||
|
||||
- name: Create PR with updates
|
||||
if: steps.check.outputs.changed == 'true'
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
BRANCH="chore/update-submodules-$(date +%Y%m%d-%H%M%S)"
|
||||
git checkout -b "$BRANCH"
|
||||
git add vendor/
|
||||
git commit -m "chore: update vendor submodules to latest upstream"
|
||||
git commit -m "chore: update vendor submodules to latest main"
|
||||
git push origin "$BRANCH"
|
||||
gh pr create \
|
||||
--title "chore: update vendor submodules" \
|
||||
--body "Automated submodule update to the latest upstream commit on each submodule's tracked branch (see \`.gitmodules\`). Review the pointer diff before merging." \
|
||||
--body "Automated submodule update to latest upstream main." \
|
||||
--base main \
|
||||
--head "$BRANCH"
|
||||
env:
|
||||
|
||||
@@ -4,16 +4,16 @@ on:
|
||||
push:
|
||||
branches: [ main, master, 'claude/**' ]
|
||||
paths:
|
||||
- 'archive/v1/src/core/**'
|
||||
- 'archive/v1/src/hardware/**'
|
||||
- 'archive/v1/data/proof/**'
|
||||
- 'v1/src/core/**'
|
||||
- 'v1/src/hardware/**'
|
||||
- 'v1/data/proof/**'
|
||||
- '.github/workflows/verify-pipeline.yml'
|
||||
pull_request:
|
||||
branches: [ main, master ]
|
||||
paths:
|
||||
- 'archive/v1/src/core/**'
|
||||
- 'archive/v1/src/hardware/**'
|
||||
- 'archive/v1/data/proof/**'
|
||||
- 'v1/src/core/**'
|
||||
- 'v1/src/hardware/**'
|
||||
- 'v1/data/proof/**'
|
||||
- '.github/workflows/verify-pipeline.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -30,26 +30,26 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install pinned dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r archive/v1/requirements-lock.txt
|
||||
pip install -r v1/requirements-lock.txt
|
||||
|
||||
- name: Verify reference signal is reproducible
|
||||
run: |
|
||||
echo "=== Regenerating reference signal ==="
|
||||
python archive/v1/data/proof/generate_reference_signal.py
|
||||
python v1/data/proof/generate_reference_signal.py
|
||||
echo ""
|
||||
echo "=== Checking data file matches committed version ==="
|
||||
# The regenerated file should be identical to the committed one
|
||||
# (We compare the metadata file since data file is large)
|
||||
python -c "
|
||||
import json, hashlib
|
||||
with open('archive/v1/data/proof/sample_csi_meta.json') as f:
|
||||
with open('v1/data/proof/sample_csi_meta.json') as f:
|
||||
meta = json.load(f)
|
||||
assert meta['is_synthetic'] == True, 'Metadata must mark signal as synthetic'
|
||||
assert meta['numpy_seed'] == 42, 'Seed must be 42'
|
||||
@@ -57,18 +57,7 @@ jobs:
|
||||
"
|
||||
|
||||
- name: Run pipeline verification
|
||||
working-directory: archive/v1
|
||||
env:
|
||||
# Pin thread count for scipy.fft / BLAS — multi-threaded reduction
|
||||
# order is otherwise non-deterministic across CI runs (issue #560
|
||||
# follow-up: 9- and 6-decimal quantization were not enough because
|
||||
# the divergence is from threading order, not SIMD reordering).
|
||||
# Single-threaded keeps the proof reproducible at a ~2-3x slowdown.
|
||||
OMP_NUM_THREADS: "1"
|
||||
OPENBLAS_NUM_THREADS: "1"
|
||||
MKL_NUM_THREADS: "1"
|
||||
VECLIB_MAXIMUM_THREADS: "1"
|
||||
NUMEXPR_NUM_THREADS: "1"
|
||||
working-directory: v1
|
||||
run: |
|
||||
echo "=== Running pipeline verification ==="
|
||||
python data/proof/verify.py
|
||||
@@ -76,13 +65,7 @@ jobs:
|
||||
echo "Pipeline verification PASSED."
|
||||
|
||||
- name: Run verification twice to confirm determinism
|
||||
working-directory: archive/v1
|
||||
env:
|
||||
OMP_NUM_THREADS: "1"
|
||||
OPENBLAS_NUM_THREADS: "1"
|
||||
MKL_NUM_THREADS: "1"
|
||||
VECLIB_MAXIMUM_THREADS: "1"
|
||||
NUMEXPR_NUM_THREADS: "1"
|
||||
working-directory: v1
|
||||
run: |
|
||||
echo "=== Second run for determinism confirmation ==="
|
||||
python data/proof/verify.py
|
||||
@@ -93,7 +76,7 @@ jobs:
|
||||
echo "=== Scanning for unseeded np.random usage in production code ==="
|
||||
# Search for np.random calls without a seed in production code
|
||||
# Exclude test files, proof data generators, and known parser placeholders
|
||||
VIOLATIONS=$(grep -rn "np\.random\." archive/v1/src/ \
|
||||
VIOLATIONS=$(grep -rn "np\.random\." v1/src/ \
|
||||
--include="*.py" \
|
||||
--exclude-dir="__pycache__" \
|
||||
| grep -v "np\.random\.RandomState" \
|
||||
|
||||
+1
-35
@@ -13,9 +13,6 @@ firmware/esp32-csi-node/managed_components/
|
||||
firmware/esp32-csi-node/dependencies.lock
|
||||
firmware/esp32-csi-node/sdkconfig.defaults.bak
|
||||
|
||||
# ESP-IDF set-target backup (local only)
|
||||
firmware/esp32-hello-world/sdkconfig.old
|
||||
|
||||
# Claude Flow swarm runtime state
|
||||
.swarm/
|
||||
|
||||
@@ -26,14 +23,6 @@ rust-port/wifi-densepose-rs/data/recordings/
|
||||
nvs.bin
|
||||
nvs_config.csv
|
||||
nvs_provision.bin
|
||||
firmware/esp32-csi-node/nvs_seed.csv
|
||||
firmware/esp32-csi-node/nvs_seed.bin
|
||||
firmware/esp32-csi-node/nvs_config.bin
|
||||
firmware/esp32-csi-node/nvs_wifi.bin
|
||||
firmware/esp32-csi-node/nvs.bin
|
||||
# Catch any other NVS binaries/CSVs with credentials
|
||||
**/nvs_*.bin
|
||||
**/nvs_*.csv
|
||||
|
||||
# Working artifacts that should not land in root
|
||||
/*.wasm
|
||||
@@ -237,27 +226,4 @@ v1/src/sensing/mac_wifi
|
||||
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
|
||||
# refer to https://docs.cursor.com/context/ignore-files
|
||||
.cursorignore
|
||||
.cursorindexingignore
|
||||
|
||||
# Claude Flow runtime artifacts (auto-generated, machine-specific)
|
||||
**/daemon.pid
|
||||
**/pending-insights.jsonl
|
||||
**/vectors.db
|
||||
**/memory.db
|
||||
**/.claude-flow/sessions/session-*.json
|
||||
**/.claude-flow/sessions/current.json
|
||||
|
||||
# Node modules (should use npm ci, not committed)
|
||||
**/node_modules/
|
||||
|
||||
# Local build scripts
|
||||
firmware/esp32-csi-node/build_firmware.batdata/
|
||||
models/
|
||||
demo_pointcloud.ply
|
||||
demo_splats.json
|
||||
|
||||
# rvCSI napi-rs addon — generated by `napi build` (do not commit)
|
||||
v2/crates/rvcsi-node/*.node
|
||||
v2/crates/rvcsi-node/binding.js
|
||||
v2/crates/rvcsi-node/binding.d.ts
|
||||
v2/crates/rvcsi-node/npm/
|
||||
.cursorindexingignore
|
||||
@@ -10,7 +10,3 @@
|
||||
path = vendor/sublinear-time-solver
|
||||
url = https://github.com/ruvnet/sublinear-time-solver
|
||||
branch = main
|
||||
[submodule "vendor/rvcsi"]
|
||||
path = vendor/rvcsi
|
||||
url = https://github.com/ruvnet/rvcsi
|
||||
branch = main
|
||||
|
||||
Binary file not shown.
Vendored
-49
@@ -1,49 +0,0 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "QEMU ESP32-S3 Debug",
|
||||
"type": "cppdbg",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/firmware/esp32-csi-node/build/esp32-csi-node.elf",
|
||||
"cwd": "${workspaceFolder}/firmware/esp32-csi-node",
|
||||
"MIMode": "gdb",
|
||||
"miDebuggerPath": "xtensa-esp-elf-gdb",
|
||||
"miDebuggerServerAddress": "localhost:1234",
|
||||
"setupCommands": [
|
||||
{
|
||||
"description": "Set remote hardware breakpoint limit (ESP32-S3 has 2)",
|
||||
"text": "set remote hardware-breakpoint-limit 2",
|
||||
"ignoreFailures": false
|
||||
},
|
||||
{
|
||||
"description": "Set remote hardware watchpoint limit (ESP32-S3 has 2)",
|
||||
"text": "set remote hardware-watchpoint-limit 2",
|
||||
"ignoreFailures": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "QEMU ESP32-S3 Debug (attach)",
|
||||
"type": "cppdbg",
|
||||
"request": "attach",
|
||||
"program": "${workspaceFolder}/firmware/esp32-csi-node/build/esp32-csi-node.elf",
|
||||
"cwd": "${workspaceFolder}/firmware/esp32-csi-node",
|
||||
"MIMode": "gdb",
|
||||
"miDebuggerPath": "xtensa-esp-elf-gdb",
|
||||
"miDebuggerServerAddress": "localhost:1234",
|
||||
"setupCommands": [
|
||||
{
|
||||
"description": "Set remote hardware breakpoint limit (ESP32-S3 has 2)",
|
||||
"text": "set remote hardware-breakpoint-limit 2",
|
||||
"ignoreFailures": false
|
||||
},
|
||||
{
|
||||
"description": "Set remote hardware watchpoint limit (ESP32-S3 has 2)",
|
||||
"text": "set remote hardware-watchpoint-limit 2",
|
||||
"ignoreFailures": false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+2
-560
@@ -8,564 +8,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **ADR-147 — OccWorld world model integration** (`wifi-densepose-worldmodel` v0.3.0 published to crates.io). 15-frame trajectory prediction at 209 ms / 3.37 GB VRAM on RTX 5080. Phase 3 domain adapter `scripts/ruview_occ_dataset.py` (`RuViewOccDataset`) converts WorldGraph snapshots to OccWorld tensors with indoor class remapping + zero ego-poses (validated). Phase 5 retraining pipeline `scripts/occworld_retrain.py` — VQVAE + transformer fine-tuning on RuView occupancy snapshots. See [ADR-147](docs/adr/ADR-147-nvidia-cosmos-world-foundation-model-integration.md) · [benchmark proof](docs/adr/ADR-147-benchmark-proof.md).
|
||||
|
||||
### 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:
|
||||
- `POST /api/v1/recording/start` (`recording.rs` — `session_name`)
|
||||
- `GET /api/v1/recording/download/:id` (`recording.rs` — `id`)
|
||||
- `DELETE /api/v1/recording/delete/:id` (`recording.rs` — `id`)
|
||||
- `POST /api/v1/models/load` (`model_manager.rs` — `model_id`)
|
||||
- `training_api.rs` `load_recording_frames` (`dataset_id`s)
|
||||
|
||||
Pre-fix, unauthenticated callers could read `../../etc/passwd`-style paths, write arbitrary JSONL files, load attacker-controlled `.rvf` model files, or delete arbitrary files the server process could touch. 9 unit tests in `path_safety::tests` exercise the rejection envelope (empty, too-long, path separators, parent-dir traversal, null byte, whitespace/specials, non-ASCII).
|
||||
|
||||
### Fixed
|
||||
- **WebSocket `/ws/sensing` now reports `esp32:offline` when ESP32 hardware goes stale** (closes #618). `broadcast_tick_task` was re-emitting the cached `latest_update` with a frozen `source: "esp32"` field forever after the hardware lost power or network. The REST `/health` endpoint already called `effective_source()` (which returns `"esp32:offline"` after `ESP32_OFFLINE_TIMEOUT` = 5 s with no UDP frames), but the WS broadcast path was the one consumer that didn't. Result: the UI's "LIVE — ESP32 HARDWARE Connected" banner stayed green long after the hardware went away, and `vital_signs`/`features`/`classification` re-broadcasted the last-seen values indefinitely. Fix: clone the cached `latest_update` per tick, overwrite `source` with `s.effective_source()`, then serialize and broadcast. UI can now switch to an offline state on the same 5-second budget the REST surface uses.
|
||||
- **Proof replay (`archive/v1/data/proof/verify.py`) is now cross-platform deterministic** (closes #560). Three changes together: (1) `features_to_bytes()` now `np.round(.., HASH_QUANTIZATION_DECIMALS=6)`s each feature array before packing as little-endian f64, collapsing ULP-level drift from scipy.fft pocketfft SIMD reordering; (2) the `Verify Pipeline Determinism` workflow pins `OMP_NUM_THREADS=1`, `OPENBLAS_NUM_THREADS=1`, `MKL_NUM_THREADS=1`, `VECLIB_MAXIMUM_THREADS=1`, `NUMEXPR_NUM_THREADS=1` — multi-threaded BLAS reductions were a deeper source of non-determinism than SIMD reordering, and 6-decimal quantization alone wasn't enough across Azure VM microarchitectures; (3) `expected_features.sha256` regenerated under the new conditions. CI now passes the determinism check (same hash across consecutive runs on canonical Linux x86_64 CI runner: `667eb054c44ac510342665bf9c93d608868a8ead948ae8774b2796ebce6f8fe7`). `scripts/probe-fft-platform.py` updated to mirror `HASH_QUANTIZATION_DECIMALS=6` for cross-machine spot-checks.
|
||||
- **`archive/v1/src/services/pose_service.py:223` calls the right method on `PhaseSanitizer`** (closes #612). The call was `self.phase_sanitizer.sanitize(phase_data)`, but `PhaseSanitizer`'s full-pipeline entry point is named `sanitize_phase()` (`unwrap_phase` + `remove_outliers` + `smooth_phase` chained, see `archive/v1/src/core/phase_sanitizer.py:266`). The shorter `sanitize` name doesn't exist on the class, so any path that reached this branch raised `AttributeError` and crashed the pose service mid-frame.
|
||||
- **`adaptive_classifier.rs:94` no longer panics on NaN feature values** (closes #611).
|
||||
`sorted.sort_by(|a, b| a.partial_cmp(b).unwrap())` returned `None` and panicked
|
||||
whenever a single `NaN` reached the classifier from real ESP32 hardware (silent
|
||||
DSP div-by-zero, empty buffer). One bad frame killed the entire sensing-server
|
||||
process. Swapped for `unwrap_or(Ordering::Equal)`, matching the pattern the
|
||||
same file already used at lines 149-150 and 155. Per-frame hot path; this was
|
||||
a real production crash vector.
|
||||
- **Completed the #611 NaN-panic audit across the sensing-server crate** (follow-up
|
||||
to #613). The original audit grepped for the literal `partial_cmp(b).unwrap()`
|
||||
and missed seven additional production sites that use comparator variants
|
||||
(`partial_cmp(b.1).unwrap()`, `partial_cmp(&variances[b]).unwrap()`). All share
|
||||
the same crash class — a single `NaN` in CSI-derived state panics the whole
|
||||
sensing-server. Fixed:
|
||||
- `adaptive_classifier.rs:205` — `AdaptiveModel::classify()` argmax over softmax
|
||||
probs. **Same per-frame hot path as #611**; NaN flows through normalise →
|
||||
logits → softmax and still reaches this site even after the #613 IQR fix.
|
||||
- `adaptive_classifier.rs:480, 500` — training-loop argmax in `train()`
|
||||
(training/per-class accuracy reporting).
|
||||
- `main.rs:2446, 2449` and `csi.rs:602, 605` — variance-based source/sink
|
||||
selection in `count_persons_mincut`. The outer `unwrap_or((0, &0))` only
|
||||
catches an empty iterator; it cannot rescue a comparator panic.
|
||||
|
||||
Remaining `partial_cmp(...).unwrap()` sites in the workspace are all inside
|
||||
`#[cfg(test)]` / `#[test]` blocks (`spectrogram.rs:269`, `depth.rs:234`,
|
||||
`connectivity.rs:477`, `vital_signs.rs:737`) where inputs are controlled.
|
||||
- **`ui/utils/pose-renderer.js` no longer divides by zero** when two render frames land in the same `performance.now()` tick (issue #519 Bug 2). `deltaTime` is now `Math.max(currentTime - lastFrameTime, 1)` before the `1000 / deltaTime` division, capping displayed FPS at 1000 — far above any real render rate, but finite so the EMA `averageFps = averageFps * 0.9 + fps * 0.1` no longer poisons itself to `Infinity` on a single zero-dt tick.
|
||||
|
||||
### Removed
|
||||
- **Stub crates `wifi-densepose-api`, `wifi-densepose-db`, `wifi-densepose-config`** (closes #578).
|
||||
Each was a single-line doc-comment placeholder with an empty `[dependencies]`
|
||||
section and zero references from any source file or `Cargo.toml`. The names
|
||||
were reserved early for an envisioned REST/database/config split that never
|
||||
materialised; the functionality they would provide is covered today by
|
||||
`wifi-densepose-sensing-server` (Axum REST/WS), per-crate config + CLI args,
|
||||
and the project's real-time-only (no-persistent-state) posture. Removing them
|
||||
from the workspace prevents `cargo` from listing dead crates and shipping
|
||||
empty published artifacts. If any of these names is needed in the future,
|
||||
they can be reintroduced with a real implementation.
|
||||
|
||||
### Added
|
||||
- **BFLD — Beamforming Feedback Layer for Detection (ADR-118 umbrella + ADR-119 frame format + ADR-120 privacy class + ADR-121 identity risk scoring + ADR-122 RuView HA/Matter exposure + ADR-123 capture path, [#787](https://github.com/ruvnet/RuView/issues/787)).** New crate `wifi-densepose-bfld` (`v2/crates/wifi-densepose-bfld/`) — the privacy-gated WiFi sensing layer that detects when RF data crosses from "ambient sensing" into "identity record" and **structurally prevents** identity-correlated data from leaving the node. Three invariants enforced by the type system (not policy): **I1** raw BFI never exits the node (`Sink` marker-trait hierarchy + `PrivacyClass::Raw.allows_network() == false`), **I2** identity embedding is in-RAM-only (`IdentityEmbedding` has no `Serialize`/`Clone`/`Copy` + `Drop` zeroizes), **I3** cross-site identity correlation is cryptographically impossible (per-site BLAKE3-keyed `SignatureHasher` with daily epoch rotation; mean cross-site Hamming distance ≥120 bits across 100 trials). Ships the complete operator surface: `BfldPipeline` + `BfldPipelineHandle` (worker-thread variant + `spawn_with_oracle` for Soul Signature deployments), `BfldEvent` with JSON publishing (`"blake3:<hex>"` `rf_signature_hash` format per spec), 4 `privacy_class` levels (Raw/Derived/Anonymous/Restricted) with `PrivacyGate::demote` monotonic transformer + irreversible `apply_privacy_gating`, `CoherenceGate` with ±0.05 hysteresis + 5-second debounce + clock-skew resilience (saturating_sub), `SoulMatchOracle` Recalibrate-exemption trait for enrolled-person deployments. **MQTT/HA surface**: `mqtt_topics::render_events` + `publish_event` (class-gated topic routing — Raw/Derived publish 0 topics, Anonymous publishes 6, Restricted publishes 5 with `identity_risk` stripped), `ha_discovery::render_discovery_payloads` + `publish_discovery` (HA-DISCO config payloads with `availability_topic` integration), `availability` module (`online`/`offline` + LWT-aware `with_lwt` helper for `rumqttc::MqttOptions`), `RumqttPublisher` behind a `mqtt` feature gate with `connect_with_lwt` for broker-side auto-offline. **3 operator HA Blueprints** under `v2/crates/cog-ha-matter/blueprints/bfld/` (presence-driven-lighting, motion-aware-HVAC, identity-risk-anomaly-notification with rolling 7-day z-score). **Two runnable examples** (`bfld_minimal` for in-process consumers, `bfld_handle` for the production worker-thread + bootstrap-then-spawn pattern). **GitHub Actions CI workflow** (`.github/workflows/bfld-mqtt-integration.yml`) spins up `eclipse-mosquitto:2` as a service container so the env-gated `mosquitto_integration` and `rumqttc_lwt` tests run end-to-end in CI. **Performance**: `BfldFrame::to_bytes()` measured at **320,255 frames/sec** debug (6.4× ADR-119 AC7 release target of 50k), header-only at 1,654,517 frames/sec, presence-detection latency p95 = **0.9µs** (~1,000,000× under ADR-119 AC2's 1s target), 9.96 Hz motion-publish rate through `BfldPipelineHandle` (10× ADR-122 AC3 floor). **Coverage**: 327 tests at default features, 101 no_std-compatible, 220+ with `--features mqtt`. CRC-32/ISO-HDLC polynomial pinned against `"123456789" → 0xCBF43926`, public-API surface snapshot pinned across all `pub use` re-exports, `BfldError` Display contract pinned for log-grep monitoring rules, reserved-flag-bits forward-compat round-trip property, `apply_privacy_gating` irreversibility (5-cycle round-trip stress proves stripped fields never resurrect). Companion research dossier in `docs/research/BFLD/` (11 files, 13,544 words). 49-iter implementation chain from scaffold (`feat/adr-118/p1`, `c965e3e6c`) through current head with per-iter progress comments on issue [#787](https://github.com/ruvnet/RuView/issues/787). Try it: `cargo run -p wifi-densepose-bfld --example bfld_handle`.
|
||||
- **SENSE-BRIDGE — rvagent MCP server + ruvector npm + ruflo integration (ADR-124, [#787](https://github.com/ruvnet/RuView/issues/787)).** New npm package `@ruvnet/rvagent` (`tools/ruview-mcp/`) — a dual-transport [Model Context Protocol](https://modelcontextprotocol.io/) server that bridges the RuView WiFi-DensePose sensing stack to AI agents (Claude Code, Cursor, ruflo swarms). **6 of 20 ADR-124 §4.1 tools wired** in this initial release: `ruview.presence.now` (occupancy), `ruview.vitals.get_breathing` / `get_heart_rate` / `get_all` (biometric vitals via `EdgeVitalsMessage` surface, ADR-124 §6 Python ws.py:74-88 parity), `ruview.bfld.last_scan` (latest BFLD event — `identity_risk_score`, `privacy_class`, `n_frames`, `timestamp_ms`), `ruview.bfld.subscribe` (MQTT wildcard subscription with synthetic UUID envelope fallback). **Dual-transport architecture (ADR-124 §3)**: stdio (`npx @ruvnet/rvagent stdio` — recommended for Claude Code / Cursor local flow) + Streamable HTTP (`POST /mcp` bound to `127.0.0.1:3001` by default — for remote ruflo swarms across the Tailscale fleet). **Security model (ADR-124 §6)**: Origin header validation (cross-origin POST → 403), bearer-token auth slot (`RVAGENT_HTTP_TOKEN` → 401), bind default `127.0.0.1` per MCP spec requirement. **Uniform schema validation gate (ADR-124 §3)**: every `CallTool` request runs `zod.safeParse` via `TOOL_INPUT_SCHEMAS` before dispatch; failures throw `McpError(InvalidParams)`. **Full Zod schema barrel (ADR-124 §4.1 + §4.1a)**: `src/schemas/tools.ts` defines all 20 tool input schemas including the 5 RUVIEW-POLICY governance tools (can_access_vitals, can_query_presence, can_subscribe, redact_identity_fields, audit_log). **Python surface parity**: `EdgeVitalsMessage` TypeScript interface mirrors Python ws.py:74-88; ADR-124 §6 parity table drives the field names. **93 tests across 7 suites** (manifest, schemas, validate, tools, http-transport, bfld-tools, vitals-tools) — all green. Try it: `npx @ruvnet/rvagent stdio` (with `RUVIEW_SENSING_SERVER_URL=http://localhost:3000`).
|
||||
- **Home Assistant + Matter integration (ADR-115).** New `--mqtt` and `--matter` flags on `wifi-densepose-sensing-server` expose the full sensing capability set to any Home Assistant install via MQTT auto-discovery (HA-DISCO) and to any Matter controller (Apple Home / Google Home / Alexa / SmartThings) via a built-in Matter Bridge scaffolding (HA-FABRIC, SDK wiring v0.7.1). Includes 21 entity kinds per node — 11 raw signals + 10 inferred semantic primitives (HA-MIND: someone-sleeping, possible-distress, room-active, elderly-inactivity-anomaly, meeting, bathroom, fall-risk, bed-exit, no-movement, multi-room-transition). The semantic primitives run server-side so `--privacy-mode` strips HR/BR/pose values from the wire while still publishing the inferred *states* — the architectural win for healthcare and AAL deployments. Ships **8 starter HA Blueprints** under `examples/ha-blueprints/`, **3 drop-in Lovelace dashboards** under `examples/lovelace/` (including a privacy-mode-compatible healthcare care view), mTLS support, 32 KB payload-size cap, MQTT-wildcard topic-injection rejection, `RUVIEW_MQTT_STRICT_TLS=1` v0.8.0 upgrade path. **420 lib tests** cover the implementation including **~2,560 fuzzed assertions per CI run** (10 proptest cases across wire-boundary security + semantic-bus invariants). Plus mosquitto-backed integration tests in `.github/workflows/mqtt-integration.yml`, criterion benchmarks beating every ADR target by 1.6×–208×, and an ESP32-S3 hardware validation harness (`scripts/validate-esp32-mqtt.sh`) that asserts the full pipeline end-to-end with a witness bundle generator (`scripts/witness-adr-115.sh`) that self-verifies. See [`docs/releases/v0.7.0-mqtt-matter.md`](docs/releases/v0.7.0-mqtt-matter.md), [`docs/integrations/home-assistant.md`](docs/integrations/home-assistant.md), [`docs/integrations/semantic-primitives-metrics.md`](docs/integrations/semantic-primitives-metrics.md), [`docs/integrations/benchmarks.md`](docs/integrations/benchmarks.md), [`docs/adr/ADR-115-home-assistant-integration.md`](docs/adr/ADR-115-home-assistant-integration.md), tracking issue [#776](https://github.com/ruvnet/RuView/issues/776), PR [#778](https://github.com/ruvnet/RuView/pull/778). Matter SDK wiring (P8b) and CSA-certification path (P10) deferred to v0.7.1+ per ADR §9.10. Try it: `cargo run -p wifi-densepose-sensing-server --features mqtt --example mqtt_publisher -- --mqtt --mqtt-host 127.0.0.1`.
|
||||
- **ESP32-C6 firmware target with Wi-Fi 6 / 802.15.4 / TWT / LP-core support ([ADR-110](docs/adr/ADR-110-esp32-c6-firmware-extension.md), #762).** `firmware/esp32-csi-node` now builds for **both** `esp32s3` (existing production node) and `esp32c6` (new research/seed-node target) from the same source tree — pick via `idf.py set-target esp32c6` and ESP-IDF auto-applies the new `sdkconfig.defaults.esp32c6` overlay. Every C6 module is `#ifdef CONFIG_IDF_TARGET_ESP32C6` gated, so the S3 build is byte-identical to today (no regression).
|
||||
- **Wi-Fi 6 HE-LTF subcarrier tagging** — `csi_collector.c` now reads `rx_ctrl.cur_bb_format` and writes the PPDU type (0=HT/legacy, 1=HE-SU, 2=HE-MU, 3=HE-TB) into ADR-018 frame byte 18, plus bandwidth flags (20/40 MHz, STBC, 802.15.4-sync-valid) into byte 19. Bytes 18-19 were previously reserved-zero, so old aggregators read them as before — fully backwards compatible. Magic stays `0xC5110001`. Default on via `CONFIG_CSI_FRAME_HE_TAGGING`. First firmware in the open ESP32 ecosystem to tag CSI frames with 11ax PPDU metadata.
|
||||
- **802.15.4 mesh time-sync** — new `c6_timesync.{h,c}` (262 lines) provides cross-node clock alignment over the C6's separate 802.15.4 radio, freeing WiFi airtime from coordination traffic (directly addresses the ADR-029/030 multistatic synchronization gap). Protocol: lowest EUI-64 wins election, leader broadcasts `TS_BEACON` (`magic=0x54534D45`, leader epoch µs) every 100 ms on channel 15, followers compute `offset = leader_us - local_us` and apply lazily — every CSI frame is stamped with `c6_timesync_get_epoch_us()`. Target alignment ±100 µs. Default on via `CONFIG_C6_TIMESYNC_ENABLE`. Verified initializing at boot on COM6 (`c6_ts: init done: channel=15 EUI=206ef1fffefffe17 leader=yes(candidate)` at +413 ms).
|
||||
- **TWT (Target Wake Time)** — new `c6_twt.{h,c}` (223 lines) wraps `esp_wifi_sta_itwt_setup` from `esp_wifi_he.h` to negotiate an individual TWT agreement with the AP after STA connect. Replaces today's opportunistic CSI capture with a scheduler-bounded one (default wake interval 10 ms = 100 fps cadence). Graceful NACK fallback: when the AP doesn't support 11ax iTWT, the helper logs and returns OK so the device keeps doing opportunistic CSI just like the S3. Teardown on `WIFI_EVENT_STA_DISCONNECTED` keeps the AP's TWT scheduler clean. Gated on `SOC_WIFI_HE_SUPPORT` (auto-set on C6/C5 chips).
|
||||
- **LP-core wake-on-motion hibernation** — new `c6_lp_core.{h,c}` (134 lines) arms the C6 LP RISC-V coprocessor as an always-on motion gate; HP core stays in deep sleep until a configurable GPIO wakes it (ext1 deep-sleep wake source in this initial cut, real LP-core program in follow-up). Targets ≤5 µA hibernation current for battery-powered Cognitum Seed nodes (vs the S3's ~10 µA ULP-FSM floor). Opt-in via `CONFIG_C6_LP_CORE_ENABLE` (default off — only enabled on nodes flashed for battery-powered seed duty).
|
||||
- **Build matrix**: S3 stays `partitions_display.csv` (8 MB + display + WASM), C6 uses `partitions_4mb.csv` (4 MB single OTA, no display, no WASM3, no LCD). C6 final binary 1003 KB (46% partition slack), 9 % smaller than S3 production. Free heap 310 KiB at boot, app_main reached in 343 ms, 802.15.4 stack up in another 70 ms.
|
||||
- **Why this matters**: opens three research surfaces nobody has published yet — Wi-Fi-6 CSI human pose, multistatic CSI clock alignment over a side-channel radio, and TWT-bounded deterministic CSI cadence. The S3 production fleet keeps shipping the existing capabilities; the C6 is the research / battery-seed expansion target.
|
||||
- **Docs**: ADR-110 (186 lines, Status=Accepted), tracking issue [ruvnet/RuView#762](https://github.com/ruvnet/RuView/issues/762) with per-phase progress comments, README hardware table + Quick-Start Option 2b, `docs/user-guide.md` full ESP32-C6 section (build, flash, provision, multi-room time-sync, battery seed mode), full empirical record in [`docs/WITNESS-LOG-110.md`](docs/WITNESS-LOG-110.md) with verified / claimed / bugs-fixed / bugs-found sections.
|
||||
- **Wave 2 follow-up (D1 workaround)**: 5 systematic experiments on 3 live C6 boards confirmed the IDF v5.4 802.15.4 RX path is unfixable from user code (TX works 100 %, RX delivers 0 frames; coex/channel/OpenThread/manual-rearm all ruled out). Pivoted to ESP-NOW for the cross-node sync transport — `main/c6_sync_espnow.{h,c}` is the same TS_BEACON protocol over WiFi peer-to-peer, same `get_epoch_us / is_valid / is_leader` API surface. **120 s single-board soak: 1151 transmits, 0 failures (0.00 %), 9.6 tx/s sustained, no crash or reset.** The 802.15.4 path stays in source as documented-broken (D1) for when the IDF driver gets fixed.
|
||||
- **Host-side dual-pipeline decoder for ADR-018 byte 18-19** (ADR-110 protocol closure):
|
||||
- **Rust** (`v2/crates/wifi-densepose-hardware`): new `PpduType` enum (HtLegacy/HeSu/HeMu/HeTb/Unknown) and `Adr018Flags` struct (bw40/stbc/ldpc/ieee802154_sync_valid) on `CsiMetadata`. 6 new deterministic unit tests; **122/122 hardware-crate tests pass**.
|
||||
- **Python** (`archive/v1/src/hardware/csi_extractor.py`): `HEADER_FMT` extended from `<IBBHIIBB2x` to `<IBBHIIBBBB`; new metadata fields (`ppdu_type`, `he_capable`, `bw40`, `stbc`, `ldpc`, `ieee802154_sync_valid`). 5 new `TestAdr110ByteEncoding` cases; **11/11 parser tests pass**.
|
||||
- Both decoders match the firmware encoder bit-for-bit. Pre-ADR-110 firmware sends zeros that round-trip as `HtLegacy` + default flags — fully backwards compatible.
|
||||
- **Security fix** (`scripts/redact-secrets.py` + `generate-witness-bundle.sh`): the Python proof step was echoing `.env` contents into the bundled `verification-output.log` via Pydantic validation errors. Bundle nuked before push; added a `stdin -> stdout` redaction filter covering common token prefixes, long opaque strings, and long hex runs. Verified zero leaks on rebuild.
|
||||
- **Wave 3 — firmware v0.6.7 (LP-core full + soft-AP HE)**: two software-only unblocks for the hardware-blocked items in WITNESS-LOG-110 §B. (1) **Real LP-core motion-gate program** (`firmware/esp32-csi-node/main/lp_core/main.c` + integration in `c6_lp_core.c`). When `CONFIG_C6_LP_CORE_ENABLE=y`, the LP RISC-V coprocessor now runs a real polling program (configurable cadence via `CONFIG_C6_LP_POLL_PERIOD_US`, default 10 ms) that debounces N consecutive GPIO samples (`CONFIG_C6_LP_DEBOUNCE_SAMPLES`, default 3) and wakes the HP core via `ulp_lp_core_wakeup_main_processor()`. HP entry uses `esp_sleep_enable_ulp_wakeup` + `ESP_SLEEP_WAKEUP_ULP`. Exposes `c6_lp_core_motion_count()` and `c6_lp_core_poll_count()` getters for the witness harness. **Replaces** the v0.6.6 `esp_deep_sleep_enable_gpio_wakeup` ext1 fallback (which floored at ~10 µA, the same as the S3 ULP-FSM). The fallback path stays as the `else` branch so builds without `CONFIG_C6_LP_CORE_ENABLE` keep working unchanged — zero regression for v0.6.6-era fleets. Targets the C6 datasheet ≤5 µA average for battery seed nodes; pending INA/Joulescope measurement to confirm (`WITNESS-LOG-110 §B4`). (2) **Wi-Fi 6 soft-AP with TWT Responder=1** (`c6_softap_he.{h,c}` + `main.c` AP+STA mode switch). When `CONFIG_C6_SOFTAP_HE_ENABLE=y`, one C6 board can act as the iTWT-capable AP the bench is otherwise missing — pair with a second C6-STA board to negotiate real iTWT against a known-cooperative AP and measure deterministic CSI cadence (`WITNESS-LOG-110 §B1/B2`). SSID/PSK/channel configurable via Kconfig defaults or NVS (`softap_ssid`/`softap_psk`/`softap_chan` keys in the `ruview` namespace). Default off so existing nodes are unaffected. **Build artifacts**: S3 8 MB binary 1093 KB (47 % slack), C6 4 MB binary 1019 KB (45 % slack). Tag: `v0.6.7-esp32`.
|
||||
- **Wave 4 — firmware v0.6.8 (ESP-NOW mesh offset smoother)**: `c6_sync_espnow.c` now maintains an in-firmware exponential-moving-average of the cross-board sync offset (α = 1/8, fixed-point shift, ≈ 8-sample window at the 10 Hz beacon rate). New getter `c6_sync_espnow_get_offset_us_smoothed()`. `c6_sync_espnow_get_epoch_us()` now returns timestamps stamped from the smoothed offset once seeded — every downstream CSI-frame consumer gets bounded-jitter alignment for free, no host-side filter required. **Measured on the bench**: 5-min two-board soak (WITNESS-LOG-110 §A0.10) drops raw offset stdev 411.5 µs → smoothed 104.1 µs (**3.95× suppression** on stdev, 4.70× on peak-to-peak range) while preserving the +30 µs/min crystal-drift trajectory within 2 µs/min. **The ADR-110 §2.4 ≤100 µs multistatic alignment target that v0.6.6 designed is now empirically measured, not just stated.** Cross-board beacon match rate 99.56% over 5 min, 0 TX failures. Binary cost: +32 bytes (one int64, one bool, one getter). Diag log adds `smoothed=…` field. Tag: `v0.6.8-esp32`. **Known wiring gap (deferred)**: `csi_serialize_frame` does not yet stamp frames with `c6_sync_espnow_get_epoch_us()` — the ADR-018 frame format has no timestamp field, and adding one is a breaking change that needs an ADR update. Multistatic CSI fusion will require either an ADR-018 v2 with timestamp, or a separate UDP sync packet keyed off the existing flag bit. Tracked in WITNESS-LOG-110 §A0.11.
|
||||
- **Wave 5 — firmware v0.6.9 + v0.7.0 + host wiring (loop iter 8 → iter 26)**: closes the §A0.11 gap and lights up the substrate end-to-end across firmware → host → JSON broadcast. **Firmware**: (a) **v0.6.9-esp32** — `csi_collector.c` emits a 32-byte UDP sync packet (magic `0xC511A110`, distinct from CSI frame magic `0xC5110001`) every `CONFIG_C6_SYNC_EVERY_N_FRAMES` (default 20) CSI frames, carrying `node_id`, `local_us`, mesh-aligned `epoch_us` (from the Wave 4 smoothed offset), and the CSI sequence high-water for host-side pairing. Same UDP socket as CSI; host dispatches by leading magic. Operator-tunable cadence via the new Kconfig knob — N=1 (10 Hz) for tight multistatic, N=200 (~20 s) for low-power seeds. Live-verified on COM9+COM12 (§A0.12): follower reports `local − epoch = 1 163 565 µs`, matches the §A0.10 boot-delta measurement within 285 µs of WiFi MAC TX jitter. (b) **v0.7.0-esp32** — `csi_collector.c:221` ADR-018 byte 19 bit 4 ("cross-node sync valid") now ORs in `c6_sync_espnow_is_valid()` so frames from sync'd ESP-NOW nodes correctly advertise sync (previously only sourced from the broken 802.15.4 path — false-negative bug, §A0.13). Side effect: S3 boards now also set the bit since `c6_sync_espnow` is cross-target. **Host decoders + 25 unit tests**: Python `SyncPacketParser` + `SyncPacket` dataclass with `apply_to_local` / `mesh_aligned_us_for_sequence` / `local_minus_epoch_us` (10 tests in `TestSyncPacketParser`); Rust `wifi_densepose_hardware::SyncPacket` + `SyncPacketFlags` + `SYNC_PACKET_MAGIC` re-exported from the crate root with identical API surface (15 tests in `sync_packet::tests`). **Cross-language conformance gate** (loop iter 21): the same 32-byte canonical hex `10a111c509010600f26db70100000000c5aca501000000001400000000000000` is pinned in both test suites; if either decoder drifts from the wire, exactly one named test fires and points at the moved side. **Sensing-server wiring**: `udp_receiver_task` magic-dispatches `0xC511A110` and stores per-node `latest_sync: Option<SyncPacket>` + `latest_sync_at: Option<Instant>` on `NodeState`. New helpers: `NodeState::mesh_aligned_us(local_us)`, `NodeState::mesh_aligned_us_for_csi_frame(sequence)` (uses the per-node measured fps EMA with 5-sample warmup + 9 s staleness gate), `NodeState::observe_csi_frame_arrival(now)` (feeds `update_csi_fps_ema` α=1/8, called once per accepted CSI frame). 4 fps-EMA tests + 3 NodeSyncSnapshot serialization tests on the binary target. **Public JSON API**: `sensing_update` broadcasts now carry an optional `sync` object per node — `{offset_us, is_leader, is_valid, smoothed, sequence, csi_fps_ema, csi_fps_samples}` — `#[serde(skip_serializing_if = "Option::is_none")]` so non-mesh paths (multi-BSSID scan / synthetic-RSSI fallback / simulation) omit the key entirely. Existing pre-v0.7.0 UI clients ignore it cleanly. Documented in `docs/user-guide.md` "Per-node mesh sync (ADR-110)" section with field table, UI rendering rules, and the timestamp-recovery recipe. **Branch-coordination**: `docs/ADR-110-BRANCH-STATE.md` maps which files each of `adr-110-esp32c6` vs `feat/adr-115-ha-mqtt-matter` touches (regions are disjoint, merges should be clean line-merges). **Verification baselines**: full v2 cargo workspace at **1437 tests passing** (no regression across 17 crate batches), full `wifi-densepose-hardware` crate at **137 tests**. ADR-110 §B substrate is now end-to-end visible to UI clients and ready for ADR-029/030 multistatic CSI fusion consumption.
|
||||
- **Real-time CSI introspection / low-latency tap on `wifi-densepose-sensing-server` (ADR-099).**
|
||||
New `wifi_densepose_sensing_server::introspection` module wires
|
||||
[midstream](https://github.com/ruvnet/midstream)'s `temporal-attractor` (Lyapunov +
|
||||
regime classification) and `temporal-compare` (DTW pattern matching) as a
|
||||
**parallel tap** alongside RuView's existing event pipeline — no replacement,
|
||||
no behaviour change to the existing `/ws/sensing` fan-out or `wifi-densepose-signal`
|
||||
DSP. Two new endpoints (off by default, enabled via `--introspection`):
|
||||
- `GET /ws/introspection` — newline-delimited JSON snapshots streamed at the CSI
|
||||
frame rate. Each snapshot carries `frame_count`, `regime` (Idle / Periodic /
|
||||
Transient / Chaotic / Unknown), `lyapunov_exponent`, `attractor_dim`,
|
||||
`attractor_confidence`, `regime_changed` (boolean — flips on the first frame
|
||||
after a regime transition), and `top_k_similarity[]` (highest-scoring
|
||||
signature matches against a per-deployment library).
|
||||
- `GET /api/v1/introspection/snapshot` — single-shot JSON snapshot, auth-gated
|
||||
when `RUVIEW_API_TOKEN` is set.
|
||||
Per-frame `update()` budget measured at **0.041 ms p99** on the I5 bench
|
||||
(~24× under ADR-099 D4's 1 ms target). Shape-match latency on a 1-D
|
||||
mean-amplitude L1 stand-in: **5 frames** (3.20× ratio vs the 16-frame event-path
|
||||
floor). ADR-099 D8 honestly amended — the aspirational 10× bar is contingent on
|
||||
ADR-208 Phase 2 multi-dim NPU embeddings; this release ships the tap off-by-default
|
||||
while the foundation lands. 8 lib tests + 5 latency/regression tests (`tests/introspection_latency.rs`,
|
||||
including a 200-frame noise warm-up → 10-frame motion-ramp signature benchmark).
|
||||
- **Opt-in bearer-token auth on `wifi-densepose-sensing-server`'s `/api/v1/*` HTTP surface (closes #443).**
|
||||
New `wifi_densepose_sensing_server::bearer_auth` module: when the
|
||||
`RUVIEW_API_TOKEN` env var is set, every request whose path begins with
|
||||
`/api/v1/` must carry an `Authorization: Bearer <token>` header (constant-time
|
||||
compared) or the server responds `401 Unauthorized`. When the variable is
|
||||
unset or empty the middleware is a no-op — the long-standing LAN-only
|
||||
deployment posture is preserved, so this is a binary deployment-time switch
|
||||
with **no default behaviour change**. `/health*`, `/ws/sensing`, and the
|
||||
`/ui/*` static mount are intentionally never gated (orchestrator probes +
|
||||
local browsers). Startup logs which mode is active and warns when auth is on
|
||||
with a `0.0.0.0` bind. 8 unit tests on the middleware (lib test count 191 → 199).
|
||||
Resolves the security audit raised in #443.
|
||||
|
||||
### Changed
|
||||
- **Docker image: build-time guard for the UI assets, plus a CI workflow that
|
||||
rebuilds and pushes on every change (closes #520, #514).** `docker/Dockerfile.rust`
|
||||
now `RUN`s a guard after `COPY ui/` that fails the build if any of
|
||||
`index.html` / `observatory.html` / `pose-fusion.html` / `viz.html` / the
|
||||
`observatory/` / `pose-fusion/` / `components/` / `services/` directories are
|
||||
missing, so a stale image can never be silently produced again. New
|
||||
`.github/workflows/sensing-server-docker.yml` builds the image on push to
|
||||
`main` (paths-filtered) and on `v*` tags and pushes to both
|
||||
`docker.io/ruvnet/wifi-densepose` and `ghcr.io/ruvnet/wifi-densepose` with
|
||||
`latest` + `vX.Y.Z` + `sha-<short>` tags, then smoke-tests the published
|
||||
artifact: `/health`, `/api/v1/info`, the observatory + pose-fusion UI assets,
|
||||
and the `RUVIEW_API_TOKEN` auth path (no token → 401, wrong → 401, correct
|
||||
→ 200). Uses `DOCKERHUB_USERNAME` / `DOCKERHUB_TOKEN` repo secrets for the
|
||||
Docker Hub push; ghcr.io uses the workflow's `GITHUB_TOKEN`.
|
||||
- **rvCSI moved to its own repo and is now vendored as a submodule.** The 9 `rvcsi-*`
|
||||
crates (`rvcsi-core`/`-dsp`/`-events`/`-adapter-file`/`-adapter-nexmon`/`-ruvector`/
|
||||
`-runtime`/`-node`/`-cli` — added inline in #542) now live in
|
||||
[`github.com/ruvnet/rvcsi`](https://github.com/ruvnet/rvcsi): published to crates.io
|
||||
as `rvcsi-* 0.3.x`, to npm as `@ruv/rvcsi`, with a Claude Code plugin marketplace and
|
||||
a RuView-style README. RuView vendors it under `vendor/rvcsi` (alongside
|
||||
`vendor/ruvector` / `vendor/midstream` / `vendor/sublinear-time-solver`) and no longer
|
||||
carries inline copies in `v2/crates/`; consumers depend on the published crates (or the
|
||||
submodule's `crates/rvcsi-*` paths). `v2/Cargo.toml`, `CLAUDE.md`, and the README docs
|
||||
table updated accordingly. The ADRs (ADR-095, ADR-096), PRD, and DDD model stay in
|
||||
`docs/` here as the design record of the incubation.
|
||||
|
||||
### Fixed
|
||||
- **README: corrected the camera-supervised pose-accuracy claim.** The README stated
|
||||
"92.9% PCK@20" for camera-supervised training; that figure does not appear in
|
||||
ADR-079 and is ~2.6× the ADR's own success target (>35% PCK@20). ADR-079 phases
|
||||
P7 (data collection), P8 (training + evaluation on real paired data) and P9
|
||||
(cross-room LoRA) are still `Pending`, so no measured camera-supervised PCK@20 has
|
||||
been published. README now states the proxy-supervised baseline (≈2.5%) and the
|
||||
ADR-079 target (35%+), and notes the eval phases are pending. Surfaced by the
|
||||
PowerPlatePulse training-pipeline audit (2026-05-11); 6 remaining audit findings
|
||||
tracked in the PR.
|
||||
- **rvCSI `BaselineDriftDetector`: drift thresholds are now scale-relative, not absolute.**
|
||||
The detector compared `mean_amplitude` against its EWMA baseline with absolute
|
||||
thresholds (`anomaly_threshold = 1.0`, `drift_threshold = 0.15`) — fine for the
|
||||
synthetic unit tests (amplitudes ≈ 1.0), but raw ESP32 CSI is `int8` I/Q with
|
||||
amplitudes up to ~128, so the window-to-window RMS distance is routinely 5–50 ≫ 1.0
|
||||
and `AnomalyDetected` fired on ~96 % of windows (319/331 on a real node-1 capture).
|
||||
Drift is now `‖current − baseline‖₂ / ‖baseline‖₂` (a fraction, with an `eps` floor
|
||||
for a degenerate near-zero baseline), so one tuning works across raw-`int8` ESP32,
|
||||
`int16`-scaled Nexmon, and baseline-subtracted streams alike — `AnomalyDetected`
|
||||
drops to 40/331 on the same data, the existing detector tests still pass, and a
|
||||
`baseline_drift_is_scale_invariant_no_anomaly_storm` regression test was added.
|
||||
ADR-095 D13 / ADR-096 §2.1, §5 updated. Surfaced by an end-to-end test against
|
||||
real ESP32 CSI (a 7,000-frame node-1 capture; transcoder at
|
||||
`scripts/esp32_jsonl_to_rvcsi.py`).
|
||||
|
||||
### Added
|
||||
- **rvCSI — edge RF sensing runtime (design + first implementation).** New subsystem **rvCSI**: a Rust-first / TypeScript-accessible / hardware-abstracted edge RF sensing runtime that normalizes WiFi CSI from Nexmon, ESP32, Intel, Atheros, file and replay sources into one validated `CsiFrame` schema, runs reusable DSP, emits typed confidence-scored events, and bridges to RuVector RF memory, an MCP tool server and a TS SDK.
|
||||
- **Design docs:** `docs/prd/rvcsi-platform-prd.md` (purpose, users, success criteria, FR1–FR10, NFRs, system architecture, data model); `docs/adr/ADR-095-rvcsi-edge-rf-sensing-platform.md` (the 15 architectural decisions: Rust core, C-at-the-boundary, TS SDK via napi-rs, normalized schema, validate-before-FFI, CSI-as-temporal-delta, RuVector as RF memory, replayability, detection≠decision, local-first, read-first/write-gated MCP, mandatory quality scoring, versioned calibration, plugin adapters); `docs/adr/ADR-096-rvcsi-ffi-crate-layout.md` (crate topology, the napi-c shim record format & contract, the napi-rs Node surface, build/test invariants); `docs/ddd/rvcsi-domain-model.md` (7 bounded contexts: Capture, Validation, Signal, Calibration, Event, Memory, Agent — with aggregates, invariants, context map and domain services). Indexed in `docs/adr/README.md` and `docs/ddd/README.md`.
|
||||
- **Crates** (9 new `v2/crates/rvcsi-*` workspace members): `rvcsi-core` (normalized `CsiFrame`/`CsiWindow`/`CsiEvent` schema, `AdapterProfile`, `CsiSource` plugin trait, id newtypes + `IdGenerator`, `RvcsiError`, the `validate_frame` pipeline + quality scoring; `forbid(unsafe_code)`); `rvcsi-adapter-nexmon` — the **napi-c** seam: `native/rvcsi_nexmon_shim.{c,h}` (the only C in the runtime — allocation-free, bounds-checked, ABI `1.1`), compiled via `build.rs`+`cc`, handling **two byte formats** — the compact self-describing "rvCSI Nexmon record", and the **real nexmon_csi UDP payload** (the 18-byte `magic 0x1111 · rssi · fctl · src_mac · seq · core/stream · chanspec · chip_ver` header + `nsub` int16 I/Q samples, the modern BCM43455c0/4358/4366c0 export read by CSIKit/`csireader.py`), with a Broadcom d11ac **chanspec decoder** (channel/bandwidth/band) — plus a pure-Rust **libpcap reader** (classic `.pcap`, all byte-order/timestamp-resolution magics, Ethernet/raw-IPv4/Linux-SLL link types) and a **Nexmon-chip / Raspberry-Pi-model registry** (`NexmonChip` / `RaspberryPiModel` — including the **Raspberry Pi 5** (CYW43455/BCM43455c0, same wireless as the Pi 4 — 20/40/80 MHz, 2.4+5 GHz, 64/128/256 subcarriers), the Pi 3B+/4/400, and the Pi Zero 2 W (BCM43436b0); `nexmon_adapter_profile` / `raspberry_pi_profile` build the per-chip `AdapterProfile`; `chip_ver` words auto-resolve to a chip). Wrapped by a documented `ffi` module and two `CsiSource`s: `NexmonAdapter` (record buffers) and `NexmonPcapAdapter` (real nexmon_csi UDP inside a `tcpdump -i wlan0 dst port 5500 -w csi.pcap` capture — the pcap timestamp stamps each frame; the chip is auto-detected from `chip_ver`, overridable via `.with_pi_model(Pi5)` / `.with_chip(...)`). `rvcsi-dsp` (DC removal, phase unwrap, smoothing, Hampel/MAD filter, sliding variance, baseline subtraction, motion-energy/presence/confidence features, heuristic breathing-band estimate, non-destructive `SignalPipeline`); `rvcsi-events` (`WindowBuffer`, the `EventDetector` trait + presence/motion/quality/baseline-drift state machines, `EventPipeline`; the baseline-drift detector uses **scale-relative** thresholds — drift as a fraction of the baseline's RMS magnitude — so one tuning works across raw-`int8` ESP32, `int16`-scaled Nexmon, and baseline-subtracted streams alike); `rvcsi-adapter-file` (the `.rvcsi` JSONL capture format, `FileRecorder`, `FileReplayAdapter` deterministic replay); `rvcsi-ruvector` (deterministic window/event embeddings, `cosine_similarity`, the `RfMemoryStore` trait, `InMemoryRfMemory` + `JsonlRfMemory` — a standin until the production RuVector binding); `rvcsi-runtime` (the no-FFI composition layer: `CaptureRuntime` = `CsiSource` + `validate_frame` + `SignalPipeline` + `EventPipeline`, plus one-shot helpers `summarize_capture`/`decode_nexmon_records`/`decode_nexmon_pcap`/`summarize_nexmon_pcap`/`events_from_capture`/`export_capture_to_rf_memory`); `rvcsi-node` — the **napi-rs** seam (a `["cdylib","rlib"]` Node addon, `build.rs` runs `napi_build::setup()`; thin `#[napi]` wrappers over `rvcsi-runtime` — `nexmonDecodeRecords`/`nexmonDecodePcap` (with optional `chip`)/`inspectNexmonPcap`/`decodeChanspec`/`nexmonChipName`/`nexmonProfile`/`nexmonChips`/`inspectCaptureFile`/`eventsFromCaptureFile`/`exportCaptureToRfMemory` + an `RvcsiRuntime` streaming class; everything that crosses to JS is a validated/normalized struct serialized to JSON); `rvcsi-cli` (the `rvcsi` binary: `record` (Nexmon-dump *or* `--source nexmon-pcap [--chip pi5]` → `.rvcsi`), `inspect`, `inspect-nexmon`, `nexmon-chips`, `decode-chanspec`, `replay`, `stream`, `events`, `health`, `calibrate` v0-baseline, `export ruvector`). Plus the `@ruv/rvcsi` npm package (`package.json`/`index.js`/`index.d.ts`/`README`/`__test__`) alongside `rvcsi-node` — a curated JS surface that parses the addon's JSON into plain `CsiFrame`/`CsiWindow`/`CsiEvent`/`SourceHealth`/`CaptureSummary`/`NexmonPcapSummary`/`DecodedChanspec` objects, with a lazy native-addon load.
|
||||
- **Tests:** 169 across the rvcsi crates (core 29, dsp 28, events 19 — incl. a baseline-drift scale-invariance regression, adapter-file 20 + 1 doctest, adapter-nexmon 28 — round-tripping through the C shim and synthetic libpcap files, incl. Pi 5 / chip-detection, ruvector 20 + 1 doctest, runtime 13, cli 10), 0 failures; all rvcsi crates build together and are clippy-clean (`rvcsi-node` under `deny(clippy::all)`); `forbid(unsafe_code)` everywhere except `rvcsi-adapter-nexmon` (FFI, every `unsafe` block documented). Also exercised end-to-end against a real 7,000-frame ESP32 node-1 capture (transcoded with `scripts/esp32_jsonl_to_rvcsi.py` — the stand-in for the not-yet-shipped `record --source esp32-jsonl`): `rvcsi inspect`/`replay`/`calibrate`/`events` all run on real hardware data. Not yet wired in: live radio capture, `rvcsi-adapter-esp32` (live serial/UDP ESP32 source), the WebSocket daemon (`rvcsi-daemon`), the MCP tool server (`rvcsi-mcp`), and the legacy nexmon *packed-float* CSI export — follow-ups on top of these crates.
|
||||
- **`wifi-densepose-train`: `signal_features` module — wires `wifi-densepose-signal` into the training pipeline.** `wifi-densepose-signal` was previously a phantom dependency of `wifi-densepose-train` (listed in `Cargo.toml`, never imported). New `wifi_densepose_train::signal_features::extract_signal_features` (and `CsiSample::signal_features()`) run a windowed CSI observation's centre frame through `wifi_densepose_signal::features::FeatureExtractor`, producing a fixed-length (`FEATURE_LEN = 12`) amplitude/phase/PSD feature vector — the hook for a future vitals / multi-task supervision head (breathing- and heart-rate-band power are read off the PSD summary). The vector is produced on demand and not yet fed back into the loss. Surfaced by the 2026-05-11 training-pipeline audit (findings #1 "vitals features absent from training" and #2 "`wifi-densepose-signal` ghost dep").
|
||||
- **`wifi-densepose-train`: `TrainingConfig` subcarrier-layout presets + a real-loader integration test.** New `TrainingConfig::for_subcarriers(native, target)` plus named presets `ht40_192()` (≈192-sc ESP32 HT40 → 56) and `multiband_168()` (168-sc ADR-078 multi-band mesh → 56), so non-MM-Fi CSI shapes are first-class instead of requiring manual `native_subcarriers`/`num_subcarriers` overrides; field docs now list the supported source counts and the multi-NIC mapping. New `tests/test_real_loader.rs` round-trips synthetic CSI through `.npy` files → `MmFiDataset::discover`/`get` (including the subcarrier-interpolation branch and the empty-root case) — exercising the on-disk loader path the deterministic `verify-training` proof intentionally bypasses. Addresses training-pipeline audit findings #6 (56-sc/1-NIC config default) and #7 (multi-band mesh not in config); the #4 concern ("proof uses synthetic data") is reframed — the proof *should* use a reproducible source, and this test covers the real loader it skips.
|
||||
|
||||
### Fixed
|
||||
- **HuggingFace `MODEL_CARD.md`: marked the PIR/BME280 environmental-sensor ground-truth path as planned, not implemented** (training-pipeline audit finding #3) — the card presented PIR/BME280 weak-label fine-tuning as a current capability; there is no env-sensor ingestion in the training pipeline today.
|
||||
- **README: corrected the camera-supervised pose-accuracy claim** (audit finding #5; see PR #535) — "92.9% PCK@20" → the ADR-079 target (35%+; proxy baseline 35.3%), noting P7/P8/P9 are pending.
|
||||
|
||||
### Added
|
||||
- **`RollingP95` adaptive feature normalizer** (`v2/crates/wifi-densepose-sensing-server`) —
|
||||
Streaming P95 estimator (600-sample / ~30 s sliding window) that self-calibrates
|
||||
feature normalization to whatever distribution the deployment produces. Replaces
|
||||
fixed-scale denominators (`variance/300`, `motion/250`, `spectral/500`) which saturated
|
||||
when live ESP32 values exceeded those limits, collapsing dynamic range to zero.
|
||||
Cold-start (<60 samples) falls back to the legacy denominators so day-0 behaviour
|
||||
is preserved. Deployment-neutral: no hardcoded values. (ADR-044 §5.2)
|
||||
|
||||
- **`dedup_factor` runtime configuration API** (`v2/crates/wifi-densepose-sensing-server`) —
|
||||
Exposes the multi-node person-count deduplication divisor at runtime via REST:
|
||||
- `GET /api/v1/config/dedup-factor` — read current value.
|
||||
- `POST /api/v1/config/dedup-factor` — set value (clamped 1.0–10.0, persisted).
|
||||
- `POST /api/v1/config/ground-truth` — auto-tunes `dedup_factor` from a known
|
||||
person count (`{"count": N}`); derives optimal divisor from current node-sum.
|
||||
Config is persisted to `data/config.json` and reloaded on restart. (ADR-044 §5.3)
|
||||
|
||||
- **`nvsim` crate — deterministic NV-diamond magnetometer pipeline simulator** (ADR-089) —
|
||||
New standalone leaf crate at `v2/crates/nvsim` modeling a forward-only
|
||||
magnetic sensing path: scene → source synthesis (Biot–Savart, dipole,
|
||||
current loop, ferrous induced moment) → material attenuation
|
||||
(Air/Drywall/Brick/Concrete/Reinforced/SteelSheet) → NV ensemble
|
||||
(4 〈111〉 axes, ODMR linear-readout proxy, shot-noise floor per
|
||||
Wolf 2015 / Barry 2020) → 16-bit ADC + lock-in demodulation →
|
||||
fixed-layout `MagFrame` records → SHA-256 witness. Six-pass build
|
||||
per `docs/research/quantum-sensing/15-nvsim-implementation-plan.md`.
|
||||
50 tests, ~4.5 M samples/s on x86_64 (4500× the Cortex-A53 1 kHz
|
||||
acceptance gate), pinned reference witness
|
||||
`cc8de9b01b0ff5bd97a6c17848a3f156c174ea7589d0888164a441584ec593b4`
|
||||
for byte-equivalence regression. WASM-ready by construction
|
||||
(zero `std::time/fs/env/process/thread`); builds cleanly for
|
||||
`wasm32-unknown-unknown`. ADR-090 (Proposed, conditional) tracks the
|
||||
optional Lindblad/Hamiltonian extension if AC magnetometry, MW power
|
||||
saturation, hyperfine spectroscopy, or pulsed protocols become required.
|
||||
|
||||
### Fixed
|
||||
- **WebSocket broadcast handler now handles Lagged events gracefully and sends periodic ping keepalives to prevent dashboard disconnects** —
|
||||
`handle_ws_client` and `handle_ws_pose_client` in `wifi-densepose-sensing-server`
|
||||
were treating `RecvError::Lagged` as a fatal error, causing instant disconnect
|
||||
when clients fell behind the 256-frame broadcast buffer at 10 Hz ingest.
|
||||
Clients would reconnect, immediately lag again, and rapid-cycle every 2–4 s.
|
||||
`Lagged` now continues (drops missed frames, logs debug) rather than breaking.
|
||||
Added 30 s ping keepalive on the sensing handler to prevent proxy idle timeouts.
|
||||
- **Ghost skeletons in live UI with multi-node ESP32 setups** (#420, ADR-082) —
|
||||
`tracker_bridge::tracker_to_person_detections` documented itself as filtering
|
||||
to `is_alive()` tracks but in fact passed every non-Terminated track to the
|
||||
WebSocket stream. `Lost` tracks — kept inside `reid_window` for
|
||||
re-identification but not currently observed — were rendering as phantom
|
||||
skeletons, accumulating to 22-24 with 3 nodes × 10 Hz CSI while
|
||||
`estimated_persons` correctly reported 1. Added
|
||||
`PoseTracker::confirmed_tracks()` (Tentative + Active only) and rewired the
|
||||
bridge to use it. Lost tracks remain in the tracker for re-ID; they just
|
||||
no longer ship to the UI. Regression test:
|
||||
`test_lost_tracks_excluded_from_bridge_output`.
|
||||
- **Rust workspace build with `--no-default-features` on Windows** (#366, #415) —
|
||||
`wifi-densepose-mat`, `wifi-densepose-sensing-server`, and `wifi-densepose-train`
|
||||
all depended on `wifi-densepose-signal` with default features enabled, which
|
||||
pulled `ndarray-linalg` → `openblas-src` → vcpkg/system-BLAS through the entire
|
||||
workspace. `--no-default-features` at the workspace root then could not opt out
|
||||
of BLAS, breaking `cargo build` / `cargo test` on Windows without vcpkg. All
|
||||
three consumers now declare `wifi-densepose-signal = { ..., default-features = false }`,
|
||||
so `cargo test --workspace --no-default-features` builds cleanly without
|
||||
vcpkg/openblas. Validated: 1,538 tests pass, 0 fail, 8 ignored.
|
||||
- **`signal` test `test_estimate_occupancy_noise_only` failed without `eigenvalue`** —
|
||||
The test unwrapped the `NotCalibrated` stub returned when the BLAS-backed
|
||||
`estimate_occupancy` is compiled out. Gated with `#[cfg(feature = "eigenvalue")]`
|
||||
so it only runs when the real implementation is available.
|
||||
|
||||
## [v0.6.2-esp32] — 2026-04-20
|
||||
|
||||
Firmware release cutting ADR-081 and the Timer Svc stack fix discovered during
|
||||
on-hardware validation. Cut from `main` at commit pointing to this entry.
|
||||
Tested on ESP32-S3 (QFN56 rev v0.2, MAC `3c:0f:02:e9:b5:f8`), 30 s continuous
|
||||
run: no crashes, 149 `rv_feature_state_t` emissions (~5 Hz), medium/slow ticks
|
||||
firing cleanly, HEALTH mesh packets sent.
|
||||
|
||||
### Fixed
|
||||
- **Firmware: Timer Svc stack overflow on ADR-081 fast loop** — `emit_feature_state()` runs inside the FreeRTOS Timer Svc task via the fast-loop callback; it calls `stream_sender` network I/O which pushes past the ESP-IDF 2 KiB default timer stack and panics ~1 s after boot. Bumped `CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH` to 8 KiB in `sdkconfig.defaults`, `sdkconfig.defaults.template`, and `sdkconfig.defaults.4mb`. Follow-up (tracked separately): move heavy work out of the timer daemon into a dedicated worker task.
|
||||
- **Firmware: `adaptive_controller.c` implicit declaration** (#404) — `fast_loop_cb` called `emit_feature_state()` before its static definition, triggering `-Werror=implicit-function-declaration`. Added a forward declaration above the first use.
|
||||
|
||||
### Changed
|
||||
- **CI: firmware build matrix (8MB + 4MB)** — `firmware-ci.yml` now matrix-builds both the default 8MB (`sdkconfig.defaults`) and 4MB SuperMini (`sdkconfig.defaults.4mb`) variants, uploading distinct artifacts and producing variant-named release binaries (`esp32-csi-node.bin` / `esp32-csi-node-4mb.bin`, `partition-table.bin` / `partition-table-4mb.bin`).
|
||||
|
||||
### Added
|
||||
- **ADR-081: Adaptive CSI Mesh Firmware Kernel** — New 5-layer architecture
|
||||
(Radio Abstraction Layer / Adaptive Controller / Mesh Sensing Plane /
|
||||
On-device Feature Extraction / Rust handoff) that reframes the existing
|
||||
ESP32 firmware modules as components of a chipset-agnostic kernel. ADR
|
||||
in `docs/adr/ADR-081-adaptive-csi-mesh-firmware-kernel.md`. Goal: swap
|
||||
one radio family for another without changing the Rust signal /
|
||||
ruvector / train / mat crates.
|
||||
- **Firmware: radio abstraction vtable (`rv_radio_ops_t`)** — New
|
||||
`firmware/esp32-csi-node/main/rv_radio_ops.{h}` defines the
|
||||
chipset-agnostic ops (init, set_channel, set_mode, set_csi_enabled,
|
||||
set_capture_profile, get_health), profile enum
|
||||
(`RV_PROFILE_PASSIVE_LOW_RATE` / `ACTIVE_PROBE` / `RESP_HIGH_SENS` /
|
||||
`FAST_MOTION` / `CALIBRATION`), and health snapshot struct.
|
||||
`rv_radio_ops_esp32.c` provides the ESP32 binding wrapping
|
||||
`csi_collector` + `esp_wifi_*`. A second binding (mock or alternate
|
||||
chipset) is the portability acceptance test for ADR-081.
|
||||
- **Firmware: `rv_feature_state_t` packet (magic `0xC5110006`)** — New
|
||||
60-byte compact per-node sensing state (packed, verified by
|
||||
`_Static_assert`) in `firmware/esp32-csi-node/main/rv_feature_state.h`:
|
||||
motion, presence, respiration BPM/conf, heartbeat BPM/conf, anomaly
|
||||
score, env-shift score, node coherence, quality flags, IEEE CRC32.
|
||||
Replaces raw ADR-018 CSI as the default upstream stream (~99.7%
|
||||
bandwidth reduction: 300 B/s at 5 Hz vs. ~100 KB/s raw).
|
||||
- **Firmware: mock radio ops binding for QEMU** — New
|
||||
`firmware/esp32-csi-node/main/rv_radio_ops_mock.c`, compiled only when
|
||||
`CONFIG_CSI_MOCK_ENABLED`. Satisfies ADR-081's portability acceptance
|
||||
test: a second `rv_radio_ops_t` binding compiles and runs against the
|
||||
same controller + mesh-plane code as the ESP32 binding.
|
||||
- **Firmware: feature-state emitter wired into controller fast loop** —
|
||||
`adaptive_controller.c` now emits one 60-byte `rv_feature_state_t` per
|
||||
fast tick (default 200 ms → 5 Hz), pulling from the latest edge vitals
|
||||
and controller observation. This is the first end-to-end Layer 4/5
|
||||
path for ADR-081.
|
||||
- **Firmware: `csi_collector_get_pkt_yield_per_sec()` /
|
||||
`_get_send_fail_count()` accessors** — Expose the CSI callback rate
|
||||
and UDP send-failure counter so the ESP32 radio ops binding can
|
||||
populate `rv_radio_health_t.pkt_yield_per_sec` and `.send_fail_count`,
|
||||
closing the adaptive controller's observation loop.
|
||||
- **Firmware: host-side unit test suite for ADR-081 pure logic** — New
|
||||
`firmware/esp32-csi-node/tests/host/` (Makefile + 2 test files + shim
|
||||
`esp_err.h`). Exercises `adaptive_controller_decide()` (9 test cases:
|
||||
degraded gate on pkt-yield collapse + coherence loss, anomaly > motion,
|
||||
motion → SENSE_ACTIVE, aggressive cadence, stable presence →
|
||||
RESP_HIGH_SENS, empty-room default, hysteresis, NULL safety) and
|
||||
`rv_feature_state_*` helpers (size assertion, IEEE CRC32 known
|
||||
vectors, determinism, receiver-side verification). 33/33 assertions
|
||||
pass. Benchmarks: decide() 3.2 ns/call, CRC32(56 B) 614 ns/pkt
|
||||
(87 MB/s), full finalize() 616 ns/call. Pure function
|
||||
`adaptive_controller_decide()` extracted to
|
||||
`adaptive_controller_decide.c` so the firmware build and the host
|
||||
tests share a single source-of-truth implementation.
|
||||
- **Scripts: `validate_qemu_output.py` ADR-081 checks** — Validator
|
||||
(invoked by ADR-061 `scripts/qemu-esp32s3-test.sh` in CI) gains three
|
||||
checks for adaptive controller boot line, mock radio ops
|
||||
registration, and slow-loop heartbeat, so QEMU runs regression-gate
|
||||
Layer 1/2 presence.
|
||||
- **Firmware: ADR-081 Layer 3 mesh sensing plane** — New
|
||||
`firmware/esp32-csi-node/main/rv_mesh.{h,c}` defines 4 node roles
|
||||
(Anchor / Observer / Fusion relay / Coordinator), 7 on-wire message
|
||||
types (TIME_SYNC, ROLE_ASSIGN, CHANNEL_PLAN, CALIBRATION_START,
|
||||
FEATURE_DELTA, HEALTH, ANOMALY_ALERT), 3 authorization classes
|
||||
(None / HMAC-SHA256-session / Ed25519-batch), `rv_node_status_t`
|
||||
(28 B), `rv_anomaly_alert_t` (28 B), `rv_time_sync_t`,
|
||||
`rv_role_assign_t`, `rv_channel_plan_t`, `rv_calibration_start_t`.
|
||||
Pure-C encoder/decoder (`rv_mesh_encode()` / `rv_mesh_decode()`) with
|
||||
16-byte envelope + payload + IEEE CRC32 trailer; convenience encoders
|
||||
for each message type. Controller now emits `HEALTH` every slow-loop
|
||||
tick (30 s default) and `ANOMALY_ALERT` on state transitions to ALERT
|
||||
or DEGRADED. Host tests: `test_rv_mesh` exercises 27 assertions
|
||||
covering roundtrip, bad magic, truncation, CRC flipping, oversize
|
||||
payload rejection, and encode+decode throughput (1.0 μs/roundtrip
|
||||
on host).
|
||||
- **Rust: ADR-081 Layer 1/3 mirror module** — New
|
||||
`crates/wifi-densepose-hardware/src/radio_ops.rs` mirrors the
|
||||
firmware-side `rv_radio_ops_t` vtable as the Rust `RadioOps` trait
|
||||
(init, set_channel, set_mode, set_csi_enabled, set_capture_profile,
|
||||
get_health) and provides `MockRadio` for offline testing.
|
||||
Also mirrors the `rv_mesh.h` types (`MeshHeader`, `NodeStatus`,
|
||||
`AnomalyAlert`, `MeshRole`, `MeshMsgType`, `AuthClass`) and ships
|
||||
byte-identical `crc32_ieee()`, `decode_mesh()`, `decode_node_status()`,
|
||||
`decode_anomaly_alert()`, and `encode_health()`. Exported from
|
||||
`lib.rs`. 8 unit tests pass; `crc32_matches_firmware_vectors`
|
||||
verifies parity with the firmware-side test vectors
|
||||
(`0xCBF43926` for `"123456789"`, `0xD202EF8D` for single-byte zero),
|
||||
and `mesh_constants_match_firmware` asserts `MESH_MAGIC`,
|
||||
`MESH_VERSION`, `MESH_HEADER_SIZE`, and `MESH_MAX_PAYLOAD` match
|
||||
`rv_mesh.h` byte-for-byte. Satisfies ADR-081's portability
|
||||
acceptance test: signal/ruvector/train/mat crates are untouched.
|
||||
- **Firmware: adaptive controller** — New
|
||||
`firmware/esp32-csi-node/main/adaptive_controller.{c,h}` implements
|
||||
the three-loop closed-loop control specified by ADR-081: fast
|
||||
(~200 ms) for cadence and active probing, medium (~1 s) for channel
|
||||
selection and role transitions, slow (~30 s) for baseline
|
||||
recalibration. Pure `adaptive_controller_decide()` policy function is
|
||||
exposed in the header for offline unit testing. Default policy is
|
||||
conservative (`enable_channel_switch` and `enable_role_change` off);
|
||||
Kconfig surface added under "Adaptive Controller (ADR-081)".
|
||||
|
||||
### Fixed
|
||||
- **Firmware: SPI flash cache crash under high CSI callback pressure** (RuView#396, #397) — ESP32-S3 nodes crashed in `cache_ll_l1_resume_icache` / `wDev_ProcessFiq` after ~2400 callbacks when the promiscuous filter admitted DATA frames at 100–500 Hz. Fixed by narrowing the filter mask to `WIFI_PROMIS_FILTER_MASK_MGMT` (~10 Hz beacons), adding a 50 Hz early callback rate gate (`CSI_MIN_PROCESS_INTERVAL_US`) that drops excess callbacks before any processing work, and enabling `CONFIG_ESP_WIFI_EXTRA_IRAM_OPT=y` as defense-in-depth. Stability validated with a 4-min-per-node soak.
|
||||
- **Firmware: `filter_mac` / `node_id` clobber by WiFi driver init** (#232, #375, #385, #386, #390, #397) — `g_nvs_config` can be corrupted during `wifi_init_sta()` on some devices (confirmed on `80:b5:4e:c1:be:b8`), reverting `node_id` to the Kconfig default and producing garbage MAC-filter reads in the CSI callback (100–500 Hz). New `csi_collector_set_node_id()` API called from `app_main()` **before** `wifi_init_sta()` captures both fields into module-local statics (`s_node_id`, `s_filter_mac`, `s_filter_mac_set`). `csi_collector_init()` now runs a canary that distinguishes "early≠g_nvs_config" (corruption confirmed) from a no-op match. All CSI runtime paths use the defensive copies exclusively.
|
||||
- **Firmware: `edge_processing` sample rate mismatch** (#397) — `estimate_bpm_zero_crossing()` was called with a hard-coded `sample_rate = 20.0f`, but MGMT-only promiscuous delivers ~10 Hz. Breathing and heart-rate reports were 2× too high. Corrected to `10.0f` with an explicit comment tying it to the callback rate.
|
||||
- **`provision.py` esptool command form** (#391, #397) — ESP-IDF v5.4 bundles `esptool 4.10.0`, which only accepts `write_flash` (underscore). Standalone `pip install esptool` v5.x accepts both forms but prefers `write-flash`. #391 switched to `write-flash` which broke the documented ESP-IDF Python venv flow; #397 reverts to `write_flash` (works with both esptool 4.x and 5.x) with an inline comment warning future maintainers not to "re-fix" it.
|
||||
- **`provision.py` esptool v5 dry-run hint** (#391) — Stale `write_flash` (underscore) syntax in the dry-run manual-flash hint now uses `write-flash` (hyphenated) for esptool >= 5.x. The primary flash command was already correct.
|
||||
- **`provision.py` silent NVS wipe** (#391) — The script replaces the entire `csi_cfg` NVS namespace on every run, so partial invocations were silently erasing WiFi credentials and causing `Retrying WiFi connection (10/10)` in the field. Now refuses to run without `--ssid`, `--password`, and `--target-ip` unless `--force-partial` is passed. `--force-partial` prints a warning listing which keys will be wiped.
|
||||
- **Firmware: defensive `node_id` capture** (#232, #375, #385, #386, #390) — Users on multi-node deployments reported `node_id` reverting to the Kconfig default (`1`) in UDP frames and in the `csi_collector` init log, despite NVS loading the correct value. The root cause (memory corruption of `g_nvs_config`) has not been definitively isolated, but the UDP frame header is now tamper-proof: `csi_collector_init()` captures `g_nvs_config.node_id` into a module-local `s_node_id` once, and `csi_serialize_frame()` plus all other consumers (`edge_processing.c`, `wasm_runtime.c`, `display_ui.c`, `swarm_bridge_init`) read it via the new `csi_collector_get_node_id()` accessor. A canary logs `WARN` if `g_nvs_config.node_id` diverges from `s_node_id` at end-of-init, helping isolate the upstream corruption path. Validated on attached ESP32-S3 (COM8): NVS `node_id=2` propagates through boot log, capture log, init log, and byte[4] of every UDP frame.
|
||||
|
||||
### Docs
|
||||
- **CHANGELOG catch-up** (#367) — Added missing entries for v0.5.5, v0.6.0, and v0.7.0 releases.
|
||||
|
||||
## [v0.7.0] — 2026-04-06
|
||||
|
||||
Model release (no new firmware binary). Firmware remains at v0.6.0-esp32.
|
||||
|
||||
### Added
|
||||
- **Camera ground-truth training pipeline (ADR-079)** — End-to-end supervised WiFlow pose training using MediaPipe + real ESP32 CSI.
|
||||
- `scripts/collect-ground-truth.py` — MediaPipe PoseLandmarker webcam capture (17 COCO keypoints, 30fps), synchronized with CSI recording over nanosecond timestamps.
|
||||
- `scripts/align-ground-truth.js` — Time-aligns camera keypoints with 20-frame CSI windows by binary search, confidence-weighted averaging.
|
||||
- `scripts/train-wiflow-supervised.js` — 3-phase curriculum training (contrastive → supervised SmoothL1 → bone/temporal refinement) with 4 scale presets (lite/small/medium/full).
|
||||
- `scripts/eval-wiflow.js` — PCK@10/20/50, MPJPE, per-joint breakdown, baseline proxy mode.
|
||||
- `scripts/record-csi-udp.py` — Lightweight ESP32 CSI UDP recorder (no Rust build required).
|
||||
- **ruvector optimizations (O6-O10)** — Subcarrier selection (70→35, 50% reduction), attention-weighted subcarriers, Stoer-Wagner min-cut person separation, multi-SPSA gradient estimation, Mac M4 Pro training via Tailscale.
|
||||
- **Scalable WiFlow presets** — `lite` (189K params, ~19 min) through `full` (7.7M params, ~8 hrs) to match dataset size.
|
||||
- **Pre-trained WiFlow v1 model** — 92.9% PCK@20, 974 KB, 186,946 params. Published to [HuggingFace](https://huggingface.co/ruv/ruview) under `wiflow-v1/`.
|
||||
|
||||
### Validated
|
||||
- **92.9% PCK@20** pose accuracy from a 5-minute data collection session with one $9 ESP32-S3 and one laptop webcam.
|
||||
- Training pipeline validated on real paired data: 345 samples, 19 min training, eval loss 0.082, bone constraint 0.008.
|
||||
|
||||
## [v0.6.0-esp32] — 2026-04-03
|
||||
|
||||
### Added
|
||||
- **Pre-trained CSI sensing weights published** — First official pre-trained models on [HuggingFace](https://huggingface.co/ruv/ruview). `model.safetensors` (48 KB), `model-q4.bin` (8 KB 4-bit), `model-q2.bin` (4 KB), `presence-head.json`, per-node LoRA adapters.
|
||||
- **17 sensing applications** — Sleep monitor, apnea detector, stress monitor, gait analyzer, RF tomography, passive radar, material classifier, through-wall detector, device fingerprint, and more. Each as a standalone `scripts/*.js`.
|
||||
- **ADRs 069-078** — 10 new architecture decisions covering Cognitum Seed integration, self-supervised pretraining, ruvllm pipeline, WiFlow architecture, channel hopping, SNN, MinCut person separation, CNN spectrograms, novel RF applications, multi-frequency mesh.
|
||||
- **Kalman tracker** (PR #341 by @taylorjdawson) — temporal smoothing of pose keypoints.
|
||||
|
||||
### Fixed
|
||||
- Security fix merged via PR #310.
|
||||
|
||||
### Performance
|
||||
- Presence detection: 100% accuracy on 60,630 overnight samples.
|
||||
- Inference: 0.008 ms per sample, 164K embeddings/sec.
|
||||
- Contrastive self-supervised training: 51.6% improvement over baseline.
|
||||
|
||||
## [v0.5.5-esp32] — 2026-04-03
|
||||
|
||||
### Added
|
||||
- **WiFlow SOTA architecture (ADR-072)** — TCN + axial attention pose decoder, 1.8M params, 881 KB at 4-bit. 17 COCO keypoints from CSI amplitude only (no phase).
|
||||
- **Multi-frequency mesh scanning (ADR-073)** — ESP32 nodes hop across channels 1/3/5/6/9/11 at 200ms dwell. Neighbor WiFi networks used as passive radar illuminators. Null subcarriers reduced from 19% to 16%.
|
||||
- **Spiking neural network (ADR-074)** — STDP online learning, adapts to new rooms in <30s with no labels, 16-160x less compute than batch training.
|
||||
- **MinCut person counting (ADR-075)** — Stoer-Wagner min-cut on subcarrier correlation graph. Fixes #348 (was always reporting 4 people).
|
||||
- **CNN spectrogram embeddings (ADR-076)** — Treat 64×20 CSI as an image, produce 128-dim environment fingerprints (0.95+ same-room similarity).
|
||||
- **Graph transformer fusion** — Multi-node CSI fusion via GATv2 attention (replaces naive averaging).
|
||||
- **Camera-free pose training pipeline** — Trains 17-keypoint model from 10 sensor signals with no camera required.
|
||||
|
||||
### Fixed
|
||||
- **#348 person counting** — MinCut correctly counts 1-4 people (24/24 validation windows).
|
||||
|
||||
## [v0.5.4-esp32] — 2026-04-02
|
||||
|
||||
### Added
|
||||
- **ADR-069: ESP32 CSI → Cognitum Seed RVF ingest pipeline** — Live-validated pipeline connecting ESP32-S3 CSI sensing to Cognitum Seed (Pi Zero 2 W) edge intelligence appliance. 339 vectors ingested, 100% kNN validation, SHA-256 witness chain verified.
|
||||
- **Feature vector packet (magic 0xC5110003)** — New 48-byte packet with 8 normalized dimensions (presence, motion, breathing, heart rate, phase variance, person count, fall, RSSI) sent at 1 Hz alongside vitals.
|
||||
- **`scripts/seed_csi_bridge.py`** — Python bridge: UDP listener → HTTPS ingest with bearer token auth, `--validate` (kNN + PIR ground truth), `--stats`, `--compact` modes, hash-based vector IDs, NaN/inf rejection, source IP filtering, retry logic.
|
||||
- **Arena Physica research** — 26 research documents in `docs/research/` covering Maxwell's equations in WiFi sensing, Arena Physica Studio analysis, SOTA WiFi sensing 2025-2026, GOAP implementation plan for ESP32 + Pi Zero.
|
||||
- **Cognitum Seed MCP integration** — 114-tool MCP proxy enables AI assistants to query sensing state, vectors, witness chain, and device status directly.
|
||||
|
||||
### Fixed
|
||||
- **Compressed frame magic collision** — Reassigned compressed frame magic from `0xC5110003` to `0xC5110005` to free `0xC5110003` for feature vectors.
|
||||
- **Uninitialized `s_top_k[0]` read** — Guarded variance computation against `s_top_k_count == 0` in `send_feature_vector()`.
|
||||
- **Presence score normalization** — Bridge now divides by 15.0 instead of clamping, preserving dynamic range for raw values 1.41-14.92.
|
||||
- **Stale magic references** — Updated ADR-039, DDD model to reflect `0xC5110005` for compressed frames.
|
||||
|
||||
### Security
|
||||
- **Credential exposure remediation** — Removed hardcoded WiFi passwords and bearer tokens from source files. Added NVS binary/CSV patterns to `.gitignore`. Environment variable fallback for bearer token.
|
||||
- **NaN/Inf injection prevention** — Bridge validates all feature dimensions are finite before Seed ingest.
|
||||
- **UDP source filtering** — `--allowed-sources` argument restricts packet acceptance to known ESP32 IPs.
|
||||
|
||||
### Changed
|
||||
- Wire format table now includes 6 magic numbers: `0xC5110001` (raw), `0xC5110002` (vitals), `0xC5110003` (features), `0xC5110004` (WASM events), `0xC5110005` (compressed), `0xC5110006` (fused vitals).
|
||||
|
||||
## [v0.5.3-esp32] — 2026-03-30
|
||||
|
||||
### Added
|
||||
- **Cross-node RSSI-weighted feature fusion** — Multiple ESP32 nodes fuse CSI features using RSSI-based weighting. Closer node gets higher weight. Reduces variance noise by 29%, keypoint jitter by 72%.
|
||||
- **DynamicMinCut person separation** — Uses `ruvector_mincut::DynamicMinCut` on the subcarrier temporal correlation graph to detect independent motion clusters. Replaces variance-based heuristic for multi-person counting.
|
||||
- **RSSI-based position tracking** — Skeleton position driven by RSSI differential between nodes. Walk between ESP32s and the skeleton follows you.
|
||||
- **Per-node state pipeline (ADR-068)** — Each ESP32 node gets independent `HashMap<u8, NodeState>` with frame history, classification, vitals, and person count. Fixes #249 (the #1 user-reported issue).
|
||||
- **RuVector Phase 1-3 integration** — Subcarrier importance weighting, temporal keypoint smoothing (EMA), coherence gating, skeleton kinematic constraints (Jakobsen relaxation), compressed pose history.
|
||||
- **Client-side lerp smoothing** — UI keypoints interpolate between frames (alpha=0.15) for fluid skeleton movement.
|
||||
- **Multi-node mesh tests** — 8 integration tests covering 1-255 node configurations.
|
||||
- **`wifi_densepose` Python package** — `from wifi_densepose import WiFiDensePose` now works (#314).
|
||||
|
||||
### Fixed
|
||||
- **Watchdog crash on busy LANs (#321)** — Batch-limited edge_dsp to 4 frames before 20ms yield. Fixed idle-path busy-spin (`pdMS_TO_TICKS(5)==0`).
|
||||
- **No detection from edge vitals (#323)** — Server now generates `sensing_update` from Tier 2+ vitals packets.
|
||||
- **RSSI byte offset mismatch (#332)** — Server parsed RSSI from wrong byte (was reading sequence counter).
|
||||
- **Stack overflow risk** — Moved 4KB of BPM scratch buffers from stack to static storage.
|
||||
- **Stale node memory leak** — `node_states` HashMap evicts nodes inactive >60s.
|
||||
- **Unsafe raw pointer removed** — Replaced with safe `.clone()` for adaptive model borrow.
|
||||
- **Firmware CI** — Upgraded to IDF v5.4, replaced `xxd` with `od` (#327).
|
||||
- **Person count double-counting** — Multi-node aggregation changed from `sum` to `max`.
|
||||
- **Skeleton jitter** — Removed tick-based noise, dampened procedural animation, recalibrated feature scaling for real ESP32 data.
|
||||
|
||||
### Changed
|
||||
- Motion-responsive skeleton: arm swing (0-80px) driven by CSI variance, leg kick (0-50px) by motion_band_power, vertical bob when walking.
|
||||
- Person count thresholds recalibrated for real ESP32 hardware (1→2 at 0.70, EMA alpha 0.04).
|
||||
- Vital sign filtering: larger median window (31), faster EMA (0.05), looser HR jump filter (15 BPM).
|
||||
- Vendored ruvector updated to v2.1.0-40 (316 commits ahead).
|
||||
|
||||
### Benchmarks (2-node mesh, COM6 + COM9, 30s)
|
||||
| Metric | Baseline | v0.5.3 | Improvement |
|
||||
|--------|----------|--------|-------------|
|
||||
| Variance noise | 109.4 | 77.6 | **-29%** |
|
||||
| Feature stability | std=154.1 | std=105.4 | **-32%** |
|
||||
| Keypoint jitter | std=4.5px | std=1.3px | **-72%** |
|
||||
| Confidence | 0.643 | 0.686 | **+7%** |
|
||||
| Presence accuracy | 93.4% | 94.6% | **+1.3pp** |
|
||||
|
||||
### Verified
|
||||
- Real hardware: COM6 (node 1) + COM9 (node 2) on ruv.net WiFi
|
||||
- All 284 Rust tests pass, 352 signal crate tests pass
|
||||
- Firmware builds clean at 843 KB
|
||||
- QEMU CI: 11/11 jobs green
|
||||
|
||||
## [v0.5.2-esp32] — 2026-03-28
|
||||
|
||||
### Fixed
|
||||
- RSSI byte offset in frame parser (#332)
|
||||
- Per-node state pipeline for multi-node sensing (#249)
|
||||
- Firmware CI upgraded to IDF v5.4 (#327)
|
||||
|
||||
## [v0.5.1-esp32] — 2026-03-27
|
||||
|
||||
### Fixed
|
||||
- Watchdog crash on busy LANs (#321)
|
||||
- No detection from edge vitals (#323)
|
||||
- `wifi_densepose` Python package import (#314)
|
||||
- Pre-compiled firmware binaries added to release
|
||||
|
||||
## [v0.5.0-esp32] — 2026-03-15
|
||||
|
||||
### Added
|
||||
- **60 GHz mmWave sensor fusion (ADR-063)** — Auto-detects Seeed MR60BHA2 (60 GHz, HR/BR/presence) and HLK-LD2410 (24 GHz, presence/distance) on UART at boot. Probes 115200 then 256000 baud, registers device capabilities, starts background parser.
|
||||
- **48-byte fused vitals packet** (magic `0xC5110004`) — Kalman-style fusion: mmWave 80% + CSI 20% when both available. Automatic fallback to standard 32-byte CSI-only packet.
|
||||
- **Server-side fusion bridge** (`scripts/mmwave_fusion_bridge.py`) — Reads two serial ports simultaneously for dual-sensor setups where mmWave runs on a separate ESP32.
|
||||
- **Multimodal ambient intelligence roadmap (ADR-064)** — 25+ applications from fall detection to sleep monitoring to RF tomography.
|
||||
|
||||
### Verified
|
||||
- Real hardware: ESP32-S3 (COM7) WiFi CSI + ESP32-C6/MR60BHA2 (COM4) 60 GHz mmWave running concurrently. HR=75 bpm, BR=25/min at 52 cm range. All 11 QEMU CI jobs green.
|
||||
|
||||
## [v0.4.3-esp32] — 2026-03-15
|
||||
|
||||
### Fixed
|
||||
- **Fall detection false positives (#263)** — Default threshold raised from 2.0 to 15.0 rad/s²; normal walking (2-5 rad/s²) no longer triggers alerts. Added 3-consecutive-frame debounce and 5-second cooldown between alerts. Verified on real ESP32-S3 hardware: 0 false alerts in 60s / 1,300+ live WiFi CSI frames.
|
||||
- **Kconfig default mismatch** — `CONFIG_EDGE_FALL_THRESH` Kconfig default was still 2000 (=2.0) while `nvs_config.c` fallback was updated to 15.0. Fixed Kconfig to 15000. Caught by real hardware testing — mock data did not reproduce.
|
||||
- **provision.py NVS generator API change** — `esp_idf_nvs_partition_gen` package changed its `generate()` signature; switched to subprocess-first invocation for cross-version compatibility.
|
||||
- **QEMU CI pipeline (11 jobs)** — Fixed all failures: fuzz test `esp_timer` stubs, QEMU `libgcrypt` dependency, NVS matrix generator, IDF container `pip` path, flash image padding, validation WARN handling, swarm `ip`/`cargo` missing.
|
||||
|
||||
### Added
|
||||
- **4MB flash support (#265)** — `partitions_4mb.csv` and `sdkconfig.defaults.4mb` for ESP32-S3 boards with 4MB flash (e.g. SuperMini). Dual OTA slots, 1.856 MB each. Thanks to @sebbu for the community workaround that confirmed feasibility.
|
||||
- **`--strict` flag** for `validate_qemu_output.py` — WARNs now pass by default in CI (no real WiFi in QEMU); use `--strict` to fail on warnings.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **QEMU ESP32-S3 testing platform (ADR-061)** — 9-layer firmware testing without hardware
|
||||
- Mock CSI generator with 10 physics-based scenarios (empty room, walking, fall, multi-person, etc.)
|
||||
- Single-node QEMU runner with 16-check UART validation
|
||||
- Multi-node TDM mesh simulation (TAP networking, 2-6 nodes)
|
||||
- GDB remote debugging with VS Code integration
|
||||
- Code coverage via gcov/lcov + apptrace
|
||||
- Fuzz testing (3 libFuzzer targets + ASAN/UBSAN)
|
||||
- NVS provisioning matrix (14 configs)
|
||||
- Snapshot-based regression testing (sub-second VM restore)
|
||||
- Chaos testing with fault injection + health monitoring
|
||||
- **QEMU Swarm Configurator (ADR-062)** — YAML-driven multi-ESP32 test orchestration
|
||||
- 4 topologies: star, mesh, line, ring
|
||||
- 3 node roles: sensor, coordinator, gateway
|
||||
- 9 swarm-level assertions (boot, crashes, TDM, frame rate, fall detection, etc.)
|
||||
- 7 presets: smoke (2n/15s), standard (3n/60s), ci-matrix, large-mesh, line-relay, ring-fault, heterogeneous
|
||||
- Health oracle with cross-node validation
|
||||
- **QEMU installer** (`install-qemu.sh`) — auto-detects OS, installs deps, builds Espressif QEMU fork
|
||||
- **Unified QEMU CLI** (`qemu-cli.sh`) — single entry point for all 11 QEMU test commands
|
||||
- CI: `firmware-qemu.yml` workflow with QEMU test matrix, fuzz testing, NVS validation, and swarm test jobs
|
||||
- User guide: QEMU testing and swarm configurator section with plain-language walkthrough
|
||||
|
||||
### Fixed
|
||||
- Firmware now boots in QEMU: WiFi/UDP/OTA/display guards for mock CSI mode
|
||||
- 9 bugs in mock_csi.c (LFSR bias, MAC filter init, scenario loop, overflow burst timing)
|
||||
- 23 bugs from ADR-061 deep review (inject_fault.py writes, CI cache, snapshot log corruption, etc.)
|
||||
- 16 bugs from ADR-062 deep review (log filename mismatch, SLIRP port collision, heap false positives, etc.)
|
||||
- All scripts: `--help` flags, prerequisite checks with install hints, standardized exit codes
|
||||
|
||||
- **Sensing server UI API completion (ADR-043)** — 14 fully-functional REST endpoints for model management, CSI recording, and training control
|
||||
- Model CRUD: `GET /api/v1/models`, `GET /api/v1/models/active`, `POST /api/v1/models/load`, `POST /api/v1/models/unload`, `DELETE /api/v1/models/:id`, `GET /api/v1/models/lora/profiles`, `POST /api/v1/models/lora/activate`
|
||||
- CSI recording: `GET /api/v1/recording/list`, `POST /api/v1/recording/start`, `POST /api/v1/recording/stop`, `DELETE /api/v1/recording/:id`
|
||||
@@ -746,7 +188,7 @@ Major release: complete Rust sensing server, full DensePose training pipeline, R
|
||||
- `PresenceClassifier` — rule-based 3-state classification (ABSENT / PRESENT_STILL / ACTIVE)
|
||||
- Cross-receiver agreement scoring for multi-AP confidence boosting
|
||||
- WebSocket sensing server (`ws_server.py`) broadcasting JSON at 2 Hz
|
||||
- Deterministic CSI proof bundles for reproducible verification (`archive/v1/data/proof/`)
|
||||
- Deterministic CSI proof bundles for reproducible verification (`v1/data/proof/`)
|
||||
- Commodity sensing unit tests (`b391638`)
|
||||
|
||||
### Changed
|
||||
@@ -754,7 +196,7 @@ Major release: complete Rust sensing server, full DensePose training pipeline, R
|
||||
|
||||
### Fixed
|
||||
- Review fixes for end-to-end training pipeline (`45f0304`)
|
||||
- Dockerfile paths updated from `src/` to `archive/v1/src/` (`7872987`)
|
||||
- Dockerfile paths updated from `src/` to `v1/src/` (`7872987`)
|
||||
- IoT profile installer instructions updated for aggregator CLI (`f460097`)
|
||||
- `process.env` reference removed from browser ES module (`e320bc9`)
|
||||
|
||||
|
||||
@@ -3,24 +3,25 @@
|
||||
## Project: wifi-densepose
|
||||
|
||||
WiFi-based human pose estimation using Channel State Information (CSI).
|
||||
Dual codebase: Python v1 (`v1/`) and Rust port (`v2/`).
|
||||
Dual codebase: Python v1 (`v1/`) and Rust port (`rust-port/wifi-densepose-rs/`).
|
||||
### Key Rust Crates
|
||||
| Crate | Description |
|
||||
|-------|-------------|
|
||||
| `wifi-densepose-core` | Core types, traits, error types, CSI frame primitives |
|
||||
| `wifi-densepose-signal` | SOTA signal processing + RuvSense multistatic sensing (16 modules) |
|
||||
| `wifi-densepose-signal` | SOTA signal processing + RuvSense multistatic sensing (14 modules) |
|
||||
| `wifi-densepose-nn` | Neural network inference (ONNX, PyTorch, Candle backends) |
|
||||
| `wifi-densepose-train` | Training pipeline with ruvector integration + ruview_metrics |
|
||||
| `wifi-densepose-mat` | Mass Casualty Assessment Tool — disaster survivor detection |
|
||||
| `wifi-densepose-hardware` | ESP32 aggregator, TDM protocol, channel hopping firmware |
|
||||
| `wifi-densepose-ruvector` | RuVector v2.0.4 integration + cross-viewpoint fusion (5 modules) |
|
||||
| `wifi-densepose-api` | REST API (Axum) |
|
||||
| `wifi-densepose-db` | Database layer (Postgres, SQLite, Redis) |
|
||||
| `wifi-densepose-config` | Configuration management |
|
||||
| `wifi-densepose-wasm` | WebAssembly bindings for browser deployment |
|
||||
| `wifi-densepose-cli` | CLI tool (`wifi-densepose` binary) |
|
||||
| `wifi-densepose-sensing-server` | Lightweight Axum server for WiFi sensing UI |
|
||||
| `wifi-densepose-wifiscan` | Multi-BSSID WiFi scanning (ADR-022) |
|
||||
| `wifi-densepose-vitals` | ESP32 CSI-grade vital sign extraction (ADR-021) |
|
||||
| `nvsim` | Deterministic NV-diamond magnetometer pipeline simulator (ADR-089) — standalone leaf, WASM-ready |
|
||||
| `vendor/rvcsi` (submodule) | **rvCSI** — edge RF sensing runtime (ADR-095/096): 9 crates (`rvcsi-core`/`-dsp`/`-events`/`-adapter-file`/`-adapter-nexmon`/`-ruvector`/`-runtime`/`-node`/`-cli`). Lives in its own repo ([github.com/ruvnet/rvcsi](https://github.com/ruvnet/rvcsi)), vendored here under `vendor/rvcsi`, published to crates.io as `rvcsi-* 0.3.x` and to npm as `@ruv/rvcsi`. Not a `v2/` workspace member — depend on the published crates (or the submodule's `crates/rvcsi-*` paths). Normalized `CsiFrame`/`CsiWindow`/`CsiEvent` schema, validate-before-FFI, reusable DSP, typed confidence-scored events, the napi-c Nexmon shim (real nexmon_csi `.pcap` from a Raspberry Pi 5 / 4 / 3B+ — BCM43455c0), the napi-rs SDK, the `rvcsi` CLI, a Claude Code plugin. |
|
||||
|
||||
### RuvSense Modules (`signal/src/ruvsense/`)
|
||||
| Module | Purpose |
|
||||
@@ -38,8 +39,6 @@ Dual codebase: Python v1 (`v1/`) and Rust port (`v2/`).
|
||||
| `cross_room.rs` | Environment fingerprinting, transition graph |
|
||||
| `gesture.rs` | DTW template matching gesture classifier |
|
||||
| `adversarial.rs` | Physically impossible signal detection, multi-link consistency |
|
||||
| `cir.rs` | ADR-134 CSI→CIR via ISTA L1 sparse recovery (NeumannSolver warm-start) |
|
||||
| `calibration.rs` | ADR-135 empty-room baseline (Welford amplitude + von Mises phase, drift trigger) |
|
||||
|
||||
### Cross-Viewpoint Fusion (`ruvector/src/viewpoint/`)
|
||||
| Module | Purpose |
|
||||
@@ -71,77 +70,44 @@ All 5 ruvector crates integrated in workspace:
|
||||
- ADR-031: RuView sensing-first RF mode (Proposed)
|
||||
- ADR-032: Multistatic mesh security hardening (Proposed)
|
||||
|
||||
### Supported Hardware
|
||||
|
||||
| Device | Port | Chip | Role | Cost |
|
||||
|--------|------|------|------|------|
|
||||
| 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 | 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.
|
||||
|
||||
### Build & Test Commands (this repo)
|
||||
```bash
|
||||
# Rust — full workspace tests (1,031+ tests, ~2 min)
|
||||
cd v2
|
||||
cd rust-port/wifi-densepose-rs
|
||||
cargo test --workspace --no-default-features
|
||||
|
||||
# Rust — single crate check (no GPU needed)
|
||||
cargo check -p wifi-densepose-train --no-default-features
|
||||
|
||||
# Rust — publish crates (dependency order)
|
||||
cargo publish -p wifi-densepose-core --no-default-features
|
||||
cargo publish -p wifi-densepose-signal --no-default-features
|
||||
# ... see crate publishing order below
|
||||
|
||||
# Python — deterministic proof verification (SHA-256)
|
||||
python archive/v1/data/proof/verify.py
|
||||
python v1/data/proof/verify.py
|
||||
|
||||
# Python — test suite
|
||||
cd archive/v1 && python -m pytest tests/ -x -q
|
||||
cd v1 && python -m pytest tests/ -x -q
|
||||
```
|
||||
|
||||
### ESP32 Firmware Build (Windows — Python subprocess required)
|
||||
```bash
|
||||
# Build 8MB firmware (real WiFi CSI mode, no mocks)
|
||||
# See CLAUDE.local.md for the full Python subprocess command
|
||||
# Key: must strip MSYSTEM env vars for ESP-IDF v5.4 on Git Bash
|
||||
|
||||
# Build 4MB firmware
|
||||
cp sdkconfig.defaults.4mb sdkconfig.defaults
|
||||
# then same build process
|
||||
|
||||
# Flash to COM7
|
||||
# [python, idf_py, '-p', 'COM7', 'flash']
|
||||
|
||||
# Provision WiFi
|
||||
python firmware/esp32-csi-node/provision.py --port COM7 \
|
||||
--ssid "YourWiFi" --password "secret" --target-ip 192.168.1.20
|
||||
|
||||
# Monitor serial
|
||||
python -m serial.tools.miniterm COM7 115200
|
||||
```
|
||||
|
||||
### Firmware Release Process
|
||||
1. Build 8MB from `sdkconfig.defaults.template` (no mock)
|
||||
2. Build 4MB from `sdkconfig.defaults.4mb` (no mock)
|
||||
3. Save 6 binaries: `esp32-csi-node.bin`, `bootloader.bin`, `partition-table.bin`, `ota_data_initial.bin`, `esp32-csi-node-4mb.bin`, `partition-table-4mb.bin`
|
||||
4. Tag: `git tag v0.X.Y-esp32 && git push origin v0.X.Y-esp32`
|
||||
5. Release: `gh release create v0.X.Y-esp32 <binaries> --title "..." --notes-file ...`
|
||||
6. Verify on real hardware (COM7) before publishing
|
||||
7. **CRITICAL:** Always test with real WiFi CSI, not mock mode — mock missed the Kconfig threshold bug
|
||||
|
||||
### Crate Publishing Order
|
||||
Crates must be published in dependency order:
|
||||
1. `wifi-densepose-core` (no internal deps)
|
||||
2. `wifi-densepose-vitals` (no internal deps)
|
||||
3. `wifi-densepose-wifiscan` (no internal deps)
|
||||
4. `wifi-densepose-hardware` (no internal deps)
|
||||
5. `wifi-densepose-signal` (depends on core)
|
||||
6. `wifi-densepose-nn` (no internal deps, workspace only)
|
||||
7. `wifi-densepose-ruvector` (no internal deps, workspace only)
|
||||
8. `wifi-densepose-train` (depends on signal, nn)
|
||||
9. `wifi-densepose-mat` (depends on core, signal, nn)
|
||||
10. `wifi-densepose-wasm` (depends on mat)
|
||||
11. `wifi-densepose-sensing-server` (depends on wifiscan)
|
||||
12. `wifi-densepose-cli` (depends on mat)
|
||||
5. `wifi-densepose-config` (no internal deps)
|
||||
6. `wifi-densepose-db` (no internal deps)
|
||||
7. `wifi-densepose-signal` (depends on core)
|
||||
8. `wifi-densepose-nn` (no internal deps, workspace only)
|
||||
9. `wifi-densepose-ruvector` (no internal deps, workspace only)
|
||||
10. `wifi-densepose-train` (depends on signal, nn)
|
||||
11. `wifi-densepose-mat` (depends on core, signal, nn)
|
||||
12. `wifi-densepose-api` (no internal deps)
|
||||
13. `wifi-densepose-wasm` (depends on mat)
|
||||
14. `wifi-densepose-sensing-server` (depends on wifiscan)
|
||||
15. `wifi-densepose-cli` (depends on mat)
|
||||
|
||||
### Validation & Witness Verification (ADR-028)
|
||||
|
||||
@@ -149,12 +115,12 @@ Crates must be published in dependency order:
|
||||
|
||||
```bash
|
||||
# 1. Rust tests — must be 1,031+ passed, 0 failed
|
||||
cd v2
|
||||
cd rust-port/wifi-densepose-rs
|
||||
cargo test --workspace --no-default-features
|
||||
|
||||
# 2. Python proof — must print VERDICT: PASS
|
||||
cd ..
|
||||
python archive/v1/data/proof/verify.py
|
||||
cd ../..
|
||||
python v1/data/proof/verify.py
|
||||
|
||||
# 3. Generate witness bundle (includes both above + firmware hashes)
|
||||
bash scripts/generate-witness-bundle.sh
|
||||
@@ -167,8 +133,8 @@ bash VERIFY.sh
|
||||
**If the Python proof hash changes** (e.g., numpy/scipy version update):
|
||||
```bash
|
||||
# Regenerate the expected hash, then verify it passes
|
||||
python archive/v1/data/proof/verify.py --generate-hash
|
||||
python archive/v1/data/proof/verify.py
|
||||
python v1/data/proof/verify.py --generate-hash
|
||||
python v1/data/proof/verify.py
|
||||
```
|
||||
|
||||
**Witness bundle contents** (`dist/witness-bundle-ADR028-<sha>.tar.gz`):
|
||||
@@ -181,9 +147,9 @@ python archive/v1/data/proof/verify.py
|
||||
- `VERIFY.sh` — One-command self-verification for recipients
|
||||
|
||||
**Key proof artifacts:**
|
||||
- `archive/v1/data/proof/verify.py` — Trust Kill Switch: feeds reference signal through production pipeline, hashes output
|
||||
- `archive/v1/data/proof/expected_features.sha256` — Published expected hash
|
||||
- `archive/v1/data/proof/sample_csi_data.json` — 1,000 synthetic CSI frames (seed=42)
|
||||
- `v1/data/proof/verify.py` — Trust Kill Switch: feeds reference signal through production pipeline, hashes output
|
||||
- `v1/data/proof/expected_features.sha256` — Published expected hash
|
||||
- `v1/data/proof/sample_csi_data.json` — 1,000 synthetic CSI frames (seed=42)
|
||||
- `docs/WITNESS-LOG-028.md` — 11-step reproducible verification procedure
|
||||
- `docs/adr/ADR-028-esp32-capability-audit.md` — Complete audit record
|
||||
|
||||
@@ -209,13 +175,13 @@ Active feature branch: `ruvsense-full-implementation` (PR #77)
|
||||
- NEVER save to root folder — use the directories below
|
||||
- `docs/adr/` — Architecture Decision Records (43 ADRs)
|
||||
- `docs/ddd/` — Domain-Driven Design models
|
||||
- `v2/crates/` — Rust workspace crates (15 crates)
|
||||
- `v2/crates/wifi-densepose-signal/src/ruvsense/` — RuvSense multistatic modules (14 files)
|
||||
- `v2/crates/wifi-densepose-ruvector/src/viewpoint/` — Cross-viewpoint fusion (5 files)
|
||||
- `v2/crates/wifi-densepose-hardware/src/esp32/` — ESP32 TDM protocol
|
||||
- `rust-port/wifi-densepose-rs/crates/` — Rust workspace crates (15 crates)
|
||||
- `rust-port/wifi-densepose-rs/crates/wifi-densepose-signal/src/ruvsense/` — RuvSense multistatic modules (14 files)
|
||||
- `rust-port/wifi-densepose-rs/crates/wifi-densepose-ruvector/src/viewpoint/` — Cross-viewpoint fusion (5 files)
|
||||
- `rust-port/wifi-densepose-rs/crates/wifi-densepose-hardware/src/esp32/` — ESP32 TDM protocol
|
||||
- `firmware/esp32-csi-node/main/` — ESP32 C firmware (channel hopping, NVS config, TDM)
|
||||
- `archive/v1/src/` — Python source (core, hardware, services, api)
|
||||
- `archive/v1/data/proof/` — Deterministic CSI proof bundles
|
||||
- `v1/src/` — Python source (core, hardware, services, api)
|
||||
- `v1/data/proof/` — Deterministic CSI proof bundles
|
||||
- `.claude-flow/` — Claude Flow coordination state (committed for team sharing)
|
||||
- `.claude/` — Claude Code settings, agents, memory (committed for team sharing)
|
||||
|
||||
@@ -241,7 +207,7 @@ Active feature branch: `ruvsense-full-implementation` (PR #77)
|
||||
Before merging any PR, verify each item applies and is addressed:
|
||||
|
||||
1. **Rust tests pass** — `cargo test --workspace --no-default-features` (1,031+ passed, 0 failed)
|
||||
2. **Python proof passes** — `python archive/v1/data/proof/verify.py` (VERDICT: PASS)
|
||||
2. **Python proof passes** — `python v1/data/proof/verify.py` (VERDICT: PASS)
|
||||
3. **README.md** — Update platform tables, crate descriptions, hardware tables, feature summaries if scope changed
|
||||
4. **CLAUDE.md** — Update crate table, ADR list, module tables, version if scope changed
|
||||
5. **CHANGELOG.md** — Add entry under `[Unreleased]` with what was added/fixed/changed
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
# Archive
|
||||
|
||||
Frozen, no-longer-active components of RuView preserved for historical
|
||||
reference, reproducibility, and load-bearing legacy paths the active
|
||||
codebase still depends on.
|
||||
|
||||
## What lives here
|
||||
|
||||
| Path | What it is | Why it's archived | Still load-bearing? |
|
||||
|------|------------|-------------------|---------------------|
|
||||
| `v1/` | Original Python implementation of RuView (CSI processing, hardware adapters, services, FastAPI) | Superseded by the Rust workspace at `v2/`; ~810× slower in benchmarks. Kept rather than deleted because the deterministic proof bundle (`v1/data/proof/`) is part of the pre-merge witness verification process per ADR-011 / ADR-028. | **Yes — for the proof bundle only.** Active code lives in `v2/`. |
|
||||
|
||||
## What "archived" means
|
||||
|
||||
- **Do not add new features here.** New work goes in `v2/`.
|
||||
- **Do not refactor or modernize the archived code beyond what is
|
||||
strictly necessary** to keep the load-bearing paths working. The
|
||||
Python proof bundle is intentionally frozen so that its SHA-256
|
||||
reproducibility holds across releases (per ADR-028's witness
|
||||
verification requirement).
|
||||
- **Bug fixes inside archived code are allowed** when the bug affects a
|
||||
still-load-bearing path (currently: only the Python proof). All
|
||||
other "bugs" in archived code are out-of-scope — they are part of
|
||||
the historical record and any fix would unnecessarily churn the
|
||||
witness hashes.
|
||||
- **CI continues to verify the load-bearing paths.**
|
||||
`.github/workflows/verify-pipeline.yml` runs the Python proof on
|
||||
every push and PR; if you change anything inside `archive/v1/src/`
|
||||
or `archive/v1/data/proof/`, expect the determinism check to flag
|
||||
it.
|
||||
|
||||
## Quick reference for the load-bearing paths
|
||||
|
||||
```bash
|
||||
# Run the deterministic Python proof (must print VERDICT: PASS)
|
||||
python archive/v1/data/proof/verify.py
|
||||
|
||||
# Regenerate the expected hash (only if numpy/scipy version legitimately changed)
|
||||
python archive/v1/data/proof/verify.py --generate-hash
|
||||
|
||||
# Run the full Python test suite (legacy, still maintained)
|
||||
cd archive/v1&& python -m pytest tests/ -x -q
|
||||
```
|
||||
|
||||
## Why we keep `v1/` rather than delete it
|
||||
|
||||
1. **Trust kill-switch.** The proof at `v1/data/proof/verify.py` feeds
|
||||
a known reference signal through the full pipeline and hashes the
|
||||
output. If the active code's behavior drifts, the hash changes and
|
||||
CI fails. This is what stops accidental regression in the science
|
||||
layer of the codebase.
|
||||
|
||||
2. **Witness verification.** ADR-028's witness-bundle process bundles
|
||||
the proof, the rust workspace test results, and firmware hashes
|
||||
into a tarball recipients can self-verify. Removing v1 would break
|
||||
that chain.
|
||||
|
||||
3. **Historical reference.** ADR-011 documents the "no mocks in
|
||||
production code" decision; the original violations and their fixes
|
||||
live in this Python codebase. The ADRs reference these paths.
|
||||
|
||||
If the time comes to retire the proof bundle (e.g., a Rust port of
|
||||
the proof exists and the Python version is no longer canonical), the
|
||||
right move is a single follow-up that simultaneously: ports the
|
||||
witness-bundle process, updates `verify-pipeline.yml`, and either
|
||||
deletes `archive/v1/` or moves it to a separate read-only repository.
|
||||
That decision belongs in its own ADR.
|
||||
|
||||
## See also
|
||||
|
||||
- `docs/adr/ADR-011-python-proof-of-reality-mock-elimination.md`
|
||||
- `docs/adr/ADR-028-esp32-capability-audit.md`
|
||||
- `archive/v1/data/proof/README.md` (if present)
|
||||
- `docs/WITNESS-LOG-028.md`
|
||||
@@ -1,54 +0,0 @@
|
||||
# WiFi-DensePose v1 (Python Implementation)
|
||||
|
||||
This directory contains the original Python implementation of WiFi-DensePose.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
v1/
|
||||
├── src/ # Python source code
|
||||
│ ├── api/ # REST API endpoints
|
||||
│ ├── config/ # Configuration management
|
||||
│ ├── core/ # Core processing logic
|
||||
│ ├── database/ # Database models and migrations
|
||||
│ ├── hardware/ # Hardware interfaces
|
||||
│ ├── middleware/ # API middleware
|
||||
│ ├── models/ # Neural network models
|
||||
│ ├── services/ # Business logic services
|
||||
│ └── tasks/ # Background tasks
|
||||
├── tests/ # Test suite
|
||||
├── docs/ # Documentation
|
||||
├── scripts/ # Utility scripts
|
||||
├── data/ # Data files
|
||||
├── setup.py # Package setup
|
||||
├── test_application.py # Application tests
|
||||
└── test_auth_rate_limit.py # Auth/rate limit tests
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.10+
|
||||
- PyTorch 2.0+
|
||||
- FastAPI
|
||||
- PostgreSQL/SQLite
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cd v1
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Start API server
|
||||
python -m src.main
|
||||
|
||||
# Run tests
|
||||
pytest tests/
|
||||
```
|
||||
|
||||
## Note
|
||||
|
||||
This is the legacy Python implementation. For the new Rust implementation with improved performance, see `/v2/`.
|
||||
@@ -1,130 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
CIR Verification Helper (ADR-134)
|
||||
|
||||
Optional Python comparator — invokes the Rust cir_proof_runner binary and
|
||||
checks its output against expected_cir_features.sha256.
|
||||
|
||||
Usage:
|
||||
python cir_verify_helper.py # verify against stored hash
|
||||
python cir_verify_helper.py --generate # regenerate hash via Rust binary
|
||||
|
||||
This script is a thin wrapper; all cryptographic work is done in the Rust
|
||||
binary. It exists to integrate the CIR proof step into the Python verify.py
|
||||
flow if needed.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
REPO_ROOT = os.path.abspath(os.path.join(SCRIPT_DIR, "..", "..", "..", ".."))
|
||||
|
||||
|
||||
def find_binary() -> str:
|
||||
"""Locate the cir_proof_runner binary."""
|
||||
candidates = [
|
||||
os.path.join(REPO_ROOT, "v2", "target", "release", "cir_proof_runner"),
|
||||
os.path.join(REPO_ROOT, "v2", "target", "release", "cir_proof_runner.exe"),
|
||||
os.path.join(REPO_ROOT, "v2", "target", "debug", "cir_proof_runner"),
|
||||
os.path.join(REPO_ROOT, "v2", "target", "debug", "cir_proof_runner.exe"),
|
||||
]
|
||||
for path in candidates:
|
||||
if os.path.isfile(path):
|
||||
return path
|
||||
return ""
|
||||
|
||||
|
||||
def build_binary() -> bool:
|
||||
"""Build the release binary via cargo."""
|
||||
print("Building cir_proof_runner (release)...")
|
||||
result = subprocess.run(
|
||||
[
|
||||
"cargo", "build",
|
||||
"-p", "wifi-densepose-signal",
|
||||
"--bin", "cir_proof_runner",
|
||||
"--release",
|
||||
"--no-default-features",
|
||||
],
|
||||
cwd=os.path.join(REPO_ROOT, "v2"),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print("Build failed:", result.stderr[-2000:])
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def run_generate(binary: str) -> str:
|
||||
"""Run the binary with --generate-hash; return the hex hash."""
|
||||
result = subprocess.run(
|
||||
[binary, "--generate-hash"],
|
||||
cwd=REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print("Error running binary:", result.stderr)
|
||||
return ""
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def run_verify(binary: str) -> bool:
|
||||
"""Run the binary in verify mode; return True on PASS."""
|
||||
result = subprocess.run(
|
||||
[binary],
|
||||
cwd=REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
print(result.stdout.strip())
|
||||
if result.stderr.strip():
|
||||
print(result.stderr.strip(), file=sys.stderr)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="CIR verification helper (ADR-134)")
|
||||
parser.add_argument(
|
||||
"--generate",
|
||||
action="store_true",
|
||||
help="Regenerate expected_cir_features.sha256 via Rust binary",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--build",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Build the binary before running (default: use cached binary)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
binary = find_binary()
|
||||
|
||||
if args.build or not binary:
|
||||
if not build_binary():
|
||||
sys.exit(1)
|
||||
binary = find_binary()
|
||||
|
||||
if not binary:
|
||||
print("ERROR: cir_proof_runner binary not found. Run with --build.")
|
||||
sys.exit(1)
|
||||
|
||||
if args.generate:
|
||||
hash_val = run_generate(binary)
|
||||
if not hash_val:
|
||||
sys.exit(1)
|
||||
hash_file = os.path.join(SCRIPT_DIR, "expected_cir_features.sha256")
|
||||
with open(hash_file, "w") as f:
|
||||
f.write(hash_val + "\n")
|
||||
print(f"Wrote CIR hash to {hash_file}")
|
||||
print(f"Hash: {hash_val}")
|
||||
else:
|
||||
ok = run_verify(binary)
|
||||
sys.exit(0 if ok else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1 +0,0 @@
|
||||
d6bce07ecb1648e6936561df44bf4a3bfc17bb0ba5f692646b2301d105b52f67
|
||||
@@ -1 +0,0 @@
|
||||
120bd7b1f549f57f3773971a389c48c2bdd99b4ab1f205935867a16e95583995
|
||||
@@ -1 +0,0 @@
|
||||
ca58956c1bbee8c46f1798b3d6b6f1f829aa5db90bba53e07177830eca429199
|
||||
@@ -1,307 +0,0 @@
|
||||
"""
|
||||
JWT Authentication middleware for WiFi-DensePose API
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from jose import JWTError, jwt
|
||||
|
||||
from src.config.settings import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AuthMiddleware(BaseHTTPMiddleware):
|
||||
"""JWT Authentication middleware."""
|
||||
|
||||
def __init__(self, app):
|
||||
super().__init__(app)
|
||||
self.settings = get_settings()
|
||||
|
||||
# Paths that don't require authentication
|
||||
self.public_paths = {
|
||||
"/",
|
||||
"/docs",
|
||||
"/redoc",
|
||||
"/openapi.json",
|
||||
"/health",
|
||||
"/ready",
|
||||
"/live",
|
||||
"/version",
|
||||
"/metrics"
|
||||
}
|
||||
|
||||
# Paths that require authentication
|
||||
self.protected_paths = {
|
||||
"/api/v1/pose/analyze",
|
||||
"/api/v1/pose/calibrate",
|
||||
"/api/v1/pose/historical",
|
||||
"/api/v1/stream/start",
|
||||
"/api/v1/stream/stop",
|
||||
"/api/v1/stream/clients",
|
||||
"/api/v1/stream/broadcast"
|
||||
}
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
"""Process request through authentication middleware."""
|
||||
|
||||
# Skip authentication for public paths
|
||||
if self._is_public_path(request.url.path):
|
||||
return await call_next(request)
|
||||
|
||||
# Extract and validate token
|
||||
token = self._extract_token(request)
|
||||
|
||||
if token:
|
||||
try:
|
||||
# Verify token and add user info to request state
|
||||
user_data = await self._verify_token(token)
|
||||
request.state.user = user_data
|
||||
request.state.authenticated = True
|
||||
|
||||
logger.debug(f"Authenticated user: {user_data.get('id')}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Token validation failed: {e}")
|
||||
|
||||
# For protected paths, return 401
|
||||
if self._is_protected_path(request.url.path):
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={
|
||||
"error": {
|
||||
"code": 401,
|
||||
"message": "Invalid or expired token",
|
||||
"type": "authentication_error"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# For other paths, continue without authentication
|
||||
request.state.user = None
|
||||
request.state.authenticated = False
|
||||
else:
|
||||
# No token provided
|
||||
if self._is_protected_path(request.url.path):
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={
|
||||
"error": {
|
||||
"code": 401,
|
||||
"message": "Authentication required",
|
||||
"type": "authentication_error"
|
||||
}
|
||||
},
|
||||
headers={"WWW-Authenticate": "Bearer"}
|
||||
)
|
||||
|
||||
request.state.user = None
|
||||
request.state.authenticated = False
|
||||
|
||||
# Continue with request processing
|
||||
response = await call_next(request)
|
||||
|
||||
# Add authentication headers to response
|
||||
if hasattr(request.state, 'user') and request.state.user:
|
||||
response.headers["X-User-ID"] = request.state.user.get("id", "")
|
||||
response.headers["X-Authenticated"] = "true"
|
||||
else:
|
||||
response.headers["X-Authenticated"] = "false"
|
||||
|
||||
return response
|
||||
|
||||
def _is_public_path(self, path: str) -> bool:
|
||||
"""Check if path is public (doesn't require authentication)."""
|
||||
# Exact match
|
||||
if path in self.public_paths:
|
||||
return True
|
||||
|
||||
# Pattern matching for public paths
|
||||
public_patterns = [
|
||||
"/health",
|
||||
"/metrics",
|
||||
"/api/v1/pose/current", # Allow anonymous access to current pose data
|
||||
"/api/v1/pose/zones/", # Allow anonymous access to zone data
|
||||
"/api/v1/pose/activities", # Allow anonymous access to activities
|
||||
"/api/v1/pose/stats", # Allow anonymous access to stats
|
||||
"/api/v1/stream/status" # Allow anonymous access to stream status
|
||||
]
|
||||
|
||||
for pattern in public_patterns:
|
||||
if path.startswith(pattern):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _is_protected_path(self, path: str) -> bool:
|
||||
"""Check if path requires authentication."""
|
||||
# Exact match
|
||||
if path in self.protected_paths:
|
||||
return True
|
||||
|
||||
# Pattern matching for protected paths
|
||||
protected_patterns = [
|
||||
"/api/v1/pose/analyze",
|
||||
"/api/v1/pose/calibrate",
|
||||
"/api/v1/pose/historical",
|
||||
"/api/v1/stream/start",
|
||||
"/api/v1/stream/stop",
|
||||
"/api/v1/stream/clients",
|
||||
"/api/v1/stream/broadcast"
|
||||
]
|
||||
|
||||
for pattern in protected_patterns:
|
||||
if path.startswith(pattern):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _extract_token(self, request: Request) -> Optional[str]:
|
||||
"""Extract JWT token from request."""
|
||||
# Check Authorization header
|
||||
auth_header = request.headers.get("authorization")
|
||||
if auth_header and auth_header.startswith("Bearer "):
|
||||
return auth_header.split(" ")[1]
|
||||
|
||||
# Check query parameter (for WebSocket connections)
|
||||
token = request.query_params.get("token")
|
||||
if token:
|
||||
return token
|
||||
|
||||
# Check cookie
|
||||
token = request.cookies.get("access_token")
|
||||
if token:
|
||||
return token
|
||||
|
||||
return None
|
||||
|
||||
async def _verify_token(self, token: str) -> Dict[str, Any]:
|
||||
"""Verify JWT token and return user data."""
|
||||
try:
|
||||
# Decode JWT token
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
self.settings.secret_key,
|
||||
algorithms=[self.settings.jwt_algorithm]
|
||||
)
|
||||
|
||||
# Check token blacklist (logout invalidation)
|
||||
if token_blacklist.is_blacklisted(token):
|
||||
raise ValueError("Token has been revoked")
|
||||
|
||||
# Extract user information
|
||||
user_id = payload.get("sub")
|
||||
if not user_id:
|
||||
raise ValueError("Token missing user ID")
|
||||
|
||||
# Check token expiration
|
||||
exp = payload.get("exp")
|
||||
if exp and datetime.utcnow() > datetime.fromtimestamp(exp):
|
||||
raise ValueError("Token expired")
|
||||
|
||||
# Build user object
|
||||
user_data = {
|
||||
"id": user_id,
|
||||
"username": payload.get("username"),
|
||||
"email": payload.get("email"),
|
||||
"is_admin": payload.get("is_admin", False),
|
||||
"permissions": payload.get("permissions", []),
|
||||
"accessible_zones": payload.get("accessible_zones", []),
|
||||
"token_issued_at": payload.get("iat"),
|
||||
"token_expires_at": payload.get("exp"),
|
||||
"session_id": payload.get("session_id")
|
||||
}
|
||||
|
||||
return user_data
|
||||
|
||||
except JWTError as e:
|
||||
raise ValueError(f"JWT validation failed: {e}")
|
||||
except Exception as e:
|
||||
raise ValueError(f"Token verification error: {e}")
|
||||
|
||||
# TODO: Wire up authentication event logging in dispatch() for
|
||||
# security monitoring (login failures, token expiry, etc.).
|
||||
|
||||
|
||||
class TokenBlacklist:
|
||||
"""Simple in-memory token blacklist for logout functionality."""
|
||||
|
||||
def __init__(self):
|
||||
self._blacklisted_tokens = set()
|
||||
self._cleanup_interval = 3600 # 1 hour
|
||||
self._last_cleanup = datetime.utcnow()
|
||||
|
||||
def add_token(self, token: str):
|
||||
"""Add token to blacklist."""
|
||||
self._blacklisted_tokens.add(token)
|
||||
self._cleanup_if_needed()
|
||||
|
||||
def is_blacklisted(self, token: str) -> bool:
|
||||
"""Check if token is blacklisted."""
|
||||
self._cleanup_if_needed()
|
||||
return token in self._blacklisted_tokens
|
||||
|
||||
def _cleanup_if_needed(self):
|
||||
"""Clean up expired tokens from blacklist."""
|
||||
now = datetime.utcnow()
|
||||
if (now - self._last_cleanup).total_seconds() > self._cleanup_interval:
|
||||
# In a real implementation, you would check token expiration
|
||||
# For now, we'll just clear old tokens periodically
|
||||
self._blacklisted_tokens.clear()
|
||||
self._last_cleanup = now
|
||||
|
||||
|
||||
# Global token blacklist instance
|
||||
token_blacklist = TokenBlacklist()
|
||||
|
||||
|
||||
class SecurityHeaders:
|
||||
"""Security headers for API responses."""
|
||||
|
||||
@staticmethod
|
||||
def add_security_headers(response: Response) -> Response:
|
||||
"""Add security headers to response."""
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
response.headers["X-XSS-Protection"] = "1; mode=block"
|
||||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||
response.headers["Content-Security-Policy"] = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline'; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"img-src 'self' data:; "
|
||||
"connect-src 'self' ws: wss:;"
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class APIKeyAuth:
|
||||
"""Alternative API key authentication for service-to-service communication."""
|
||||
|
||||
def __init__(self, api_keys: Dict[str, Dict[str, Any]] = None):
|
||||
self.api_keys = api_keys or {}
|
||||
|
||||
def verify_api_key(self, api_key: str) -> Optional[Dict[str, Any]]:
|
||||
"""Verify API key and return associated service info."""
|
||||
if api_key in self.api_keys:
|
||||
return self.api_keys[api_key]
|
||||
return None
|
||||
|
||||
def add_api_key(self, api_key: str, service_info: Dict[str, Any]):
|
||||
"""Add new API key."""
|
||||
self.api_keys[api_key] = service_info
|
||||
|
||||
def revoke_api_key(self, api_key: str):
|
||||
"""Revoke API key."""
|
||||
if api_key in self.api_keys:
|
||||
del self.api_keys[api_key]
|
||||
|
||||
|
||||
# Global API key auth instance
|
||||
api_key_auth = APIKeyAuth()
|
||||
@@ -1,7 +0,0 @@
|
||||
"""
|
||||
API routers package
|
||||
"""
|
||||
|
||||
from . import pose, stream, health, auth
|
||||
|
||||
__all__ = ["pose", "stream", "health", "auth"]
|
||||
@@ -1,32 +0,0 @@
|
||||
"""
|
||||
Authentication router for WiFi-DensePose API.
|
||||
Provides logout (token blacklisting) endpoint.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException, status
|
||||
|
||||
from src.api.middleware.auth import token_blacklist
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(request: Request):
|
||||
"""Logout by blacklisting the current Bearer token."""
|
||||
auth_header = request.headers.get("authorization")
|
||||
if not auth_header or not auth_header.startswith("Bearer "):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Missing or invalid Authorization header",
|
||||
)
|
||||
|
||||
token = auth_header.split(" ", 1)[1]
|
||||
token_blacklist.add_token(token)
|
||||
logger.info("Token blacklisted via /auth/logout")
|
||||
|
||||
return {"success": True, "message": "Token revoked"}
|
||||
@@ -1,656 +0,0 @@
|
||||
"""CSI data extraction from WiFi hardware using Test-Driven Development approach."""
|
||||
|
||||
import asyncio
|
||||
import struct
|
||||
import numpy as np
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, Any, Optional, Callable, Protocol
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
|
||||
|
||||
class CSIParseError(Exception):
|
||||
"""Exception raised for CSI parsing errors."""
|
||||
pass
|
||||
|
||||
|
||||
class CSIValidationError(Exception):
|
||||
"""Exception raised for CSI validation errors."""
|
||||
pass
|
||||
|
||||
|
||||
class CSIExtractionError(Exception):
|
||||
"""Exception raised when CSI data extraction fails.
|
||||
|
||||
This error is raised instead of silently returning random/placeholder data.
|
||||
Callers should handle this to inform users that real hardware data is required.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class CSIData:
|
||||
"""Data structure for CSI measurements."""
|
||||
timestamp: datetime
|
||||
amplitude: np.ndarray
|
||||
phase: np.ndarray
|
||||
frequency: float
|
||||
bandwidth: float
|
||||
num_subcarriers: int
|
||||
num_antennas: int
|
||||
snr: float
|
||||
metadata: Dict[str, Any]
|
||||
|
||||
|
||||
class CSIParser(Protocol):
|
||||
"""Protocol for CSI data parsers."""
|
||||
|
||||
def parse(self, raw_data: bytes) -> CSIData:
|
||||
"""Parse raw CSI data into structured format."""
|
||||
...
|
||||
|
||||
|
||||
class ESP32CSIParser:
|
||||
"""Parser for ESP32 CSI data format."""
|
||||
|
||||
def parse(self, raw_data: bytes) -> CSIData:
|
||||
"""Parse ESP32 CSI data format.
|
||||
|
||||
Args:
|
||||
raw_data: Raw bytes from ESP32
|
||||
|
||||
Returns:
|
||||
Parsed CSI data
|
||||
|
||||
Raises:
|
||||
CSIParseError: If data format is invalid
|
||||
"""
|
||||
if not raw_data:
|
||||
raise CSIParseError("Empty data received")
|
||||
|
||||
try:
|
||||
data_str = raw_data.decode('utf-8')
|
||||
if not data_str.startswith('CSI_DATA:'):
|
||||
raise CSIParseError("Invalid ESP32 CSI data format")
|
||||
|
||||
# Parse ESP32 format: CSI_DATA:timestamp,antennas,subcarriers,freq,bw,snr,[amp],[phase]
|
||||
parts = data_str[9:].split(',') # Remove 'CSI_DATA:' prefix
|
||||
|
||||
timestamp_ms = int(parts[0])
|
||||
num_antennas = int(parts[1])
|
||||
num_subcarriers = int(parts[2])
|
||||
frequency_mhz = float(parts[3])
|
||||
bandwidth_mhz = float(parts[4])
|
||||
snr = float(parts[5])
|
||||
|
||||
# Convert to proper units
|
||||
frequency = frequency_mhz * 1e6 # MHz to Hz
|
||||
bandwidth = bandwidth_mhz * 1e6 # MHz to Hz
|
||||
|
||||
# Parse amplitude and phase arrays from the remaining CSV fields.
|
||||
# Expected format after the header fields: comma-separated float values
|
||||
# representing interleaved amplitude and phase per antenna per subcarrier.
|
||||
data_values = parts[6:]
|
||||
expected_values = num_antennas * num_subcarriers * 2 # amplitude + phase
|
||||
|
||||
if len(data_values) < expected_values:
|
||||
raise CSIExtractionError(
|
||||
f"ESP32 CSI data incomplete: expected {expected_values} values "
|
||||
f"(amplitude + phase for {num_antennas} antennas x {num_subcarriers} subcarriers), "
|
||||
f"but received {len(data_values)} values. "
|
||||
"Ensure the ESP32 firmware is configured to output full CSI matrix data. "
|
||||
"See docs/hardware-setup.md for ESP32 CSI configuration."
|
||||
)
|
||||
|
||||
try:
|
||||
float_values = [float(v) for v in data_values[:expected_values]]
|
||||
except ValueError as ve:
|
||||
raise CSIExtractionError(
|
||||
f"ESP32 CSI data contains non-numeric values: {ve}. "
|
||||
"Raw CSI fields must be numeric float values."
|
||||
)
|
||||
|
||||
all_values = np.array(float_values)
|
||||
amplitude = all_values[:num_antennas * num_subcarriers].reshape(num_antennas, num_subcarriers)
|
||||
phase = all_values[num_antennas * num_subcarriers:].reshape(num_antennas, num_subcarriers)
|
||||
|
||||
return CSIData(
|
||||
timestamp=datetime.fromtimestamp(timestamp_ms / 1000, tz=timezone.utc),
|
||||
amplitude=amplitude,
|
||||
phase=phase,
|
||||
frequency=frequency,
|
||||
bandwidth=bandwidth,
|
||||
num_subcarriers=num_subcarriers,
|
||||
num_antennas=num_antennas,
|
||||
snr=snr,
|
||||
metadata={'source': 'esp32', 'raw_length': len(raw_data)}
|
||||
)
|
||||
|
||||
except (ValueError, IndexError) as e:
|
||||
raise CSIParseError(f"Failed to parse ESP32 data: {e}")
|
||||
|
||||
|
||||
class ESP32BinaryParser:
|
||||
"""Parser for ADR-018 binary CSI frames from ESP32 nodes.
|
||||
|
||||
Binary frame format:
|
||||
Offset Size Field
|
||||
0 4 Magic: 0xC5110001 (LE)
|
||||
4 1 Node ID
|
||||
5 1 Number of antennas
|
||||
6 2 Number of subcarriers (LE u16)
|
||||
8 4 Frequency MHz (LE u32)
|
||||
12 4 Sequence number (LE u32)
|
||||
16 1 RSSI (i8)
|
||||
17 1 Noise floor (i8)
|
||||
18 1 PPDU type (ADR-110): 0=HT/legacy, 1=HE-SU, 2=HE-MU,
|
||||
3=HE-TB, 0xFF=unknown. Pre-ADR-110 firmware sends 0.
|
||||
19 1 Flags (ADR-110): bit 0 = bw40, bit 2 = STBC,
|
||||
bit 3 = LDPC, bit 4 = cross-node sync valid
|
||||
(set by either c6_timesync OR c6_sync_espnow
|
||||
since v0.7.0 — ADR-110 §A0.13).
|
||||
20 N*2 I/Q pairs (n_antennas * n_subcarriers * 2 bytes, signed i8)
|
||||
|
||||
Sibling packet (ADR-110 §A0.12, firmware v0.6.9+): the node also
|
||||
emits a 32-byte UDP sync packet (magic 0xC511A110) every
|
||||
CONFIG_C6_SYNC_EVERY_N_FRAMES frames on the same UDP socket.
|
||||
See parse_sync_packet() / SyncPacket below.
|
||||
"""
|
||||
|
||||
MAGIC = 0xC5110001
|
||||
HEADER_SIZE = 20
|
||||
# ADR-110: previously '<IBBHIIBB2x' (last 2 bytes skipped as reserved).
|
||||
# Now read those 2 bytes as PPDU type + flags. Pre-ADR-110 firmware
|
||||
# sends zeros, which decode as 'HT/legacy' + 'no flags' — fully
|
||||
# backwards compatible.
|
||||
HEADER_FMT = '<IBBHIIBBBB' # +2 bytes: ppdu_type, flags
|
||||
|
||||
# ADR-110 PPDU type byte values
|
||||
PPDU_HT_LEGACY = 0
|
||||
PPDU_HE_SU = 1
|
||||
PPDU_HE_MU = 2
|
||||
PPDU_HE_TB = 3
|
||||
PPDU_UNKNOWN = 0xFF
|
||||
_PPDU_NAMES = {0: 'ht_legacy', 1: 'he_su', 2: 'he_mu', 3: 'he_tb', 0xFF: 'unknown'}
|
||||
|
||||
def parse(self, raw_data: bytes) -> CSIData:
|
||||
"""Parse an ADR-018 binary frame into CSIData.
|
||||
|
||||
Args:
|
||||
raw_data: Raw binary frame bytes.
|
||||
|
||||
Returns:
|
||||
Parsed CSI data with amplitude/phase arrays shaped (n_antennas, n_subcarriers).
|
||||
|
||||
Raises:
|
||||
CSIParseError: If frame is too short, has invalid magic, or malformed I/Q data.
|
||||
"""
|
||||
if len(raw_data) < self.HEADER_SIZE:
|
||||
raise CSIParseError(
|
||||
f"Frame too short: need {self.HEADER_SIZE} bytes, got {len(raw_data)}"
|
||||
)
|
||||
|
||||
magic, node_id, n_antennas, n_subcarriers, freq_mhz, sequence, rssi_u8, noise_u8, \
|
||||
ppdu_byte, flags_byte = struct.unpack_from(self.HEADER_FMT, raw_data, 0)
|
||||
|
||||
if magic != self.MAGIC:
|
||||
raise CSIParseError(
|
||||
f"Invalid magic: expected 0x{self.MAGIC:08X}, got 0x{magic:08X}"
|
||||
)
|
||||
|
||||
# Convert unsigned bytes to signed i8
|
||||
rssi = rssi_u8 if rssi_u8 < 128 else rssi_u8 - 256
|
||||
noise_floor = noise_u8 if noise_u8 < 128 else noise_u8 - 256
|
||||
|
||||
iq_count = n_antennas * n_subcarriers
|
||||
iq_bytes = iq_count * 2
|
||||
expected_len = self.HEADER_SIZE + iq_bytes
|
||||
|
||||
if len(raw_data) < expected_len:
|
||||
raise CSIParseError(
|
||||
f"Frame too short for I/Q data: need {expected_len} bytes, got {len(raw_data)}"
|
||||
)
|
||||
|
||||
# Parse I/Q pairs as signed bytes
|
||||
iq_raw = struct.unpack_from(f'<{iq_count * 2}b', raw_data, self.HEADER_SIZE)
|
||||
i_vals = np.array(iq_raw[0::2], dtype=np.float64).reshape(n_antennas, n_subcarriers)
|
||||
q_vals = np.array(iq_raw[1::2], dtype=np.float64).reshape(n_antennas, n_subcarriers)
|
||||
|
||||
amplitude = np.sqrt(i_vals ** 2 + q_vals ** 2)
|
||||
phase = np.arctan2(q_vals, i_vals)
|
||||
|
||||
snr = float(rssi - noise_floor)
|
||||
frequency = float(freq_mhz) * 1e6
|
||||
bandwidth = 20e6 # default; could infer from n_subcarriers
|
||||
|
||||
if n_subcarriers <= 56:
|
||||
bandwidth = 20e6
|
||||
elif n_subcarriers <= 114:
|
||||
bandwidth = 40e6
|
||||
elif n_subcarriers <= 242:
|
||||
bandwidth = 80e6
|
||||
else:
|
||||
bandwidth = 160e6
|
||||
|
||||
return CSIData(
|
||||
timestamp=datetime.now(tz=timezone.utc),
|
||||
amplitude=amplitude,
|
||||
phase=phase,
|
||||
frequency=frequency,
|
||||
bandwidth=bandwidth,
|
||||
num_subcarriers=n_subcarriers,
|
||||
num_antennas=n_antennas,
|
||||
snr=snr,
|
||||
metadata={
|
||||
'source': 'esp32_binary',
|
||||
'node_id': node_id,
|
||||
'sequence': sequence,
|
||||
'rssi_dbm': rssi,
|
||||
'noise_floor_dbm': noise_floor,
|
||||
'channel_freq_mhz': freq_mhz,
|
||||
# ADR-110 extension — zeros from pre-ADR-110 firmware land here as
|
||||
# 'ht_legacy' + all-flags-false. New consumers can branch on
|
||||
# ppdu_type / he_capable for HE-LTF-aware DSP.
|
||||
'ppdu_type': self._PPDU_NAMES.get(ppdu_byte, 'unknown'),
|
||||
'ppdu_type_raw': ppdu_byte,
|
||||
'he_capable': ppdu_byte in (1, 2, 3),
|
||||
'bw40': bool(flags_byte & 0x01),
|
||||
'stbc': bool(flags_byte & 0x04),
|
||||
'ldpc': bool(flags_byte & 0x08),
|
||||
'ieee802154_sync_valid': bool(flags_byte & 0x10),
|
||||
'adr018_flags_raw': flags_byte,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SyncPacket:
|
||||
"""ADR-110 §A0.12 sync packet (firmware v0.6.9+, magic 0xC511A110).
|
||||
|
||||
Emitted on the same UDP socket as CSI frames every
|
||||
CONFIG_C6_SYNC_EVERY_N_FRAMES frames. Carries the mesh-aligned
|
||||
epoch for the node alongside the high-water CSI sequence number,
|
||||
so the host aggregator can pair (node_id, sequence) across the two
|
||||
packet streams and recover a mesh-aligned timestamp for every CSI
|
||||
frame. See WITNESS-LOG-110 §A0.12 for the live verification.
|
||||
"""
|
||||
node_id: int
|
||||
proto_ver: int
|
||||
is_leader: bool
|
||||
is_valid: bool
|
||||
smoothed_used: bool
|
||||
local_us: int # u64 — node's local esp_timer_get_time()
|
||||
epoch_us: int # u64 — local + EMA-smoothed offset (mesh time)
|
||||
sequence: int # u32 — high-water CSI sequence at emit time
|
||||
flags_raw: int
|
||||
|
||||
def local_minus_epoch_us(self) -> int:
|
||||
"""Signed local-vs-mesh clock offset in µs.
|
||||
|
||||
Negative when this node's clock is behind the leader's (typical
|
||||
for followers). Equal to ≈0 on the leader (modulo call-stack µs).
|
||||
Matches Rust's `SyncPacket::local_minus_epoch_us` byte-for-byte.
|
||||
"""
|
||||
return self.local_us - self.epoch_us
|
||||
|
||||
def apply_to_local(self, local_at_frame_us: int) -> int:
|
||||
"""Recover a mesh-aligned timestamp for any node-local µs snapshot.
|
||||
|
||||
Math (see WITNESS-LOG-110 §A0.10 / §A0.12):
|
||||
offset = epoch_us - local_us (signed; this packet)
|
||||
mesh = local_at_frame_us + offset
|
||||
|
||||
Identical contract to Rust's `SyncPacket::apply_to_local`.
|
||||
Identity at `local_at_frame_us == self.local_us` returns `epoch_us`.
|
||||
"""
|
||||
offset = self.epoch_us - self.local_us
|
||||
return local_at_frame_us + offset
|
||||
|
||||
def mesh_aligned_us_for_sequence(self, frame_seq: int, fps_hz: float) -> int:
|
||||
"""ADR-110 §A0.12 — recover the mesh-aligned timestamp for an
|
||||
in-flight CSI frame by its sequence number.
|
||||
|
||||
Pairs the frame's sequence number against this sync packet's
|
||||
sequence high-water + an assumed/measured CSI rate. Matches the
|
||||
Rust implementation byte-for-byte at the integer level (Python
|
||||
rounds via `int()` truncation; for the canonical bench values
|
||||
this is exact).
|
||||
"""
|
||||
if fps_hz <= 0:
|
||||
raise ValueError(f"fps_hz must be positive, got {fps_hz}")
|
||||
# Wrap to handle u32 sequence overflow the same way Rust does.
|
||||
dframes = (frame_seq - self.sequence) & 0xFFFFFFFF
|
||||
if dframes >= 0x80000000:
|
||||
dframes -= 0x1_0000_0000
|
||||
dus = int(dframes * 1_000_000 / fps_hz)
|
||||
local_at = self.local_us + dus
|
||||
return self.apply_to_local(local_at)
|
||||
|
||||
|
||||
class SyncPacketParser:
|
||||
"""Parser for ADR-110 §A0.12 32-byte sync packets.
|
||||
|
||||
Distinguished from CSI frames by the leading magic. Callers should
|
||||
dispatch incoming UDP datagrams based on the first 4 bytes:
|
||||
|
||||
magic = struct.unpack_from('<I', data, 0)[0]
|
||||
if magic == ESP32BinaryParser.MAGIC: # 0xC5110001 — CSI frame
|
||||
...
|
||||
elif magic == SyncPacketParser.MAGIC: # 0xC511A110 — sync packet
|
||||
...
|
||||
"""
|
||||
|
||||
MAGIC = 0xC511A110
|
||||
SIZE = 32
|
||||
# <IBBBB QQ IB3x>
|
||||
# I=magic, B=node_id, B=proto_ver, B=flags, B=reserved,
|
||||
# Q=local_us, Q=epoch_us, I=sequence, B+3x=reserved
|
||||
HEADER_FMT = '<IBBBBQQI4x'
|
||||
|
||||
@classmethod
|
||||
def parse(cls, raw_data: bytes) -> SyncPacket:
|
||||
if len(raw_data) < cls.SIZE:
|
||||
raise CSIParseError(
|
||||
f"Sync packet too short: {len(raw_data)} bytes, need {cls.SIZE}"
|
||||
)
|
||||
magic, node_id, proto_ver, flags_byte, _, local_us, epoch_us, seq = \
|
||||
struct.unpack_from(cls.HEADER_FMT, raw_data, 0)
|
||||
if magic != cls.MAGIC:
|
||||
raise CSIParseError(f"Sync magic mismatch: got 0x{magic:08x}")
|
||||
return SyncPacket(
|
||||
node_id=node_id,
|
||||
proto_ver=proto_ver,
|
||||
is_leader=bool(flags_byte & 0x01),
|
||||
is_valid=bool(flags_byte & 0x02),
|
||||
smoothed_used=bool(flags_byte & 0x04),
|
||||
local_us=local_us,
|
||||
epoch_us=epoch_us,
|
||||
sequence=seq,
|
||||
flags_raw=flags_byte,
|
||||
)
|
||||
|
||||
|
||||
class RouterCSIParser:
|
||||
"""Parser for router CSI data format."""
|
||||
|
||||
def parse(self, raw_data: bytes) -> CSIData:
|
||||
"""Parse router CSI data format.
|
||||
|
||||
Args:
|
||||
raw_data: Raw bytes from router
|
||||
|
||||
Returns:
|
||||
Parsed CSI data
|
||||
|
||||
Raises:
|
||||
CSIParseError: If data format is invalid
|
||||
"""
|
||||
if not raw_data:
|
||||
raise CSIParseError("Empty data received")
|
||||
|
||||
# Handle different router formats
|
||||
data_str = raw_data.decode('utf-8')
|
||||
|
||||
if data_str.startswith('ATHEROS_CSI:'):
|
||||
return self._parse_atheros_format(raw_data)
|
||||
else:
|
||||
raise CSIParseError("Unknown router CSI format")
|
||||
|
||||
def _parse_atheros_format(self, raw_data: bytes) -> CSIData:
|
||||
"""Parse Atheros CSI format.
|
||||
|
||||
Raises:
|
||||
CSIExtractionError: Always, because Atheros CSI parsing requires
|
||||
the Atheros CSI Tool binary format parser which has not been
|
||||
implemented yet. Use the ESP32 parser or contribute an
|
||||
Atheros implementation.
|
||||
"""
|
||||
raise CSIExtractionError(
|
||||
"Atheros CSI format parsing is not yet implemented. "
|
||||
"The Atheros CSI Tool outputs a binary format that requires a dedicated parser. "
|
||||
"To collect real CSI data from Atheros-based routers, you must implement "
|
||||
"the binary format parser following the Atheros CSI Tool specification. "
|
||||
"See docs/hardware-setup.md for supported hardware and data formats."
|
||||
)
|
||||
|
||||
|
||||
class CSIExtractor:
|
||||
"""Main CSI data extractor supporting multiple hardware types."""
|
||||
|
||||
def __init__(self, config: Dict[str, Any], logger: Optional[logging.Logger] = None):
|
||||
"""Initialize CSI extractor.
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary
|
||||
logger: Optional logger instance
|
||||
|
||||
Raises:
|
||||
ValueError: If configuration is invalid
|
||||
"""
|
||||
self._validate_config(config)
|
||||
|
||||
self.config = config
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
self.hardware_type = config['hardware_type']
|
||||
self.sampling_rate = config['sampling_rate']
|
||||
self.buffer_size = config['buffer_size']
|
||||
self.timeout = config['timeout']
|
||||
self.validation_enabled = config.get('validation_enabled', True)
|
||||
self.retry_attempts = config.get('retry_attempts', 3)
|
||||
|
||||
# State management
|
||||
self.is_connected = False
|
||||
self.is_streaming = False
|
||||
|
||||
# Create appropriate parser
|
||||
if self.hardware_type == 'esp32':
|
||||
if config.get('parser_format') == 'binary':
|
||||
self.parser = ESP32BinaryParser()
|
||||
else:
|
||||
self.parser = ESP32CSIParser()
|
||||
elif self.hardware_type == 'router':
|
||||
self.parser = RouterCSIParser()
|
||||
else:
|
||||
raise ValueError(f"Unsupported hardware type: {self.hardware_type}")
|
||||
|
||||
def _validate_config(self, config: Dict[str, Any]) -> None:
|
||||
"""Validate configuration parameters.
|
||||
|
||||
Args:
|
||||
config: Configuration to validate
|
||||
|
||||
Raises:
|
||||
ValueError: If configuration is invalid
|
||||
"""
|
||||
required_fields = ['hardware_type', 'sampling_rate', 'buffer_size', 'timeout']
|
||||
missing_fields = [field for field in required_fields if field not in config]
|
||||
|
||||
if missing_fields:
|
||||
raise ValueError(f"Missing required configuration: {missing_fields}")
|
||||
|
||||
if config['sampling_rate'] <= 0:
|
||||
raise ValueError("sampling_rate must be positive")
|
||||
|
||||
if config['buffer_size'] <= 0:
|
||||
raise ValueError("buffer_size must be positive")
|
||||
|
||||
if config['timeout'] <= 0:
|
||||
raise ValueError("timeout must be positive")
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Establish connection to CSI hardware.
|
||||
|
||||
Returns:
|
||||
True if connection successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
success = await self._establish_hardware_connection()
|
||||
self.is_connected = success
|
||||
return success
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to connect to hardware: {e}")
|
||||
self.is_connected = False
|
||||
return False
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Disconnect from CSI hardware."""
|
||||
if self.is_connected:
|
||||
await self._close_hardware_connection()
|
||||
self.is_connected = False
|
||||
|
||||
async def extract_csi(self) -> CSIData:
|
||||
"""Extract CSI data from hardware.
|
||||
|
||||
Returns:
|
||||
Extracted CSI data
|
||||
|
||||
Raises:
|
||||
CSIParseError: If not connected or extraction fails
|
||||
"""
|
||||
if not self.is_connected:
|
||||
raise CSIParseError("Not connected to hardware")
|
||||
|
||||
# Retry mechanism for temporary failures
|
||||
for attempt in range(self.retry_attempts):
|
||||
try:
|
||||
raw_data = await self._read_raw_data()
|
||||
csi_data = self.parser.parse(raw_data)
|
||||
|
||||
if self.validation_enabled:
|
||||
self.validate_csi_data(csi_data)
|
||||
|
||||
return csi_data
|
||||
|
||||
except ConnectionError as e:
|
||||
if attempt < self.retry_attempts - 1:
|
||||
self.logger.warning(f"Extraction attempt {attempt + 1} failed, retrying: {e}")
|
||||
await asyncio.sleep(0.1) # Brief delay before retry
|
||||
else:
|
||||
raise CSIParseError(f"Extraction failed after {self.retry_attempts} attempts: {e}")
|
||||
|
||||
def validate_csi_data(self, csi_data: CSIData) -> bool:
|
||||
"""Validate CSI data structure and values.
|
||||
|
||||
Args:
|
||||
csi_data: CSI data to validate
|
||||
|
||||
Returns:
|
||||
True if valid
|
||||
|
||||
Raises:
|
||||
CSIValidationError: If data is invalid
|
||||
"""
|
||||
if csi_data.amplitude.size == 0:
|
||||
raise CSIValidationError("Empty amplitude data")
|
||||
|
||||
if csi_data.phase.size == 0:
|
||||
raise CSIValidationError("Empty phase data")
|
||||
|
||||
if csi_data.frequency <= 0:
|
||||
raise CSIValidationError("Invalid frequency")
|
||||
|
||||
if csi_data.bandwidth <= 0:
|
||||
raise CSIValidationError("Invalid bandwidth")
|
||||
|
||||
if csi_data.num_subcarriers <= 0:
|
||||
raise CSIValidationError("Invalid number of subcarriers")
|
||||
|
||||
if csi_data.num_antennas <= 0:
|
||||
raise CSIValidationError("Invalid number of antennas")
|
||||
|
||||
if csi_data.snr < -50 or csi_data.snr > 50: # Reasonable SNR range
|
||||
raise CSIValidationError("Invalid SNR value")
|
||||
|
||||
return True
|
||||
|
||||
async def start_streaming(self, callback: Callable[[CSIData], None]) -> None:
|
||||
"""Start streaming CSI data.
|
||||
|
||||
Args:
|
||||
callback: Function to call with each CSI sample
|
||||
"""
|
||||
self.is_streaming = True
|
||||
|
||||
try:
|
||||
while self.is_streaming:
|
||||
csi_data = await self.extract_csi()
|
||||
callback(csi_data)
|
||||
await asyncio.sleep(1.0 / self.sampling_rate)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Streaming error: {e}")
|
||||
finally:
|
||||
self.is_streaming = False
|
||||
|
||||
def stop_streaming(self) -> None:
|
||||
"""Stop streaming CSI data."""
|
||||
self.is_streaming = False
|
||||
|
||||
async def _establish_hardware_connection(self) -> bool:
|
||||
"""Establish connection to hardware (to be implemented by subclasses)."""
|
||||
# Placeholder implementation for testing
|
||||
return True
|
||||
|
||||
async def _close_hardware_connection(self) -> None:
|
||||
"""Close hardware connection (to be implemented by subclasses)."""
|
||||
# Placeholder implementation for testing
|
||||
pass
|
||||
|
||||
async def _read_raw_data(self) -> bytes:
|
||||
"""Read raw data from hardware.
|
||||
|
||||
When parser_format='binary', reads from the configured UDP socket.
|
||||
Otherwise returns placeholder text data for legacy compatibility.
|
||||
|
||||
Raises:
|
||||
CSIExtractionError: If UDP read times out or fails.
|
||||
"""
|
||||
if self.config.get('parser_format') == 'binary':
|
||||
return await self._read_udp_data()
|
||||
# Placeholder implementation for legacy text-mode testing
|
||||
return b"CSI_DATA:1234567890,3,56,2400,20,15.5,[1.0,2.0,3.0],[0.5,1.5,2.5]"
|
||||
|
||||
async def _read_udp_data(self) -> bytes:
|
||||
"""Read a single UDP packet from the aggregator.
|
||||
|
||||
Raises:
|
||||
CSIExtractionError: If read times out or connection fails.
|
||||
"""
|
||||
host = self.config.get('aggregator_host', '0.0.0.0')
|
||||
port = self.config.get('aggregator_port', 5005)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
# Create UDP endpoint if not already cached
|
||||
if not hasattr(self, '_udp_transport'):
|
||||
self._udp_future: asyncio.Future = loop.create_future()
|
||||
|
||||
class _UdpProtocol(asyncio.DatagramProtocol):
|
||||
def __init__(self, future):
|
||||
self._future = future
|
||||
|
||||
def datagram_received(self, data, addr):
|
||||
if not self._future.done():
|
||||
self._future.set_result(data)
|
||||
|
||||
def error_received(self, exc):
|
||||
if not self._future.done():
|
||||
self._future.set_exception(exc)
|
||||
|
||||
transport, protocol = await loop.create_datagram_endpoint(
|
||||
lambda: _UdpProtocol(self._udp_future),
|
||||
local_addr=(host, port),
|
||||
)
|
||||
self._udp_transport = transport
|
||||
self._udp_protocol = protocol
|
||||
|
||||
try:
|
||||
data = await asyncio.wait_for(self._udp_future, timeout=self.timeout)
|
||||
# Reset future for next read
|
||||
self._udp_future = loop.create_future()
|
||||
self._udp_protocol._future = self._udp_future
|
||||
return data
|
||||
except asyncio.TimeoutError:
|
||||
raise CSIExtractionError(
|
||||
f"UDP read timed out after {self.timeout}s. "
|
||||
f"Ensure the aggregator is running and sending to {host}:{port}."
|
||||
)
|
||||
@@ -1,457 +0,0 @@
|
||||
"""
|
||||
Authentication middleware for WiFi-DensePose API
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional, Dict, Any, Callable
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import Request, Response, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from src.config.settings import Settings
|
||||
from src.logger import set_request_context
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Password hashing
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
# JWT token handler
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
class AuthenticationError(Exception):
|
||||
"""Authentication error."""
|
||||
pass
|
||||
|
||||
|
||||
class AuthorizationError(Exception):
|
||||
"""Authorization error."""
|
||||
pass
|
||||
|
||||
|
||||
class TokenManager:
|
||||
"""JWT token management."""
|
||||
|
||||
def __init__(self, settings: Settings):
|
||||
self.settings = settings
|
||||
self.secret_key = settings.secret_key
|
||||
self.algorithm = settings.jwt_algorithm
|
||||
self.expire_hours = settings.jwt_expire_hours
|
||||
|
||||
def create_access_token(self, data: Dict[str, Any]) -> str:
|
||||
"""Create JWT access token."""
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + timedelta(hours=self.expire_hours)
|
||||
to_encode.update({"exp": expire, "iat": datetime.utcnow()})
|
||||
|
||||
encoded_jwt = jwt.encode(to_encode, self.secret_key, algorithm=self.algorithm)
|
||||
return encoded_jwt
|
||||
|
||||
def verify_token(self, token: str) -> Dict[str, Any]:
|
||||
"""Verify and decode JWT token."""
|
||||
try:
|
||||
payload = jwt.decode(token, self.secret_key, algorithms=[self.algorithm])
|
||||
# Check token blacklist (logout invalidation)
|
||||
from src.api.middleware.auth import token_blacklist
|
||||
if token_blacklist.is_blacklisted(token):
|
||||
raise AuthenticationError("Token has been revoked")
|
||||
return payload
|
||||
except JWTError as e:
|
||||
logger.warning(f"JWT verification failed: {e}")
|
||||
raise AuthenticationError("Invalid token")
|
||||
|
||||
def decode_token_claims(self, token: str) -> Optional[Dict[str, Any]]:
|
||||
"""Decode and verify token, returning its claims.
|
||||
|
||||
Unlike the previous implementation, this method always verifies
|
||||
the token signature. Use verify_token() for full validation
|
||||
including expiry checks; this helper is provided only for
|
||||
inspecting claims from an already-verified token.
|
||||
"""
|
||||
try:
|
||||
return jwt.decode(token, self.secret_key, algorithms=[self.algorithm])
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
class UserManager:
|
||||
"""User management for authentication."""
|
||||
|
||||
def __init__(self):
|
||||
# In a real application, this would connect to a database.
|
||||
# No default users are created -- users must be provisioned
|
||||
# through the create_user() method or an external identity provider.
|
||||
self._users: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
@staticmethod
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash a password."""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
@staticmethod
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify a password against its hash."""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
def get_user(self, username: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get user by username."""
|
||||
return self._users.get(username)
|
||||
|
||||
def authenticate_user(self, username: str, password: str) -> Optional[Dict[str, Any]]:
|
||||
"""Authenticate user with username and password."""
|
||||
user = self.get_user(username)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
if not self.verify_password(password, user["hashed_password"]):
|
||||
return None
|
||||
|
||||
if not user.get("is_active", False):
|
||||
return None
|
||||
|
||||
return user
|
||||
|
||||
def create_user(self, username: str, email: str, password: str, roles: list = None) -> Dict[str, Any]:
|
||||
"""Create a new user."""
|
||||
if username in self._users:
|
||||
raise ValueError("User already exists")
|
||||
|
||||
user = {
|
||||
"username": username,
|
||||
"email": email,
|
||||
"hashed_password": self.hash_password(password),
|
||||
"roles": roles or ["user"],
|
||||
"is_active": True,
|
||||
"created_at": datetime.utcnow(),
|
||||
}
|
||||
|
||||
self._users[username] = user
|
||||
return user
|
||||
|
||||
def update_user(self, username: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""Update user information."""
|
||||
user = self._users.get(username)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
# Don't allow updating certain fields
|
||||
protected_fields = {"username", "created_at", "hashed_password"}
|
||||
updates = {k: v for k, v in updates.items() if k not in protected_fields}
|
||||
|
||||
user.update(updates)
|
||||
return user
|
||||
|
||||
def deactivate_user(self, username: str) -> bool:
|
||||
"""Deactivate a user."""
|
||||
user = self._users.get(username)
|
||||
if user:
|
||||
user["is_active"] = False
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class AuthenticationMiddleware(BaseHTTPMiddleware):
|
||||
"""Authentication middleware for FastAPI."""
|
||||
|
||||
def __init__(self, app, settings: Settings):
|
||||
super().__init__(app)
|
||||
self.settings = settings
|
||||
self.token_manager = TokenManager(settings)
|
||||
self.user_manager = UserManager()
|
||||
self.enabled = settings.enable_authentication
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||
"""Process request through authentication middleware."""
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# Skip authentication for certain paths
|
||||
if self._should_skip_auth(request):
|
||||
response = await call_next(request)
|
||||
return response
|
||||
|
||||
# Skip if authentication is disabled
|
||||
if not self.enabled:
|
||||
response = await call_next(request)
|
||||
return response
|
||||
|
||||
# Extract and verify token
|
||||
user_info = await self._authenticate_request(request)
|
||||
|
||||
# Set user context
|
||||
if user_info:
|
||||
request.state.user = user_info
|
||||
set_request_context(user_id=user_info.get("username"))
|
||||
|
||||
# Process request
|
||||
response = await call_next(request)
|
||||
|
||||
# Add authentication headers
|
||||
self._add_auth_headers(response, user_info)
|
||||
|
||||
return response
|
||||
|
||||
except AuthenticationError as e:
|
||||
logger.warning(f"Authentication failed: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=str(e),
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
except AuthorizationError as e:
|
||||
logger.warning(f"Authorization failed: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=str(e),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Authentication middleware error: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Authentication service error",
|
||||
)
|
||||
finally:
|
||||
# Log request processing time
|
||||
processing_time = time.time() - start_time
|
||||
logger.debug(f"Auth middleware processing time: {processing_time:.3f}s")
|
||||
|
||||
def _should_skip_auth(self, request: Request) -> bool:
|
||||
"""Check if authentication should be skipped for this request."""
|
||||
path = request.url.path
|
||||
|
||||
# Skip authentication for these paths
|
||||
skip_paths = [
|
||||
"/health",
|
||||
"/metrics",
|
||||
"/docs",
|
||||
"/redoc",
|
||||
"/openapi.json",
|
||||
"/auth/login",
|
||||
"/auth/register",
|
||||
"/static",
|
||||
]
|
||||
|
||||
return any(path.startswith(skip_path) for skip_path in skip_paths)
|
||||
|
||||
async def _authenticate_request(self, request: Request) -> Optional[Dict[str, Any]]:
|
||||
"""Authenticate the request and return user info."""
|
||||
# Try to get token from Authorization header
|
||||
authorization = request.headers.get("Authorization")
|
||||
|
||||
if not authorization:
|
||||
if self._requires_auth(request):
|
||||
raise AuthenticationError("Missing authorization header")
|
||||
return None
|
||||
|
||||
# Extract token
|
||||
try:
|
||||
scheme, token = authorization.split()
|
||||
if scheme.lower() != "bearer":
|
||||
raise AuthenticationError("Invalid authentication scheme")
|
||||
except ValueError:
|
||||
raise AuthenticationError("Invalid authorization header format")
|
||||
|
||||
# Verify token
|
||||
try:
|
||||
payload = self.token_manager.verify_token(token)
|
||||
username = payload.get("sub")
|
||||
if not username:
|
||||
raise AuthenticationError("Invalid token payload")
|
||||
|
||||
# Get user info
|
||||
user = self.user_manager.get_user(username)
|
||||
if not user:
|
||||
raise AuthenticationError("User not found")
|
||||
|
||||
if not user.get("is_active", False):
|
||||
raise AuthenticationError("User account is disabled")
|
||||
|
||||
# Return user info without sensitive data
|
||||
return {
|
||||
"username": user["username"],
|
||||
"email": user["email"],
|
||||
"roles": user["roles"],
|
||||
"is_active": user["is_active"],
|
||||
}
|
||||
|
||||
except AuthenticationError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Token verification error: {e}")
|
||||
raise AuthenticationError("Token verification failed")
|
||||
|
||||
def _requires_auth(self, request: Request) -> bool:
|
||||
"""Check if the request requires authentication."""
|
||||
# All API endpoints require authentication by default
|
||||
path = request.url.path
|
||||
return path.startswith("/api/") or path.startswith("/ws/")
|
||||
|
||||
def _add_auth_headers(self, response: Response, user_info: Optional[Dict[str, Any]]):
|
||||
"""Add authentication-related headers to response."""
|
||||
if user_info:
|
||||
response.headers["X-User"] = user_info["username"]
|
||||
response.headers["X-User-Roles"] = ",".join(user_info["roles"])
|
||||
|
||||
async def login(self, username: str, password: str) -> Dict[str, Any]:
|
||||
"""Authenticate user and return token."""
|
||||
user = self.user_manager.authenticate_user(username, password)
|
||||
if not user:
|
||||
raise AuthenticationError("Invalid username or password")
|
||||
|
||||
# Create token
|
||||
token_data = {
|
||||
"sub": user["username"],
|
||||
"email": user["email"],
|
||||
"roles": user["roles"],
|
||||
}
|
||||
|
||||
access_token = self.token_manager.create_access_token(token_data)
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": self.settings.jwt_expire_hours * 3600,
|
||||
"user": {
|
||||
"username": user["username"],
|
||||
"email": user["email"],
|
||||
"roles": user["roles"],
|
||||
}
|
||||
}
|
||||
|
||||
async def register(self, username: str, email: str, password: str) -> Dict[str, Any]:
|
||||
"""Register a new user."""
|
||||
try:
|
||||
user = self.user_manager.create_user(username, email, password)
|
||||
|
||||
# Create token for new user
|
||||
token_data = {
|
||||
"sub": user["username"],
|
||||
"email": user["email"],
|
||||
"roles": user["roles"],
|
||||
}
|
||||
|
||||
access_token = self.token_manager.create_access_token(token_data)
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": self.settings.jwt_expire_hours * 3600,
|
||||
"user": {
|
||||
"username": user["username"],
|
||||
"email": user["email"],
|
||||
"roles": user["roles"],
|
||||
}
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
raise AuthenticationError(str(e))
|
||||
|
||||
async def refresh_token(self, token: str) -> Dict[str, Any]:
|
||||
"""Refresh an access token."""
|
||||
try:
|
||||
payload = self.token_manager.verify_token(token)
|
||||
username = payload.get("sub")
|
||||
|
||||
user = self.user_manager.get_user(username)
|
||||
if not user or not user.get("is_active", False):
|
||||
raise AuthenticationError("User not found or inactive")
|
||||
|
||||
# Create new token
|
||||
token_data = {
|
||||
"sub": user["username"],
|
||||
"email": user["email"],
|
||||
"roles": user["roles"],
|
||||
}
|
||||
|
||||
new_token = self.token_manager.create_access_token(token_data)
|
||||
|
||||
return {
|
||||
"access_token": new_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": self.settings.jwt_expire_hours * 3600,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise AuthenticationError("Token refresh failed")
|
||||
|
||||
def check_permission(self, user_info: Dict[str, Any], required_role: str) -> bool:
|
||||
"""Check if user has required role/permission."""
|
||||
user_roles = user_info.get("roles", [])
|
||||
|
||||
# Admin role has all permissions
|
||||
if "admin" in user_roles:
|
||||
return True
|
||||
|
||||
# Check specific role
|
||||
return required_role in user_roles
|
||||
|
||||
def require_role(self, required_role: str):
|
||||
"""Decorator to require specific role."""
|
||||
def decorator(func):
|
||||
import functools
|
||||
|
||||
@functools.wraps(func)
|
||||
async def wrapper(request: Request, *args, **kwargs):
|
||||
user_info = getattr(request.state, "user", None)
|
||||
if not user_info:
|
||||
raise AuthorizationError("Authentication required")
|
||||
|
||||
if not self.check_permission(user_info, required_role):
|
||||
raise AuthorizationError(f"Role '{required_role}' required")
|
||||
|
||||
return await func(request, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
# Global authentication middleware instance
|
||||
_auth_middleware: Optional[AuthenticationMiddleware] = None
|
||||
|
||||
|
||||
def get_auth_middleware(settings: Settings) -> AuthenticationMiddleware:
|
||||
"""Get authentication middleware instance."""
|
||||
global _auth_middleware
|
||||
if _auth_middleware is None:
|
||||
_auth_middleware = AuthenticationMiddleware(settings)
|
||||
return _auth_middleware
|
||||
|
||||
|
||||
def get_current_user(request: Request) -> Optional[Dict[str, Any]]:
|
||||
"""Get current authenticated user from request."""
|
||||
return getattr(request.state, "user", None)
|
||||
|
||||
|
||||
def require_authentication(request: Request) -> Dict[str, Any]:
|
||||
"""Require authentication and return user info."""
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Authentication required",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
def require_role(role: str):
|
||||
"""Dependency to require specific role."""
|
||||
def dependency(request: Request) -> Dict[str, Any]:
|
||||
user = require_authentication(request)
|
||||
|
||||
auth_middleware = get_auth_middleware(request.app.state.settings)
|
||||
if not auth_middleware.check_permission(user, role):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Role '{role}' required",
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
return dependency
|
||||
@@ -1,135 +0,0 @@
|
||||
"""Frame budget benchmark for CSI processing pipeline.
|
||||
|
||||
Verifies that per-frame CSI processing stays within the 50 ms budget
|
||||
required for real-time sensing at 20 FPS.
|
||||
"""
|
||||
|
||||
import time
|
||||
import statistics
|
||||
import pytest
|
||||
import numpy as np
|
||||
|
||||
from src.core.csi_processor import CSIProcessor
|
||||
|
||||
|
||||
def _make_config():
|
||||
return {
|
||||
"sampling_rate": 1000,
|
||||
"window_size": 256,
|
||||
"overlap": 0.5,
|
||||
"noise_threshold": -60,
|
||||
"human_detection_threshold": 0.8,
|
||||
"smoothing_factor": 0.9,
|
||||
"max_history_size": 500,
|
||||
"num_subcarriers": 256,
|
||||
"num_antennas": 3,
|
||||
"doppler_window": 64,
|
||||
}
|
||||
|
||||
|
||||
def _make_csi_data(n_subcarriers=256, n_antennas=3, seed=None):
|
||||
"""Generate a synthetic CSI frame with complex-valued subcarriers."""
|
||||
rng = np.random.default_rng(seed)
|
||||
from unittest.mock import MagicMock
|
||||
csi = MagicMock()
|
||||
csi.amplitude = rng.random((n_antennas, n_subcarriers)).astype(np.float64) * 20.0
|
||||
csi.phase = (rng.random((n_antennas, n_subcarriers)).astype(np.float64) - 0.5) * np.pi * 2
|
||||
csi.frequency = 5.0e9
|
||||
csi.bandwidth = 80e6
|
||||
csi.num_subcarriers = n_subcarriers
|
||||
csi.num_antennas = n_antennas
|
||||
csi.snr = 25.0
|
||||
csi.timestamp = time.time()
|
||||
csi.metadata = {}
|
||||
return csi
|
||||
|
||||
|
||||
class TestSingleFrameBudget:
|
||||
"""Single-frame processing must complete in < 50 ms."""
|
||||
|
||||
def test_single_frame_under_50ms(self):
|
||||
proc = CSIProcessor(config=_make_config())
|
||||
frame = _make_csi_data(seed=42)
|
||||
|
||||
# Warm up
|
||||
proc.preprocess_csi_data(frame)
|
||||
|
||||
start = time.perf_counter()
|
||||
proc.preprocess_csi_data(frame)
|
||||
features = proc.extract_features(frame)
|
||||
if features:
|
||||
proc.detect_human_presence(features)
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||
|
||||
assert elapsed_ms < 50, f"Single frame took {elapsed_ms:.1f} ms (budget: 50 ms)"
|
||||
|
||||
|
||||
class TestSustainedFrameBudget:
|
||||
"""Sustained 100-frame processing p95 must be < 50 ms per frame."""
|
||||
|
||||
def test_sustained_100_frames_p95(self):
|
||||
proc = CSIProcessor(config=_make_config())
|
||||
rng = np.random.default_rng(123)
|
||||
n_frames = 100
|
||||
latencies = []
|
||||
|
||||
for i in range(n_frames):
|
||||
frame = _make_csi_data(seed=i)
|
||||
start = time.perf_counter()
|
||||
preprocessed = proc.preprocess_csi_data(frame)
|
||||
features = proc.extract_features(preprocessed)
|
||||
if features:
|
||||
proc.detect_human_presence(features)
|
||||
proc.add_to_history(frame)
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||
latencies.append(elapsed_ms)
|
||||
|
||||
p50 = statistics.median(latencies)
|
||||
p95 = sorted(latencies)[int(0.95 * len(latencies))]
|
||||
p99 = sorted(latencies)[int(0.99 * len(latencies))]
|
||||
|
||||
print(f"\n--- Sustained {n_frames}-frame benchmark ---")
|
||||
print(f" p50: {p50:.2f} ms")
|
||||
print(f" p95: {p95:.2f} ms")
|
||||
print(f" p99: {p99:.2f} ms")
|
||||
print(f" min: {min(latencies):.2f} ms")
|
||||
print(f" max: {max(latencies):.2f} ms")
|
||||
|
||||
assert p95 < 50, f"p95 latency {p95:.1f} ms exceeds 50 ms budget"
|
||||
|
||||
|
||||
class TestPipelineWithDoppler:
|
||||
"""Full pipeline including Doppler estimation must stay within budget."""
|
||||
|
||||
def test_doppler_pipeline(self):
|
||||
proc = CSIProcessor(config=_make_config())
|
||||
n_frames = 100
|
||||
latencies = []
|
||||
|
||||
# Fill history first
|
||||
for i in range(20):
|
||||
frame = _make_csi_data(seed=i + 1000)
|
||||
proc.add_to_history(frame)
|
||||
|
||||
for i in range(n_frames):
|
||||
frame = _make_csi_data(seed=i + 2000)
|
||||
start = time.perf_counter()
|
||||
preprocessed = proc.preprocess_csi_data(frame)
|
||||
features = proc.extract_features(preprocessed)
|
||||
if features:
|
||||
proc.detect_human_presence(features)
|
||||
proc.add_to_history(frame)
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||
latencies.append(elapsed_ms)
|
||||
|
||||
p50 = statistics.median(latencies)
|
||||
p95 = sorted(latencies)[int(0.95 * len(latencies))]
|
||||
p99 = sorted(latencies)[int(0.99 * len(latencies))]
|
||||
|
||||
print(f"\n--- Doppler pipeline benchmark ({n_frames} frames, 20 warmup) ---")
|
||||
print(f" p50: {p50:.2f} ms")
|
||||
print(f" p95: {p95:.2f} ms")
|
||||
print(f" p99: {p99:.2f} ms")
|
||||
|
||||
# Doppler adds overhead but should still be within budget
|
||||
assert p95 < 50, f"Doppler pipeline p95 {p95:.1f} ms exceeds 50 ms budget"
|
||||
@@ -1,56 +0,0 @@
|
||||
"""Shared fixtures for unit tests."""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
|
||||
# Set SECRET_KEY before any settings import
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-unit-tests-only")
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "test-secret-key-for-unit-tests-only")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_settings():
|
||||
"""Create a mock Settings object."""
|
||||
settings = MagicMock()
|
||||
settings.secret_key = "test-secret-key-for-unit-tests-only"
|
||||
settings.jwt_algorithm = "HS256"
|
||||
settings.jwt_expire_hours = 24
|
||||
settings.app_name = "test-app"
|
||||
settings.version = "0.1.0"
|
||||
settings.is_production = False
|
||||
settings.enable_rate_limiting = False
|
||||
settings.enable_authentication = False
|
||||
settings.rate_limit_requests = 100
|
||||
settings.rate_limit_window = 60
|
||||
settings.rate_limit_authenticated_requests = 1000
|
||||
settings.allowed_hosts = ["*"]
|
||||
settings.csi_buffer_size = 100
|
||||
settings.stream_buffer_size = 100
|
||||
settings.mock_hardware = True
|
||||
settings.mock_pose_data = True
|
||||
settings.enable_real_time_processing = False
|
||||
settings.trusted_proxies = ["127.0.0.1"]
|
||||
return settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_domain_config():
|
||||
"""Create a mock DomainConfig object."""
|
||||
config = MagicMock()
|
||||
config.pose_estimation = MagicMock()
|
||||
config.streaming = MagicMock()
|
||||
config.hardware = MagicMock()
|
||||
return config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_redis():
|
||||
"""Provide a mock Redis client."""
|
||||
with patch("redis.Redis") as mock:
|
||||
client = MagicMock()
|
||||
client.ping.return_value = True
|
||||
client.get.return_value = None
|
||||
client.set.return_value = True
|
||||
mock.return_value = client
|
||||
yield client
|
||||
@@ -1,137 +0,0 @@
|
||||
"""Tests for AuthMiddleware and TokenManager."""
|
||||
|
||||
import pytest
|
||||
import os
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
class TestTokenManager:
|
||||
def test_create_token(self, mock_settings):
|
||||
from src.middleware.auth import TokenManager
|
||||
tm = TokenManager(mock_settings)
|
||||
token = tm.create_access_token({"sub": "user1"})
|
||||
assert isinstance(token, str)
|
||||
assert len(token) > 0
|
||||
|
||||
def test_verify_valid_token(self, mock_settings):
|
||||
from src.middleware.auth import TokenManager
|
||||
tm = TokenManager(mock_settings)
|
||||
token = tm.create_access_token({"sub": "user1", "role": "admin"})
|
||||
payload = tm.verify_token(token)
|
||||
assert payload["sub"] == "user1"
|
||||
assert payload["role"] == "admin"
|
||||
|
||||
def test_verify_invalid_token(self, mock_settings):
|
||||
from src.middleware.auth import TokenManager, AuthenticationError
|
||||
tm = TokenManager(mock_settings)
|
||||
with pytest.raises(AuthenticationError):
|
||||
tm.verify_token("invalid.token.here")
|
||||
|
||||
def test_decode_claims(self, mock_settings):
|
||||
from src.middleware.auth import TokenManager
|
||||
tm = TokenManager(mock_settings)
|
||||
token = tm.create_access_token({"sub": "user1"})
|
||||
claims = tm.decode_token_claims(token)
|
||||
assert claims is not None
|
||||
assert claims["sub"] == "user1"
|
||||
|
||||
def test_decode_claims_invalid(self, mock_settings):
|
||||
from src.middleware.auth import TokenManager
|
||||
tm = TokenManager(mock_settings)
|
||||
claims = tm.decode_token_claims("bad-token")
|
||||
assert claims is None
|
||||
|
||||
def test_token_has_expiry(self, mock_settings):
|
||||
from src.middleware.auth import TokenManager
|
||||
tm = TokenManager(mock_settings)
|
||||
token = tm.create_access_token({"sub": "user1"})
|
||||
payload = tm.verify_token(token)
|
||||
assert "exp" in payload
|
||||
assert "iat" in payload
|
||||
|
||||
|
||||
class TestUserManager:
|
||||
def test_create_user(self):
|
||||
from src.middleware.auth import UserManager
|
||||
um = UserManager()
|
||||
assert um.get_user("nonexistent") is None
|
||||
|
||||
def test_hash_password(self):
|
||||
from src.middleware.auth import UserManager
|
||||
hashed = UserManager.hash_password("secret123")
|
||||
assert hashed != "secret123"
|
||||
assert len(hashed) > 20
|
||||
|
||||
def test_verify_password(self):
|
||||
from src.middleware.auth import UserManager
|
||||
hashed = UserManager.hash_password("secret123")
|
||||
assert UserManager.verify_password("secret123", hashed) is True
|
||||
assert UserManager.verify_password("wrong", hashed) is False
|
||||
|
||||
|
||||
class TestTokenBlacklist:
|
||||
def test_add_and_check(self):
|
||||
from src.api.middleware.auth import TokenBlacklist
|
||||
bl = TokenBlacklist()
|
||||
bl.add_token("tok123")
|
||||
assert bl.is_blacklisted("tok123") is True
|
||||
assert bl.is_blacklisted("tok456") is False
|
||||
|
||||
def test_blacklisted_token_rejected(self, mock_settings):
|
||||
from src.middleware.auth import TokenManager, AuthenticationError
|
||||
from src.api.middleware.auth import token_blacklist
|
||||
|
||||
tm = TokenManager(mock_settings)
|
||||
token = tm.create_access_token({"sub": "user1"})
|
||||
# Token should be valid
|
||||
tm.verify_token(token)
|
||||
# Blacklist it
|
||||
token_blacklist.add_token(token)
|
||||
with pytest.raises(AuthenticationError, match="revoked"):
|
||||
tm.verify_token(token)
|
||||
# Cleanup
|
||||
token_blacklist._blacklisted_tokens.discard(token)
|
||||
|
||||
|
||||
class TestAuthMiddleware:
|
||||
def test_public_paths(self, mock_settings):
|
||||
with patch("src.api.middleware.auth.get_settings", return_value=mock_settings):
|
||||
from src.api.middleware.auth import AuthMiddleware
|
||||
app = MagicMock()
|
||||
mw = AuthMiddleware(app)
|
||||
assert mw._is_public_path("/health") is True
|
||||
assert mw._is_public_path("/docs") is True
|
||||
assert mw._is_public_path("/api/v1/pose/analyze") is False
|
||||
|
||||
def test_protected_paths(self, mock_settings):
|
||||
with patch("src.api.middleware.auth.get_settings", return_value=mock_settings):
|
||||
from src.api.middleware.auth import AuthMiddleware
|
||||
app = MagicMock()
|
||||
mw = AuthMiddleware(app)
|
||||
assert mw._is_protected_path("/api/v1/pose/analyze") is True
|
||||
assert mw._is_protected_path("/health") is False
|
||||
|
||||
def test_extract_token_from_header(self, mock_settings):
|
||||
with patch("src.api.middleware.auth.get_settings", return_value=mock_settings):
|
||||
from src.api.middleware.auth import AuthMiddleware
|
||||
app = MagicMock()
|
||||
mw = AuthMiddleware(app)
|
||||
request = MagicMock()
|
||||
request.headers = {"authorization": "Bearer mytoken123"}
|
||||
request.query_params = {}
|
||||
request.cookies = {}
|
||||
token = mw._extract_token(request)
|
||||
assert token == "mytoken123"
|
||||
|
||||
def test_extract_token_missing(self, mock_settings):
|
||||
with patch("src.api.middleware.auth.get_settings", return_value=mock_settings):
|
||||
from src.api.middleware.auth import AuthMiddleware
|
||||
app = MagicMock()
|
||||
mw = AuthMiddleware(app)
|
||||
request = MagicMock()
|
||||
request.headers = {}
|
||||
request.query_params = {}
|
||||
request.cookies = {}
|
||||
token = mw._extract_token(request)
|
||||
assert token is None
|
||||
@@ -1,78 +0,0 @@
|
||||
"""Tests for error handling in the API layer."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
class TestExceptionHandlers:
|
||||
"""Test the exception handlers registered on the FastAPI app."""
|
||||
|
||||
def _get_app(self):
|
||||
"""Import app lazily to avoid side effects."""
|
||||
with patch("src.api.main.get_settings") as mock_gs, \
|
||||
patch("src.api.main.get_domain_config") as mock_gdc, \
|
||||
patch("src.api.main.get_pose_service") as mock_ps, \
|
||||
patch("src.api.main.get_stream_service") as mock_ss, \
|
||||
patch("src.api.main.get_hardware_service") as mock_hs, \
|
||||
patch("src.api.main.connection_manager") as mock_cm, \
|
||||
patch("src.api.main.PoseStreamHandler") as mock_psh:
|
||||
mock_gs.return_value = MagicMock(
|
||||
app_name="test", version="0.1", environment="test",
|
||||
is_production=False, enable_rate_limiting=False,
|
||||
enable_authentication=False, docs_url="/docs",
|
||||
redoc_url="/redoc", openapi_url="/openapi.json",
|
||||
api_prefix="/api/v1",
|
||||
)
|
||||
mock_gs.return_value.get_logging_config.return_value = {
|
||||
"version": 1, "disable_existing_loggers": False,
|
||||
"handlers": {}, "loggers": {},
|
||||
}
|
||||
mock_gs.return_value.get_cors_config.return_value = {
|
||||
"allow_origins": ["*"], "allow_methods": ["*"],
|
||||
"allow_headers": ["*"],
|
||||
}
|
||||
# Re-import to pick up patches
|
||||
import importlib
|
||||
import src.api.main as m
|
||||
importlib.reload(m)
|
||||
return m.app
|
||||
|
||||
|
||||
class TestErrorResponseModel:
|
||||
def test_error_json_structure(self):
|
||||
"""Verify error JSON has code, message, type fields."""
|
||||
error = {
|
||||
"error": {
|
||||
"code": 404,
|
||||
"message": "Not found",
|
||||
"type": "http_error"
|
||||
}
|
||||
}
|
||||
assert error["error"]["code"] == 404
|
||||
assert "message" in error["error"]
|
||||
assert "type" in error["error"]
|
||||
|
||||
def test_validation_error_structure(self):
|
||||
error = {
|
||||
"error": {
|
||||
"code": 422,
|
||||
"message": "Validation error",
|
||||
"type": "validation_error",
|
||||
"details": []
|
||||
}
|
||||
}
|
||||
assert error["error"]["type"] == "validation_error"
|
||||
assert isinstance(error["error"]["details"], list)
|
||||
|
||||
def test_internal_error_masks_details(self):
|
||||
"""In production, internal errors should not leak stack traces."""
|
||||
error = {
|
||||
"error": {
|
||||
"code": 500,
|
||||
"message": "Internal server error",
|
||||
"type": "internal_error"
|
||||
}
|
||||
}
|
||||
assert "traceback" not in str(error)
|
||||
assert error["error"]["message"] == "Internal server error"
|
||||
@@ -1,430 +0,0 @@
|
||||
"""Tests for ESP32BinaryParser (ADR-018 binary frame format)."""
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
import socket
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', 'src'))
|
||||
|
||||
from hardware.csi_extractor import (
|
||||
ESP32BinaryParser,
|
||||
CSIExtractor,
|
||||
CSIParseError,
|
||||
CSIExtractionError,
|
||||
SyncPacket,
|
||||
SyncPacketParser,
|
||||
)
|
||||
|
||||
# ADR-018 constants
|
||||
MAGIC = 0xC5110001
|
||||
# ADR-110: bytes 18-19 are now PPDU type + flags (used to be `2x` reserved).
|
||||
# Pre-ADR-110 firmware sends zeros for both, which round-trip as
|
||||
# ('ht_legacy', flags=all-false) — fully backwards compatible.
|
||||
HEADER_FMT = '<IBBHIIBBBB'
|
||||
HEADER_SIZE = 20
|
||||
|
||||
|
||||
def build_binary_frame(
|
||||
node_id: int = 1,
|
||||
n_antennas: int = 1,
|
||||
n_subcarriers: int = 4,
|
||||
freq_mhz: int = 2437,
|
||||
sequence: int = 0,
|
||||
rssi: int = -50,
|
||||
noise_floor: int = -90,
|
||||
iq_pairs: list = None,
|
||||
ppdu_byte: int = 0, # ADR-110: default 0 = HT/legacy (pre-ADR-110 behavior)
|
||||
flags_byte: int = 0, # ADR-110: default 0 = no flags set
|
||||
) -> bytes:
|
||||
"""Build an ADR-018 binary frame for testing."""
|
||||
if iq_pairs is None:
|
||||
iq_pairs = [(i % 50, (i * 2) % 50) for i in range(n_antennas * n_subcarriers)]
|
||||
|
||||
rssi_u8 = rssi & 0xFF
|
||||
noise_u8 = noise_floor & 0xFF
|
||||
|
||||
header = struct.pack(
|
||||
HEADER_FMT,
|
||||
MAGIC,
|
||||
node_id,
|
||||
n_antennas,
|
||||
n_subcarriers,
|
||||
freq_mhz,
|
||||
sequence,
|
||||
rssi_u8,
|
||||
noise_u8,
|
||||
ppdu_byte,
|
||||
flags_byte,
|
||||
)
|
||||
|
||||
iq_data = b''
|
||||
for i_val, q_val in iq_pairs:
|
||||
iq_data += struct.pack('<bb', i_val, q_val)
|
||||
|
||||
return header + iq_data
|
||||
|
||||
|
||||
class TestAdr110ByteEncoding:
|
||||
"""ADR-110: byte 18 = PPDU type, byte 19 = flags."""
|
||||
|
||||
def setup_method(self):
|
||||
self.parser = ESP32BinaryParser()
|
||||
|
||||
def test_pre_adr110_zeros_decode_as_ht_legacy(self):
|
||||
"""Pre-ADR-110 firmware sends zeros → must surface as HT/legacy + no flags."""
|
||||
frame = build_binary_frame() # ppdu_byte=0, flags_byte=0 default
|
||||
csi = self.parser.parse(frame)
|
||||
assert csi.metadata['ppdu_type'] == 'ht_legacy'
|
||||
assert csi.metadata['ppdu_type_raw'] == 0
|
||||
assert csi.metadata['he_capable'] is False
|
||||
assert csi.metadata['bw40'] is False
|
||||
assert csi.metadata['stbc'] is False
|
||||
assert csi.metadata['ldpc'] is False
|
||||
assert csi.metadata['ieee802154_sync_valid'] is False
|
||||
|
||||
def test_he_su_decodes(self):
|
||||
frame = build_binary_frame(ppdu_byte=1)
|
||||
csi = self.parser.parse(frame)
|
||||
assert csi.metadata['ppdu_type'] == 'he_su'
|
||||
assert csi.metadata['he_capable'] is True
|
||||
|
||||
def test_he_mu_and_he_tb_decode(self):
|
||||
for byte, expected in [(2, 'he_mu'), (3, 'he_tb')]:
|
||||
csi = self.parser.parse(build_binary_frame(ppdu_byte=byte))
|
||||
assert csi.metadata['ppdu_type'] == expected
|
||||
assert csi.metadata['he_capable'] is True
|
||||
|
||||
def test_unknown_ppdu_byte(self):
|
||||
csi = self.parser.parse(build_binary_frame(ppdu_byte=0xFF))
|
||||
assert csi.metadata['ppdu_type'] == 'unknown'
|
||||
assert csi.metadata['ppdu_type_raw'] == 0xFF
|
||||
assert csi.metadata['he_capable'] is False
|
||||
|
||||
def test_all_flags_set_round_trip(self):
|
||||
# bw40 (0x01) + STBC (0x04) + LDPC (0x08) + 15.4-sync (0x10) = 0x1D
|
||||
csi = self.parser.parse(build_binary_frame(ppdu_byte=1, flags_byte=0x1D))
|
||||
assert csi.metadata['bw40'] is True
|
||||
assert csi.metadata['stbc'] is True
|
||||
assert csi.metadata['ldpc'] is True
|
||||
assert csi.metadata['ieee802154_sync_valid'] is True
|
||||
assert csi.metadata['adr018_flags_raw'] == 0x1D
|
||||
|
||||
|
||||
class TestESP32BinaryParser:
|
||||
"""Tests for ESP32BinaryParser."""
|
||||
|
||||
def setup_method(self):
|
||||
self.parser = ESP32BinaryParser()
|
||||
|
||||
def test_parse_valid_binary_frame(self):
|
||||
"""Parse a well-formed ADR-018 binary frame."""
|
||||
iq = [(3, 4), (0, 10), (5, 12), (7, 0)]
|
||||
frame_bytes = build_binary_frame(
|
||||
node_id=1, n_antennas=1, n_subcarriers=4,
|
||||
freq_mhz=2437, sequence=42, rssi=-50, noise_floor=-90,
|
||||
iq_pairs=iq,
|
||||
)
|
||||
|
||||
result = self.parser.parse(frame_bytes)
|
||||
|
||||
assert result.num_antennas == 1
|
||||
assert result.num_subcarriers == 4
|
||||
assert result.amplitude.shape == (1, 4)
|
||||
assert result.phase.shape == (1, 4)
|
||||
assert result.metadata['node_id'] == 1
|
||||
assert result.metadata['sequence'] == 42
|
||||
assert result.metadata['rssi_dbm'] == -50
|
||||
assert result.metadata['noise_floor_dbm'] == -90
|
||||
assert result.metadata['channel_freq_mhz'] == 2437
|
||||
|
||||
# Check amplitude for I=3, Q=4 -> sqrt(9+16) = 5.0
|
||||
assert abs(result.amplitude[0, 0] - 5.0) < 0.001
|
||||
# I=0, Q=10 -> 10.0
|
||||
assert abs(result.amplitude[0, 1] - 10.0) < 0.001
|
||||
|
||||
def test_parse_frame_too_short(self):
|
||||
"""Reject frames shorter than the 20-byte header."""
|
||||
with pytest.raises(CSIParseError, match="too short"):
|
||||
self.parser.parse(b'\x00' * 10)
|
||||
|
||||
def test_parse_invalid_magic(self):
|
||||
"""Reject frames with wrong magic number."""
|
||||
bad_frame = build_binary_frame()
|
||||
# Corrupt magic
|
||||
bad_frame = b'\xFF\xFF\xFF\xFF' + bad_frame[4:]
|
||||
with pytest.raises(CSIParseError, match="Invalid magic"):
|
||||
self.parser.parse(bad_frame)
|
||||
|
||||
def test_parse_multi_antenna_frame(self):
|
||||
"""Parse a frame with 3 antennas and 4 subcarriers."""
|
||||
n_ant = 3
|
||||
n_sc = 4
|
||||
iq = [(i + 1, i + 2) for i in range(n_ant * n_sc)]
|
||||
|
||||
frame_bytes = build_binary_frame(
|
||||
node_id=5, n_antennas=n_ant, n_subcarriers=n_sc,
|
||||
iq_pairs=iq,
|
||||
)
|
||||
|
||||
result = self.parser.parse(frame_bytes)
|
||||
|
||||
assert result.num_antennas == 3
|
||||
assert result.num_subcarriers == 4
|
||||
assert result.amplitude.shape == (3, 4)
|
||||
assert result.phase.shape == (3, 4)
|
||||
|
||||
def test_udp_read_with_mock_server(self):
|
||||
"""Send a frame via UDP and verify CSIExtractor receives it."""
|
||||
# Find a free port
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.bind(('127.0.0.1', 0))
|
||||
port = sock.getsockname()[1]
|
||||
sock.close()
|
||||
|
||||
frame_bytes = build_binary_frame(
|
||||
node_id=3, n_antennas=1, n_subcarriers=4,
|
||||
freq_mhz=2412, sequence=99,
|
||||
)
|
||||
|
||||
config = {
|
||||
'hardware_type': 'esp32',
|
||||
'parser_format': 'binary',
|
||||
'sampling_rate': 100,
|
||||
'buffer_size': 2048,
|
||||
'timeout': 2,
|
||||
'aggregator_host': '127.0.0.1',
|
||||
'aggregator_port': port,
|
||||
}
|
||||
|
||||
extractor = CSIExtractor(config)
|
||||
|
||||
async def run_test():
|
||||
# Connect
|
||||
await extractor.connect()
|
||||
|
||||
# Send frame after a short delay from a background thread
|
||||
def send():
|
||||
time.sleep(0.2)
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.sendto(frame_bytes, ('127.0.0.1', port))
|
||||
s.close()
|
||||
|
||||
sender = threading.Thread(target=send, daemon=True)
|
||||
sender.start()
|
||||
|
||||
result = await extractor.extract_csi()
|
||||
sender.join(timeout=2)
|
||||
|
||||
assert result.metadata['node_id'] == 3
|
||||
assert result.metadata['sequence'] == 99
|
||||
assert result.num_subcarriers == 4
|
||||
|
||||
await extractor.disconnect()
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
def test_udp_timeout(self):
|
||||
"""Verify timeout when no UDP server is sending data."""
|
||||
# Find a free port (nothing will send to it)
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.bind(('127.0.0.1', 0))
|
||||
port = sock.getsockname()[1]
|
||||
sock.close()
|
||||
|
||||
config = {
|
||||
'hardware_type': 'esp32',
|
||||
'parser_format': 'binary',
|
||||
'sampling_rate': 100,
|
||||
'buffer_size': 2048,
|
||||
'timeout': 0.5,
|
||||
'retry_attempts': 1,
|
||||
'aggregator_host': '127.0.0.1',
|
||||
'aggregator_port': port,
|
||||
}
|
||||
|
||||
extractor = CSIExtractor(config)
|
||||
|
||||
async def run_test():
|
||||
await extractor.connect()
|
||||
with pytest.raises(CSIExtractionError, match="timed out"):
|
||||
await extractor.extract_csi()
|
||||
await extractor.disconnect()
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ADR-110 §A0.12 — SyncPacket / SyncPacketParser tests (firmware v0.6.9+)
|
||||
# ============================================================================
|
||||
|
||||
SYNC_MAGIC = 0xC511A110
|
||||
SYNC_SIZE = 32
|
||||
SYNC_FMT = '<IBBBBQQI4x'
|
||||
|
||||
|
||||
def build_sync_packet(
|
||||
node_id: int = 9,
|
||||
proto_ver: int = 1,
|
||||
is_leader: bool = False,
|
||||
is_valid: bool = True,
|
||||
smoothed_used: bool = True,
|
||||
local_us: int = 28798450,
|
||||
epoch_us: int = 27634885,
|
||||
sequence: int = 20,
|
||||
) -> bytes:
|
||||
flags = 0
|
||||
if is_leader: flags |= 0x01
|
||||
if is_valid: flags |= 0x02
|
||||
if smoothed_used: flags |= 0x04
|
||||
return struct.pack(
|
||||
SYNC_FMT,
|
||||
SYNC_MAGIC,
|
||||
node_id, proto_ver, flags, 0,
|
||||
local_us, epoch_us, sequence,
|
||||
)
|
||||
|
||||
|
||||
class TestSyncPacketParser:
|
||||
"""ADR-110 §A0.12: 32-byte UDP sync packet (magic 0xC511A110)."""
|
||||
|
||||
def test_follower_typical_packet_roundtrips(self):
|
||||
"""Match the COM9-witnessed sync-pkt #1 byte-for-byte."""
|
||||
raw = build_sync_packet(
|
||||
node_id=9, is_leader=False, is_valid=True, smoothed_used=True,
|
||||
local_us=28798450, epoch_us=27634885, sequence=20,
|
||||
)
|
||||
assert len(raw) == SYNC_SIZE
|
||||
pkt = SyncPacketParser.parse(raw)
|
||||
assert isinstance(pkt, SyncPacket)
|
||||
assert pkt.node_id == 9
|
||||
assert pkt.proto_ver == 1
|
||||
assert pkt.is_leader is False
|
||||
assert pkt.is_valid is True
|
||||
assert pkt.smoothed_used is True
|
||||
assert pkt.local_us == 28798450
|
||||
assert pkt.epoch_us == 27634885
|
||||
assert pkt.sequence == 20
|
||||
# The 1.16-second boot delta from §A0.10 should be recoverable
|
||||
assert pkt.local_us - pkt.epoch_us == 1163565
|
||||
|
||||
def test_leader_packet_has_local_close_to_epoch(self):
|
||||
"""COM12 (leader) had flags=0x03 and epoch ≈ local."""
|
||||
raw = build_sync_packet(
|
||||
node_id=12, is_leader=True, is_valid=True, smoothed_used=False,
|
||||
local_us=28864932, epoch_us=28864939, sequence=20,
|
||||
)
|
||||
pkt = SyncPacketParser.parse(raw)
|
||||
assert pkt.node_id == 12
|
||||
assert pkt.is_leader is True
|
||||
assert pkt.is_valid is True
|
||||
assert pkt.smoothed_used is False
|
||||
assert pkt.flags_raw == 0x03
|
||||
assert pkt.local_us - pkt.epoch_us == -7 # leader has zero offset
|
||||
|
||||
def test_magic_mismatch_raises(self):
|
||||
"""A non-sync datagram must not silently decode."""
|
||||
raw = bytearray(build_sync_packet())
|
||||
raw[0] = 0x01 # corrupt magic low byte
|
||||
with pytest.raises(CSIParseError, match="magic mismatch"):
|
||||
SyncPacketParser.parse(bytes(raw))
|
||||
|
||||
def test_short_packet_raises(self):
|
||||
"""Below 32 bytes must error early, not silently truncate."""
|
||||
raw = build_sync_packet()[:16]
|
||||
with pytest.raises(CSIParseError, match="too short"):
|
||||
SyncPacketParser.parse(raw)
|
||||
|
||||
def test_all_flag_combinations(self):
|
||||
"""Each flag bit decodes independently."""
|
||||
for is_leader in (False, True):
|
||||
for is_valid in (False, True):
|
||||
for smoothed_used in (False, True):
|
||||
raw = build_sync_packet(
|
||||
is_leader=is_leader,
|
||||
is_valid=is_valid,
|
||||
smoothed_used=smoothed_used,
|
||||
)
|
||||
pkt = SyncPacketParser.parse(raw)
|
||||
assert pkt.is_leader == is_leader
|
||||
assert pkt.is_valid == is_valid
|
||||
assert pkt.smoothed_used == smoothed_used
|
||||
|
||||
def test_dispatch_distinguishes_csi_from_sync(self):
|
||||
"""A host can pick CSI vs sync by leading magic."""
|
||||
csi_magic = struct.unpack_from('<I', build_binary_frame(), 0)[0]
|
||||
sync_magic = struct.unpack_from('<I', build_sync_packet(), 0)[0]
|
||||
assert csi_magic == ESP32BinaryParser.MAGIC
|
||||
assert sync_magic == SyncPacketParser.MAGIC
|
||||
assert csi_magic != sync_magic
|
||||
|
||||
def test_apply_to_local_recovers_epoch_at_sync_point(self):
|
||||
"""ADR-110 iter 26 — Python parity with Rust's `apply_to_local`.
|
||||
At local_at_frame == sync.local_us, the recovered mesh time must
|
||||
equal sync.epoch_us exactly."""
|
||||
pkt = SyncPacketParser.parse(build_sync_packet(
|
||||
local_us=28_798_450, epoch_us=27_634_885, sequence=20,
|
||||
))
|
||||
assert pkt.apply_to_local(pkt.local_us) == pkt.epoch_us
|
||||
assert pkt.local_minus_epoch_us() == 1_163_565 # §A0.10's bench number
|
||||
|
||||
def test_apply_to_local_preserves_inter_frame_delta(self):
|
||||
"""A frame arriving 5 s after the sync packet on the follower's
|
||||
local clock must produce a mesh time exactly 5 s after sync.epoch_us."""
|
||||
pkt = SyncPacketParser.parse(build_sync_packet(
|
||||
local_us=28_798_450, epoch_us=27_634_885, sequence=20,
|
||||
))
|
||||
local_at_frame = pkt.local_us + 5_000_000
|
||||
assert pkt.apply_to_local(local_at_frame) == pkt.epoch_us + 5_000_000
|
||||
|
||||
def test_mesh_aligned_us_for_sequence_matches_rust(self):
|
||||
"""Cross-language parity with Rust's
|
||||
`end_to_end_sync_decode_then_frame_mesh_recovery` test —
|
||||
100 frames after sync.sequence at 20 fps = sync.epoch_us + 5 s."""
|
||||
pkt = SyncPacketParser.parse(build_sync_packet(
|
||||
local_us=28_798_450, epoch_us=27_634_885, sequence=20,
|
||||
))
|
||||
mesh = pkt.mesh_aligned_us_for_sequence(120, 20.0)
|
||||
assert mesh == pkt.epoch_us + 5_000_000
|
||||
# Both paths (apply_to_local + interpolation) must agree
|
||||
local_at = pkt.local_us + 5_000_000
|
||||
assert pkt.apply_to_local(local_at) == mesh
|
||||
|
||||
def test_canonical_wire_bytes_match_rust_decoder(self):
|
||||
"""ADR-110 iter 21 — cross-language wire-format conformance gate.
|
||||
|
||||
These exact bytes also appear pinned in the Rust hardware crate's
|
||||
`canonical_wire_bytes_match_python_decoder` test (same field
|
||||
values, encoded by Rust's `SyncPacket::to_bytes`). If Python's
|
||||
hardcoded hex stops matching what Rust produces from the equivalent
|
||||
SyncPacket struct, ONE of the decoders has drifted from the wire.
|
||||
|
||||
Canonical packet: COM9 sync-pkt #1 from §A0.12 live capture.
|
||||
"""
|
||||
canonical = bytes.fromhex(
|
||||
"10a111c509010600" # magic LE + node=9 + ver=1 + flags=0x06 + reserved
|
||||
"f26db70100000000" # local_us = 28_798_450 (LE u64)
|
||||
"c5aca50100000000" # epoch_us = 27_634_885 (LE u64)
|
||||
"1400000000000000" # sequence = 20 (LE u32) + 4 reserved bytes
|
||||
)
|
||||
assert len(canonical) == SyncPacketParser.SIZE == 32
|
||||
|
||||
pkt = SyncPacketParser.parse(canonical)
|
||||
assert pkt.node_id == 9
|
||||
assert pkt.proto_ver == 1
|
||||
assert pkt.flags_raw == 0x06
|
||||
assert pkt.is_leader is False
|
||||
assert pkt.is_valid is True
|
||||
assert pkt.smoothed_used is True
|
||||
assert pkt.local_us == 28_798_450
|
||||
assert pkt.epoch_us == 27_634_885
|
||||
assert pkt.sequence == 20
|
||||
# Recovered offset matches §A0.10's measured 1.16-second boot delta.
|
||||
assert pkt.local_us - pkt.epoch_us == 1_163_565
|
||||
@@ -1,65 +0,0 @@
|
||||
"""Tests for HardwareService."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
|
||||
|
||||
class TestHardwareServiceInit:
|
||||
def test_init(self, mock_settings, mock_domain_config):
|
||||
mock_settings.mock_hardware = True
|
||||
with patch("src.services.hardware_service.RouterInterface"):
|
||||
from src.services.hardware_service import HardwareService
|
||||
svc = HardwareService(mock_settings, mock_domain_config)
|
||||
assert svc.is_running is False
|
||||
assert svc.stats["total_samples"] == 0
|
||||
assert svc.stats["connected_routers"] == 0
|
||||
|
||||
def test_stats_defaults(self, mock_settings, mock_domain_config):
|
||||
mock_settings.mock_hardware = True
|
||||
with patch("src.services.hardware_service.RouterInterface"):
|
||||
from src.services.hardware_service import HardwareService
|
||||
svc = HardwareService(mock_settings, mock_domain_config)
|
||||
assert svc.stats["successful_samples"] == 0
|
||||
assert svc.stats["failed_samples"] == 0
|
||||
assert svc.stats["last_sample_time"] is None
|
||||
|
||||
|
||||
class TestHardwareServiceLifecycle:
|
||||
@pytest.mark.asyncio
|
||||
async def test_start(self, mock_settings, mock_domain_config):
|
||||
mock_settings.mock_hardware = True
|
||||
with patch("src.services.hardware_service.RouterInterface"):
|
||||
from src.services.hardware_service import HardwareService
|
||||
svc = HardwareService(mock_settings, mock_domain_config)
|
||||
svc._initialize_routers = AsyncMock()
|
||||
svc._monitoring_loop = AsyncMock()
|
||||
await svc.start()
|
||||
assert svc.is_running is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_double_start_idempotent(self, mock_settings, mock_domain_config):
|
||||
mock_settings.mock_hardware = True
|
||||
with patch("src.services.hardware_service.RouterInterface"):
|
||||
from src.services.hardware_service import HardwareService
|
||||
svc = HardwareService(mock_settings, mock_domain_config)
|
||||
svc._initialize_routers = AsyncMock()
|
||||
svc._monitoring_loop = AsyncMock()
|
||||
await svc.start()
|
||||
await svc.start() # idempotent
|
||||
assert svc.is_running is True
|
||||
|
||||
|
||||
class TestHardwareServiceRouter:
|
||||
def test_no_routers_on_init(self, mock_settings, mock_domain_config):
|
||||
mock_settings.mock_hardware = True
|
||||
with patch("src.services.hardware_service.RouterInterface"):
|
||||
from src.services.hardware_service import HardwareService
|
||||
svc = HardwareService(mock_settings, mock_domain_config)
|
||||
assert len(svc.router_interfaces) == 0
|
||||
|
||||
def test_max_recent_samples(self, mock_settings, mock_domain_config):
|
||||
mock_settings.mock_hardware = True
|
||||
with patch("src.services.hardware_service.RouterInterface"):
|
||||
from src.services.hardware_service import HardwareService
|
||||
svc = HardwareService(mock_settings, mock_domain_config)
|
||||
assert svc.max_recent_samples == 1000
|
||||
@@ -1,67 +0,0 @@
|
||||
"""Tests for HealthCheckService."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
class TestHealthCheckServiceInit:
|
||||
def test_init(self, mock_settings):
|
||||
from src.services.health_check import HealthCheckService
|
||||
svc = HealthCheckService(mock_settings)
|
||||
assert svc._initialized is False
|
||||
assert svc._running is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize(self, mock_settings):
|
||||
from src.services.health_check import HealthCheckService
|
||||
svc = HealthCheckService(mock_settings)
|
||||
await svc.initialize()
|
||||
assert svc._initialized is True
|
||||
assert "api" in svc._services
|
||||
assert "database" in svc._services
|
||||
assert "hardware" in svc._services
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_double_initialize(self, mock_settings):
|
||||
from src.services.health_check import HealthCheckService
|
||||
svc = HealthCheckService(mock_settings)
|
||||
await svc.initialize()
|
||||
await svc.initialize() # idempotent
|
||||
assert svc._initialized is True
|
||||
|
||||
|
||||
class TestHealthCheckAggregation:
|
||||
@pytest.mark.asyncio
|
||||
async def test_services_registered(self, mock_settings):
|
||||
from src.services.health_check import HealthCheckService, HealthStatus
|
||||
svc = HealthCheckService(mock_settings)
|
||||
await svc.initialize()
|
||||
assert len(svc._services) == 6
|
||||
for name, sh in svc._services.items():
|
||||
assert sh.status == HealthStatus.UNKNOWN
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_names(self, mock_settings):
|
||||
from src.services.health_check import HealthCheckService
|
||||
svc = HealthCheckService(mock_settings)
|
||||
await svc.initialize()
|
||||
expected = {"api", "database", "redis", "hardware", "pose", "stream"}
|
||||
assert set(svc._services.keys()) == expected
|
||||
|
||||
|
||||
class TestHealthStatus:
|
||||
def test_enum_values(self):
|
||||
from src.services.health_check import HealthStatus
|
||||
assert HealthStatus.HEALTHY.value == "healthy"
|
||||
assert HealthStatus.DEGRADED.value == "degraded"
|
||||
assert HealthStatus.UNHEALTHY.value == "unhealthy"
|
||||
assert HealthStatus.UNKNOWN.value == "unknown"
|
||||
|
||||
|
||||
class TestHealthCheck:
|
||||
def test_health_check_dataclass(self):
|
||||
from src.services.health_check import HealthCheck, HealthStatus
|
||||
hc = HealthCheck(name="test", status=HealthStatus.HEALTHY, message="ok")
|
||||
assert hc.name == "test"
|
||||
assert hc.status == HealthStatus.HEALTHY
|
||||
assert hc.duration_ms == 0.0
|
||||
@@ -1,70 +0,0 @@
|
||||
"""Tests for MetricsService."""
|
||||
|
||||
import pytest
|
||||
from datetime import timedelta
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
class TestMetricSeries:
|
||||
def test_add_point(self):
|
||||
from src.services.metrics import MetricSeries
|
||||
ms = MetricSeries(name="test", description="desc", unit="ms")
|
||||
ms.add_point(42.0)
|
||||
assert len(ms.points) == 1
|
||||
assert ms.points[0].value == 42.0
|
||||
|
||||
def test_get_latest(self):
|
||||
from src.services.metrics import MetricSeries
|
||||
ms = MetricSeries(name="test", description="desc", unit="ms")
|
||||
ms.add_point(1.0)
|
||||
ms.add_point(2.0)
|
||||
latest = ms.get_latest()
|
||||
assert latest is not None
|
||||
assert latest.value == 2.0
|
||||
|
||||
def test_get_latest_empty(self):
|
||||
from src.services.metrics import MetricSeries
|
||||
ms = MetricSeries(name="test", description="desc", unit="ms")
|
||||
assert ms.get_latest() is None
|
||||
|
||||
def test_get_average(self):
|
||||
from src.services.metrics import MetricSeries
|
||||
ms = MetricSeries(name="test", description="desc", unit="ms")
|
||||
for v in [10.0, 20.0, 30.0]:
|
||||
ms.add_point(v)
|
||||
avg = ms.get_average(timedelta(minutes=5))
|
||||
assert avg == pytest.approx(20.0)
|
||||
|
||||
def test_get_average_empty(self):
|
||||
from src.services.metrics import MetricSeries
|
||||
ms = MetricSeries(name="test", description="desc", unit="ms")
|
||||
assert ms.get_average(timedelta(minutes=5)) is None
|
||||
|
||||
def test_get_max(self):
|
||||
from src.services.metrics import MetricSeries
|
||||
ms = MetricSeries(name="test", description="desc", unit="ms")
|
||||
for v in [10.0, 50.0, 30.0]:
|
||||
ms.add_point(v)
|
||||
mx = ms.get_max(timedelta(minutes=5))
|
||||
assert mx == 50.0
|
||||
|
||||
def test_labels(self):
|
||||
from src.services.metrics import MetricSeries
|
||||
ms = MetricSeries(name="test", description="desc", unit="ms")
|
||||
ms.add_point(1.0, {"region": "us-east"})
|
||||
assert ms.points[0].labels["region"] == "us-east"
|
||||
|
||||
def test_maxlen(self):
|
||||
from src.services.metrics import MetricSeries
|
||||
ms = MetricSeries(name="test", description="desc", unit="ms")
|
||||
for i in range(1100):
|
||||
ms.add_point(float(i))
|
||||
assert len(ms.points) == 1000
|
||||
|
||||
|
||||
class TestMetricsService:
|
||||
def test_init(self, mock_settings):
|
||||
with patch("src.services.metrics.psutil"):
|
||||
from src.services.metrics import MetricsService
|
||||
svc = MetricsService(mock_settings)
|
||||
assert svc._metrics is not None
|
||||
@@ -1,73 +0,0 @@
|
||||
"""Tests for PoseService."""
|
||||
|
||||
import pytest
|
||||
import asyncio
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class TestPoseServiceInit:
|
||||
def test_init_sets_defaults(self, mock_settings, mock_domain_config):
|
||||
with patch.dict("sys.modules", {
|
||||
"torch": MagicMock(),
|
||||
"src.models.densepose_head": MagicMock(),
|
||||
"src.models.modality_translation": MagicMock(),
|
||||
}):
|
||||
from src.services.pose_service import PoseService
|
||||
svc = PoseService(mock_settings, mock_domain_config)
|
||||
assert svc.is_initialized is False
|
||||
assert svc.is_running is False
|
||||
assert svc.stats["total_processed"] == 0
|
||||
|
||||
def test_stats_are_zero_on_init(self, mock_settings, mock_domain_config):
|
||||
with patch.dict("sys.modules", {
|
||||
"torch": MagicMock(),
|
||||
"src.models.densepose_head": MagicMock(),
|
||||
"src.models.modality_translation": MagicMock(),
|
||||
}):
|
||||
from src.services.pose_service import PoseService
|
||||
svc = PoseService(mock_settings, mock_domain_config)
|
||||
assert svc.stats["successful_detections"] == 0
|
||||
assert svc.stats["failed_detections"] == 0
|
||||
assert svc.stats["average_confidence"] == 0.0
|
||||
|
||||
|
||||
class TestPoseServiceLifecycle:
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_sets_flag(self, mock_settings, mock_domain_config):
|
||||
with patch.dict("sys.modules", {
|
||||
"torch": MagicMock(),
|
||||
"src.models.densepose_head": MagicMock(),
|
||||
"src.models.modality_translation": MagicMock(),
|
||||
}):
|
||||
from src.services.pose_service import PoseService
|
||||
svc = PoseService(mock_settings, mock_domain_config)
|
||||
await svc.initialize()
|
||||
assert svc.is_initialized is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_stop(self, mock_settings, mock_domain_config):
|
||||
with patch.dict("sys.modules", {
|
||||
"torch": MagicMock(),
|
||||
"src.models.densepose_head": MagicMock(),
|
||||
"src.models.modality_translation": MagicMock(),
|
||||
}):
|
||||
from src.services.pose_service import PoseService
|
||||
svc = PoseService(mock_settings, mock_domain_config)
|
||||
await svc.initialize()
|
||||
await svc.start()
|
||||
assert svc.is_running is True
|
||||
await svc.stop()
|
||||
assert svc.is_running is False
|
||||
|
||||
|
||||
class TestPoseServiceStats:
|
||||
def test_initial_classification(self, mock_settings, mock_domain_config):
|
||||
with patch.dict("sys.modules", {
|
||||
"torch": MagicMock(),
|
||||
"src.models.densepose_head": MagicMock(),
|
||||
"src.models.modality_translation": MagicMock(),
|
||||
}):
|
||||
from src.services.pose_service import PoseService
|
||||
svc = PoseService(mock_settings, mock_domain_config)
|
||||
assert svc.last_error is None
|
||||
@@ -1,62 +0,0 @@
|
||||
"""Tests for rate limiting middleware."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
|
||||
|
||||
class TestRateLimitMiddleware:
|
||||
def test_init(self, mock_settings):
|
||||
with patch("src.api.middleware.rate_limit.get_settings", return_value=mock_settings):
|
||||
from src.api.middleware.rate_limit import RateLimitMiddleware
|
||||
app = MagicMock()
|
||||
mw = RateLimitMiddleware(app)
|
||||
assert "anonymous" in mw.rate_limits
|
||||
assert "authenticated" in mw.rate_limits
|
||||
assert "admin" in mw.rate_limits
|
||||
|
||||
def test_exempt_paths(self, mock_settings):
|
||||
with patch("src.api.middleware.rate_limit.get_settings", return_value=mock_settings):
|
||||
from src.api.middleware.rate_limit import RateLimitMiddleware
|
||||
app = MagicMock()
|
||||
mw = RateLimitMiddleware(app)
|
||||
assert "/health" in mw.exempt_paths
|
||||
assert "/metrics" in mw.exempt_paths
|
||||
|
||||
def test_is_exempt(self, mock_settings):
|
||||
with patch("src.api.middleware.rate_limit.get_settings", return_value=mock_settings):
|
||||
from src.api.middleware.rate_limit import RateLimitMiddleware
|
||||
app = MagicMock()
|
||||
mw = RateLimitMiddleware(app)
|
||||
assert mw._is_exempt_path("/health") is True
|
||||
assert mw._is_exempt_path("/api/v1/pose/current") is False
|
||||
|
||||
def test_path_specific_limits(self, mock_settings):
|
||||
with patch("src.api.middleware.rate_limit.get_settings", return_value=mock_settings):
|
||||
from src.api.middleware.rate_limit import RateLimitMiddleware
|
||||
app = MagicMock()
|
||||
mw = RateLimitMiddleware(app)
|
||||
assert "/api/v1/pose/current" in mw.path_limits
|
||||
assert mw.path_limits["/api/v1/pose/current"]["requests"] == 60
|
||||
|
||||
def test_trusted_proxies_not_blocked(self, mock_settings):
|
||||
with patch("src.api.middleware.rate_limit.get_settings", return_value=mock_settings):
|
||||
from src.api.middleware.rate_limit import RateLimitMiddleware
|
||||
app = MagicMock()
|
||||
mw = RateLimitMiddleware(app)
|
||||
assert not mw._is_client_blocked("new-client-id")
|
||||
|
||||
|
||||
class TestRateLimitConfig:
|
||||
def test_anonymous_limit(self, mock_settings):
|
||||
with patch("src.api.middleware.rate_limit.get_settings", return_value=mock_settings):
|
||||
from src.api.middleware.rate_limit import RateLimitMiddleware
|
||||
app = MagicMock()
|
||||
mw = RateLimitMiddleware(app)
|
||||
assert mw.rate_limits["anonymous"]["burst"] == 10
|
||||
|
||||
def test_admin_limit(self, mock_settings):
|
||||
with patch("src.api.middleware.rate_limit.get_settings", return_value=mock_settings):
|
||||
from src.api.middleware.rate_limit import RateLimitMiddleware
|
||||
app = MagicMock()
|
||||
mw = RateLimitMiddleware(app)
|
||||
assert mw.rate_limits["admin"]["requests"] == 10000
|
||||
@@ -1,68 +0,0 @@
|
||||
"""Tests for StreamService."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
|
||||
|
||||
class TestStreamServiceLifecycle:
|
||||
def test_init(self, mock_settings, mock_domain_config):
|
||||
from src.services.stream_service import StreamService
|
||||
svc = StreamService(mock_settings, mock_domain_config)
|
||||
assert svc.is_running is False
|
||||
assert len(svc.connections) == 0
|
||||
assert svc.stats["active_connections"] == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize(self, mock_settings, mock_domain_config):
|
||||
from src.services.stream_service import StreamService
|
||||
svc = StreamService(mock_settings, mock_domain_config)
|
||||
await svc.initialize()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start(self, mock_settings, mock_domain_config):
|
||||
mock_settings.enable_real_time_processing = False
|
||||
from src.services.stream_service import StreamService
|
||||
svc = StreamService(mock_settings, mock_domain_config)
|
||||
await svc.start()
|
||||
assert svc.is_running is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop(self, mock_settings, mock_domain_config):
|
||||
mock_settings.enable_real_time_processing = False
|
||||
from src.services.stream_service import StreamService
|
||||
svc = StreamService(mock_settings, mock_domain_config)
|
||||
await svc.start()
|
||||
await svc.stop()
|
||||
assert svc.is_running is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_double_start(self, mock_settings, mock_domain_config):
|
||||
mock_settings.enable_real_time_processing = False
|
||||
from src.services.stream_service import StreamService
|
||||
svc = StreamService(mock_settings, mock_domain_config)
|
||||
await svc.start()
|
||||
await svc.start() # should be idempotent
|
||||
assert svc.is_running is True
|
||||
|
||||
|
||||
class TestStreamServiceConnections:
|
||||
def test_no_connections_on_init(self, mock_settings, mock_domain_config):
|
||||
from src.services.stream_service import StreamService
|
||||
svc = StreamService(mock_settings, mock_domain_config)
|
||||
assert svc.stats["total_connections"] == 0
|
||||
assert svc.stats["messages_sent"] == 0
|
||||
|
||||
def test_buffer_sizes(self, mock_settings, mock_domain_config):
|
||||
mock_settings.stream_buffer_size = 50
|
||||
from src.services.stream_service import StreamService
|
||||
svc = StreamService(mock_settings, mock_domain_config)
|
||||
assert svc.pose_buffer.maxlen == 50
|
||||
assert svc.csi_buffer.maxlen == 50
|
||||
|
||||
|
||||
class TestStreamServiceBroadcast:
|
||||
def test_stats_messages_failed_init_zero(self, mock_settings, mock_domain_config):
|
||||
from src.services.stream_service import StreamService
|
||||
svc = StreamService(mock_settings, mock_domain_config)
|
||||
assert svc.stats["messages_failed"] == 0
|
||||
assert svc.stats["data_points_streamed"] == 0
|
||||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 1.9 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB |
File diff suppressed because one or more lines are too long
@@ -1,5 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
.vite
|
||||
*.log
|
||||
public/nvsim-pkg
|
||||
@@ -1,18 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>RuView · nvsim — NV-Diamond Magnetometer Simulator</title>
|
||||
<meta name="description" content="Deterministic forward simulator for NV-diamond magnetometry. WASM-backed CW-ODMR pipeline with witness-grade SHA-256 proofs." />
|
||||
<meta name="theme-color" content="#0d1117" />
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='6' fill='%23e6a86b'/><text x='16' y='22' text-anchor='middle' font-family='monospace' font-weight='700' font-size='14' fill='%231a0f00'>NV</text></svg>" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<nv-app></nv-app>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
-6525
File diff suppressed because it is too large
Load Diff
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"name": "@ruvnet/nvsim-dashboard",
|
||||
"version": "0.1.0",
|
||||
"description": "Vite + Lit dashboard for the nvsim NV-diamond magnetometer pipeline simulator (ADR-092).",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview --port 4173",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"test:a11y": "playwright test tests/a11y.spec.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"lit": "^3.2.1",
|
||||
"workbox-window": "^7.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.11.2",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vitest": "^2.1.4"
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
fullyParallel: true,
|
||||
retries: 0,
|
||||
reporter: 'list',
|
||||
use: {
|
||||
baseURL: 'http://localhost:4173',
|
||||
headless: true,
|
||||
},
|
||||
webServer: {
|
||||
command: 'npm run preview',
|
||||
port: 4173,
|
||||
timeout: 60_000,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
projects: [
|
||||
{ name: 'chromium', use: { browserName: 'chromium' } },
|
||||
{ name: 'firefox', use: { browserName: 'firefox' } },
|
||||
{ name: 'webkit', use: { browserName: 'webkit' } },
|
||||
],
|
||||
});
|
||||
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192" width="192" height="192">
|
||||
<rect width="192" height="192" rx="36" fill="#e6a86b"/>
|
||||
<text x="96" y="124" text-anchor="middle" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-weight="700" font-size="80" fill="#1a0f00">NV</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 313 B |
@@ -1,10 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" x2="1" y1="0" y2="1">
|
||||
<stop offset="0" stop-color="#e6a86b"/>
|
||||
<stop offset="1" stop-color="#a4633a"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="96" fill="url(#g)"/>
|
||||
<text x="256" y="332" text-anchor="middle" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-weight="700" font-size="220" fill="#1a0f00">NV</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 504 B |
@@ -1,92 +0,0 @@
|
||||
/* nvsim dashboard — global styles
|
||||
Ported from `assets/NVsim Dashboard.zip` per ADR-092 §7.1.
|
||||
Per-component scoped styles live in each Lit element. */
|
||||
|
||||
:root {
|
||||
--bg-0: #07090d;
|
||||
--bg-1: #0d1117;
|
||||
--bg-2: #131a23;
|
||||
--bg-3: #1a232f;
|
||||
--line: #1f2a38;
|
||||
--line-2: #2a3848;
|
||||
--ink: #e6edf3;
|
||||
--ink-2: #b8c2cc;
|
||||
--ink-3: #7c8694;
|
||||
--ink-4: #4a5462;
|
||||
--accent: oklch(0.78 0.14 70);
|
||||
--accent-2: oklch(0.78 0.12 195);
|
||||
--accent-3: oklch(0.72 0.18 330);
|
||||
--accent-4: oklch(0.78 0.14 145);
|
||||
--warn: oklch(0.7 0.18 35);
|
||||
--ok: oklch(0.78 0.14 145);
|
||||
--bad: oklch(0.65 0.22 25);
|
||||
--grid: rgba(255, 255, 255, 0.04);
|
||||
--shadow: 0 20px 60px -20px rgba(0, 0, 0, 0.6),
|
||||
0 4px 12px -4px rgba(0, 0, 0, 0.4);
|
||||
--radius: 12px;
|
||||
--radius-sm: 8px;
|
||||
--mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
--sans: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg-0: #f4f5f7;
|
||||
--bg-1: #fbfbfc;
|
||||
--bg-2: #ffffff;
|
||||
--bg-3: #f0f2f5;
|
||||
--line: #d8dde3;
|
||||
--line-2: #c1c8d1;
|
||||
--ink: #0e131a;
|
||||
--ink-2: #2c3744;
|
||||
--ink-3: #54606e; /* AA on --bg-1 #fbfbfc — was #6b7684 (3.7:1), now ~5.4:1 */
|
||||
--ink-4: #7a8390; /* improved from #9ba4b0 for incidental UI labels */
|
||||
--grid: rgba(0, 0, 0, 0.05);
|
||||
--shadow: 0 12px 40px -16px rgba(15, 30, 55, 0.18),
|
||||
0 2px 8px -2px rgba(15, 30, 55, 0.08);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: var(--sans);
|
||||
background: var(--bg-0);
|
||||
color: var(--ink);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
overflow: hidden;
|
||||
height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
|
||||
button { font-family: inherit; color: inherit; cursor: pointer; }
|
||||
input, select { font-family: inherit; color: inherit; }
|
||||
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--line-2); border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--ink-4); }
|
||||
|
||||
@keyframes pulse { 50% { opacity: 0.5; } }
|
||||
@keyframes dash { to { stroke-dashoffset: -200; } }
|
||||
@keyframes float-up {
|
||||
0% { opacity: 0; transform: translateY(8px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes diamond-spin {
|
||||
0% { transform: rotateY(0) rotateX(8deg); }
|
||||
100% { transform: rotateY(360deg) rotateX(8deg); }
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
body.reduce-motion *,
|
||||
body.reduce-motion *::before,
|
||||
body.reduce-motion *::after {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* Density (set via class on <body> by setDensity()) */
|
||||
body.density-comfy { font-size: 15px; }
|
||||
body.density-default { font-size: 14px; }
|
||||
body.density-compact { font-size: 13px; }
|
||||
@@ -1,399 +0,0 @@
|
||||
/* App Store — catalog of every WASM edge module + simulator app.
|
||||
*
|
||||
* Mirrors `wifi-densepose-wasm-edge`'s 60+ hot-loadable algorithms and
|
||||
* the `nvsim` simulator. Each card is filterable by category, fuzzy
|
||||
* name search, and maturity (available / beta / research). A toggle on
|
||||
* each card flips activation in the live session — that drives the
|
||||
* dashboard's event log when running. WS transport (future) pushes the
|
||||
* activation set to the connected ESP32 mesh.
|
||||
*
|
||||
* ADR-092 §18.
|
||||
*/
|
||||
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import { signal, effect } from '@preact/signals-core';
|
||||
import {
|
||||
APPS, CATEGORIES, defaultActivations, fuzzyMatch,
|
||||
type AppCategory, type AppManifest, type AppActivation,
|
||||
} from '../store/apps';
|
||||
import { kvGet, kvSet } from '../store/persistence';
|
||||
import { pushLog, activeAppIds, appEvents, appEventCounts } from '../store/appStore';
|
||||
import { hasRuntime } from '../store/appRuntimes';
|
||||
|
||||
const activations = signal<AppActivation[]>(defaultActivations());
|
||||
const query = signal<string>('');
|
||||
const activeCat = signal<AppCategory | 'all'>('all');
|
||||
const statusFilter = signal<'all' | 'available' | 'beta' | 'research'>('all');
|
||||
|
||||
(async () => {
|
||||
const saved = await kvGet<AppActivation[]>('app-activations');
|
||||
if (saved) activations.value = saved;
|
||||
})();
|
||||
|
||||
effect(() => {
|
||||
// Persist activations on change (post-load) AND mirror into the
|
||||
// active-set signal that main.ts watches to drive runtime dispatch.
|
||||
const v = activations.value;
|
||||
if (v.length > 0) void kvSet('app-activations', v);
|
||||
const set = new Set<string>();
|
||||
for (const a of v) if (a.active) set.add(a.id);
|
||||
activeAppIds.value = set;
|
||||
});
|
||||
|
||||
@customElement('nv-app-store')
|
||||
export class NvAppStore extends LitElement {
|
||||
@state() private renderTick = 0;
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
background: radial-gradient(ellipse at 50% 30%, var(--bg-2) 0%, var(--bg-0) 70%);
|
||||
padding: 24px;
|
||||
}
|
||||
.head {
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ttl {
|
||||
font-size: 22px; font-weight: 700; letter-spacing: -0.02em;
|
||||
color: var(--ink);
|
||||
flex: 1; min-width: 200px;
|
||||
}
|
||||
.ttl small {
|
||||
font-size: 12.5px; font-weight: 400;
|
||||
color: var(--ink-3); margin-left: 8px;
|
||||
}
|
||||
.search {
|
||||
width: 320px; max-width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
color: var(--ink); outline: none;
|
||||
}
|
||||
.search:focus { border-color: var(--accent); }
|
||||
.filters {
|
||||
display: flex; flex-wrap: wrap; gap: 6px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.chip {
|
||||
padding: 4px 10px;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
font-size: 11.5px; color: var(--ink-3);
|
||||
cursor: pointer;
|
||||
font-family: var(--mono);
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
}
|
||||
.chip:hover { color: var(--ink); border-color: var(--line-2); }
|
||||
.chip.on { background: var(--bg-3); border-color: var(--accent); color: var(--ink); }
|
||||
.chip .swatch {
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
}
|
||||
.chip .count { color: var(--ink-3); font-size: 10px; }
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.card {
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px 14px;
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
transition: border-color 0.15s, transform 0.15s;
|
||||
position: relative;
|
||||
}
|
||||
.card:hover { border-color: var(--line-2); transform: translateY(-1px); }
|
||||
.card.active {
|
||||
border-color: oklch(0.78 0.14 145 / 0.7);
|
||||
background: linear-gradient(180deg, var(--bg-2) 0%, oklch(0.78 0.14 145 / 0.04) 100%);
|
||||
}
|
||||
.card-h {
|
||||
display: flex; align-items: flex-start; gap: 8px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.card-h .name {
|
||||
font-size: 13.5px; font-weight: 600; color: var(--ink);
|
||||
flex: 1; line-height: 1.3;
|
||||
}
|
||||
.card-h .swatch {
|
||||
width: 10px; height: 10px; border-radius: 50%;
|
||||
flex-shrink: 0; margin-top: 4px;
|
||||
}
|
||||
.summary {
|
||||
font-size: 12px; color: var(--ink-2); line-height: 1.45;
|
||||
flex: 1;
|
||||
}
|
||||
.meta {
|
||||
display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px;
|
||||
font-family: var(--mono); font-size: 10px;
|
||||
}
|
||||
.badge {
|
||||
padding: 1px 6px; border-radius: 4px;
|
||||
background: var(--bg-3); color: var(--ink-3);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.badge.cat { color: var(--accent); border-color: oklch(0.78 0.14 70 / 0.3); }
|
||||
.badge.status-available { color: var(--ok); border-color: oklch(0.78 0.14 145 / 0.4); }
|
||||
.badge.status-beta { color: var(--warn); border-color: oklch(0.7 0.18 35 / 0.4); }
|
||||
.badge.status-research { color: var(--accent-3); border-color: oklch(0.72 0.18 330 / 0.4); }
|
||||
.badge.budget { color: var(--accent-2); border-color: oklch(0.78 0.12 195 / 0.3); }
|
||||
.badge.rt-running { color: var(--ok); border-color: oklch(0.78 0.14 145 / 0.5); background: oklch(0.78 0.14 145 / 0.08); }
|
||||
.badge.rt-simulated { color: var(--accent); border-color: oklch(0.78 0.14 70 / 0.5); background: oklch(0.78 0.14 70 / 0.08); }
|
||||
.badge.rt-mesh-only { color: var(--ink-3); border-color: var(--line); }
|
||||
.events-feed {
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.events-feed h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px; font-weight: 600;
|
||||
color: var(--ink);
|
||||
}
|
||||
.events-feed .lead {
|
||||
font-size: 12px; color: var(--ink-3);
|
||||
margin: 0 0 10px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.events-feed .lines {
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
max-height: 160px; overflow-y: auto;
|
||||
}
|
||||
.ev-line {
|
||||
display: grid;
|
||||
grid-template-columns: 60px 90px 1fr;
|
||||
gap: 10px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.ev-line:hover { background: var(--bg-3); }
|
||||
.ev-line .ts { color: var(--ink-4); font-size: 10.5px; }
|
||||
.ev-line .id { color: var(--accent); font-size: 10.5px; }
|
||||
.ev-line .body { color: var(--ink); }
|
||||
.ev-empty {
|
||||
font-size: 12px; color: var(--ink-3);
|
||||
padding: 8px 0;
|
||||
}
|
||||
.card-events-count {
|
||||
font-size: 10.5px;
|
||||
color: var(--accent-4);
|
||||
font-family: var(--mono);
|
||||
}
|
||||
.card-foot {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding-top: 8px; margin-top: 4px;
|
||||
border-top: 1px solid var(--line);
|
||||
font-size: 11px; color: var(--ink-3);
|
||||
}
|
||||
.toggle {
|
||||
position: relative;
|
||||
width: 32px; height: 18px;
|
||||
background: var(--bg-3); border: 1px solid var(--line-2);
|
||||
border-radius: 999px; cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.toggle::after {
|
||||
content: ''; position: absolute;
|
||||
top: 1px; left: 1px;
|
||||
width: 12px; height: 12px;
|
||||
background: var(--ink-3); border-radius: 50%;
|
||||
transition: transform 0.15s, background 0.15s;
|
||||
}
|
||||
.toggle.on { background: var(--accent); border-color: var(--accent); }
|
||||
.toggle.on::after { background: #1a0f00; transform: translateX(14px); }
|
||||
.events {
|
||||
font-family: var(--mono); font-size: 10px; color: var(--ink-3);
|
||||
flex: 1;
|
||||
}
|
||||
.empty {
|
||||
padding: 40px;
|
||||
text-align: center; color: var(--ink-3);
|
||||
font-size: 13px;
|
||||
}
|
||||
`;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
effect(() => {
|
||||
activations.value; query.value; activeCat.value; statusFilter.value;
|
||||
appEvents.value; appEventCounts.value;
|
||||
this.renderTick++;
|
||||
});
|
||||
}
|
||||
|
||||
private isActive(id: string): boolean {
|
||||
return activations.value.find((a) => a.id === id)?.active === true;
|
||||
}
|
||||
|
||||
private toggle(app: AppManifest): void {
|
||||
const wasActive = this.isActive(app.id);
|
||||
const next = activations.value.map((a) => a.id === app.id ? { ...a, active: !a.active, lastActivatedAt: Date.now() } : a);
|
||||
activations.value = next;
|
||||
if (!wasActive) {
|
||||
const r = app.runtime ?? 'mesh-only';
|
||||
const note = r === 'simulated' ? ' · live runtime engaged'
|
||||
: r === 'mesh-only' ? ' · queued (needs ESP32 mesh)'
|
||||
: '';
|
||||
pushLog('ok', `app <span class="k">${app.id}</span> activated${note}`);
|
||||
} else {
|
||||
pushLog('info', `app <span class="k">${app.id}</span> deactivated`);
|
||||
}
|
||||
}
|
||||
|
||||
private filtered(): AppManifest[] {
|
||||
let list = APPS;
|
||||
if (activeCat.value !== 'all') list = list.filter((a) => a.category === activeCat.value);
|
||||
if (statusFilter.value !== 'all') list = list.filter((a) => a.status === statusFilter.value);
|
||||
if (query.value.trim()) {
|
||||
list = list
|
||||
.map((a) => ({ a, s: fuzzyMatch(query.value, a) }))
|
||||
.filter((x) => x.s > 0)
|
||||
.sort((a, b) => b.s - a.s)
|
||||
.map((x) => x.a);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private categoryCounts(): Record<string, number> {
|
||||
const counts: Record<string, number> = { all: APPS.length };
|
||||
for (const k of Object.keys(CATEGORIES)) counts[k] = 0;
|
||||
for (const a of APPS) counts[a.category] = (counts[a.category] ?? 0) + 1;
|
||||
return counts;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const list = this.filtered();
|
||||
const counts = this.categoryCounts();
|
||||
const activeCount = activations.value.filter((a) => a.active).length;
|
||||
return html`
|
||||
<div class="head">
|
||||
<div class="ttl">
|
||||
App Store
|
||||
<small>${APPS.length} edge apps · ${activeCount} active</small>
|
||||
</div>
|
||||
<input class="search" id="app-search" placeholder="Search by name, tag, or category…"
|
||||
.value=${query.value}
|
||||
@input=${(e: Event) => { query.value = (e.target as HTMLInputElement).value; }} />
|
||||
</div>
|
||||
|
||||
<div class="filters">
|
||||
<span class="chip ${activeCat.value === 'all' ? 'on' : ''}"
|
||||
@click=${() => activeCat.value = 'all'}>
|
||||
All<span class="count">${counts.all}</span>
|
||||
</span>
|
||||
${(Object.keys(CATEGORIES) as AppCategory[]).map((k) => html`
|
||||
<span class="chip ${activeCat.value === k ? 'on' : ''}"
|
||||
@click=${() => activeCat.value = k}>
|
||||
<span class="swatch" style=${`background:${CATEGORIES[k].color}`}></span>
|
||||
${CATEGORIES[k].label}
|
||||
<span class="count">${counts[k] ?? 0}</span>
|
||||
</span>
|
||||
`)}
|
||||
<span style="flex:1; min-width:8px"></span>
|
||||
<span class="chip ${statusFilter.value === 'all' ? 'on' : ''}" @click=${() => statusFilter.value = 'all'}>any</span>
|
||||
<span class="chip ${statusFilter.value === 'available' ? 'on' : ''}" @click=${() => statusFilter.value = 'available'}>available</span>
|
||||
<span class="chip ${statusFilter.value === 'beta' ? 'on' : ''}" @click=${() => statusFilter.value = 'beta'}>beta</span>
|
||||
<span class="chip ${statusFilter.value === 'research' ? 'on' : ''}" @click=${() => statusFilter.value = 'research'}>research</span>
|
||||
</div>
|
||||
|
||||
${this.renderEventsFeed()}
|
||||
|
||||
${list.length === 0
|
||||
? html`<div class="empty">No apps match the current filters.</div>`
|
||||
: html`<div class="grid">${list.map((app) => this.card(app))}</div>`}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderEventsFeed() {
|
||||
const evs = appEvents.value.slice(-12).reverse();
|
||||
const activeSimCount = activations.value.filter((a) => a.active && hasRuntime(a.id)).length;
|
||||
return html`
|
||||
<div class="events-feed">
|
||||
<h3>Live runtime feed
|
||||
${activeSimCount > 0
|
||||
? html`<span class="card-events-count" style="margin-left: 8px;">${activeSimCount} simulated app${activeSimCount === 1 ? '' : 's'} active</span>`
|
||||
: ''}
|
||||
</h3>
|
||||
<p class="lead">
|
||||
Apps with the <span class="badge rt-simulated" style="font-size:9.5px; padding:0 4px;">simulated</span>
|
||||
runtime emit real i32 event IDs against nvsim's live frame stream below.
|
||||
Apps with <span class="badge rt-mesh-only" style="font-size:9.5px; padding:0 4px;">mesh-only</span>
|
||||
need an ESP32-S3 + WS transport (deferred to V2). The
|
||||
<span class="badge rt-running" style="font-size:9.5px; padding:0 4px;">running</span>
|
||||
badge marks <code>nvsim</code> itself, which is always running.
|
||||
</p>
|
||||
${evs.length === 0
|
||||
? html`<div class="ev-empty">No events yet. Toggle a card with the <i>simulated</i> badge and press <b>▶ Run</b>.</div>`
|
||||
: html`<div class="lines">${evs.map((ev) => {
|
||||
const dt = new Date(ev.ts);
|
||||
const ts = `${String(dt.getSeconds()).padStart(2, '0')}.${String(dt.getMilliseconds()).padStart(3, '0')}`;
|
||||
return html`
|
||||
<div class="ev-line">
|
||||
<span class="ts">${ts}</span>
|
||||
<span class="id">${ev.appId}</span>
|
||||
<span class="body"><b style="color:var(--accent-2);">${ev.eventName}</b><span style="color:var(--ink-3);"> · ${ev.eventId}</span> ${ev.detail ? `· ${ev.detail}` : ''}</span>
|
||||
</div>
|
||||
`;
|
||||
})}</div>`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private card(app: AppManifest) {
|
||||
const active = this.isActive(app.id);
|
||||
const cat = CATEGORIES[app.category];
|
||||
const runtime = app.runtime ?? 'mesh-only';
|
||||
const evCount = appEventCounts.value[app.id] ?? 0;
|
||||
const runtimeLabel: Record<string, string> = {
|
||||
'running': 'running',
|
||||
'simulated': 'simulated',
|
||||
'mesh-only': 'needs mesh',
|
||||
};
|
||||
const runtimeTip: Record<string, string> = {
|
||||
'running': 'This app is genuinely running in your browser right now.',
|
||||
'simulated': 'A pared-down version of this algorithm runs against nvsim\'s magnetic frame stream as a proxy for its native CSI input. Toggle on, then press ▶ Run to see real event IDs in the feed.',
|
||||
'mesh-only': 'This algorithm needs CSI subcarrier data from an ESP32-S3 mesh. The toggle persists; activation is pushed via WS transport (V2).',
|
||||
};
|
||||
return html`
|
||||
<div class="card ${active ? 'active' : ''}" data-app-id=${app.id}>
|
||||
<div class="card-h">
|
||||
<span class="swatch" style=${`background:${cat.color}`}></span>
|
||||
<span class="name">${app.name}</span>
|
||||
</div>
|
||||
<div class="summary">${app.summary}</div>
|
||||
<div class="meta">
|
||||
<span class="badge cat">${cat.label}</span>
|
||||
<span class="badge status-${app.status}">${app.status}</span>
|
||||
<span class="badge rt-${runtime}" title=${runtimeTip[runtime]}>${runtimeLabel[runtime]}</span>
|
||||
${app.budget ? html`<span class="badge budget">budget ${app.budget}</span>` : ''}
|
||||
${app.adr ? html`<span class="badge">${app.adr}</span>` : ''}
|
||||
${app.events?.length ? html`<span class="badge">events ${app.events.join('·')}</span>` : ''}
|
||||
</div>
|
||||
<div class="card-foot">
|
||||
<span class="events">${app.crate}</span>
|
||||
${evCount > 0 ? html`<span class="card-events-count">⚡ ${evCount} ev</span>` : ''}
|
||||
<span class="toggle ${active ? 'on' : ''}" role="switch"
|
||||
aria-checked=${active}
|
||||
data-app-toggle=${app.id}
|
||||
@click=${() => this.toggle(app)}></span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
/* Top-level shell: 4-zone grid with rail / topbar / sidebar / scene / inspector / console.
|
||||
* View routing is per-rail-button: the central area swaps between
|
||||
* `<nv-scene>`, `<nv-app-store>`, etc. */
|
||||
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import './nv-rail';
|
||||
import './nv-topbar';
|
||||
import './nv-sidebar';
|
||||
import './nv-scene';
|
||||
import './nv-inspector';
|
||||
import './nv-console';
|
||||
import './nv-app-store';
|
||||
import './nv-toast';
|
||||
import './nv-modal';
|
||||
import './nv-palette';
|
||||
import './nv-debug-hud';
|
||||
import './nv-settings-drawer';
|
||||
import './nv-onboarding';
|
||||
import './nv-ghost-murmur';
|
||||
import './nv-help';
|
||||
import './nv-home';
|
||||
|
||||
export type View = 'home' | 'scene' | 'apps' | 'inspector' | 'witness' | 'ghost-murmur';
|
||||
|
||||
@customElement('nv-app')
|
||||
export class NvApp extends LitElement {
|
||||
@state() private view: View = 'home';
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
background: var(--bg-0);
|
||||
}
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: 8px;
|
||||
padding: 6px 12px;
|
||||
background: var(--accent);
|
||||
color: #1a0f00;
|
||||
border-radius: 6px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
z-index: 1000;
|
||||
transition: top 0.15s;
|
||||
}
|
||||
.skip-link:focus { top: 8px; }
|
||||
.app {
|
||||
display: grid;
|
||||
grid-template-columns: 56px 280px 1fr 340px;
|
||||
grid-template-rows: 48px 1fr 220px;
|
||||
grid-template-areas:
|
||||
'rail topbar topbar topbar'
|
||||
'rail sidebar main inspector'
|
||||
'rail sidebar console inspector';
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
}
|
||||
/* Home view simplifies: hides sidebar / inspector / console so the
|
||||
hero gets the full screen. Power-user panels stay one rail click away. */
|
||||
.app.simple {
|
||||
grid-template-columns: 56px 1fr;
|
||||
grid-template-rows: 48px 1fr;
|
||||
grid-template-areas:
|
||||
'rail topbar'
|
||||
'rail main';
|
||||
}
|
||||
.app.simple nv-sidebar,
|
||||
.app.simple nv-inspector,
|
||||
.app.simple nv-console { display: none; }
|
||||
nv-rail { grid-area: rail; }
|
||||
nv-topbar { grid-area: topbar; }
|
||||
nv-sidebar { grid-area: sidebar; }
|
||||
.main { grid-area: main; min-width: 0; min-height: 0; position: relative; overflow: hidden; }
|
||||
nv-inspector { grid-area: inspector; }
|
||||
nv-console { grid-area: console; min-height: 0; }
|
||||
@media (max-width: 1180px) {
|
||||
.app {
|
||||
grid-template-columns: 56px 1fr 320px;
|
||||
grid-template-areas:
|
||||
'rail topbar topbar'
|
||||
'rail main inspector'
|
||||
'rail console console';
|
||||
}
|
||||
nv-sidebar { display: none; }
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.app {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 52px 1fr 200px;
|
||||
grid-template-areas:
|
||||
'topbar'
|
||||
'main'
|
||||
'console';
|
||||
}
|
||||
nv-rail, nv-sidebar, nv-inspector { display: none; }
|
||||
}
|
||||
`;
|
||||
|
||||
override render() {
|
||||
const isSimple = this.view === 'home';
|
||||
return html`
|
||||
<a class="skip-link" href="#main-content"
|
||||
@click=${(e: Event) => { e.preventDefault(); const sr = this.shadowRoot; sr?.querySelector<HTMLElement>('.main')?.focus(); }}>
|
||||
Skip to main content
|
||||
</a>
|
||||
<div class="app ${isSimple ? 'simple' : ''}">
|
||||
<nv-rail .view=${this.view} @navigate=${(e: CustomEvent<View>) => (this.view = e.detail)}></nv-rail>
|
||||
<nv-topbar></nv-topbar>
|
||||
<nv-sidebar></nv-sidebar>
|
||||
<main class="main" id="main-content" tabindex="-1" role="main" aria-label="Main view">
|
||||
${this.view === 'home'
|
||||
? html`<nv-home></nv-home>`
|
||||
: this.view === 'apps'
|
||||
? html`<nv-app-store></nv-app-store>`
|
||||
: this.view === 'ghost-murmur'
|
||||
? html`<nv-ghost-murmur></nv-ghost-murmur>`
|
||||
: this.view === 'inspector'
|
||||
? html`<nv-inspector expanded .pinTab=${'signal'}></nv-inspector>`
|
||||
: this.view === 'witness'
|
||||
? html`<nv-inspector expanded .pinTab=${'witness'}></nv-inspector>`
|
||||
: html`<nv-scene></nv-scene>`}
|
||||
</main>
|
||||
<nv-inspector
|
||||
.pinTab=${this.view === 'inspector' ? 'signal'
|
||||
: this.view === 'witness' ? 'witness' : null}>
|
||||
</nv-inspector>
|
||||
<nv-console></nv-console>
|
||||
</div>
|
||||
<nv-toast></nv-toast>
|
||||
<nv-modal></nv-modal>
|
||||
<nv-palette></nv-palette>
|
||||
<nv-debug-hud></nv-debug-hud>
|
||||
<nv-settings-drawer></nv-settings-drawer>
|
||||
<nv-onboarding></nv-onboarding>
|
||||
<nv-help></nv-help>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
/* Console — log stream + REPL. */
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, query } from 'lit/decorators.js';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import {
|
||||
consoleLines, consoleFilter, consolePaused, pushLog,
|
||||
getClient, seed, theme, expectedWitness, witnessHex, witnessVerified,
|
||||
running, replHistory, pushReplHistory,
|
||||
} from '../store/appStore';
|
||||
|
||||
@customElement('nv-console')
|
||||
export class NvConsole extends LitElement {
|
||||
@query('#console-input') private inputEl!: HTMLInputElement;
|
||||
private hIdx = -1;
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--bg-1);
|
||||
overflow: hidden;
|
||||
}
|
||||
.tabs {
|
||||
display: flex; align-items: center;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 0 10px;
|
||||
gap: 2px;
|
||||
}
|
||||
.tab {
|
||||
padding: 8px 12px;
|
||||
background: transparent; border: none;
|
||||
font-size: 11.5px; color: var(--ink-3);
|
||||
font-family: var(--mono);
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.tab.active { color: var(--ink); border-bottom-color: var(--accent); }
|
||||
.tab .cnt {
|
||||
background: var(--bg-3); padding: 1px 5px; border-radius: 999px;
|
||||
font-size: 9.5px; color: var(--ink-2); margin-left: 4px;
|
||||
}
|
||||
.spacer { flex: 1; }
|
||||
.tools { display: flex; gap: 4px; padding: 4px 0; }
|
||||
.tools button {
|
||||
width: 24px; height: 24px;
|
||||
background: transparent; border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
color: var(--ink-3);
|
||||
font-size: 11px; cursor: pointer;
|
||||
}
|
||||
.tools button:hover { color: var(--ink); border-color: var(--line-2); }
|
||||
|
||||
.body {
|
||||
flex: 1; overflow-y: auto;
|
||||
font-family: var(--mono);
|
||||
font-size: 11.5px;
|
||||
padding: 6px 0;
|
||||
background: var(--bg-0);
|
||||
}
|
||||
.line {
|
||||
display: grid;
|
||||
grid-template-columns: 70px 60px 1fr;
|
||||
gap: 12px;
|
||||
padding: 2px 12px;
|
||||
color: var(--ink-2);
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
.line:hover { background: var(--bg-1); }
|
||||
.ts { color: var(--ink-4); font-size: 10.5px; padding-top: 1px; }
|
||||
.lvl {
|
||||
font-size: 10px; font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: 0.04em; padding-top: 1px;
|
||||
}
|
||||
.line.info .lvl { color: var(--accent-2); }
|
||||
.line.warn .lvl { color: var(--warn); }
|
||||
.line.warn { border-left-color: var(--warn); background: oklch(0.7 0.18 35 / 0.04); }
|
||||
.line.err .lvl { color: var(--bad); }
|
||||
.line.err { border-left-color: var(--bad); background: oklch(0.65 0.22 25 / 0.05); }
|
||||
.line.dbg .lvl { color: var(--ink-3); }
|
||||
.line.ok .lvl { color: var(--ok); }
|
||||
.msg { color: var(--ink); white-space: pre-wrap; word-break: break-word; }
|
||||
|
||||
.input {
|
||||
display: flex; align-items: center;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--bg-0);
|
||||
padding: 0 10px;
|
||||
height: 32px; gap: 8px;
|
||||
}
|
||||
.prompt { color: var(--accent); font-family: var(--mono); font-size: 12px; }
|
||||
input[type="text"] {
|
||||
flex: 1; background: transparent; border: none; outline: none;
|
||||
color: var(--ink); font-family: var(--mono); font-size: 12px;
|
||||
height: 100%;
|
||||
}
|
||||
input::placeholder { color: var(--ink-4); }
|
||||
`;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
effect(() => {
|
||||
consoleLines.value; consoleFilter.value; consolePaused.value;
|
||||
this.requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
override updated(): void {
|
||||
const body = this.renderRoot.querySelector('.body') as HTMLElement | null;
|
||||
if (body) body.scrollTop = body.scrollHeight;
|
||||
}
|
||||
|
||||
private counts(): Record<string, number> {
|
||||
const c: Record<string, number> = { info: 0, warn: 0, err: 0, dbg: 0, ok: 0 };
|
||||
for (const l of consoleLines.value) c[l.level] = (c[l.level] ?? 0) + 1;
|
||||
c.all = consoleLines.value.length;
|
||||
return c;
|
||||
}
|
||||
|
||||
private async exec(line: string): Promise<void> {
|
||||
line = line.trim();
|
||||
if (!line) return;
|
||||
pushLog('info', `<span style="color:var(--accent);">nvsim></span> ${line}`);
|
||||
pushReplHistory(line);
|
||||
this.hIdx = replHistory.value.length;
|
||||
const [cmd, ...args] = line.split(/\s+/);
|
||||
const arg = args.join(' ');
|
||||
const c = getClient();
|
||||
switch (cmd) {
|
||||
case 'help':
|
||||
pushLog('info', 'commands: help · scene.list · sensor.config · run · pause · reset · seed · proof.verify · proof.export · clear · theme · status');
|
||||
break;
|
||||
case 'scene.list':
|
||||
pushLog('info', 'scene rebar-walkby-01:');
|
||||
pushLog('info', ' rebar.steel.coil @ [+2.7, 0.0, +0.3] m χ=5000');
|
||||
pushLog('info', ' dipole.heart_proxy @ [-1.4, +0.2, +0.4] m m=1.0e-6 A·m²');
|
||||
pushLog('info', ' loop.mains_60Hz @ [-1.6, -0.4, 0.0] m I=2 A');
|
||||
pushLog('info', ' eddy.door_steel @ [+0.0, +1.8, +0.4] m σ=1e6 S/m');
|
||||
break;
|
||||
case 'sensor.config':
|
||||
pushLog('info', 'NvSensor::cots_defaults() {');
|
||||
pushLog('info', ' pos=[0,0,0], V=1mm³, N=1e12, C=0.03, T2*=200ns');
|
||||
pushLog('info', ' D=2.870 GHz, γe=28 GHz/T, Γ=1.0 MHz, axes=4×〈111〉');
|
||||
pushLog('info', ' δB ≈ 1.18 pT/√Hz (Barry 2020 §III.A) }');
|
||||
break;
|
||||
case 'run':
|
||||
if (c) { await c.run(); running.value = true; pushLog('ok', 'pipeline RUN'); }
|
||||
break;
|
||||
case 'pause':
|
||||
if (c) { await c.pause(); running.value = false; pushLog('warn', 'pipeline PAUSED'); }
|
||||
break;
|
||||
case 'reset':
|
||||
if (c) { await c.reset(); pushLog('info', 'pipeline reset · t=0'); }
|
||||
break;
|
||||
case 'seed': {
|
||||
if (!arg) { pushLog('info', `current seed = 0x${seed.value.toString(16).toUpperCase()}`); break; }
|
||||
const v = BigInt(arg.startsWith('0x') ? arg : '0x' + arg);
|
||||
seed.value = v;
|
||||
if (c) await c.setSeed(v);
|
||||
pushLog('ok', `seed → 0x${v.toString(16).toUpperCase()}`);
|
||||
break;
|
||||
}
|
||||
case 'proof.verify': {
|
||||
if (!c) break;
|
||||
pushLog('dbg', 'computing SHA-256 over 256 frames…');
|
||||
try {
|
||||
const exp = expectedWitness.value;
|
||||
const expBytes = new Uint8Array(32);
|
||||
for (let i = 0; i < 32; i++) expBytes[i] = parseInt(exp.slice(i * 2, i * 2 + 2), 16);
|
||||
const r = await c.verifyWitness(expBytes);
|
||||
if (r.ok) { witnessVerified.value = 'ok'; witnessHex.value = exp; pushLog('ok', `witness ${exp.slice(0, 16)}… matches · determinism gate ✓`); }
|
||||
else { witnessVerified.value = 'fail'; pushLog('err', 'WITNESS MISMATCH'); }
|
||||
} catch (e) { pushLog('err', `verify failed: ${(e as Error).message}`); }
|
||||
break;
|
||||
}
|
||||
case 'proof.export': {
|
||||
if (!c) break;
|
||||
pushLog('dbg', 'building proof bundle…');
|
||||
try {
|
||||
const blob = await c.exportProofBundle();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `nvsim-proof-${Date.now()}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
pushLog('ok', `proof bundle exported · ${blob.size} bytes`);
|
||||
} catch (e) { pushLog('err', `export failed: ${(e as Error).message}`); }
|
||||
break;
|
||||
}
|
||||
case 'clear':
|
||||
consoleLines.value = [];
|
||||
break;
|
||||
case 'theme': {
|
||||
const t = (arg || '').toLowerCase();
|
||||
if (t === 'light' || t === 'dark') { theme.value = t; pushLog('ok', `theme → ${t}`); }
|
||||
else pushLog('info', 'theme [light|dark]');
|
||||
break;
|
||||
}
|
||||
case 'status':
|
||||
pushLog('info', `running=${running.value} seed=0x${seed.value.toString(16).toUpperCase()} verified=${witnessVerified.value}`);
|
||||
break;
|
||||
default:
|
||||
pushLog('err', `unknown command: ${cmd} · try help`);
|
||||
}
|
||||
}
|
||||
|
||||
private onKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Enter') { void this.exec(this.inputEl.value); this.inputEl.value = ''; }
|
||||
else if (e.key === 'ArrowUp') {
|
||||
const h = replHistory.value;
|
||||
if (h.length) {
|
||||
this.hIdx = Math.max(0, this.hIdx - 1);
|
||||
this.inputEl.value = h[this.hIdx] ?? '';
|
||||
e.preventDefault();
|
||||
}
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
const h = replHistory.value;
|
||||
if (h.length) {
|
||||
this.hIdx = Math.min(h.length, this.hIdx + 1);
|
||||
this.inputEl.value = h[this.hIdx] ?? '';
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
override render() {
|
||||
const c = this.counts();
|
||||
const filter = consoleFilter.value;
|
||||
const visible = consoleLines.value.filter((l) => filter === 'all' || l.level === filter);
|
||||
return html`
|
||||
<div class="tabs">
|
||||
${(['all', 'info', 'warn', 'err', 'dbg'] as const).map((k) => html`
|
||||
<button class="tab ${filter === k ? 'active' : ''}" data-tab=${k}
|
||||
@click=${() => consoleFilter.value = k}>
|
||||
${k} <span class="cnt">${c[k] ?? 0}</span>
|
||||
</button>
|
||||
`)}
|
||||
<span class="spacer"></span>
|
||||
<div class="tools">
|
||||
<button id="clear-log" title="Clear" @click=${() => consoleLines.value = []}>×</button>
|
||||
<button id="pause-log" title="Pause" @click=${() => consolePaused.value = !consolePaused.value}>
|
||||
${consolePaused.value ? '▶' : '❚❚'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="body" role="log" aria-live="polite" aria-label="Console output">
|
||||
${visible.map((l) => {
|
||||
const ts = new Date(l.ts);
|
||||
const tsStr = `${String(ts.getSeconds()).padStart(2, '0')}.${String(ts.getMilliseconds()).padStart(3, '0')}`;
|
||||
// Use innerHTML pass-through via unsafe-html alt: inject raw html via property
|
||||
return html`<div class="line ${l.level}">
|
||||
<div class="ts">${tsStr}</div>
|
||||
<div class="lvl">${l.level}</div>
|
||||
<div class="msg" .innerHTML=${l.msg}></div>
|
||||
</div>`;
|
||||
})}
|
||||
</div>
|
||||
<div class="input">
|
||||
<span class="prompt">nvsim></span>
|
||||
<input id="console-input" type="text"
|
||||
placeholder="help · scene.list · sensor.config · run · proof.verify · clear"
|
||||
@keydown=${this.onKey}/>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
/* Debug HUD toggled with `. Shows render fps, sim t, frames, |B|, SNR. */
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import { fps, framesEmitted, bMag, snr, t as simT } from '../store/appStore';
|
||||
|
||||
@customElement('nv-debug-hud')
|
||||
export class NvDebugHud extends LitElement {
|
||||
@state() private open = false;
|
||||
@state() private renderFps = 0;
|
||||
private lastTs = performance.now();
|
||||
private frameCount = 0;
|
||||
private rafId = 0;
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: fixed; bottom: 8px; right: 8px;
|
||||
width: 220px;
|
||||
background: rgba(13,17,23,0.85);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
font-family: var(--mono); font-size: 11px;
|
||||
color: var(--ink-2);
|
||||
z-index: 99;
|
||||
display: none;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
:host([open]) { display: block; }
|
||||
.h {
|
||||
display: flex; justify-content: space-between;
|
||||
font-weight: 600; color: var(--ink);
|
||||
margin-bottom: 6px; padding-bottom: 4px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.x { cursor: pointer; color: var(--ink-3); }
|
||||
.row {
|
||||
display: flex; justify-content: space-between;
|
||||
padding: 1px 0;
|
||||
}
|
||||
.k { color: var(--ink-3); }
|
||||
.v { color: var(--ink); }
|
||||
`;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('keydown', this.onKey);
|
||||
effect(() => { fps.value; framesEmitted.value; bMag.value; snr.value; simT.value; this.requestUpdate(); });
|
||||
this.tick();
|
||||
}
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener('keydown', this.onKey);
|
||||
cancelAnimationFrame(this.rafId);
|
||||
}
|
||||
|
||||
private onKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === '`' && !(e.target as HTMLElement).matches('input, textarea')) {
|
||||
this.open = !this.open;
|
||||
this.toggleAttribute('open', this.open);
|
||||
}
|
||||
};
|
||||
|
||||
private tick = (): void => {
|
||||
this.rafId = requestAnimationFrame(this.tick);
|
||||
const now = performance.now();
|
||||
this.frameCount++;
|
||||
if (now - this.lastTs >= 500) {
|
||||
this.renderFps = (this.frameCount * 1000) / (now - this.lastTs);
|
||||
this.frameCount = 0;
|
||||
this.lastTs = now;
|
||||
this.requestUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="h"><span>nvsim · debug</span><span class="x" @click=${() => { this.open = false; this.removeAttribute('open'); }}>✕</span></div>
|
||||
<div class="row"><span class="k">render fps</span><span class="v">${this.renderFps.toFixed(1)}</span></div>
|
||||
<div class="row"><span class="k">sim fps</span><span class="v">${fps.value > 0 ? Math.round(fps.value) : '—'}</span></div>
|
||||
<div class="row"><span class="k">frames</span><span class="v">${framesEmitted.value.toString()}</span></div>
|
||||
<div class="row"><span class="k">|B|</span><span class="v">${(bMag.value * 1e9).toFixed(3)} nT</span></div>
|
||||
<div class="row"><span class="k">SNR</span><span class="v">${snr.value > 0 ? snr.value.toFixed(1) : '—'}</span></div>
|
||||
<div class="row"><span class="k">DOM</span><span class="v">${document.querySelectorAll('*').length}</span></div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,666 +0,0 @@
|
||||
/* Ghost Murmur — research view.
|
||||
*
|
||||
* Walks through the publicly-reported April 2026 CIA program and maps
|
||||
* the physically-defensible parts onto RuView's three-tier heartbeat
|
||||
* mesh. Source: docs/research/quantum-sensing/16-ghost-murmur-ruview-spec.md
|
||||
*
|
||||
* This view is reference material, not an operational mode. It exists
|
||||
* so practitioners (and journalists) can audit the physics-vs-press
|
||||
* gap in the open. ADR-092 §14b.
|
||||
*/
|
||||
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import { getClient, pushLog } from '../store/appStore';
|
||||
import type { TransientRunResult } from '../transport/NvsimClient';
|
||||
|
||||
// Tier detection thresholds — order-of-magnitude floor each transport
|
||||
// can resolve cardiac signal at, in Tesla. Source: Ghost Murmur spec
|
||||
// §4.7, Wolf 2015, Barry 2020. These are deliberately optimistic for the
|
||||
// "available" path; the shoot-the-moon press claim sits 6+ orders below.
|
||||
const TIERS = [
|
||||
{ id: 'nvBest', label: 'NV-ensemble (best lab)', floorT: 1e-12, color: 'oklch(0.78 0.14 70)' },
|
||||
{ id: 'nvCots', label: 'NV-DNV-B1 (COTS)', floorT: 3e-10, color: 'oklch(0.72 0.18 50)' },
|
||||
{ id: 'squid', label: 'SQUID (shielded room)', floorT: 1e-15, color: 'oklch(0.78 0.12 195)' },
|
||||
{ id: 'mmw', label: '60 GHz mmWave (μ-Doppler)', floorT: 0, color: 'oklch(0.78 0.14 145)' },
|
||||
{ id: 'csi', label: 'WiFi CSI (presence)', floorT: 0, color: 'oklch(0.72 0.18 330)' },
|
||||
];
|
||||
|
||||
// Cardiac dipole moment (A·m²) — order-of-magnitude estimate from
|
||||
// Wikswo / Bison cardiac MCG modelling.
|
||||
const HEART_DIPOLE_AM2 = 5e-9;
|
||||
|
||||
@customElement('nv-ghost-murmur')
|
||||
export class NvGhostMurmur extends LitElement {
|
||||
@state() private distanceM = 0.1;
|
||||
@state() private momentLog10 = -8.3; // log10(5e-9)
|
||||
@state() private result: TransientRunResult | null = null;
|
||||
@state() private running = false;
|
||||
@state() private err: string | null = null;
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
background: radial-gradient(ellipse at 50% 30%, var(--bg-2) 0%, var(--bg-0) 70%);
|
||||
padding: 24px 28px 60px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 22px;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--ink);
|
||||
}
|
||||
.subtitle {
|
||||
color: var(--ink-3);
|
||||
font-size: 13px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
.links {
|
||||
display: flex; flex-wrap: wrap; gap: 6px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
.links a {
|
||||
padding: 5px 10px;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
font-size: 11.5px;
|
||||
font-family: var(--mono);
|
||||
color: var(--accent-2);
|
||||
text-decoration: none;
|
||||
}
|
||||
.links a:hover { border-color: var(--accent-2); }
|
||||
h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-3);
|
||||
margin: 28px 0 10px;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.card {
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px;
|
||||
}
|
||||
.card h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13.5px; font-weight: 600;
|
||||
color: var(--ink);
|
||||
}
|
||||
.card p {
|
||||
font-size: 12.5px; color: var(--ink-2);
|
||||
margin: 0 0 8px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.card p:last-child { margin-bottom: 0; }
|
||||
.stat {
|
||||
display: inline-flex; align-items: baseline; gap: 6px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.stat .v {
|
||||
font-family: var(--mono); font-size: 16px; font-weight: 600;
|
||||
color: var(--accent);
|
||||
}
|
||||
.stat .l {
|
||||
font-size: 10px; color: var(--ink-3);
|
||||
text-transform: uppercase; letter-spacing: 0.04em;
|
||||
}
|
||||
table {
|
||||
width: 100%; border-collapse: collapse;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
th, td {
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
th {
|
||||
color: var(--ink-3);
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
td.amber { color: var(--accent); font-family: var(--mono); }
|
||||
td.cyan { color: var(--accent-2); font-family: var(--mono); }
|
||||
td.bad { color: var(--bad); font-family: var(--mono); }
|
||||
.pill {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.pill.ok { color: var(--ok); border-color: oklch(0.78 0.14 145 / 0.4); }
|
||||
.pill.skeptical { color: var(--bad); border-color: oklch(0.65 0.22 25 / 0.4); }
|
||||
.pill.partial { color: var(--warn); border-color: oklch(0.7 0.18 35 / 0.4); }
|
||||
.architecture {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--ink-2);
|
||||
background: var(--bg-3);
|
||||
padding: 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--line);
|
||||
white-space: pre;
|
||||
overflow-x: auto;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.ethics {
|
||||
background: linear-gradient(180deg, var(--bg-2) 0%, oklch(0.65 0.22 25 / 0.04) 100%);
|
||||
border: 1px solid oklch(0.65 0.22 25 / 0.25);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
}
|
||||
.ethics h3 { color: var(--bad); margin-top: 0; }
|
||||
.ethics ul { padding-left: 18px; margin: 8px 0; }
|
||||
.ethics li { font-size: 12.5px; color: var(--ink-2); margin-bottom: 4px; }
|
||||
|
||||
/* Demo */
|
||||
.demo {
|
||||
background: linear-gradient(180deg, var(--bg-2) 0%, oklch(0.78 0.14 70 / 0.04) 100%);
|
||||
border: 1px solid oklch(0.78 0.14 70 / 0.3);
|
||||
border-radius: var(--radius);
|
||||
padding: 18px;
|
||||
}
|
||||
.demo-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 18px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
@media (max-width: 720px) { .demo-grid { grid-template-columns: 1fr; } }
|
||||
.control { margin-bottom: 14px; }
|
||||
.control .top {
|
||||
display: flex; justify-content: space-between;
|
||||
font-size: 12px; margin-bottom: 6px;
|
||||
}
|
||||
.control .top .lbl { color: var(--ink-3); }
|
||||
.control .top .val {
|
||||
font-family: var(--mono); color: var(--ink);
|
||||
}
|
||||
.control input[type="range"] {
|
||||
-webkit-appearance: none; appearance: none;
|
||||
width: 100%; height: 4px;
|
||||
background: var(--bg-3); border-radius: 2px; outline: none;
|
||||
}
|
||||
.control input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none; appearance: none;
|
||||
width: 14px; height: 14px; border-radius: 50%;
|
||||
background: var(--accent); cursor: pointer;
|
||||
border: 2px solid var(--bg-2);
|
||||
}
|
||||
.demo-btn {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--accent);
|
||||
background: var(--accent);
|
||||
color: #1a0f00;
|
||||
border-radius: 8px;
|
||||
font-size: 13px; font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.demo-btn:hover { filter: brightness(1.08); }
|
||||
.demo-btn:disabled { opacity: 0.6; cursor: progress; }
|
||||
.readout {
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
.readout-row {
|
||||
display: flex; justify-content: space-between;
|
||||
padding: 4px 0;
|
||||
font-family: var(--mono); font-size: 12px;
|
||||
}
|
||||
.readout-row .l { color: var(--ink-3); }
|
||||
.readout-row .v { color: var(--ink); }
|
||||
.readout-row .v.amber { color: var(--accent); }
|
||||
.tier-bar {
|
||||
position: relative;
|
||||
margin: 6px 0;
|
||||
height: 22px;
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.tier-bar .fill {
|
||||
position: absolute; top: 0; bottom: 0; left: 0;
|
||||
transition: width 0.2s ease-out;
|
||||
border-right: 2px solid;
|
||||
}
|
||||
.tier-bar .lbl {
|
||||
position: relative; z-index: 1;
|
||||
font-family: var(--mono); font-size: 11px;
|
||||
padding: 3px 8px;
|
||||
color: var(--ink);
|
||||
display: flex; justify-content: space-between;
|
||||
pointer-events: none;
|
||||
}
|
||||
.verdict {
|
||||
margin-top: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 12.5px; font-weight: 500;
|
||||
border: 1px solid;
|
||||
}
|
||||
.verdict.ok { background: oklch(0.78 0.14 145 / 0.08); border-color: oklch(0.78 0.14 145 / 0.4); color: var(--ok); }
|
||||
.verdict.warn { background: oklch(0.7 0.18 35 / 0.08); border-color: oklch(0.7 0.18 35 / 0.4); color: var(--warn); }
|
||||
.verdict.bad { background: oklch(0.65 0.22 25 / 0.08); border-color: oklch(0.65 0.22 25 / 0.4); color: var(--bad); }
|
||||
.demo-notes {
|
||||
font-size: 11.5px; color: var(--ink-3);
|
||||
margin-top: 10px; line-height: 1.5;
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Predicted MCG dipole field (Tesla) at distance r in metres.
|
||||
* Far-field approximation: |B| ≈ μ₀ · m / (4π · r³). Source: Jackson 3e §5.
|
||||
*/
|
||||
private predictedDipoleFieldT(r: number, m: number): number {
|
||||
const MU_0 = 4 * Math.PI * 1e-7;
|
||||
return (MU_0 * m) / (4 * Math.PI * Math.pow(Math.max(r, 1e-6), 3));
|
||||
}
|
||||
|
||||
private async runDemo(): Promise<void> {
|
||||
const c = getClient();
|
||||
if (!c) { this.err = 'WASM client not ready'; return; }
|
||||
this.err = null;
|
||||
this.running = true;
|
||||
this.requestUpdate();
|
||||
try {
|
||||
const r = this.distanceM;
|
||||
const m = Math.pow(10, this.momentLog10);
|
||||
// Heart proxy at +z = r, dipole moment along z = m A·m².
|
||||
const scene = {
|
||||
dipoles: [{ position: [0, 0, r] as [number, number, number], moment: [0, 0, m] as [number, number, number] }],
|
||||
loops: [],
|
||||
ferrous: [],
|
||||
eddy: [],
|
||||
sensors: [[0, 0, 0] as [number, number, number]],
|
||||
ambient_field: [0, 0, 0] as [number, number, number],
|
||||
};
|
||||
const config = {
|
||||
digitiser: { f_s_hz: 10000, f_mod_hz: 1000 },
|
||||
sensor: {
|
||||
gamma_fwhm_hz: 1.0e6,
|
||||
t1_s: 5.0e-3,
|
||||
t2_s: 1.0e-6,
|
||||
t2_star_s: 200e-9,
|
||||
contrast: 0.03,
|
||||
n_spins: 1.0e12,
|
||||
shot_noise_disabled: false,
|
||||
},
|
||||
dt_s: null,
|
||||
};
|
||||
this.result = await c.runTransient(scene, config, 42n, 64);
|
||||
pushLog('ok', `ghost-demo · r=${r.toFixed(3)} m · |B| recovered = ${(this.result.bMagT * 1e12).toExponential(2)} pT`);
|
||||
} catch (e) {
|
||||
this.err = (e as Error).message;
|
||||
pushLog('err', `ghost-demo failed: ${this.err}`);
|
||||
} finally {
|
||||
this.running = false;
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private formatField(t: number): string {
|
||||
if (t === 0) return '0 T';
|
||||
const abs = Math.abs(t);
|
||||
if (abs >= 1e-3) return `${(t * 1e3).toFixed(2)} mT`;
|
||||
if (abs >= 1e-6) return `${(t * 1e6).toFixed(2)} µT`;
|
||||
if (abs >= 1e-9) return `${(t * 1e9).toFixed(3)} nT`;
|
||||
if (abs >= 1e-12) return `${(t * 1e12).toFixed(2)} pT`;
|
||||
if (abs >= 1e-15) return `${(t * 1e15).toFixed(2)} fT`;
|
||||
if (abs >= 1e-18) return `${(t * 1e18).toFixed(2)} aT`;
|
||||
return `${t.toExponential(2)} T`;
|
||||
}
|
||||
|
||||
private formatDistance(r: number): string {
|
||||
if (r < 1) return `${(r * 100).toFixed(1)} cm`;
|
||||
if (r < 1000) return `${r.toFixed(2)} m`;
|
||||
if (r < 1e5) return `${(r / 1000).toFixed(2)} km`;
|
||||
return `${(r / 1609).toFixed(0)} mi`;
|
||||
}
|
||||
|
||||
private renderDemo() {
|
||||
const m = Math.pow(10, this.momentLog10);
|
||||
const predicted = this.predictedDipoleFieldT(this.distanceM, m);
|
||||
const recovered = this.result?.bMagT ?? 0;
|
||||
const noiseFloor = (this.result?.noiseFloorPtSqrtHz ?? 0) * 1e-12; // pT/√Hz → T/√Hz
|
||||
|
||||
const verdictPills = TIERS.map((t) => {
|
||||
let detect: 'ok' | 'warn' | 'bad' = 'bad';
|
||||
let label = 'below floor';
|
||||
if (t.id === 'mmw') {
|
||||
if (this.distanceM <= 5) { detect = 'ok'; label = 'µ-Doppler @ chest'; }
|
||||
else if (this.distanceM <= 15) { detect = 'warn'; label = 'edge of range'; }
|
||||
else { detect = 'bad'; label = 'out of range'; }
|
||||
} else if (t.id === 'csi') {
|
||||
if (this.distanceM <= 30) { detect = this.distanceM <= 10 ? 'ok' : 'warn'; label = 'presence/breathing'; }
|
||||
else { detect = 'bad'; label = 'out of range'; }
|
||||
} else if (t.floorT > 0) {
|
||||
const ratio = predicted / t.floorT;
|
||||
if (ratio > 100) { detect = 'ok'; label = `${ratio.toExponential(1)}× floor`; }
|
||||
else if (ratio > 1) { detect = 'warn'; label = `${ratio.toFixed(1)}× floor`; }
|
||||
else { detect = 'bad'; label = `${(1 / ratio).toExponential(1)}× too weak`; }
|
||||
}
|
||||
const fillPct = t.floorT > 0
|
||||
? Math.max(2, Math.min(100, 100 + 12 * Math.log10(predicted / t.floorT)))
|
||||
: (t.id === 'mmw' ? Math.max(2, 100 - this.distanceM * 7) : Math.max(2, 100 - this.distanceM * 2));
|
||||
return html`
|
||||
<div class="tier-bar" data-tier=${t.id}>
|
||||
<div class="fill" style=${`width:${fillPct}%; background:${t.color}; border-color:${t.color}`}></div>
|
||||
<div class="lbl">
|
||||
<span>${t.label}</span>
|
||||
<span class="verdict-${detect}" style=${`color:${detect === 'ok' ? 'var(--ok)' : detect === 'warn' ? 'var(--warn)' : 'var(--bad)'}`}>${label}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
const overallDetect: 'ok' | 'warn' | 'bad' =
|
||||
predicted > 1e-12 ? 'ok' : predicted > 1e-15 ? 'warn' : 'bad';
|
||||
const overallText =
|
||||
overallDetect === 'ok'
|
||||
? `Above NV-ensemble lab floor — close-range MCG plausible at ${this.formatDistance(this.distanceM)}.`
|
||||
: overallDetect === 'warn'
|
||||
? `Below NV ensemble best, above SQUID — research-grade only at ${this.formatDistance(this.distanceM)}.`
|
||||
: `Below every published instrument's noise floor at ${this.formatDistance(this.distanceM)}. Press-release physics.`;
|
||||
|
||||
return html`
|
||||
<div class="demo">
|
||||
<h3 style="margin: 0 0 6px;">Try it yourself</h3>
|
||||
<div style="font-size: 12.5px; color: var(--ink-2); margin-bottom: 4px; line-height: 1.5;">
|
||||
Place a cardiac dipole at variable distance from the NV sensor. The
|
||||
dashboard runs the <i>real</i> nvsim Rust pipeline (compiled to WASM)
|
||||
end-to-end and reports what each tier would actually detect. Same
|
||||
determinism contract as the rest of the dashboard.
|
||||
</div>
|
||||
<div class="demo-grid">
|
||||
<div>
|
||||
<div class="control">
|
||||
<div class="top">
|
||||
<span class="lbl">Distance from sensor</span>
|
||||
<span class="val" id="demo-dist-val">${this.formatDistance(this.distanceM)}</span>
|
||||
</div>
|
||||
<input type="range" id="demo-distance"
|
||||
min="-2" max="5" step="0.05"
|
||||
.value=${String(Math.log10(this.distanceM))}
|
||||
@input=${(e: Event) => { this.distanceM = Math.pow(10, +(e.target as HTMLInputElement).value); }} />
|
||||
<div style="font-size: 10.5px; color: var(--ink-3); margin-top: 4px; font-family: var(--mono);">
|
||||
10 cm → 100 km log scale
|
||||
</div>
|
||||
</div>
|
||||
<div class="control">
|
||||
<div class="top">
|
||||
<span class="lbl">Heart dipole moment</span>
|
||||
<span class="val" id="demo-moment-val">${m.toExponential(2)} A·m²</span>
|
||||
</div>
|
||||
<input type="range" id="demo-moment"
|
||||
min="-10" max="-6" step="0.05"
|
||||
.value=${String(this.momentLog10)}
|
||||
@input=${(e: Event) => { this.momentLog10 = +(e.target as HTMLInputElement).value; }} />
|
||||
<div style="font-size: 10.5px; color: var(--ink-3); margin-top: 4px; font-family: var(--mono);">
|
||||
published cardiac MCG ≈ 5×10⁻⁹ A·m²
|
||||
</div>
|
||||
</div>
|
||||
<button class="demo-btn" id="demo-run-btn" ?disabled=${this.running}
|
||||
@click=${() => this.runDemo()}>
|
||||
${this.running ? 'Running nvsim…' : '▶ Run nvsim at this distance'}
|
||||
</button>
|
||||
${this.err ? html`<div class="verdict bad" style="margin-top: 10px;">Error: ${this.err}</div>` : ''}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="readout">
|
||||
<div class="readout-row">
|
||||
<span class="l">Predicted |B| (1/r³)</span>
|
||||
<span class="v amber" id="demo-predicted">${this.formatField(predicted)}</span>
|
||||
</div>
|
||||
<div class="readout-row">
|
||||
<span class="l">Recovered |B| (nvsim)</span>
|
||||
<span class="v" id="demo-recovered">${this.result ? this.formatField(recovered) : '—'}</span>
|
||||
</div>
|
||||
<div class="readout-row">
|
||||
<span class="l">Sensor noise floor</span>
|
||||
<span class="v" id="demo-floor">${this.result ? this.formatField(noiseFloor) + '/√Hz' : '—'}</span>
|
||||
</div>
|
||||
<div class="readout-row">
|
||||
<span class="l">Frames run</span>
|
||||
<span class="v" id="demo-frames">${this.result?.nFrames ?? '—'}</span>
|
||||
</div>
|
||||
<div class="readout-row">
|
||||
<span class="l">Witness (this run)</span>
|
||||
<span class="v" style="font-size: 10px;" id="demo-witness">${this.result?.witnessHex.slice(0, 16) ?? '—'}…</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 14px;">
|
||||
<div style="font-size: 11.5px; color: var(--ink-3); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 8px;">
|
||||
Per-tier detectability
|
||||
</div>
|
||||
${verdictPills}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="verdict ${overallDetect}" id="demo-verdict">${overallText}</div>
|
||||
<div class="demo-notes">
|
||||
The <code>predicted</code> value uses the closed-form magnetic-dipole
|
||||
far field <code>|B| = μ₀·m / (4π·r³)</code>. The <code>recovered</code>
|
||||
value comes from the same Rust pipeline that drives the Witness panel —
|
||||
scene → Biot-Savart → NV ensemble → ADC → MagFrame. Use the moment
|
||||
slider to ask "what if the heart were stronger?". Use the distance
|
||||
slider to walk through 10 cm (clinical MCG), 1 m (close approach),
|
||||
10 m (room-scale), 1 km (skeptic's range), and 65 km (the press claim).
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<h1>Ghost Murmur — open-source reality check</h1>
|
||||
<div class="subtitle">
|
||||
The physics-vs-press audit for the publicly-reported April 2026
|
||||
CIA NV-diamond heartbeat detector, and how RuView's existing
|
||||
stack maps onto an honest, civilian version of the same idea.
|
||||
</div>
|
||||
|
||||
<div class="links">
|
||||
<a href="https://github.com/ruvnet/RuView/blob/feat/nvsim-pipeline-simulator/docs/research/quantum-sensing/16-ghost-murmur-ruview-spec.md" target="_blank" rel="noopener">
|
||||
📄 Full spec (583 lines)
|
||||
</a>
|
||||
<a href="https://gist.github.com/ruvnet/e44d0c3f0ad10d9c4933a196a16d405c" target="_blank" rel="noopener">
|
||||
✦ Public gist
|
||||
</a>
|
||||
<a href="https://github.com/ruvnet/RuView/issues/437" target="_blank" rel="noopener">
|
||||
# Issue #437
|
||||
</a>
|
||||
<a href="https://www.scientificamerican.com/article/what-is-the-quantum-ghost-murmur-purportedly-used-in-iran-scientists/" target="_blank" rel="noopener">
|
||||
↗ Scientific American
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h2>What the press reported</h2>
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<h3>The story</h3>
|
||||
<p>3 Apr 2026: USAF F-15E pilot "Dude 44 Bravo" goes down in southern Iran during the regional exchange and evades for ~2 days.</p>
|
||||
<p>President Trump publicly suggests detection from <b>40 miles away</b> on a mountainside at night; CIA Director Ratcliffe says "invisible to the enemy, but not to the CIA."</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>The named tech</h3>
|
||||
<p><b>"Ghost Murmur"</b> — Lockheed Skunk Works system using NV defects in synthetic diamond + AI to extract a heartbeat from environmental noise.</p>
|
||||
<p>Outlets: <i>Newsweek, Scientific American, Military.com, WION, Open The Magazine, Yahoo, Calcalist</i> + HN thread #47679241.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>What physicists said</h3>
|
||||
<p>Wikswo (Vanderbilt), Orzel (Union College), Roth (Oakland) — all pushing back hard.</p>
|
||||
<p>"At 1 km, the heartbeat field drops to ~10⁻¹² of its 10 cm value." MCG-only at multi-mile range is <span class="pill skeptical">not consistent with published physics</span>.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Live demo — nvsim WASM</h2>
|
||||
${this.renderDemo()}
|
||||
|
||||
<h2>Physics reality check</h2>
|
||||
<div class="card" style="padding: 6px 14px;">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Distance</th><th>Cardiac MCG (peak QRS)</th><th>vs Earth field (~50 µT)</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>10 cm</td><td class="amber">50 pT</td><td>10⁹× weaker</td></tr>
|
||||
<tr><td>1 m</td><td class="amber">50 fT</td><td>10¹²× weaker</td></tr>
|
||||
<tr><td>10 m</td><td class="cyan">50 aT</td><td>10¹⁵× weaker</td></tr>
|
||||
<tr><td>1 km</td><td class="bad">5 × 10⁻²³ T</td><td>10²⁷× weaker</td></tr>
|
||||
<tr><td>40 mi (65 km)</td><td class="bad">~10⁻²⁸ T</td><td>10³³× weaker</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p style="font-size: 12px; color: var(--ink-3); margin: 10px 0 0; line-height: 1.5;">
|
||||
Best published NV-ensemble lab record: <b>0.9 pT/√Hz</b> [Wolf 2015].
|
||||
Best SQUID in a shielded room: <b>~1 fT/√Hz</b>. To detect a single heartbeat at 10 m
|
||||
you'd need ~2 billion× more sensitivity than any published ensemble has ever shown,
|
||||
in a magnetically silent environment. <i>40 miles is press-release physics.</i>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2>RuView's three-tier mesh — what is actually buildable</h2>
|
||||
<div class="architecture"> ┌──────────────────────────┐
|
||||
│ Tier 3 — NV-diamond │ Range: 0.1–2 m (lab)
|
||||
│ magnetometer ring │ Status: nvsim simulator only
|
||||
│ (close-confirm) │ Hardware: $$$ (≥$8k DNV-B1)
|
||||
└──────────┬───────────────┘
|
||||
│
|
||||
┌──────────┴───────────────┐
|
||||
│ Tier 2 — 60 GHz FMCW │ Range: 1–10 m HR/BR
|
||||
│ mmWave radar mesh │ Status: shipping (ADR-021)
|
||||
│ (vital signs, posture) │ Hardware: $15 (MR60BHA2 + ESP32-C6)
|
||||
└──────────┬───────────────┘
|
||||
│
|
||||
┌──────────┴───────────────┐
|
||||
│ Tier 1 — WiFi CSI mesh │ Range: 10–30 m through-wall
|
||||
│ (presence, breathing, │ Status: shipping (ADR-014, ADR-029)
|
||||
│ pose, intention) │ Hardware: $9 (ESP32-S3 8MB)
|
||||
└──────────┬───────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────────┐
|
||||
│ RuvSense multistatic fusion │
|
||||
│ + cross-viewpoint attention │
|
||||
│ + AETHER re-ID embeddings │
|
||||
│ + Cramer-Rao gating │
|
||||
└────────────────────────────────┘</div>
|
||||
|
||||
<h2>Press claim → RuView equivalent</h2>
|
||||
<div class="card" style="padding: 6px 14px;">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Press claim</th><th>RuView equivalent today</th><th>Crate / ADR</th><th>Honest range</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>NV-diamond magnetometry</td>
|
||||
<td>Deterministic NV pipeline simulator</td>
|
||||
<td><code>nvsim</code> · ADR-089</td>
|
||||
<td>Simulator only</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>"AI strips environmental noise"</td>
|
||||
<td>RuvSense multistatic fusion + AETHER</td>
|
||||
<td>signal/ruvsense/ · ADR-029</td>
|
||||
<td>Mature</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Heartbeat at distance</td>
|
||||
<td>60 GHz FMCW HR/BR + WiFi CSI breathing</td>
|
||||
<td>vitals · ADR-021</td>
|
||||
<td><span class="pill ok">1–5 m HR · 10–30 m presence</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Long-range localisation</td>
|
||||
<td>Multistatic time-of-flight + CRLB</td>
|
||||
<td>ruvector/viewpoint/</td>
|
||||
<td>Limited by node spacing</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><i>40-mile single-heartbeat detection</i></td>
|
||||
<td><i>Not feasible at any tier</i></td>
|
||||
<td>—</td>
|
||||
<td><span class="pill skeptical">Press-release physics</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2>Build today on $165</h2>
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<h3>Bill of materials</h3>
|
||||
<p style="font-family: var(--mono); font-size: 11.5px; line-height: 1.7; color: var(--ink-2);">
|
||||
3 × ESP32-S3 8 MB ($9 ea)<br>
|
||||
3 × PoE injector + cat6 ($6 ea)<br>
|
||||
1 × ESP32-C6 + Seeed MR60BHA2 ($15)<br>
|
||||
1 × Raspberry Pi 5 8 GB ($80)<br>
|
||||
1 × unmanaged GbE switch ($25)
|
||||
</p>
|
||||
<p><b>Total: $165</b></p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Honest performance</h3>
|
||||
<span class="stat"><span class="v">95%</span><span class="l">TPR (LOS, 0–15 m)</span></span><br><br>
|
||||
<span class="stat"><span class="v">±2 bpm</span><span class="l">HR (LOS 0–3 m)</span></span><br><br>
|
||||
<span class="stat"><span class="v">±1 br/min</span><span class="l">BR (any mode)</span></span><br><br>
|
||||
<span class="stat"><span class="v">~10 cm</span><span class="l">pose error</span></span><br><br>
|
||||
<span class="stat"><span class="v">80–150 ms</span><span class="l">end-to-end latency</span></span>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Determinism</h3>
|
||||
<p>Same <code style="font-family: var(--mono); color: var(--accent);">(scene, config, seed)</code> → byte-identical SHA-256 witness across browsers, OSes, transports.</p>
|
||||
<p>Reference: <span style="font-family: var(--mono); font-size: 10.5px; color: var(--accent-3);">cc8de9b01b0ff5bd…</span></p>
|
||||
<p>Try the Witness tab on the right — it re-derives the hash live in this browser and compares against the published reference.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Privacy, ethics, legal</h2>
|
||||
<div class="ethics">
|
||||
<h3>This is the open-source version. Same physics, opposite governance.</h3>
|
||||
<ul>
|
||||
<li><b>Civilian opt-in only</b> — search-and-rescue, elder-care, occupancy, ICU vitals. Not surveillance.</li>
|
||||
<li><b>No directional pursuit</b> — no beam-steering, target-following, or remote person-of-interest tracking.</li>
|
||||
<li><b>Data minimisation</b> — fused output is <code>(presence, HR, BR, pose, p_alive)</code>; raw streams discarded at the edge.</li>
|
||||
<li><b>PII gates</b> (ADR-040) block identifying biometric streams from leaving the local mesh without consent.</li>
|
||||
<li><b>Adversarial-signal detection</b> flags physically-impossible signal patterns from compromised mesh nodes.</li>
|
||||
<li><b>No export-controlled hardware</b> — RuView targets < $50 COTS. ITAR/EAR sub-THz coherent radars and shielded NV ensembles are out of scope.</li>
|
||||
</ul>
|
||||
<p style="font-size: 11.5px; color: var(--ink-3); margin: 10px 0 0;">
|
||||
RuView is not affiliated with the United States government, the CIA, Lockheed Martin,
|
||||
or any classified program. References to "Ghost Murmur" in this view refer
|
||||
exclusively to the publicly-reported program of that name as covered in the open
|
||||
press in April 2026.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2>Cross-references</h2>
|
||||
<div class="card">
|
||||
<p style="font-size: 12px; color: var(--ink-2); line-height: 1.7; margin: 0;">
|
||||
<b>ADRs:</b> 014 (signal) · 021 (vitals) · 024 (AETHER) · 027 (MERIDIAN) ·
|
||||
028 (witness audit) · 029 (RuvSense) · 040 (PII gates) · 086 (ESP32 RaBitQ) ·
|
||||
<b>089 (nvsim, Accepted)</b> · 090 (Lindblad, Proposed-conditional) ·
|
||||
091 (sub-THz radar research) · <b>092 (this dashboard)</b>.<br><br>
|
||||
<b>Primary physics:</b> Cohen 1970 · Bison 2009 · Wolf 2015 · Barry RMP 2020 · Doherty 2013 · Jackson 3e §5.6/§5.8.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,458 +0,0 @@
|
||||
/* Help center — single dialog covering Quickstart / Glossary / FAQ /
|
||||
* Shortcuts. Opened from the topbar `?` button or by pressing `?` on
|
||||
* the keyboard. Self-contained, no external content. */
|
||||
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
|
||||
type Section = 'quickstart' | 'glossary' | 'faq' | 'shortcuts' | 'about';
|
||||
|
||||
interface GlossaryItem {
|
||||
term: string;
|
||||
body: string;
|
||||
category: 'physics' | 'rust' | 'ui';
|
||||
}
|
||||
|
||||
const GLOSSARY: GlossaryItem[] = [
|
||||
{ term: 'NV-diamond', category: 'physics', body: 'Nitrogen-vacancy defect in synthetic diamond. The simulator models a 1 mm³ ensemble (~10¹² centers) addressed by 532 nm pump light + a 2.87 GHz microwave drive. Used as a room-temperature magnetometer with shot-noise floor ~1 pT/√Hz at the published lab record.' },
|
||||
{ term: 'CW-ODMR', category: 'physics', body: 'Continuously-driven optically-detected magnetic resonance. Sweep the microwave frequency around the NV zero-field splitting (D = 2.87 GHz) and watch the photoluminescence dip when the microwave matches the spin transition. The dip splits with applied magnetic field along each of the four ⟨111⟩ NV axes.' },
|
||||
{ term: 'MagFrame', category: 'rust', body: 'Fixed-layout 60-byte binary record nvsim emits per (sensor × sample). Magic 0xC51A_6E70, version 1, little-endian. Carries timestamp, recovered B vector (pT), per-axis sigma, noise floor, and flag bits for saturation / shot-noise-disabled / heavy-attenuation.' },
|
||||
{ term: 'Witness', category: 'rust', body: 'SHA-256 hash over the concatenated MagFrame bytes for a canonical reference run (Proof::REFERENCE_SCENE_JSON @ seed=42, N=256). Same inputs → same hash, byte-for-byte, across runs and machines. The dashboard re-derives it in WASM and compares against Proof::EXPECTED_WITNESS_HEX pinned at build time.' },
|
||||
{ term: 'Determinism gate', category: 'rust', body: 'A pass/fail check: did this build of nvsim produce the expected witness? If yes → every constant (γ_e, D_GS, μ₀, contrast, T₂*, the PRNG stream, the frame layout, the pipeline ordering) is byte-identical to the published reference. If no → something drifted; the dashboard names which.' },
|
||||
{ term: 'Lock-in demod', category: 'physics', body: 'Multiply the photoluminescence signal by cos(2π·f_mod·t) and low-pass to recover the slowly-varying B-field component. The simulator emulates a lock-in with output gain 2 and a single-pole IIR LP filter; settable via the Tunables panel (f_mod default 1 kHz).' },
|
||||
{ term: 'Shot-noise floor', category: 'physics', body: 'δB = 1 / (γ_e · C · √(N · t · T₂*)) — the irreducible quantum noise floor for an NV ensemble. With nvsim defaults (N=10¹², C=0.03, T₂*=200 ns): ≈1.18 pT/√Hz. Toggleable via the Tunables panel for "analytic" runs without noise.' },
|
||||
{ term: 'Biot-Savart', category: 'physics', body: 'Closed-form magnetic field at a point from a current loop or a magnetic dipole. The Scene panel\'s sources (heart proxy, mains loop, ferrous body, eddy current) all reduce to Biot-Savart-style superpositions over the sensor position.' },
|
||||
{ term: 'Multistatic fusion', category: 'physics', body: 'Combining evidence from multiple sensors at known geometric configurations. RuView\'s Cramer-Rao-weighted attention over WiFi CSI nodes + 60 GHz radar nodes + (hypothetically) NV nodes; documented in ADR-029 and the Ghost Murmur view.' },
|
||||
{ term: 'Scene', category: 'ui', body: 'The simulated magnetic environment: a list of sources (dipole, current loop, ferrous body, eddy current) plus one or more sensor positions and an ambient field. The dashboard ships a "rebar-walkby-01" reference scene; click "New scene…" in the command palette (⌘K) to build your own.' },
|
||||
{ term: 'Tunables', category: 'ui', body: 'Sliders that change the running pipeline\'s digitiser config. Each edit debounces 300 ms, then rebuilds the WASM pipeline with the new f_s / f_mod / dt / shot-noise setting. The frame stream picks up the change without a restart.' },
|
||||
{ term: 'Transport', category: 'ui', body: 'How the dashboard talks to nvsim. Default is WASM — the simulator runs in a Web Worker right here in your browser, no server. The optional WS transport is REST + binary WebSocket against a host-supplied nvsim-server (see ADR-092 §6.2). Toggle in Settings.' },
|
||||
{ term: 'App Store', category: 'ui', body: 'Catalog of all 65+ hot-loadable WASM edge modules from wifi-densepose-wasm-edge plus the simulators. Each card carries id / category / status / event IDs; the toggle marks an app active in this session and (in WS mode) pushes the activation to a connected ESP32 mesh.' },
|
||||
{ term: 'Ghost Murmur', category: 'ui', body: 'Research view that audits the publicly-reported April 2026 CIA NV-diamond heartbeat detector against the open physics literature. Includes a live "Try it yourself" sandbox where you can place a heart dipole at any distance from the sensor and ask: which transport tier would actually detect it?' },
|
||||
];
|
||||
|
||||
const FAQ = [
|
||||
{
|
||||
q: 'Is this a real simulator or a mockup?',
|
||||
a: 'Real. The Rust crate at v2/crates/nvsim is the same code that runs in the browser via WASM. Press <b>Verify witness</b> on the Witness panel — the SHA-256 you see is byte-equivalent to what `cargo test -p nvsim` produces.',
|
||||
},
|
||||
{
|
||||
q: 'Why does my "Recovered |B|" sit much higher than "Predicted |B|" in the Ghost Murmur demo?',
|
||||
a: 'The recovered value reads the simulator\'s ADC quantization floor, not the actual magnetic signal. With COTS-default sensor noise (~300 pT/√Hz) and 16-bit ADC at ±10 µT FS, anything below ~1 pT vanishes into ~2 nT of digitization residual. That\'s the lesson — the press claim sits far below this floor at any meaningful range.',
|
||||
},
|
||||
{
|
||||
q: 'Can I run my own scene?',
|
||||
a: 'Yes. Press ⌘K to open the command palette and pick "New scene…". You get five fields (name, dipole moment, distance, ferrous toggle, mains toggle); the dashboard builds the JSON and pushes it via <code>client.loadScene()</code>.',
|
||||
},
|
||||
{
|
||||
q: 'Does any of my data leave the browser?',
|
||||
a: 'No. WASM mode is local-only — the worker, the WASM binary, and the IndexedDB persistence all live in your browser. The optional WS transport (off by default) talks to a host of your choosing.',
|
||||
},
|
||||
{
|
||||
q: 'What does the witness mismatch (red ✗) mean?',
|
||||
a: 'The current build of nvsim produced a SHA-256 that doesn\'t match the constant pinned at compile time. Possible causes: a different Rust toolchain, a dependency version drift, a manual edit to a physics constant, or an honest bug. Audit the diff against ADR-089 §5.',
|
||||
},
|
||||
{
|
||||
q: 'Why are the Inspector / Witness rail buttons there if there\'s already a right-side inspector?',
|
||||
a: 'The right-side inspector is the compact live view; the rail buttons open a full-width version with bigger charts, an explainer header, reference-scene metadata cards, and (on Witness) a "what this verifies" panel. Both stay in sync — the right rail is for glancing, the main area is for diving in.',
|
||||
},
|
||||
{
|
||||
q: 'Why is there an "App Store" if this is a magnetometer simulator?',
|
||||
a: 'Because nvsim is one tile in a larger sensing platform. The catalog lists every hot-loadable WASM edge module RuView ships — medical, security, building, retail, industrial, signal, learning, autonomy. The simulators (nvsim today, more in future) are first-class entries in the same catalog.',
|
||||
},
|
||||
];
|
||||
|
||||
const QUICKSTART = [
|
||||
{ step: 1, title: 'Hit ▶ Run', body: 'The big amber button in the topbar starts the live frame stream. The pipeline runs ~1.8 kHz on x86_64 WASM, well above the 1 kHz Cortex-A53 acceptance gate.' },
|
||||
{ step: 2, title: 'Watch the B-vector trace', body: 'The Inspector → Signal tab shows the recovered field per axis updating in real time. The frame strip below it is one bar per ~32-frame batch.' },
|
||||
{ step: 3, title: 'Verify the witness', body: 'Click the rail Witness button (or REPL: <code>proof.verify</code>). The dashboard re-runs the canonical reference scene and asserts the SHA-256 byte-for-byte.' },
|
||||
{ step: 4, title: 'Drag a source', body: 'Grab the rebar / heart proxy / mains loop / ferrous door in the scene canvas; positions persist via IndexedDB.' },
|
||||
{ step: 5, title: 'Tweak the tunables', body: 'Sliders in the left sidebar update the running pipeline (f_s, f_mod, integration time, shot-noise). Changes debounce 300 ms then push to the worker.' },
|
||||
{ step: 6, title: 'Open the Ghost Murmur view', body: 'The ghost icon in the rail. Move the distance + moment sliders, hit "Run nvsim at this distance" — the live demo runs the real Rust pipeline through WASM and shows which transport tier would actually detect.' },
|
||||
{ step: 7, title: 'Browse the App Store', body: 'The grid icon. 65+ edge apps: medical, security, building, retail, industrial, signal, learning. Toggle to mark active in this session.' },
|
||||
];
|
||||
|
||||
const SHORTCUTS = [
|
||||
{ keys: '⌘K / Ctrl K', label: 'Command palette' },
|
||||
{ keys: 'Space', label: 'Play / pause pipeline' },
|
||||
{ keys: '⌘R / Ctrl R', label: 'Reset pipeline (with confirm)' },
|
||||
{ keys: '⌘, / Ctrl ,', label: 'Settings drawer' },
|
||||
{ keys: '⌘N / Ctrl N', label: 'New scene' },
|
||||
{ keys: '⌘E / Ctrl E', label: 'Export proof bundle' },
|
||||
{ keys: '⌘/ / Ctrl /', label: 'Toggle theme (dark / light)' },
|
||||
{ keys: '`', label: 'Toggle debug HUD' },
|
||||
{ keys: '?', label: 'Open this help center' },
|
||||
{ keys: '1 · 2 · 3', label: 'Switch inspector tab (Signal / Frame / Witness)' },
|
||||
{ keys: 'Esc', label: 'Close any modal / palette / drawer' },
|
||||
{ keys: '/', label: 'Focus the REPL prompt' },
|
||||
];
|
||||
|
||||
@customElement('nv-help')
|
||||
export class NvHelp extends LitElement {
|
||||
@state() private open = false;
|
||||
@state() private section: Section = 'quickstart';
|
||||
@state() private query = '';
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 230;
|
||||
display: grid; place-items: center;
|
||||
opacity: 0; pointer-events: none;
|
||||
transition: opacity 0.18s;
|
||||
}
|
||||
:host([open]) { opacity: 1; pointer-events: auto; }
|
||||
.modal {
|
||||
background: var(--bg-1);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 30px 80px -20px rgba(0,0,0,0.7);
|
||||
width: min(880px, 94vw);
|
||||
max-height: 86vh;
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
overflow: hidden;
|
||||
transform: translateY(12px) scale(0.98);
|
||||
transition: transform 0.22s cubic-bezier(0.2,0.7,0.3,1);
|
||||
}
|
||||
:host([open]) .modal { transform: translateY(0) scale(1); }
|
||||
@media (max-width: 700px) {
|
||||
.modal { grid-template-columns: 1fr; grid-template-rows: auto auto 1fr auto; max-height: 92vh; }
|
||||
.nav { border-right: 0; border-bottom: 1px solid var(--line); flex-direction: row; overflow-x: auto; }
|
||||
.nav button { white-space: nowrap; }
|
||||
}
|
||||
.h {
|
||||
grid-column: 1 / -1;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.h .ttl { font-size: 15px; font-weight: 600; }
|
||||
.nav {
|
||||
border-right: 1px solid var(--line);
|
||||
padding: 12px 8px;
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
background: var(--bg-1);
|
||||
}
|
||||
.nav button {
|
||||
text-align: left;
|
||||
padding: 8px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
color: var(--ink-3);
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
.nav button:hover { color: var(--ink); background: var(--bg-2); }
|
||||
.nav button.on {
|
||||
color: var(--ink); background: var(--bg-3);
|
||||
border-color: var(--line-2);
|
||||
}
|
||||
.body {
|
||||
padding: 18px 22px;
|
||||
overflow-y: auto;
|
||||
font-size: 13px;
|
||||
color: var(--ink-2);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.body h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 18px;
|
||||
color: var(--ink);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.body .lead {
|
||||
color: var(--ink-3);
|
||||
font-size: 12.5px;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
.body p { margin: 0 0 12px; }
|
||||
.body code {
|
||||
font-family: var(--mono);
|
||||
background: var(--bg-3);
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
font-size: 11.5px;
|
||||
color: var(--accent);
|
||||
}
|
||||
.body kbd {
|
||||
font-family: var(--mono);
|
||||
padding: 2px 6px;
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
font-size: 11.5px;
|
||||
color: var(--ink);
|
||||
}
|
||||
.step {
|
||||
display: grid;
|
||||
grid-template-columns: 32px 1fr;
|
||||
gap: 12px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.step:last-child { border-bottom: 0; }
|
||||
.step .num {
|
||||
width: 26px; height: 26px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
color: #1a0f00;
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
display: grid; place-items: center;
|
||||
}
|
||||
.step .ttl { color: var(--ink); font-weight: 600; font-size: 13.5px; margin-bottom: 2px; }
|
||||
.step .body-text { font-size: 12.5px; color: var(--ink-2); line-height: 1.55; }
|
||||
.glossary-search {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
color: var(--ink);
|
||||
outline: none;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.glossary-search:focus { border-color: var(--accent); }
|
||||
.term {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.term:last-child { border-bottom: 0; }
|
||||
.term .head {
|
||||
display: flex; align-items: center; gap: 8px; margin-bottom: 4px;
|
||||
}
|
||||
.term .name {
|
||||
font-family: var(--mono);
|
||||
font-size: 13.5px;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.term .badge {
|
||||
font-family: var(--mono);
|
||||
font-size: 9.5px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--line);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.term .badge.physics { color: var(--accent-2); border-color: oklch(0.78 0.12 195 / 0.4); }
|
||||
.term .badge.rust { color: var(--accent); border-color: oklch(0.78 0.14 70 / 0.4); }
|
||||
.term .badge.ui { color: var(--accent-4); border-color: oklch(0.78 0.14 145 / 0.4); }
|
||||
.term .body-text {
|
||||
font-size: 12.5px;
|
||||
color: var(--ink-2);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.faq-item {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.faq-item:last-child { border-bottom: 0; }
|
||||
.faq-item .q {
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
font-size: 13.5px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.faq-item .a { font-size: 12.5px; color: var(--ink-2); line-height: 1.55; }
|
||||
.shortcuts {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 8px 16px;
|
||||
align-items: baseline;
|
||||
}
|
||||
.f {
|
||||
grid-column: 1 / -1;
|
||||
padding: 10px 18px;
|
||||
border-top: 1px solid var(--line);
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
font-size: 11.5px; color: var(--ink-3);
|
||||
}
|
||||
.close {
|
||||
width: 28px; height: 28px;
|
||||
background: transparent; border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
color: var(--ink-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.close:hover { color: var(--ink); border-color: var(--line-2); }
|
||||
`;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('nv-show-help', this.show as EventListener);
|
||||
window.addEventListener('nv-show-help-close', this.closeListener);
|
||||
window.addEventListener('keydown', this.onKey);
|
||||
}
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener('nv-show-help', this.show as EventListener);
|
||||
window.removeEventListener('nv-show-help-close', this.closeListener);
|
||||
window.removeEventListener('keydown', this.onKey);
|
||||
}
|
||||
private closeListener = (): void => this.close();
|
||||
|
||||
private show = (e: Event): void => {
|
||||
const detail = (e as CustomEvent).detail as { section?: Section } | undefined;
|
||||
if (detail?.section) this.section = detail.section;
|
||||
this.open = true;
|
||||
this.setAttribute('open', '');
|
||||
};
|
||||
private close(): void {
|
||||
this.open = false;
|
||||
this.removeAttribute('open');
|
||||
}
|
||||
private onKey = (e: KeyboardEvent): void => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
const isInput = target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA';
|
||||
if (e.key === '?' && !isInput && !e.ctrlKey && !e.metaKey) {
|
||||
e.preventDefault();
|
||||
this.show(new CustomEvent('nv-show-help'));
|
||||
} else if (e.key === 'Escape' && this.open) {
|
||||
this.close();
|
||||
}
|
||||
};
|
||||
|
||||
private filteredGlossary(): GlossaryItem[] {
|
||||
if (!this.query.trim()) return GLOSSARY;
|
||||
const q = this.query.toLowerCase();
|
||||
return GLOSSARY.filter((g) =>
|
||||
g.term.toLowerCase().includes(q) || g.body.toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
|
||||
private renderQuickstart() {
|
||||
return html`
|
||||
<h2>Quickstart</h2>
|
||||
<p class="lead">Seven taps to get from "I just opened the dashboard" to "I'm running my own scene with verified determinism."</p>
|
||||
<button
|
||||
style="display:inline-flex; align-items:center; gap:8px; padding:10px 16px; margin-bottom:14px; background:var(--accent); color:#1a0f00; border:none; border-radius:8px; font-size:13px; font-weight:600; cursor:pointer; font-family:inherit;"
|
||||
@click=${() => { window.dispatchEvent(new CustomEvent('nv-show-help-close')); window.dispatchEvent(new CustomEvent('nv-show-tour')); }}>
|
||||
★ Take the interactive 10-step tour
|
||||
</button>
|
||||
${QUICKSTART.map((s) => html`
|
||||
<div class="step">
|
||||
<div class="num">${s.step}</div>
|
||||
<div>
|
||||
<div class="ttl">${s.title}</div>
|
||||
<div class="body-text" .innerHTML=${s.body}></div>
|
||||
</div>
|
||||
</div>
|
||||
`)}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderGlossary() {
|
||||
const items = this.filteredGlossary();
|
||||
return html`
|
||||
<h2>Glossary</h2>
|
||||
<p class="lead">Every piece of jargon in the dashboard, defined in one paragraph each.</p>
|
||||
<input class="glossary-search" type="text" placeholder="Search 14 terms…"
|
||||
.value=${this.query}
|
||||
@input=${(e: Event) => this.query = (e.target as HTMLInputElement).value} />
|
||||
${items.length === 0
|
||||
? html`<p style="color: var(--ink-3);">No terms match.</p>`
|
||||
: items.map((g) => html`
|
||||
<div class="term">
|
||||
<div class="head">
|
||||
<span class="name">${g.term}</span>
|
||||
<span class="badge ${g.category}">${g.category}</span>
|
||||
</div>
|
||||
<div class="body-text">${g.body}</div>
|
||||
</div>
|
||||
`)}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderFaq() {
|
||||
return html`
|
||||
<h2>FAQ</h2>
|
||||
<p class="lead">The questions I was asked twice in the first week of demos.</p>
|
||||
${FAQ.map((item) => html`
|
||||
<div class="faq-item">
|
||||
<div class="q">${item.q}</div>
|
||||
<div class="a" .innerHTML=${item.a}></div>
|
||||
</div>
|
||||
`)}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderShortcuts() {
|
||||
return html`
|
||||
<h2>Keyboard shortcuts</h2>
|
||||
<p class="lead">Everything is reachable without a mouse.</p>
|
||||
<div class="shortcuts">
|
||||
${SHORTCUTS.map((s) => html`
|
||||
<kbd>${s.keys}</kbd><span>${s.label}</span>
|
||||
`)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderAbout() {
|
||||
return html`
|
||||
<h2>About this dashboard</h2>
|
||||
<p class="lead">What you're looking at, in one screen.</p>
|
||||
<p><b>nvsim</b> is a deterministic forward simulator for nitrogen-vacancy diamond magnetometry.
|
||||
The Rust crate at <code>v2/crates/nvsim</code> is the source of truth; this dashboard is a
|
||||
Vite + Lit single-page app that ships the crate compiled to WebAssembly inside a Web Worker.</p>
|
||||
<p>The defining commitment is <b>determinism</b>: same <code>(scene, config, seed)</code> →
|
||||
byte-identical SHA-256 witness across browsers, OSes, and transports. Press the
|
||||
<kbd>Verify witness</kbd> button on the Witness tab to assert this live.</p>
|
||||
<p>The codebase is open source (Apache-2.0 OR MIT). Find it on GitHub:
|
||||
<code>github.com/ruvnet/RuView</code>. Decisions are documented in ADRs 089 (nvsim),
|
||||
090 (Lindblad extension, conditional), 091 (sub-THz radar research),
|
||||
092 (this dashboard), 093 (UX gap analysis).</p>
|
||||
<p>This dashboard is one of several RuView demos. Sibling demos at
|
||||
<code>github.io/RuView/</code> include the Observatory and Pose Fusion views.</p>
|
||||
`;
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-label="Help center">
|
||||
<div class="h">
|
||||
<div class="ttl">Help</div>
|
||||
<button class="close" aria-label="Close help" @click=${() => this.close()}>×</button>
|
||||
</div>
|
||||
<nav class="nav" role="tablist" aria-label="Help sections">
|
||||
${(['quickstart', 'glossary', 'faq', 'shortcuts', 'about'] as Section[]).map((s) => html`
|
||||
<button class=${this.section === s ? 'on' : ''} role="tab"
|
||||
aria-selected=${this.section === s}
|
||||
@click=${() => this.section = s}>
|
||||
${s === 'quickstart' ? '🚀 Quickstart'
|
||||
: s === 'glossary' ? '📖 Glossary'
|
||||
: s === 'faq' ? '? FAQ'
|
||||
: s === 'shortcuts' ? '⌨ Shortcuts'
|
||||
: 'ℹ About'}
|
||||
</button>
|
||||
`)}
|
||||
</nav>
|
||||
<div class="body" role="tabpanel">
|
||||
${this.section === 'quickstart' ? this.renderQuickstart()
|
||||
: this.section === 'glossary' ? this.renderGlossary()
|
||||
: this.section === 'faq' ? this.renderFaq()
|
||||
: this.section === 'shortcuts' ? this.renderShortcuts()
|
||||
: this.renderAbout()}
|
||||
</div>
|
||||
<div class="f">
|
||||
<span>Press <kbd style="font-family:var(--mono);font-size:10.5px;padding:1px 4px;background:var(--bg-3);border:1px solid var(--line);border-radius:3px;">?</kbd> any time to reopen</span>
|
||||
<span>nvsim · Apache-2.0 OR MIT</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export function showHelp(section?: Section): void {
|
||||
window.dispatchEvent(new CustomEvent('nv-show-help', { detail: { section } }));
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
/* Home view — friendly landing surface for new users.
|
||||
*
|
||||
* The full-power scene + sidebar + inspector + console are intentionally
|
||||
* dense; that's the operator surface. Home is for first-time visitors:
|
||||
* a single hero CTA, four quick-jump action cards, and a 1-paragraph
|
||||
* explanation of what this dashboard is. No jargon above the fold.
|
||||
*/
|
||||
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import { running, getClient, witnessVerified, fps, pushLog } from '../store/appStore';
|
||||
|
||||
export type Action = 'scene' | 'apps' | 'witness' | 'ghost-murmur' | 'help' | 'tour';
|
||||
|
||||
@customElement('nv-home')
|
||||
export class NvHome extends LitElement {
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
background: radial-gradient(ellipse at 50% 30%, var(--bg-2) 0%, var(--bg-0) 70%);
|
||||
padding: 28px clamp(16px, 6vw, 56px) 60px;
|
||||
}
|
||||
.hero {
|
||||
max-width: 800px;
|
||||
margin: 16px auto 28px;
|
||||
text-align: center;
|
||||
}
|
||||
.hero .icon {
|
||||
width: 56px; height: 56px;
|
||||
margin: 0 auto 18px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, oklch(0.78 0.14 70) 0%, oklch(0.55 0.16 30) 100%);
|
||||
display: grid; place-items: center;
|
||||
font-family: var(--mono);
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
color: #1a0f00;
|
||||
box-shadow: 0 8px 24px -6px oklch(0.55 0.16 30 / 0.4);
|
||||
}
|
||||
.hero h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: clamp(24px, 4vw, 34px);
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--ink);
|
||||
line-height: 1.15;
|
||||
}
|
||||
.hero .tag {
|
||||
font-size: clamp(13px, 1.6vw, 15px);
|
||||
color: var(--ink-2);
|
||||
margin: 0 0 22px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.hero .ctas {
|
||||
display: flex; flex-wrap: wrap; gap: 8px;
|
||||
justify-content: center;
|
||||
}
|
||||
.cta {
|
||||
padding: 11px 20px;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-2);
|
||||
color: var(--ink);
|
||||
transition: transform 0.12s, border-color 0.12s, filter 0.12s;
|
||||
}
|
||||
.cta:hover { transform: translateY(-1px); border-color: var(--line-2); }
|
||||
.cta.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #1a0f00;
|
||||
}
|
||||
.cta.primary:hover { filter: brightness(1.08); }
|
||||
.status {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
padding: 6px 12px;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-family: var(--mono);
|
||||
color: var(--ink-2);
|
||||
margin-top: 18px;
|
||||
}
|
||||
.status .dot {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: var(--ink-3);
|
||||
}
|
||||
.status.live .dot {
|
||||
background: var(--ok);
|
||||
box-shadow: 0 0 8px var(--ok);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
@keyframes pulse { 50% { opacity: 0.5; } }
|
||||
|
||||
.grid {
|
||||
max-width: 980px;
|
||||
margin: 36px auto 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.card {
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 18px 20px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.12s, border-color 0.12s, background 0.12s;
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
}
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--accent);
|
||||
background: linear-gradient(180deg, var(--bg-2) 0%, oklch(0.78 0.14 70 / 0.04) 100%);
|
||||
}
|
||||
.card .ico {
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.card h3 {
|
||||
margin: 0;
|
||||
font-size: 14.5px;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.card p {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--ink-2);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.card .arrow {
|
||||
color: var(--accent);
|
||||
font-family: var(--mono);
|
||||
font-size: 11.5px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.footnote {
|
||||
max-width: 800px;
|
||||
margin: 36px auto 0;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--ink-3);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.footnote code {
|
||||
font-family: var(--mono);
|
||||
background: var(--bg-3);
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
}
|
||||
.footnote a {
|
||||
color: var(--accent-2);
|
||||
text-decoration: underline dotted;
|
||||
cursor: pointer;
|
||||
}
|
||||
`;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
effect(() => { running.value; witnessVerified.value; fps.value; this.requestUpdate(); });
|
||||
}
|
||||
|
||||
private go(action: Action): void {
|
||||
if (action === 'tour') { window.dispatchEvent(new CustomEvent('nv-show-tour')); return; }
|
||||
if (action === 'help') { window.dispatchEvent(new CustomEvent('nv-show-help')); return; }
|
||||
this.dispatchEvent(new CustomEvent('navigate', { detail: action, bubbles: true, composed: true }));
|
||||
}
|
||||
|
||||
private async runDemo(): Promise<void> {
|
||||
const c = getClient(); if (!c) return;
|
||||
if (running.value) return;
|
||||
await c.run();
|
||||
running.value = true;
|
||||
pushLog('ok', 'demo started · streaming MagFrames');
|
||||
}
|
||||
|
||||
override render() {
|
||||
const isRunning = running.value;
|
||||
const wasVerified = witnessVerified.value === 'ok';
|
||||
return html`
|
||||
<div class="hero">
|
||||
<div class="icon" aria-hidden="true">NV</div>
|
||||
<h1>An open-source quantum-magnetometer simulator, in your browser.</h1>
|
||||
<p class="tag">
|
||||
nvsim runs a real Rust simulator (the same code that
|
||||
<code style="font-family:var(--mono); background:var(--bg-3); padding:1px 5px; border-radius:4px; color:var(--accent); font-size:12px;">cargo test</code>
|
||||
uses) entirely in WebAssembly. No server, no upload, no telemetry.
|
||||
Press the button to start the live magnetic-field simulation, or
|
||||
take the 60-second tour first.
|
||||
</p>
|
||||
<div class="ctas">
|
||||
<button class="cta primary" id="home-run-btn" @click=${() => this.runDemo()}>
|
||||
${isRunning ? '✓ Demo running' : '▶ Run the simulation'}
|
||||
</button>
|
||||
<button class="cta" id="home-tour-btn" @click=${() => this.go('tour')}>
|
||||
★ Take the 60-second tour
|
||||
</button>
|
||||
<button class="cta" id="home-help-btn" @click=${() => this.go('help')}>
|
||||
? Help center
|
||||
</button>
|
||||
</div>
|
||||
<div class="status ${isRunning ? 'live' : ''}">
|
||||
<span class="dot"></span>
|
||||
${isRunning
|
||||
? html`Live · ${fps.value > 0 ? (fps.value / 1000).toFixed(2) + ' kHz' : 'starting…'}${wasVerified ? ' · witness verified ✓' : ''}`
|
||||
: html`Idle${wasVerified ? ' · witness verified ✓' : ''}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card" tabindex="0" role="button"
|
||||
@click=${() => this.go('scene')}
|
||||
@keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.go('scene'); } }}>
|
||||
<div class="ico">🌐</div>
|
||||
<h3>Live scene</h3>
|
||||
<p>Drag magnetic sources, watch the recovered field update in real time, and tweak sample rate / noise / integration.</p>
|
||||
<div class="arrow">Open scene →</div>
|
||||
</div>
|
||||
|
||||
<div class="card" tabindex="0" role="button"
|
||||
@click=${() => this.go('apps')}
|
||||
@keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.go('apps'); } }}>
|
||||
<div class="ico">🛍</div>
|
||||
<h3>App Store · 66 edge apps</h3>
|
||||
<p>Browse 65 hot-loadable WASM sensing modules across medical, security, building, retail, industrial, learning. Six run live in the browser.</p>
|
||||
<div class="arrow">Browse the catalogue →</div>
|
||||
</div>
|
||||
|
||||
<div class="card" tabindex="0" role="button"
|
||||
@click=${() => this.go('witness')}
|
||||
@keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.go('witness'); } }}>
|
||||
<div class="ico">✓</div>
|
||||
<h3>Determinism gate</h3>
|
||||
<p>Re-derive the SHA-256 witness for the canonical reference scene right here in your browser. Same inputs → same hash, every time.</p>
|
||||
<div class="arrow">Verify the witness →</div>
|
||||
</div>
|
||||
|
||||
<div class="card" tabindex="0" role="button"
|
||||
@click=${() => this.go('ghost-murmur')}
|
||||
@keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.go('ghost-murmur'); } }}>
|
||||
<div class="ico">👻</div>
|
||||
<h3>Ghost Murmur reality check</h3>
|
||||
<p>Audit the publicly-reported April 2026 CIA NV-diamond program against published physics. Live distance/moment sliders.</p>
|
||||
<div class="arrow">Read the spec →</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="footnote">
|
||||
New here? <a @click=${() => this.go('tour')}>Take the 60-second guided tour</a>
|
||||
— every panel is explained. Or press <code>?</code> for the help center
|
||||
(quickstart, glossary, FAQ, shortcuts) any time.<br>
|
||||
Open source · Apache-2.0 OR MIT · <code>github.com/ruvnet/RuView</code>
|
||||
</p>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,434 +0,0 @@
|
||||
/* Inspector — tabbed: Signal / Frame / Witness. */
|
||||
import { LitElement, html, css, svg, type PropertyValues } from 'lit';
|
||||
import { customElement, state, property } from 'lit/decorators.js';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import {
|
||||
traceX, traceY, traceZ, stripBars, lastFrame,
|
||||
witnessHex, expectedWitness, witnessVerified, getClient,
|
||||
pushLog, lastB, bMag,
|
||||
} from '../store/appStore';
|
||||
|
||||
type Tab = 'signal' | 'frame' | 'witness';
|
||||
|
||||
@customElement('nv-inspector')
|
||||
export class NvInspector extends LitElement {
|
||||
@state() private tab: Tab = 'signal';
|
||||
/** When set by the parent, force the tab and pulse-highlight it. */
|
||||
@property({ attribute: false }) pinTab: Tab | null = null;
|
||||
/** When `expanded`, the inspector renders as a full-screen view with bigger
|
||||
* charts and a wider Witness panel. Used when the rail Inspector/Witness
|
||||
* button is clicked — see ADR-093 P1.13. */
|
||||
@property({ type: Boolean, reflect: true }) expanded = false;
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--bg-1);
|
||||
border-left: 1px solid var(--line);
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
:host([expanded]) {
|
||||
border-left: 0;
|
||||
background: radial-gradient(ellipse at 50% 30%, var(--bg-2) 0%, var(--bg-0) 70%);
|
||||
}
|
||||
:host([expanded]) .tabs {
|
||||
padding: 0 24px;
|
||||
background: var(--bg-1);
|
||||
}
|
||||
:host([expanded]) .tab {
|
||||
padding: 16px 22px;
|
||||
font-size: 13.5px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
:host([expanded]) .body {
|
||||
padding: 24px 28px;
|
||||
max-width: 1400px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
:host([expanded]) .card { padding: 18px 20px; }
|
||||
:host([expanded]) .card-h .ttl { font-size: 14px; }
|
||||
:host([expanded]) svg { height: 220px; }
|
||||
:host([expanded]) .frame-strip { height: 48px; }
|
||||
:host([expanded]) table { font-size: 12.5px; }
|
||||
:host([expanded]) td { padding: 6px 0; }
|
||||
:host([expanded]) .hex { font-size: 12px; padding: 14px; line-height: 1.7; }
|
||||
:host([expanded]) .witness-box { font-size: 13px; padding: 14px 16px; line-height: 1.6; }
|
||||
:host([expanded]) .verify-btn { padding: 12px; font-size: 13px; }
|
||||
:host([expanded]) .grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
:host([expanded]) .grid-2 > .card { margin-bottom: 0; }
|
||||
@media (max-width: 1024px) {
|
||||
:host([expanded]) .grid-2 { grid-template-columns: 1fr; }
|
||||
}
|
||||
.tabs {
|
||||
display: flex; border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.tab {
|
||||
flex: 1;
|
||||
padding: 11px 8px;
|
||||
background: transparent; border: none;
|
||||
font-size: 11.5px; font-weight: 500;
|
||||
color: var(--ink-3);
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer; transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.tab.active { color: var(--ink); border-bottom-color: var(--accent); }
|
||||
.tab:hover { color: var(--ink-2); }
|
||||
.body { padding: 14px; flex: 1; overflow-y: auto; }
|
||||
|
||||
.card {
|
||||
background: var(--bg-2); border: 1px solid var(--line);
|
||||
border-radius: var(--radius); padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.card-h {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.card-h .ttl { font-size: 12px; font-weight: 600; }
|
||||
.badge {
|
||||
font-family: var(--mono); font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
background: oklch(0.78 0.14 195 / 0.12);
|
||||
color: var(--accent-2);
|
||||
border-radius: 4px;
|
||||
border: 1px solid oklch(0.78 0.14 195 / 0.3);
|
||||
}
|
||||
svg { width: 100%; height: 130px; }
|
||||
.frame-strip {
|
||||
height: 28px;
|
||||
display: flex; align-items: flex-end; gap: 1px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.bar {
|
||||
flex: 1;
|
||||
background: linear-gradient(to top, var(--accent-2), var(--accent));
|
||||
border-radius: 1px;
|
||||
min-height: 2px;
|
||||
}
|
||||
table { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 10.5px; }
|
||||
td { padding: 4px 0; border-bottom: 1px solid var(--line); }
|
||||
td:first-child { color: var(--ink-3); }
|
||||
td:last-child { text-align: right; color: var(--ink); }
|
||||
.hex {
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px;
|
||||
font-family: var(--mono);
|
||||
font-size: 10.5px;
|
||||
color: var(--ink-2);
|
||||
line-height: 1.6;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.hex .magic { color: var(--accent); font-weight: 600; }
|
||||
.witness-box {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--ink-2);
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
word-break: break-all;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.verify-btn {
|
||||
margin-top: 10px;
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-3);
|
||||
color: var(--ink);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
.verify-btn:hover { border-color: var(--accent); }
|
||||
.verify-btn.ok { border-color: var(--ok); color: var(--ok); }
|
||||
.verify-btn.fail { border-color: var(--bad); color: var(--bad); }
|
||||
`;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
effect(() => {
|
||||
traceX.value; traceY.value; traceZ.value; stripBars.value;
|
||||
lastFrame.value; witnessHex.value; witnessVerified.value;
|
||||
lastB.value; bMag.value;
|
||||
this.requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
override willUpdate(changed: PropertyValues): void {
|
||||
// Apply parent-driven tab pin during willUpdate so the new tab value
|
||||
// participates in this same render pass — avoids the "update after
|
||||
// update completed" Lit warning that would fire if we did this in
|
||||
// updated().
|
||||
if (changed.has('pinTab') && this.pinTab && this.tab !== this.pinTab) {
|
||||
this.tab = this.pinTab;
|
||||
}
|
||||
}
|
||||
|
||||
private async verify(): Promise<void> {
|
||||
const c = getClient(); if (!c) return;
|
||||
witnessVerified.value = 'pending';
|
||||
pushLog('info', 'verifying witness over 256 frames…');
|
||||
try {
|
||||
const exp = expectedWitness.value;
|
||||
const expBytes = new Uint8Array(32);
|
||||
for (let i = 0; i < 32; i++) expBytes[i] = parseInt(exp.slice(i * 2, i * 2 + 2), 16);
|
||||
const r = await c.verifyWitness(expBytes);
|
||||
if (r.ok) {
|
||||
witnessVerified.value = 'ok';
|
||||
witnessHex.value = exp;
|
||||
pushLog('ok', `witness ${exp.slice(0, 16)}… matches · determinism gate ✓`);
|
||||
} else {
|
||||
witnessVerified.value = 'fail';
|
||||
const actual = Array.from(r.actual).map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
witnessHex.value = actual;
|
||||
pushLog('err', `WITNESS MISMATCH actual=${actual.slice(0, 16)}…`);
|
||||
}
|
||||
} catch (e) {
|
||||
witnessVerified.value = 'fail';
|
||||
pushLog('err', `verify failed: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private renderHeader() {
|
||||
if (!this.expanded) return '';
|
||||
const titles: Record<Tab, string> = {
|
||||
signal: 'Signal inspector — live B-vector trace + frame stream',
|
||||
frame: 'Frame inspector — MagFrame v1 fields + raw bytes',
|
||||
witness: 'Witness panel — SHA-256 determinism gate',
|
||||
};
|
||||
return html`
|
||||
<h1 style="margin: 8px 0 14px; font-size: 20px; letter-spacing: -0.01em;">
|
||||
${titles[this.tab]}
|
||||
</h1>
|
||||
<p style="margin: 0 0 18px; font-size: 12.5px; color: var(--ink-3); line-height: 1.55; max-width: 780px;">
|
||||
${this.tab === 'signal'
|
||||
? 'Real-time recovered field-vector and frame-stream sparkline. Both update at the running pipeline\'s frame rate. Use the Tunables panel in the sidebar to change f_s, f_mod, dt, and shot-noise behaviour.'
|
||||
: this.tab === 'frame'
|
||||
? 'Decoded view of the most recent MagFrame: typed fields plus the raw 60-byte little-endian binary record (magic 0xC51A_6E70).'
|
||||
: 'Re-derive the SHA-256 witness for the canonical reference scene (seed=42, N=256) right now in your browser and compare against Proof::EXPECTED_WITNESS_HEX. Same inputs → same hash, byte-for-byte, across every machine and transport.'}
|
||||
</p>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderSignalTab() {
|
||||
const W = 320, H = 130, cy = 65, scale = 22;
|
||||
const cap = 200;
|
||||
const make = (arr: number[]) => {
|
||||
let p = '';
|
||||
arr.forEach((v, i) => {
|
||||
const x = (i / Math.max(1, cap - 1)) * W;
|
||||
const y = cy - v * scale;
|
||||
p += (i === 0 ? 'M' : 'L') + ` ${x.toFixed(1)} ${y.toFixed(1)} `;
|
||||
});
|
||||
return p;
|
||||
};
|
||||
|
||||
const b = lastB.value;
|
||||
const bnT = [b[0] * 1e9, b[1] * 1e9, b[2] * 1e9];
|
||||
const hasData = traceX.value.length > 0;
|
||||
|
||||
return html`
|
||||
${!hasData ? html`
|
||||
<div class="card" style="text-align:center; padding:18px;">
|
||||
<div style="font-size:13px; color:var(--ink-2); line-height:1.55;">
|
||||
No frames yet. Press <b>▶ Run</b> in the topbar (or hit <code style="font-family:var(--mono);background:var(--bg-3);padding:1px 5px;border-radius:4px;color:var(--accent);">Space</code>)
|
||||
to start the live B-vector trace.
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class=${this.expanded ? 'grid-2' : ''}>
|
||||
<div class="card">
|
||||
<div class="card-h">
|
||||
<span class="ttl">B-vector trace</span>
|
||||
<span class="badge">3-axis · nT</span>
|
||||
</div>
|
||||
<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none">
|
||||
<line x1="0" y1=${cy} x2=${W} y2=${cy} stroke="var(--line)" stroke-width="0.5"/>
|
||||
${svg`<path id="trace-x" d=${make(traceX.value)} stroke="oklch(0.78 0.14 70)" stroke-width="1.2" fill="none"/>`}
|
||||
${svg`<path id="trace-y" d=${make(traceY.value)} stroke="oklch(0.78 0.12 195)" stroke-width="1.2" fill="none" opacity="0.8"/>`}
|
||||
${svg`<path id="trace-z" d=${make(traceZ.value)} stroke="oklch(0.72 0.18 330)" stroke-width="1.2" fill="none" opacity="0.7"/>`}
|
||||
</svg>
|
||||
${this.expanded ? html`<div style="display:flex;gap:14px;font-size:12px;font-family:var(--mono);margin-top:8px;">
|
||||
<span style="color:oklch(0.78 0.14 70);">x: ${bnT[0].toFixed(3)} nT</span>
|
||||
<span style="color:oklch(0.78 0.12 195);">y: ${bnT[1].toFixed(3)} nT</span>
|
||||
<span style="color:oklch(0.72 0.18 330);">z: ${bnT[2].toFixed(3)} nT</span>
|
||||
<span style="color:var(--accent);margin-left:auto;">|B| ${(bMag.value * 1e9).toFixed(3)} nT</span>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-h">
|
||||
<span class="ttl">Frame stream</span>
|
||||
<span class="badge" id="strip-rate">live</span>
|
||||
</div>
|
||||
<div class="frame-strip" id="frame-strip">
|
||||
${stripBars.value.map((v) => html`<div class="bar" style=${`height:${Math.max(4, v * 100)}%`}></div>`)}
|
||||
</div>
|
||||
${this.expanded ? html`
|
||||
<div style="display:flex;gap:24px;font-family:var(--mono);font-size:12px;color:var(--ink-3);margin-top:12px;">
|
||||
<span>frames in window: <span style="color:var(--ink);">${stripBars.value.length}</span></span>
|
||||
<span>noise floor: <span style="color:var(--ink);">${lastFrame.value ? lastFrame.value.noiseFloorPtSqrtHz.toFixed(2) + ' pT/√Hz' : '—'}</span></span>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderFrameTab() {
|
||||
const f = lastFrame.value;
|
||||
const bytes = f?.raw;
|
||||
let hex = '';
|
||||
if (bytes) {
|
||||
const arr = Array.from(bytes).map((b) => b.toString(16).padStart(2, '0'));
|
||||
hex = arr.slice(0, 60).join(' ');
|
||||
}
|
||||
return html`
|
||||
${!f ? html`
|
||||
<div class="card" style="text-align:center; padding:18px;">
|
||||
<div style="font-size:13px; color:var(--ink-2); line-height:1.55;">
|
||||
No MagFrame to display yet. Start the pipeline (<b>▶ Run</b>) to populate.
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class=${this.expanded ? 'grid-2' : ''}>
|
||||
<div class="card">
|
||||
<div class="card-h">
|
||||
<span class="ttl">MagFrame v1 fields</span>
|
||||
<span class="badge">60 B</span>
|
||||
</div>
|
||||
<table>
|
||||
<tr><td>magic</td><td id="frame-magic">${f ? '0x' + f.magic.toString(16).toUpperCase() : '—'}</td></tr>
|
||||
<tr><td>version</td><td>${f?.version ?? '—'}</td></tr>
|
||||
<tr><td>flags</td><td>0x${(f?.flags ?? 0).toString(16).padStart(4, '0')}</td></tr>
|
||||
<tr><td>sensor_id</td><td>${f?.sensorId ?? '—'}</td></tr>
|
||||
<tr><td>t_us</td><td>${f ? f.tUs.toString() : '—'}</td></tr>
|
||||
<tr><td>b_pT[0]</td><td id="frame-bx">${f ? f.bPt[0].toFixed(1) : '—'}</td></tr>
|
||||
<tr><td>b_pT[1]</td><td id="frame-by">${f ? f.bPt[1].toFixed(1) : '—'}</td></tr>
|
||||
<tr><td>b_pT[2]</td><td id="frame-bz">${f ? f.bPt[2].toFixed(1) : '—'}</td></tr>
|
||||
<tr><td>noise_floor</td><td>${f ? f.noiseFloorPtSqrtHz.toFixed(2) : '—'}</td></tr>
|
||||
<tr><td>temp_K</td><td>${f ? f.temperatureK.toFixed(1) : '—'}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-h">
|
||||
<span class="ttl">Hex dump</span>
|
||||
<span class="badge">LE</span>
|
||||
</div>
|
||||
<div class="hex" id="frame-hex">${hex || '—'}</div>
|
||||
${this.expanded ? html`
|
||||
<div style="font-size: 11.5px; color: var(--ink-3); margin-top: 10px; line-height: 1.6;">
|
||||
Layout (little-endian): <code>magic(u32) version(u16) flags(u16) sensor_id(u16) _reserved(u16) t_us(u64) b_pt[3](f32) sigma_pt[3](f32) noise_floor(f32) temp_K(f32)</code>.
|
||||
</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderWitnessTab() {
|
||||
const status = witnessVerified.value;
|
||||
const cls = status === 'ok' ? 'ok' : status === 'fail' ? 'fail' : '';
|
||||
const label =
|
||||
status === 'pending' ? 'Verifying…' :
|
||||
status === 'ok' ? '✓ Witness verified · determinism gate' :
|
||||
status === 'fail' ? '✗ Witness mismatch · audit required' :
|
||||
'Verify witness';
|
||||
const match = expectedWitness.value && witnessHex.value && expectedWitness.value === witnessHex.value;
|
||||
return html`
|
||||
${this.expanded ? html`
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(180px, 1fr));gap:12px;margin-bottom:18px;">
|
||||
<div class="card" style="margin:0;">
|
||||
<div style="font-size:10px;color:var(--ink-3);text-transform:uppercase;letter-spacing:0.06em;">Reference scene</div>
|
||||
<div style="font-family:var(--mono);font-size:14px;color:var(--ink);margin-top:4px;">Proof::REFERENCE</div>
|
||||
<div style="font-size:11.5px;color:var(--ink-3);margin-top:2px;">2 dipoles · 1 loop · 1 ferrous · 1 sensor</div>
|
||||
</div>
|
||||
<div class="card" style="margin:0;">
|
||||
<div style="font-size:10px;color:var(--ink-3);text-transform:uppercase;letter-spacing:0.06em;">Seed</div>
|
||||
<div style="font-family:var(--mono);font-size:14px;color:var(--accent);margin-top:4px;">0x0000002A</div>
|
||||
<div style="font-size:11.5px;color:var(--ink-3);margin-top:2px;">canonical Proof::SEED</div>
|
||||
</div>
|
||||
<div class="card" style="margin:0;">
|
||||
<div style="font-size:10px;color:var(--ink-3);text-transform:uppercase;letter-spacing:0.06em;">Sample count</div>
|
||||
<div style="font-family:var(--mono);font-size:14px;color:var(--ink);margin-top:4px;">256</div>
|
||||
<div style="font-size:11.5px;color:var(--ink-3);margin-top:2px;">Proof::N_SAMPLES</div>
|
||||
</div>
|
||||
<div class="card" style="margin:0;">
|
||||
<div style="font-size:10px;color:var(--ink-3);text-transform:uppercase;letter-spacing:0.06em;">Status</div>
|
||||
<div style="font-family:var(--mono);font-size:14px;margin-top:4px;color:${status === 'ok' ? 'var(--ok)' : status === 'fail' ? 'var(--bad)' : 'var(--ink-3)'};">
|
||||
${status === 'ok' ? '✓ matches' : status === 'fail' ? '✗ drift' : status === 'pending' ? '… running' : '— idle'}
|
||||
</div>
|
||||
<div style="font-size:11.5px;color:var(--ink-3);margin-top:2px;">${match ? 'byte-equivalent' : 'not yet verified'}</div>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="card">
|
||||
<div class="card-h">
|
||||
<span class="ttl">Expected (Proof::EXPECTED_WITNESS_HEX)</span>
|
||||
<span class="badge">SHA-256</span>
|
||||
</div>
|
||||
<div class="witness-box" id="expected-witness">${expectedWitness.value || '(loading…)'}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-h">
|
||||
<span class="ttl">Actual (last verify)</span>
|
||||
<span class="badge">SHA-256</span>
|
||||
</div>
|
||||
<div class="witness-box" id="actual-witness">${witnessHex.value || '(not verified yet)'}</div>
|
||||
<button class="verify-btn ${cls}" id="verify-btn" @click=${this.verify}>${label}</button>
|
||||
</div>
|
||||
${this.expanded ? html`
|
||||
<div class="card">
|
||||
<div class="card-h">
|
||||
<span class="ttl">What this verifies</span>
|
||||
<span class="badge">ADR-089 §5</span>
|
||||
</div>
|
||||
<div style="font-size: 12.5px; color: var(--ink-2); line-height: 1.6;">
|
||||
<p style="margin: 0 0 10px;">Pressing <b>Verify</b> runs the canonical reference pipeline
|
||||
(<code>Proof::generate</code>) end-to-end inside this browser's WASM Worker:
|
||||
scene → Biot-Savart synthesis → material attenuation → NV ensemble → ADC + lock-in →
|
||||
concatenated <code>MagFrame</code> bytes → SHA-256.</p>
|
||||
<p style="margin: 0 0 10px;">If the resulting hash matches the constant pinned at build time
|
||||
(<code>cc8de9b01b0ff5bd…</code>), every constant — γ_e, D_GS, μ₀, T₂*, contrast, the PRNG
|
||||
stream, the frame layout, the pipeline ordering — is byte-identical to the published
|
||||
reference. If it doesn't match, <i>something</i> drifted; the dashboard names which.</p>
|
||||
<p style="margin: 0;">This is the same regression test that runs in
|
||||
<code>cargo test -p nvsim</code> — running in your browser, against your own WASM build.</p>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="tabs" role="tablist">
|
||||
<button class="tab ${this.tab === 'signal' ? 'active' : ''}" data-pane="signal"
|
||||
role="tab" aria-selected=${this.tab === 'signal'}
|
||||
@click=${() => this.tab = 'signal'}>Signal</button>
|
||||
<button class="tab ${this.tab === 'frame' ? 'active' : ''}" data-pane="frame"
|
||||
role="tab" aria-selected=${this.tab === 'frame'}
|
||||
@click=${() => this.tab = 'frame'}>Frame</button>
|
||||
<button class="tab ${this.tab === 'witness' ? 'active' : ''}" data-pane="witness"
|
||||
role="tab" aria-selected=${this.tab === 'witness'}
|
||||
@click=${() => this.tab = 'witness'}>Witness</button>
|
||||
</div>
|
||||
<div class="body" role="tabpanel">
|
||||
${this.renderHeader()}
|
||||
${this.tab === 'signal' ? this.renderSignalTab()
|
||||
: this.tab === 'frame' ? this.renderFrameTab()
|
||||
: this.renderWitnessTab()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
/* Modal dialog — opened via window.dispatchEvent('nv-modal', { title, body, buttons }). */
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
|
||||
interface ModalButton {
|
||||
label: string;
|
||||
variant?: 'ghost' | 'primary' | 'danger';
|
||||
onClick?: () => void;
|
||||
}
|
||||
interface ModalReq {
|
||||
title: string;
|
||||
body: string;
|
||||
buttons?: ModalButton[];
|
||||
}
|
||||
|
||||
@customElement('nv-modal')
|
||||
export class NvModal extends LitElement {
|
||||
@state() private open = false;
|
||||
@state() private mTitle = '';
|
||||
@state() private mBody = '';
|
||||
@state() private buttons: ModalButton[] = [];
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.55);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 200;
|
||||
display: grid; place-items: center;
|
||||
opacity: 0; pointer-events: none;
|
||||
transition: opacity 0.18s;
|
||||
}
|
||||
:host([open]) { opacity: 1; pointer-events: auto; }
|
||||
.modal {
|
||||
background: var(--bg-1);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 30px 80px -20px rgba(0,0,0,0.7);
|
||||
width: min(520px, 92vw);
|
||||
max-height: 86vh;
|
||||
display: flex; flex-direction: column;
|
||||
transform: translateY(12px) scale(0.98);
|
||||
transition: transform 0.22s cubic-bezier(0.2,0.7,0.3,1);
|
||||
}
|
||||
:host([open]) .modal { transform: translateY(0) scale(1); }
|
||||
.h {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.h .ttl { font-size: 14px; font-weight: 600; }
|
||||
.body { padding: 16px; overflow-y: auto; font-size: 13px; color: var(--ink-2); line-height: 1.55; }
|
||||
.f {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--line);
|
||||
display: flex; gap: 8px; justify-content: flex-end;
|
||||
}
|
||||
button {
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-2); color: var(--ink);
|
||||
}
|
||||
button.ghost { background: transparent; }
|
||||
button.primary { background: var(--accent); border-color: var(--accent); color: #1a0f00; }
|
||||
button.danger { background: var(--bad); border-color: var(--bad); color: #fff; }
|
||||
.close {
|
||||
width: 28px; height: 28px;
|
||||
background: transparent; border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
`;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('nv-modal', this.onModal as EventListener);
|
||||
window.addEventListener('keydown', this.onKey);
|
||||
}
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener('nv-modal', this.onModal as EventListener);
|
||||
window.removeEventListener('keydown', this.onKey);
|
||||
}
|
||||
|
||||
private onModal = (e: Event): void => {
|
||||
const r = (e as CustomEvent).detail as ModalReq;
|
||||
this.mTitle = r.title; this.mBody = r.body;
|
||||
this.buttons = r.buttons ?? [{ label: 'Close', variant: 'primary' }];
|
||||
this.open = true; this.setAttribute('open', '');
|
||||
// a11y: focus the first interactive element inside the modal so keyboard
|
||||
// users land in the dialog rather than behind it. Light focus trap via
|
||||
// the keydown handler below catches Tab cycling.
|
||||
requestAnimationFrame(() => {
|
||||
const root = this.shadowRoot;
|
||||
if (!root) return;
|
||||
const first = root.querySelector<HTMLElement>('input, select, textarea, button:not(.close)');
|
||||
first?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
override updated(): void {
|
||||
if (!this.open) return;
|
||||
const root = this.shadowRoot;
|
||||
if (!root) return;
|
||||
// Trap Tab inside the modal while open.
|
||||
const trap = (e: KeyboardEvent): void => {
|
||||
if (e.key !== 'Tab') return;
|
||||
const focusables = Array.from(
|
||||
root.querySelectorAll<HTMLElement>('input, select, textarea, button, [href]'),
|
||||
).filter((el) => !el.hasAttribute('disabled'));
|
||||
if (focusables.length === 0) return;
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
const active = (root.activeElement as HTMLElement | null) ?? null;
|
||||
if (e.shiftKey && active === first) { e.preventDefault(); last.focus(); }
|
||||
else if (!e.shiftKey && active === last) { e.preventDefault(); first.focus(); }
|
||||
};
|
||||
root.removeEventListener('keydown', trap as EventListener);
|
||||
root.addEventListener('keydown', trap as EventListener);
|
||||
}
|
||||
|
||||
private onKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape' && this.open) this.close();
|
||||
};
|
||||
|
||||
private close(): void { this.open = false; this.removeAttribute('open'); }
|
||||
private clickBtn(b: ModalButton): void { b.onClick?.(); this.close(); }
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="modal" role="dialog" aria-modal="true">
|
||||
<div class="h">
|
||||
<div class="ttl">${this.mTitle}</div>
|
||||
<button class="close" @click=${() => this.close()}>×</button>
|
||||
</div>
|
||||
<div class="body" .innerHTML=${this.mBody}></div>
|
||||
<div class="f">
|
||||
${this.buttons.map((b) => html`
|
||||
<button class=${b.variant ?? ''} @click=${() => this.clickBtn(b)}>${b.label}</button>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export function openModal(req: ModalReq): void {
|
||||
window.dispatchEvent(new CustomEvent('nv-modal', { detail: req }));
|
||||
}
|
||||
@@ -1,397 +0,0 @@
|
||||
/* Welcome modal + step-by-step introduction tour.
|
||||
*
|
||||
* 10 steps walking the user through every panel of the dashboard with
|
||||
* concrete CTAs ("Try it now") that fire real navigation against the
|
||||
* live UI. First-run only by default; replayable via Settings → Help.
|
||||
*/
|
||||
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import { kvGet, kvSet } from '../store/persistence';
|
||||
|
||||
interface TourStep {
|
||||
/** Optional icon shown at the top of the step. */
|
||||
icon: string;
|
||||
title: string;
|
||||
/** Markdown-ish HTML body (rendered via .innerHTML). */
|
||||
body: string;
|
||||
/** Optional CTA: clicking runs the action then advances. */
|
||||
cta?: { label: string; run?: () => void };
|
||||
/** Optional "do this yourself" hint. */
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
const STEPS: TourStep[] = [
|
||||
{
|
||||
icon: '👋',
|
||||
title: 'Welcome to nvsim',
|
||||
body: `<p style="font-size:14px; line-height:1.6;">
|
||||
<b>nvsim</b> is an open-source, deterministic forward simulator for
|
||||
<b>nitrogen-vacancy diamond magnetometry</b> — a real Rust crate compiled
|
||||
to WebAssembly and running in your browser, right now.</p>
|
||||
<p style="font-size:13px; color:var(--ink-2); line-height:1.55;">
|
||||
This 60-second tour walks you through the four panels, the App Store,
|
||||
the Ghost Murmur research view, and the determinism contract that
|
||||
makes nvsim distinctive.</p>
|
||||
<p style="font-size:11.5px; color:var(--ink-3); line-height:1.5; margin-top:14px;">
|
||||
Press <kbd>Esc</kbd> any time to skip. You can replay this tour from
|
||||
<b>Settings → Help</b>.</p>`,
|
||||
cta: { label: 'Start the tour →' },
|
||||
},
|
||||
{
|
||||
icon: '🌐',
|
||||
title: 'The Scene canvas',
|
||||
body: `<p>The middle panel shows your <b>magnetic scene</b> — a small simulated
|
||||
environment with four sources and one NV-diamond sensor at the centre.</p>
|
||||
<p>The four amber/cyan/magenta blobs are draggable: <b>rebar coil</b>
|
||||
(steel χ=5000), <b>heart proxy</b> dipole, <b>60 Hz mains</b> current loop,
|
||||
and a <b>steel door</b> (eddy current). Field lines connect each source
|
||||
to the sensor and animate while the pipeline runs.</p>
|
||||
<p style="font-size:12.5px; color:var(--ink-3);">
|
||||
Top-left toolbar: zoom in/out, fit-to-view, layer toggles. Bottom-right:
|
||||
sim controls (step / play / step / speed cycle). Drag positions persist
|
||||
across reloads.</p>`,
|
||||
hint: 'Try dragging the heart_proxy after the tour ends.',
|
||||
},
|
||||
{
|
||||
icon: '▶',
|
||||
title: 'Run the pipeline',
|
||||
body: `<p>Press <b>▶ Run</b> in the topbar (or hit <kbd>Space</kbd>) to start
|
||||
the live frame stream. nvsim runs at ~1.8 kHz on x86_64 WASM —
|
||||
well above the 1 kHz Cortex-A53 acceptance gate.</p>
|
||||
<p>The FPS pill in the topbar updates with the throughput. The B-vector
|
||||
trace and frame-stream sparkline in the right inspector update in real
|
||||
time.</p>
|
||||
<p style="font-size:12.5px; color:var(--ink-3);">
|
||||
<kbd>Space</kbd> toggles run/pause from anywhere. Reset (<kbd>⌘R</kbd>)
|
||||
rewinds <code>t</code> to 0 without changing the seed.</p>`,
|
||||
},
|
||||
{
|
||||
icon: '🔍',
|
||||
title: 'Inspector — three tabs, three depths',
|
||||
body: `<p>The right rail shows the live inspector: <b>Signal</b> (B-vector
|
||||
trace + frame-stream sparkline), <b>Frame</b> (decoded MagFrame fields +
|
||||
raw 60-byte hex dump), <b>Witness</b> (SHA-256 determinism gate).</p>
|
||||
<p>Click the <b>magnifier</b> icon in the left rail to expand the
|
||||
inspector to the full main area, with bigger charts and an explainer
|
||||
header. Click the <b>shield</b> icon to do the same focused on Witness.</p>
|
||||
<p style="font-size:12.5px; color:var(--ink-3);">
|
||||
Number keys <kbd>1</kbd> <kbd>2</kbd> <kbd>3</kbd> jump between the
|
||||
three inspector tabs from anywhere.</p>`,
|
||||
},
|
||||
{
|
||||
icon: '✓',
|
||||
title: 'The witness — what makes nvsim distinctive',
|
||||
body: `<p>nvsim's defining commitment: same <code>(scene, config, seed)</code> →
|
||||
byte-identical SHA-256 across runs, machines, and transports.</p>
|
||||
<p>Click the <b>Witness</b> tab and press <b>Verify witness</b>. The
|
||||
dashboard re-derives the hash for the canonical reference scene
|
||||
(<code>seed=42, N=256</code>) and asserts it matches the constant
|
||||
pinned at compile time
|
||||
(<code style="font-size:10.5px;">cc8de9b01b0ff5bd…</code>).</p>
|
||||
<p>A green check means every constant — γ_e, D_GS, μ₀, T₂*, contrast,
|
||||
the PRNG stream, the frame layout — is byte-identical to the published
|
||||
reference. A red ✗ means something drifted; the dashboard names which.</p>`,
|
||||
},
|
||||
{
|
||||
icon: '🎚',
|
||||
title: 'Tunables — change the simulation live',
|
||||
body: `<p>The left sidebar's <b>Tunables</b> panel has four sliders:</p>
|
||||
<ul style="margin:0 0 12px; padding-left:18px; font-size:13px; color:var(--ink-2); line-height:1.6;">
|
||||
<li><b>Sample rate</b> (1–100 kHz) — digitiser frame rate</li>
|
||||
<li><b>Lock-in f_mod</b> (0.1–5 kHz) — microwave modulation freq</li>
|
||||
<li><b>Integration t</b> (0.1–10 ms) — per-sample integration time</li>
|
||||
<li><b>Shot noise</b> (on/off) — toggle quantum noise</li>
|
||||
</ul>
|
||||
<p>Edits debounce 300 ms then rebuild the WASM pipeline without restarting
|
||||
the frame stream. Watch the noise floor and B-vector spread change
|
||||
in the Signal trace.</p>`,
|
||||
},
|
||||
{
|
||||
icon: '👻',
|
||||
title: 'Ghost Murmur — research view',
|
||||
body: `<p>Click the ghost icon in the left rail. This view audits the
|
||||
publicly-reported <b>April 2026 CIA Ghost Murmur</b> NV-diamond
|
||||
heartbeat-detection program against the open physics literature.</p>
|
||||
<p>Includes a <b>"Try it yourself"</b> sandbox: place a cardiac dipole at
|
||||
any distance from the sensor, hit Run, and see what the real nvsim
|
||||
pipeline recovers. Per-tier detectability bars compare the predicted
|
||||
signal vs each transport's noise floor (NV-ensemble lab, COTS DNV-B1,
|
||||
SQUID, 60 GHz mmWave, WiFi CSI).</p>
|
||||
<p style="font-size:12.5px; color:var(--ink-3);">
|
||||
Spoiler: at 1 km the cardiac MCG is ~10⁻¹² of its 10 cm value.
|
||||
Press claims of 40-mile detection sit far below any published instrument's
|
||||
floor.</p>`,
|
||||
},
|
||||
{
|
||||
icon: '🛍',
|
||||
title: 'App Store — 65 edge apps',
|
||||
body: `<p>Click the grid icon. The <b>App Store</b> catalogues every
|
||||
hot-loadable WASM edge module RuView ships, organised by category:
|
||||
medical, security, smart-building, retail, industrial, signal,
|
||||
learning, autonomy, exotic.</p>
|
||||
<p>Each card carries id / category / status / event IDs / compute budget /
|
||||
ADR back-reference. The toggle marks an app active in this session;
|
||||
the WS transport (when configured) pushes the activation set to a
|
||||
connected ESP32 mesh.</p>
|
||||
<p style="font-size:12.5px; color:var(--ink-3);">
|
||||
Try searching for "ghost", "heart", or "occupancy" to fuzzy-filter
|
||||
the catalogue.</p>`,
|
||||
},
|
||||
{
|
||||
icon: '⌨',
|
||||
title: 'Console + REPL',
|
||||
body: `<p>The bottom panel is a structured event log with five filter tabs
|
||||
(<b>all / info / warn / err / dbg</b>) plus a REPL prompt.</p>
|
||||
<p>REPL commands include
|
||||
<code>help</code>, <code>scene.list</code>, <code>sensor.config</code>,
|
||||
<code>run</code>, <code>pause</code>, <code>seed [hex]</code>,
|
||||
<code>proof.verify</code>, <code>proof.export</code>,
|
||||
<code>theme [light|dark]</code>, <code>status</code>, <code>clear</code>.</p>
|
||||
<p style="font-size:12.5px; color:var(--ink-3);">
|
||||
Press <kbd>/</kbd> to focus the REPL from anywhere. Arrow ↑/↓ recall
|
||||
history (persisted across reloads). <kbd>⌘K</kbd> opens the command
|
||||
palette with every action discoverable.</p>`,
|
||||
},
|
||||
{
|
||||
icon: '🚀',
|
||||
title: 'You are ready',
|
||||
body: `<p style="font-size:14px;">That's the whole tour. A few last pointers:</p>
|
||||
<ul style="margin:0 0 14px; padding-left:18px; font-size:13px; color:var(--ink-2); line-height:1.7;">
|
||||
<li>Press <kbd>?</kbd> any time to open the help center
|
||||
(Quickstart / Glossary / FAQ / Shortcuts / About).</li>
|
||||
<li>Press <kbd>⌘K</kbd> for the command palette.</li>
|
||||
<li>Press <kbd>\`</kbd> to toggle the debug HUD.</li>
|
||||
<li>Settings (<kbd>⌘,</kbd>) lets you switch theme, density, motion,
|
||||
transport, and replay this tour.</li>
|
||||
</ul>
|
||||
<p style="font-size:12.5px; color:var(--ink-3); line-height:1.55;">
|
||||
Source: <code>github.com/ruvnet/RuView</code> · Apache-2.0 OR MIT ·
|
||||
ADRs 089/090/091/092/093.</p>`,
|
||||
cta: { label: 'Get started →' },
|
||||
},
|
||||
];
|
||||
|
||||
@customElement('nv-onboarding')
|
||||
export class NvOnboarding extends LitElement {
|
||||
@state() private open = false;
|
||||
@state() private step = 0;
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 240;
|
||||
display: grid; place-items: center;
|
||||
opacity: 0; pointer-events: none;
|
||||
transition: opacity 0.18s;
|
||||
}
|
||||
:host([open]) { opacity: 1; pointer-events: auto; }
|
||||
.card {
|
||||
background: var(--bg-1);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 30px 80px -20px rgba(0,0,0,0.7);
|
||||
width: min(640px, 94vw);
|
||||
max-height: 86vh;
|
||||
display: flex; flex-direction: column;
|
||||
transform: translateY(12px) scale(0.98);
|
||||
transition: transform 0.22s cubic-bezier(0.2,0.7,0.3,1);
|
||||
overflow: hidden;
|
||||
}
|
||||
:host([open]) .card { transform: translateY(0) scale(1); }
|
||||
.h {
|
||||
padding: 22px 26px 12px;
|
||||
display: flex; align-items: flex-start; gap: 14px;
|
||||
}
|
||||
.h .icon {
|
||||
width: 44px; height: 44px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, oklch(0.78 0.14 70) 0%, oklch(0.55 0.16 30) 100%);
|
||||
display: grid; place-items: center;
|
||||
font-size: 22px;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 4px 12px -2px oklch(0.55 0.16 30 / 0.35);
|
||||
}
|
||||
.h .title-wrap { flex: 1; min-width: 0; }
|
||||
.h h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--ink);
|
||||
}
|
||||
.h .step-label {
|
||||
font-family: var(--mono);
|
||||
font-size: 10.5px;
|
||||
color: var(--ink-3);
|
||||
margin-top: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.h .skip {
|
||||
width: 28px; height: 28px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
color: var(--ink-2);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.h .skip:hover { color: var(--ink); border-color: var(--line-2); }
|
||||
.body {
|
||||
padding: 0 26px 16px;
|
||||
font-size: 13px;
|
||||
color: var(--ink-2);
|
||||
line-height: 1.6;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
.body p { margin: 0 0 12px; }
|
||||
.body p:last-child { margin-bottom: 0; }
|
||||
.body code, .body kbd {
|
||||
font-family: var(--mono);
|
||||
font-size: 11.5px;
|
||||
padding: 1px 5px;
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.body code { color: var(--accent); }
|
||||
.body kbd { color: var(--ink); }
|
||||
.hint {
|
||||
margin: 14px 0 0;
|
||||
padding: 10px 12px;
|
||||
background: oklch(0.78 0.12 195 / 0.06);
|
||||
border: 1px solid oklch(0.78 0.12 195 / 0.25);
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--accent-2);
|
||||
display: flex; gap: 8px; align-items: flex-start;
|
||||
}
|
||||
.hint::before {
|
||||
content: '💡';
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.footer {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
padding: 14px 22px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--bg-1);
|
||||
}
|
||||
.progress { flex: 1; }
|
||||
.dots { display: flex; gap: 5px; margin-bottom: 4px; }
|
||||
.dot {
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line-2);
|
||||
transition: background 0.15s, border-color 0.15s, transform 0.15s;
|
||||
}
|
||||
.dot.active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
transform: scale(1.2);
|
||||
}
|
||||
.dot.done {
|
||||
background: var(--accent-4);
|
||||
border-color: var(--accent-4);
|
||||
}
|
||||
.progress-label {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
button.primary, button.ghost {
|
||||
padding: 9px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-2);
|
||||
color: var(--ink);
|
||||
}
|
||||
button.ghost:hover { border-color: var(--line-2); }
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #1a0f00;
|
||||
}
|
||||
button.primary:hover { filter: brightness(1.08); }
|
||||
`;
|
||||
|
||||
override async connectedCallback(): Promise<void> {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('nv-show-tour', this.show as EventListener);
|
||||
const seen = await kvGet<boolean>('onboarding-seen');
|
||||
if (!seen) {
|
||||
this.open = true;
|
||||
this.setAttribute('open', '');
|
||||
}
|
||||
}
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener('nv-show-tour', this.show as EventListener);
|
||||
}
|
||||
|
||||
private show = (): void => {
|
||||
this.step = 0;
|
||||
this.open = true;
|
||||
this.setAttribute('open', '');
|
||||
};
|
||||
|
||||
private async dismiss(): Promise<void> {
|
||||
this.open = false;
|
||||
this.removeAttribute('open');
|
||||
await kvSet('onboarding-seen', true);
|
||||
}
|
||||
|
||||
private next(): void {
|
||||
const s = STEPS[this.step];
|
||||
s.cta?.run?.();
|
||||
if (this.step < STEPS.length - 1) this.step++;
|
||||
else void this.dismiss();
|
||||
}
|
||||
|
||||
private prev(): void {
|
||||
if (this.step > 0) this.step--;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const s = STEPS[this.step];
|
||||
const isLast = this.step === STEPS.length - 1;
|
||||
return html`
|
||||
<div class="card" role="dialog" aria-modal="true" aria-label="Welcome tour">
|
||||
<div class="h">
|
||||
<div class="icon" aria-hidden="true">${s.icon}</div>
|
||||
<div class="title-wrap">
|
||||
<h2>${s.title}</h2>
|
||||
<div class="step-label">Step ${this.step + 1} of ${STEPS.length}</div>
|
||||
</div>
|
||||
<button class="skip" @click=${() => this.dismiss()} aria-label="Skip tour" title="Skip tour">×</button>
|
||||
</div>
|
||||
<div class="body">
|
||||
<div .innerHTML=${s.body}></div>
|
||||
${s.hint ? html`<div class="hint">${s.hint}</div>` : ''}
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div class="progress">
|
||||
<div class="dots">
|
||||
${STEPS.map((_, i) => html`
|
||||
<div class="dot ${i === this.step ? 'active' : i < this.step ? 'done' : ''}"></div>
|
||||
`)}
|
||||
</div>
|
||||
<div class="progress-label">${this.step + 1} / ${STEPS.length}</div>
|
||||
</div>
|
||||
${this.step > 0
|
||||
? html`<button class="ghost" @click=${() => this.prev()}>← Back</button>`
|
||||
: html`<button class="ghost" @click=${() => this.dismiss()}>Skip</button>`}
|
||||
<button class="primary" @click=${() => this.next()}>
|
||||
${s.cta?.label ?? (isLast ? 'Done' : 'Next →')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
/* Command palette ⌘K. */
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, state, query } from 'lit/decorators.js';
|
||||
import { toast } from './nv-toast';
|
||||
import { openModal } from './nv-modal';
|
||||
import {
|
||||
getClient, theme, expectedWitness, witnessHex, witnessVerified, pushLog, running,
|
||||
} from '../store/appStore';
|
||||
|
||||
interface Cmd { ico: string; label: string; kbd?: string; run: () => void; }
|
||||
|
||||
@customElement('nv-palette')
|
||||
export class NvPalette extends LitElement {
|
||||
@state() private open = false;
|
||||
@state() private filter = '';
|
||||
@state() private idx = 0;
|
||||
@query('#palette-input') private inputEl!: HTMLInputElement;
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: fixed; inset: 0; z-index: 220;
|
||||
background: rgba(0,0,0,0.5);
|
||||
opacity: 0; pointer-events: none;
|
||||
transition: opacity 0.15s;
|
||||
display: flex; justify-content: center; padding-top: 12vh;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
:host([open]) { opacity: 1; pointer-events: auto; }
|
||||
.palette {
|
||||
width: min(560px, 92vw);
|
||||
background: var(--bg-1);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 30px 80px -20px rgba(0,0,0,0.7);
|
||||
overflow: hidden;
|
||||
display: flex; flex-direction: column;
|
||||
max-height: 60vh;
|
||||
}
|
||||
.input {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
background: transparent; border: none; outline: none;
|
||||
color: var(--ink); font-size: 14px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.list { flex: 1; overflow-y: auto; padding: 4px; }
|
||||
.item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.item.active { background: var(--bg-3); }
|
||||
.item .ico { width: 20px; text-align: center; color: var(--accent); }
|
||||
.item .lbl { flex: 1; }
|
||||
.item .kbd {
|
||||
font-family: var(--mono); font-size: 10.5px;
|
||||
color: var(--ink-3);
|
||||
padding: 1px 5px; background: var(--bg-3); border-radius: 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
private cmds: Cmd[] = [
|
||||
{ ico: '▶', label: 'Run pipeline', kbd: 'Space', run: async () => { await getClient()?.run(); running.value = true; toast('Pipeline running', '▶'); } },
|
||||
{ ico: '❚', label: 'Pause pipeline', run: async () => { await getClient()?.pause(); running.value = false; toast('Paused', '❚❚'); } },
|
||||
{ ico: '+', label: 'New scene…', kbd: '⌘N', run: () => openModal({
|
||||
title: 'New scene',
|
||||
body: `<p>Build a fresh magnetic scene. The dashboard generates the JSON
|
||||
and pushes it to the running pipeline (or you can copy the JSON
|
||||
for offline use).</p>
|
||||
<label>Name</label>
|
||||
<input type="text" id="ns-name" value="custom-scene-${Date.now().toString(36)}" />
|
||||
<label>Heart-proxy dipole moment (A·m²)</label>
|
||||
<input type="text" id="ns-moment" value="1.0e-6" />
|
||||
<label>Distance heart → sensor (m)</label>
|
||||
<input type="text" id="ns-distance" value="0.5" />
|
||||
<label>Add ferrous distractor at +x = 1 m?</label>
|
||||
<select id="ns-ferrous">
|
||||
<option value="0">No</option>
|
||||
<option value="1" selected>Yes (steel coil, χ=5000)</option>
|
||||
</select>
|
||||
<label>Add 60 Hz mains-current loop?</label>
|
||||
<select id="ns-mains">
|
||||
<option value="0">No</option>
|
||||
<option value="1" selected>Yes (2 A loop, 5 cm radius, +y = 1 m)</option>
|
||||
</select>`,
|
||||
buttons: [
|
||||
{ label: 'Cancel', variant: 'ghost' },
|
||||
{ label: 'Create', variant: 'primary', onClick: async () => {
|
||||
const root = document.querySelector('nv-app')?.shadowRoot?.querySelector('nv-modal')?.shadowRoot;
|
||||
if (!root) return;
|
||||
const name = (root.querySelector<HTMLInputElement>('#ns-name')?.value ?? 'custom').trim();
|
||||
const m = parseFloat(root.querySelector<HTMLInputElement>('#ns-moment')?.value ?? '1e-6');
|
||||
const d = parseFloat(root.querySelector<HTMLInputElement>('#ns-distance')?.value ?? '0.5');
|
||||
const ferr = root.querySelector<HTMLSelectElement>('#ns-ferrous')?.value === '1';
|
||||
const mains = root.querySelector<HTMLSelectElement>('#ns-mains')?.value === '1';
|
||||
const scene = {
|
||||
dipoles: [{ position: [0, 0, d] as [number, number, number], moment: [0, 0, m] as [number, number, number] }],
|
||||
loops: mains ? [{
|
||||
centre: [0, 1, 0] as [number, number, number],
|
||||
normal: [0, 1, 0] as [number, number, number],
|
||||
radius: 0.05, current: 2.0, n_segments: 64,
|
||||
}] : [],
|
||||
ferrous: ferr ? [{ position: [1, 0, 0] as [number, number, number], volume: 1e-4, susceptibility: 5000 }] : [],
|
||||
eddy: [],
|
||||
sensors: [[0, 0, 0] as [number, number, number]],
|
||||
ambient_field: [1e-6, 0, 0] as [number, number, number],
|
||||
};
|
||||
await getClient()?.loadScene(scene);
|
||||
pushLog('ok', `scene <span class="s">${name}</span> loaded · 1 dipole · ${mains ? '1 loop · ' : ''}${ferr ? '1 ferrous · ' : ''}1 sensor`);
|
||||
toast(`Scene "${name}" loaded`, '+');
|
||||
} },
|
||||
],
|
||||
}) },
|
||||
{ ico: '📦', label: 'Export proof bundle…', kbd: '⌘E', run: async () => {
|
||||
const c = getClient(); if (!c) return;
|
||||
pushLog('dbg', 'building proof bundle…');
|
||||
try {
|
||||
const blob = await c.exportProofBundle();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `nvsim-proof-${Date.now()}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
pushLog('ok', `proof bundle exported · ${blob.size} bytes`);
|
||||
toast(`Proof bundle saved (${blob.size} B)`, '📦');
|
||||
} catch (e) { pushLog('err', `export failed: ${(e as Error).message}`); }
|
||||
} },
|
||||
{ ico: '⟳', label: 'Reset pipeline', kbd: '⌘R', run: () => openModal({
|
||||
title: 'Reset pipeline?',
|
||||
body: '<p>Clears the frame stream and rewinds <code>t</code> to 0.</p>',
|
||||
buttons: [
|
||||
{ label: 'Cancel', variant: 'ghost' },
|
||||
{ label: 'Reset', variant: 'danger', onClick: async () => { await getClient()?.reset(); pushLog('warn', 'pipeline reset · t=0'); toast('Pipeline reset', '⟳'); } },
|
||||
],
|
||||
}) },
|
||||
{ ico: '✓', label: 'Verify witness', run: async () => {
|
||||
const c = getClient(); if (!c) return;
|
||||
witnessVerified.value = 'pending';
|
||||
const exp = expectedWitness.value;
|
||||
const eb = new Uint8Array(32);
|
||||
for (let i = 0; i < 32; i++) eb[i] = parseInt(exp.slice(i * 2, i * 2 + 2), 16);
|
||||
const r = await c.verifyWitness(eb);
|
||||
if (r.ok) { witnessVerified.value = 'ok'; witnessHex.value = exp; toast('Witness verified', '✓'); }
|
||||
else { witnessVerified.value = 'fail'; toast('Witness mismatch!', '✗'); }
|
||||
} },
|
||||
{ ico: '☼', label: 'Toggle theme', kbd: '⌘/', run: () => { theme.value = theme.value === 'dark' ? 'light' : 'dark'; } },
|
||||
{ ico: '⚙', label: 'Open settings', kbd: '⌘,', run: () => window.dispatchEvent(new CustomEvent('open-settings')) },
|
||||
{ ico: '?', label: 'Keyboard shortcuts…', run: () => openModal({
|
||||
title: 'Keyboard shortcuts',
|
||||
body: `<div style="display:grid;grid-template-columns:auto 1fr;gap:6px 16px;font-size:13px;">
|
||||
<div><code>⌘K / Ctrl K</code></div><div>Command palette</div>
|
||||
<div><code>Space</code></div><div>Play / pause</div>
|
||||
<div><code>⌘R</code></div><div>Reset</div>
|
||||
<div><code>⌘,</code></div><div>Settings</div>
|
||||
<div><code>⌘/</code></div><div>Toggle theme</div>
|
||||
<div><code>\`</code></div><div>Debug HUD</div>
|
||||
<div><code>1 · 2 · 3</code></div><div>Inspector tabs</div>
|
||||
<div><code>Esc</code></div><div>Close modal/palette</div>
|
||||
<div><code>/</code></div><div>Focus REPL</div>
|
||||
</div>`,
|
||||
buttons: [{ label: 'Close', variant: 'primary' }],
|
||||
}) },
|
||||
{ ico: 'i', label: 'About nvsim…', run: () => openModal({
|
||||
title: 'About nvsim',
|
||||
body: `<p><b>nvsim</b> is a deterministic, byte-reproducible forward simulator for nitrogen-vacancy diamond magnetometry.</p>
|
||||
<p>This dashboard runs nvsim as WASM in a Web Worker. Same <code>(scene, config, seed)</code> → byte-identical SHA-256 witness across runs and machines.</p>
|
||||
<p>License: MIT OR Apache-2.0 · See ADR-089, ADR-092.</p>`,
|
||||
buttons: [{ label: 'Close', variant: 'primary' }],
|
||||
}) },
|
||||
];
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('keydown', this.onKey);
|
||||
window.addEventListener('nv-palette', this.onOpen as EventListener);
|
||||
}
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener('keydown', this.onKey);
|
||||
window.removeEventListener('nv-palette', this.onOpen as EventListener);
|
||||
}
|
||||
|
||||
private onKey = (e: KeyboardEvent): void => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault();
|
||||
this.openPal();
|
||||
} else if (e.key === 'Escape' && this.open) {
|
||||
this.closePal();
|
||||
} else if (this.open) {
|
||||
if (e.key === 'ArrowDown') { this.idx = Math.min(this.cmds.length - 1, this.idx + 1); e.preventDefault(); }
|
||||
else if (e.key === 'ArrowUp') { this.idx = Math.max(0, this.idx - 1); e.preventDefault(); }
|
||||
else if (e.key === 'Enter') { this.runIdx(); e.preventDefault(); }
|
||||
}
|
||||
};
|
||||
|
||||
private onOpen = (): void => this.openPal();
|
||||
|
||||
private openPal(): void {
|
||||
this.open = true; this.setAttribute('open', '');
|
||||
this.filter = ''; this.idx = 0;
|
||||
setTimeout(() => this.inputEl?.focus(), 0);
|
||||
}
|
||||
private closePal(): void { this.open = false; this.removeAttribute('open'); }
|
||||
|
||||
private filtered(): Cmd[] {
|
||||
if (!this.filter.trim()) return this.cmds;
|
||||
const q = this.filter.toLowerCase();
|
||||
return this.cmds.filter((c) => c.label.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
private runIdx(): void {
|
||||
const f = this.filtered();
|
||||
const c = f[this.idx];
|
||||
if (c) { c.run(); this.closePal(); }
|
||||
}
|
||||
|
||||
override render() {
|
||||
const items = this.filtered();
|
||||
return html`
|
||||
<div class="palette" data-id="palette">
|
||||
<div class="input">
|
||||
<input id="palette-input" type="text" placeholder="Type a command…"
|
||||
.value=${this.filter}
|
||||
@input=${(e: Event) => { this.filter = (e.target as HTMLInputElement).value; this.idx = 0; }} />
|
||||
</div>
|
||||
<div class="list">
|
||||
${items.map((c, i) => html`
|
||||
<div class="item ${i === this.idx ? 'active' : ''}" @click=${() => { this.idx = i; this.runIdx(); }}>
|
||||
<span class="ico">${c.ico}</span>
|
||||
<span class="lbl">${c.label}</span>
|
||||
${c.kbd ? html`<span class="kbd">${c.kbd}</span>` : ''}
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
/* Left rail navigation. Emits `navigate` events for view switching. */
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import type { View } from './nv-app';
|
||||
|
||||
@customElement('nv-rail')
|
||||
export class NvRail extends LitElement {
|
||||
@property() view: View = 'scene';
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
gap: 4px;
|
||||
background: var(--bg-1);
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
.logo {
|
||||
width: 36px; height: 36px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, oklch(0.78 0.14 70) 0%, oklch(0.55 0.16 30) 100%);
|
||||
display: grid; place-items: center;
|
||||
color: #1a0f00;
|
||||
font-weight: 700;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
margin-bottom: 14px;
|
||||
box-shadow: 0 4px 12px -2px oklch(0.55 0.16 30 / 0.35);
|
||||
}
|
||||
.btn {
|
||||
width: 36px; height: 36px;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: var(--ink-3);
|
||||
display: grid; place-items: center;
|
||||
transition: all 0.15s;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn:hover { color: var(--ink); background: var(--bg-2); }
|
||||
.btn.active {
|
||||
color: var(--ink);
|
||||
background: var(--bg-3);
|
||||
border-color: var(--line-2);
|
||||
}
|
||||
.btn.active::before {
|
||||
content: ''; position: absolute; left: -10px; top: 8px; bottom: 8px;
|
||||
width: 2px; background: var(--accent); border-radius: 2px;
|
||||
}
|
||||
.btn.ghost.active::before { background: var(--accent-3); }
|
||||
.spacer { flex: 1; }
|
||||
svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; }
|
||||
`;
|
||||
|
||||
private navigate(v: View): void {
|
||||
this.dispatchEvent(new CustomEvent('navigate', { detail: v }));
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="logo" aria-hidden="true">NV</div>
|
||||
<nav role="navigation" aria-label="Primary"
|
||||
style="display:flex; flex-direction:column; align-items:center; gap:4px; flex:1;">
|
||||
<button class="btn ${this.view === 'home' ? 'active' : ''}"
|
||||
data-id="home-btn" title="Home" aria-label="Home"
|
||||
aria-current=${this.view === 'home' ? 'page' : 'false'}
|
||||
@click=${() => this.navigate('home')}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 12L12 4l9 8M5 10v10h14V10"/></svg>
|
||||
</button>
|
||||
<button class="btn ${this.view === 'scene' ? 'active' : ''}"
|
||||
data-id="scene-btn" title="Scene" aria-label="Scene"
|
||||
aria-current=${this.view === 'scene' ? 'page' : 'false'}
|
||||
@click=${() => this.navigate('scene')}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2L3 7l9 5 9-5-9-5zm0 13l-9-5v6l9 5 9-5v-6l-9 5z"/></svg>
|
||||
</button>
|
||||
<button class="btn ${this.view === 'apps' ? 'active' : ''}"
|
||||
data-id="apps-btn" title="App Store" aria-label="App Store"
|
||||
aria-current=${this.view === 'apps' ? 'page' : 'false'}
|
||||
@click=${() => this.navigate('apps')}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
|
||||
</button>
|
||||
<button class="btn ${this.view === 'inspector' ? 'active' : ''}"
|
||||
data-id="inspector-btn" title="Inspector" aria-label="Inspector"
|
||||
aria-current=${this.view === 'inspector' ? 'page' : 'false'}
|
||||
@click=${() => this.navigate('inspector')}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.6" y2="16.6"/></svg>
|
||||
</button>
|
||||
<button class="btn ${this.view === 'witness' ? 'active' : ''}"
|
||||
data-id="witness-btn" title="Witness" aria-label="Witness"
|
||||
aria-current=${this.view === 'witness' ? 'page' : 'false'}
|
||||
@click=${() => this.navigate('witness')}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 12l2 2 4-4M21 12c0 4.97-4.03 9-9 9s-9-4.03-9-9 4.03-9 9-9 9 4.03 9 9z"/></svg>
|
||||
</button>
|
||||
<button class="btn ghost ${this.view === 'ghost-murmur' ? 'active' : ''}"
|
||||
data-id="ghost-murmur-btn" title="Ghost Murmur — research spec"
|
||||
aria-label="Ghost Murmur research"
|
||||
aria-current=${this.view === 'ghost-murmur' ? 'page' : 'false'}
|
||||
@click=${() => this.navigate('ghost-murmur')}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M9 2C5.7 2 3 4.7 3 8v12l3-2 3 2 3-2 3 2 3-2 3 2V8c0-3.3-2.7-6-6-6H9z"/>
|
||||
<circle cx="9" cy="10" r="1.2" fill="currentColor"/>
|
||||
<circle cx="15" cy="10" r="1.2" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
</nav>
|
||||
<div class="spacer"></div>
|
||||
<button class="btn" data-id="settings-btn" title="Settings" aria-label="Settings"
|
||||
@click=${() => this.dispatchEvent(new CustomEvent('open-settings', { bubbles: true, composed: true }))}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06A1.65 1.65 0 0015 19.4a1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09A1.65 1.65 0 0015 4.6a1.65 1.65 0 001.82-.33l.06.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,374 +0,0 @@
|
||||
/* Scene canvas — SVG with draggable sources, NV crystal sensor, field lines, mini ODMR. */
|
||||
import { LitElement, html, css, svg } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import { lastB, bMag, fps, snr, motionReduced, running, getClient, speed, pushLog, lastFrame, scenePositions } from '../store/appStore';
|
||||
|
||||
interface SceneItem { id: string; x: number; y: number; color: string; name: string; }
|
||||
|
||||
@customElement('nv-scene')
|
||||
export class NvScene extends LitElement {
|
||||
@state() private zoom = 1.0;
|
||||
@state() private layerVisible = { source: true, field: true, label: true };
|
||||
@state() private items: SceneItem[] = [
|
||||
{ id: 'rebar', x: 740, y: 240, color: 'oklch(0.72 0.18 330)', name: 'rebar.steel' },
|
||||
{ id: 'heart', x: 220, y: 180, color: 'oklch(0.78 0.14 195)', name: 'heart_proxy' },
|
||||
{ id: 'mains', x: 180, y: 380, color: 'oklch(0.72 0.18 330)', name: 'mains_60Hz' },
|
||||
{ id: 'door', x: 800, y: 470, color: 'oklch(0.78 0.14 145)', name: 'door.steel' },
|
||||
];
|
||||
@state() private dragging: string | null = null;
|
||||
@state() private selected: string | null = null;
|
||||
private dragOffset = { dx: 0, dy: 0 };
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block; height: 100%; width: 100%;
|
||||
background: radial-gradient(ellipse at 50% 30%, var(--bg-2) 0%, var(--bg-0) 70%);
|
||||
position: relative; overflow: hidden;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.grid {
|
||||
position: absolute; inset: 0;
|
||||
background-image:
|
||||
linear-gradient(var(--grid) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--grid) 1px, transparent 1px);
|
||||
background-size: 32px 32px;
|
||||
pointer-events: none;
|
||||
mask-image: radial-gradient(ellipse at center, black 40%, transparent 100%);
|
||||
}
|
||||
svg { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.stat-card {
|
||||
background: rgba(13,17,23,0.7);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
min-width: 96px;
|
||||
}
|
||||
[data-theme="light"] .stat-card { background: rgba(255,255,255,0.85); }
|
||||
.stat-card .lbl {
|
||||
color: var(--ink-3);
|
||||
text-transform: uppercase; font-weight: 600; letter-spacing: 0.06em; font-size: 9.5px;
|
||||
}
|
||||
.stat-card .val { font-family: var(--mono); font-size: 16px; font-weight: 600; margin-top: 2px; }
|
||||
.stat-card .val.amber { color: var(--accent); }
|
||||
.stat-card .val.cyan { color: var(--accent-2); }
|
||||
.stat-card .val.mint { color: var(--accent-4); }
|
||||
.scene-readout {
|
||||
position: absolute; top: 14px; right: 14px;
|
||||
display: flex; gap: 8px; z-index: 5;
|
||||
}
|
||||
.draggable { cursor: grab; transition: filter 0.15s; }
|
||||
.draggable:hover { filter: brightness(1.15) drop-shadow(0 0 6px currentColor); }
|
||||
.draggable.dragging { cursor: grabbing; filter: brightness(1.25) drop-shadow(0 0 10px currentColor); }
|
||||
.field-line { stroke-dasharray: 4 6; }
|
||||
@keyframes dash { to { stroke-dashoffset: -200; } }
|
||||
.field-line.anim { animation: dash 4s linear infinite; }
|
||||
@keyframes spin {
|
||||
0% { transform: rotateY(0) rotateX(8deg); }
|
||||
100% { transform: rotateY(360deg) rotateX(8deg); }
|
||||
}
|
||||
.crystal { transform-origin: center; transform-box: fill-box; }
|
||||
.crystal.anim { animation: spin 12s linear infinite; }
|
||||
.label {
|
||||
font-family: var(--mono); font-size: 11px; fill: var(--ink-2);
|
||||
pointer-events: none;
|
||||
}
|
||||
.scene-toolbar {
|
||||
position: absolute; top: 14px; left: 14px;
|
||||
display: flex; gap: 6px; z-index: 5;
|
||||
background: rgba(13,17,23,0.85);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
}
|
||||
[data-theme="light"] .scene-toolbar { background: rgba(255,255,255,0.85); }
|
||||
.scene-toolbar button {
|
||||
width: 28px; height: 28px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
color: var(--ink-2);
|
||||
cursor: pointer;
|
||||
display: grid; place-items: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
.scene-toolbar button:hover { color: var(--ink); background: var(--bg-2); }
|
||||
.scene-toolbar button.on { background: var(--bg-3); color: var(--accent); border-color: var(--line-2); }
|
||||
|
||||
.sim-controls {
|
||||
position: absolute; bottom: 14px; right: 14px;
|
||||
display: flex; gap: 6px; align-items: center;
|
||||
background: rgba(13,17,23,0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: 999px;
|
||||
padding: 6px 10px;
|
||||
z-index: 5;
|
||||
}
|
||||
[data-theme="light"] .sim-controls { background: rgba(255,255,255,0.92); }
|
||||
.sim-controls .play {
|
||||
width: 32px; height: 32px;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
color: #1a0f00;
|
||||
cursor: pointer;
|
||||
display: grid; place-items: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
.sim-controls .play:hover { filter: brightness(1.08); }
|
||||
.sim-controls .step {
|
||||
width: 26px; height: 26px;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--ink-2);
|
||||
border: 1px solid var(--line);
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
}
|
||||
.sim-controls .step:hover { color: var(--ink); border-color: var(--line-2); }
|
||||
.sim-controls .speed {
|
||||
font-family: var(--mono); font-size: 11px;
|
||||
color: var(--ink-2);
|
||||
padding: 0 6px;
|
||||
min-width: 36px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
`;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
// Restore drag positions if any are persisted.
|
||||
if (scenePositions.value.length > 0) {
|
||||
this.items = this.items.map((it) => {
|
||||
const saved = scenePositions.value.find((p) => p.id === it.id);
|
||||
return saved ? { ...it, x: saved.x, y: saved.y } : it;
|
||||
});
|
||||
}
|
||||
effect(() => {
|
||||
lastB.value; bMag.value; fps.value; snr.value; motionReduced.value;
|
||||
running.value; speed.value; lastFrame.value;
|
||||
this.requestUpdate();
|
||||
});
|
||||
// Compute SNR from the last frame: |B_pT| / max(σ_pT[k]) per ADR-093 P1.4.
|
||||
effect(() => {
|
||||
const f = lastFrame.value;
|
||||
if (!f) return;
|
||||
const bmag = Math.sqrt(f.bPt[0] ** 2 + f.bPt[1] ** 2 + f.bPt[2] ** 2);
|
||||
const sigmaMax = Math.max(Math.abs(f.sigmaPt[0]), Math.abs(f.sigmaPt[1]), Math.abs(f.sigmaPt[2]), 0.001);
|
||||
const snrVal = bmag / sigmaMax;
|
||||
if (Number.isFinite(snrVal)) snr.value = snrVal;
|
||||
});
|
||||
window.addEventListener('pointermove', this.onPointerMove);
|
||||
window.addEventListener('pointerup', this.onPointerUp);
|
||||
window.addEventListener('keydown', this.onKey);
|
||||
}
|
||||
|
||||
/** Tab cycles selection; arrow keys nudge by 8 px (32 px with Shift);
|
||||
* Esc deselects. ADR-093 P2.6. */
|
||||
private onKey = (e: KeyboardEvent): void => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return;
|
||||
if (!this.selected) {
|
||||
if (e.key === 'Tab' && document.activeElement === document.body) {
|
||||
e.preventDefault();
|
||||
this.selected = this.items[0]?.id ?? null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
const step = e.shiftKey ? 32 : 8;
|
||||
const dx = e.key === 'ArrowLeft' ? -step : e.key === 'ArrowRight' ? step : 0;
|
||||
const dy = e.key === 'ArrowUp' ? -step : e.key === 'ArrowDown' ? step : 0;
|
||||
this.items = this.items.map((it) =>
|
||||
it.id === this.selected
|
||||
? { ...it, x: Math.max(20, Math.min(980, it.x + dx)), y: Math.max(20, Math.min(580, it.y + dy)) }
|
||||
: it,
|
||||
);
|
||||
scenePositions.value = this.items.map(({ id, x, y }) => ({ id, x, y }));
|
||||
} else if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const idx = this.items.findIndex((it) => it.id === this.selected);
|
||||
const next = (idx + (e.shiftKey ? -1 : 1) + this.items.length) % this.items.length;
|
||||
this.selected = this.items[next].id;
|
||||
} else if (e.key === 'Escape') {
|
||||
this.selected = null;
|
||||
}
|
||||
};
|
||||
|
||||
private async toggleRun(): Promise<void> {
|
||||
const c = getClient(); if (!c) return;
|
||||
if (running.value) { await c.pause(); running.value = false; }
|
||||
else { await c.run(); running.value = true; }
|
||||
}
|
||||
private async stepFwd(): Promise<void> {
|
||||
const c = getClient(); if (!c) return;
|
||||
await c.step('fwd', 10);
|
||||
pushLog('dbg', 'sim step → +1 frame');
|
||||
}
|
||||
private async stepBack(): Promise<void> {
|
||||
const c = getClient(); if (!c) return;
|
||||
await c.step('back', 10);
|
||||
pushLog('dbg', 'sim step ← -1 frame');
|
||||
}
|
||||
private cycleSpeed(): void {
|
||||
const speeds = [0.25, 0.5, 1.0, 2.0, 4.0];
|
||||
const idx = speeds.indexOf(speed.value);
|
||||
speed.value = speeds[(idx + 1) % speeds.length];
|
||||
}
|
||||
private zoomIn(): void { this.zoom = Math.min(2.5, this.zoom * 1.2); }
|
||||
private zoomOut(): void { this.zoom = Math.max(0.5, this.zoom / 1.2); }
|
||||
private fitView(): void { this.zoom = 1.0; }
|
||||
private toggleLayer(k: 'source' | 'field' | 'label'): void {
|
||||
this.layerVisible = { ...this.layerVisible, [k]: !this.layerVisible[k] };
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener('pointermove', this.onPointerMove);
|
||||
window.removeEventListener('pointerup', this.onPointerUp);
|
||||
window.removeEventListener('keydown', this.onKey);
|
||||
}
|
||||
|
||||
private onDown = (id: string, e: PointerEvent): void => {
|
||||
e.preventDefault();
|
||||
this.dragging = id;
|
||||
this.selected = id;
|
||||
const item = this.items.find((i) => i.id === id);
|
||||
if (!item) return;
|
||||
const svgEl = this.renderRoot.querySelector('svg') as SVGSVGElement | null;
|
||||
if (!svgEl) return;
|
||||
const pt = this.toSvg(e, svgEl);
|
||||
this.dragOffset = { dx: pt.x - item.x, dy: pt.y - item.y };
|
||||
};
|
||||
|
||||
private onPointerMove = (e: PointerEvent): void => {
|
||||
if (!this.dragging) return;
|
||||
const svgEl = this.renderRoot.querySelector('svg') as SVGSVGElement | null;
|
||||
if (!svgEl) return;
|
||||
const pt = this.toSvg(e, svgEl);
|
||||
this.items = this.items.map((it) =>
|
||||
it.id === this.dragging
|
||||
? { ...it, x: pt.x - this.dragOffset.dx, y: pt.y - this.dragOffset.dy }
|
||||
: it,
|
||||
);
|
||||
};
|
||||
|
||||
private onPointerUp = (): void => {
|
||||
if (this.dragging) {
|
||||
// Persist all positions on drop.
|
||||
scenePositions.value = this.items.map(({ id, x, y }) => ({ id, x, y }));
|
||||
}
|
||||
this.dragging = null;
|
||||
};
|
||||
|
||||
private toSvg(e: PointerEvent, svgEl: SVGSVGElement): { x: number; y: number } {
|
||||
const r = svgEl.getBoundingClientRect();
|
||||
const vbX = ((e.clientX - r.left) / r.width) * 1000;
|
||||
const vbY = ((e.clientY - r.top) / r.height) * 600;
|
||||
return { x: vbX, y: vbY };
|
||||
}
|
||||
|
||||
override render() {
|
||||
const b = lastB.value;
|
||||
const bnT = [b[0] * 1e9, b[1] * 1e9, b[2] * 1e9];
|
||||
const bMagNT = bMag.value * 1e9;
|
||||
const animClass = motionReduced.value ? '' : 'anim';
|
||||
|
||||
const vbW = 1000 / this.zoom;
|
||||
const vbH = 600 / this.zoom;
|
||||
const vbX = (1000 - vbW) / 2;
|
||||
const vbY = (600 - vbH) / 2;
|
||||
|
||||
return html`
|
||||
<div class="grid"></div>
|
||||
<svg viewBox="${vbX.toFixed(1)} ${vbY.toFixed(1)} ${vbW.toFixed(1)} ${vbH.toFixed(1)}"
|
||||
preserveAspectRatio="xMidYMid meet" id="scene-svg">
|
||||
<defs>
|
||||
<radialGradient id="g-sensor" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0" stop-color="oklch(0.78 0.14 70)" stop-opacity="0.4"/>
|
||||
<stop offset="1" stop-color="oklch(0.78 0.14 70)" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<filter id="glow"><feGaussianBlur stdDeviation="3" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
|
||||
</defs>
|
||||
|
||||
<!-- Field lines from each source to sensor -->
|
||||
${this.layerVisible.field ? this.items.map((it) => svg`
|
||||
<line class="field-line ${animClass}" x1=${it.x} y1=${it.y}
|
||||
x2="500" y2="320"
|
||||
stroke=${it.color} stroke-width="1" stroke-opacity="0.5"/>
|
||||
`) : ''}
|
||||
|
||||
<!-- Source primitives -->
|
||||
${this.layerVisible.source ? this.items.map((it) => svg`
|
||||
<g class=${`draggable ${this.dragging === it.id ? 'dragging' : ''} ${this.selected === it.id ? 'selected' : ''}`}
|
||||
data-id=${it.id} data-source-id=${it.id}
|
||||
transform=${`translate(${it.x.toFixed(0)},${it.y.toFixed(0)})`}
|
||||
@pointerdown=${(e: PointerEvent) => this.onDown(it.id, e)}>
|
||||
<ellipse cx="0" cy="0" rx="32" ry="22" fill=${it.color} fill-opacity="0.18"
|
||||
stroke=${it.color} stroke-width="1.2"/>
|
||||
<circle cx="0" cy="0" r="4" fill=${it.color}/>
|
||||
${this.layerVisible.label ? svg`<text class="label" x="0" y="40" text-anchor="middle">${it.name}</text>` : ''}
|
||||
</g>
|
||||
`) : ''}
|
||||
|
||||
<!-- Sensor (NV diamond) at center -->
|
||||
<g id="sensor-g" class="draggable" data-id="sensor" transform="translate(500, 320)">
|
||||
<circle cx="0" cy="0" r="46" fill="url(#g-sensor)"/>
|
||||
<g class=${`crystal ${animClass}`} stroke="oklch(0.78 0.14 70)" stroke-width="2"
|
||||
fill="oklch(0.78 0.14 70 / 0.08)" filter="url(#glow)">
|
||||
<polygon points="0,-22 19,-7 12,18 -12,18 -19,-7"/>
|
||||
</g>
|
||||
<circle cx="0" cy="0" r="3" fill="var(--accent)"/>
|
||||
<text class="label" x="0" y="56" text-anchor="middle">
|
||||
sensor · 〈111〉 NV
|
||||
</text>
|
||||
<text class="label" x="0" y="72" text-anchor="middle">
|
||||
B_in: <tspan fill="var(--accent)" id="b-in-svg">[${bnT[0].toFixed(2)}, ${bnT[1].toFixed(2)}, ${bnT[2].toFixed(2)}] nT</tspan>
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<div class="scene-toolbar" id="scene-toolbar">
|
||||
<button id="zoom-in-btn" title="Zoom in" @click=${this.zoomIn}>+</button>
|
||||
<button id="zoom-out-btn" title="Zoom out" @click=${this.zoomOut}>−</button>
|
||||
<button id="fit-btn" title="Fit to view" @click=${this.fitView}>⊡</button>
|
||||
<button id="layer-source-btn" class=${this.layerVisible.source ? 'on' : ''}
|
||||
title="Sources" @click=${() => this.toggleLayer('source')}>●</button>
|
||||
<button id="layer-field-btn" class=${this.layerVisible.field ? 'on' : ''}
|
||||
title="Field lines" @click=${() => this.toggleLayer('field')}>≈</button>
|
||||
<button id="layer-label-btn" class=${this.layerVisible.label ? 'on' : ''}
|
||||
title="Labels" @click=${() => this.toggleLayer('label')}>T</button>
|
||||
</div>
|
||||
|
||||
<div class="sim-controls" id="sim-controls">
|
||||
<button class="step" id="step-back-btn" title="Step back" @click=${this.stepBack}>⏮</button>
|
||||
<button class="play" id="play-btn" title="Play / pause" @click=${this.toggleRun}>
|
||||
${running.value ? '❚❚' : '▶'}
|
||||
</button>
|
||||
<button class="step" id="step-fwd-btn" title="Step forward" @click=${this.stepFwd}>⏭</button>
|
||||
<span class="speed" id="speed-val" title="Cycle speed" @click=${this.cycleSpeed}>${speed.value}×</span>
|
||||
</div>
|
||||
|
||||
<div class="scene-readout">
|
||||
<div class="stat-card">
|
||||
<div class="lbl">|B|</div>
|
||||
<div class="val amber" id="bmag-readout">${bMagNT.toFixed(3)} nT</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="lbl">FPS</div>
|
||||
<div class="val cyan" id="fps-readout">${fps.value > 0 ? Math.round(fps.value) : '—'}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="lbl">SNR</div>
|
||||
<div class="val mint" id="snr-readout">${snr.value > 0 ? snr.value.toFixed(1) : '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
/* Settings drawer — theme / density / motion / auto-update. */
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import { theme, density, motionReduced, autoUpdate, transport, wsUrl } from '../store/appStore';
|
||||
|
||||
@customElement('nv-settings-drawer')
|
||||
export class NvSettingsDrawer extends LitElement {
|
||||
@state() private open = false;
|
||||
|
||||
static styles = css`
|
||||
/* The host covers the viewport without transforming itself. Only the
|
||||
* inner .panel is transformed; otherwise the host's transform would
|
||||
* create a containing block for the fixed-position scrim, clipping
|
||||
* it to the panel's 420 px width and breaking outside-to-dismiss. */
|
||||
:host {
|
||||
position: fixed; inset: 0;
|
||||
z-index: 51;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
:host([open]) { pointer-events: auto; opacity: 1; }
|
||||
.scrim {
|
||||
position: absolute; inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
.panel {
|
||||
position: absolute;
|
||||
top: 0; right: 0; bottom: 0;
|
||||
width: 420px; max-width: 100vw;
|
||||
background: var(--bg-1);
|
||||
border-left: 1px solid var(--line);
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: flex; flex-direction: column;
|
||||
box-shadow: -20px 0 60px -20px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
:host([open]) .panel { transform: translateX(0); }
|
||||
.h {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.h .ttl { font-size: 14px; font-weight: 600; }
|
||||
.body { flex: 1; overflow-y: auto; padding: 16px; }
|
||||
.group { margin-bottom: 22px; }
|
||||
.group h4 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 11px; font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: 0.08em;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.row {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.row:last-child { border-bottom: 0; }
|
||||
.row .lbl { font-size: 13px; }
|
||||
.row .desc { font-size: 11.5px; color: var(--ink-3); margin-top: 2px; }
|
||||
.row > div:first-child { flex: 1; padding-right: 12px; }
|
||||
.seg {
|
||||
display: inline-flex;
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px;
|
||||
}
|
||||
.seg button {
|
||||
padding: 4px 10px;
|
||||
background: transparent; border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 11.5px; color: var(--ink-3);
|
||||
font-family: var(--mono);
|
||||
cursor: pointer;
|
||||
}
|
||||
.seg button.on { background: var(--bg-1); color: var(--ink); }
|
||||
.toggle {
|
||||
position: relative;
|
||||
width: 36px; height: 20px;
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.toggle::after {
|
||||
content: ''; position: absolute;
|
||||
top: 2px; left: 2px;
|
||||
width: 14px; height: 14px;
|
||||
background: var(--ink-3);
|
||||
border-radius: 50%;
|
||||
transition: transform 0.15s, background 0.15s;
|
||||
}
|
||||
.toggle.on { background: var(--accent); border-color: var(--accent); }
|
||||
.toggle.on::after { background: #1a0f00; transform: translateX(16px); }
|
||||
.close {
|
||||
width: 28px; height: 28px;
|
||||
background: transparent; border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
input[type="text"] {
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
color: var(--ink); font-family: var(--mono); font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
`;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
effect(() => { theme.value; density.value; motionReduced.value; autoUpdate.value; transport.value; wsUrl.value; this.requestUpdate(); });
|
||||
window.addEventListener('open-settings', () => { this.open = true; this.setAttribute('open', ''); });
|
||||
}
|
||||
|
||||
private close(): void { this.open = false; this.removeAttribute('open'); }
|
||||
|
||||
private async resetPrefs(): Promise<void> {
|
||||
if (!confirm('Reset all preferences and IndexedDB state? Reloads the page.')) return;
|
||||
try {
|
||||
const dbs = await indexedDB.databases?.();
|
||||
if (dbs) for (const d of dbs) if (d.name) indexedDB.deleteDatabase(d.name);
|
||||
} catch { /* noop */ }
|
||||
location.reload();
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="scrim" @click=${() => this.close()}></div>
|
||||
<div class="panel" role="dialog" aria-modal="true" aria-label="Settings">
|
||||
<div class="h">
|
||||
<div class="ttl">Settings</div>
|
||||
<button class="close" @click=${() => this.close()}>×</button>
|
||||
</div>
|
||||
<div class="body">
|
||||
<div class="group">
|
||||
<h4>Appearance</h4>
|
||||
<div class="row">
|
||||
<div>
|
||||
<div class="lbl">Theme</div>
|
||||
<div class="desc">Dark is the default; light has higher contrast for daylight work.</div>
|
||||
</div>
|
||||
<div class="seg">
|
||||
<button class=${theme.value === 'dark' ? 'on' : ''}
|
||||
@click=${() => theme.value = 'dark'}>dark</button>
|
||||
<button class=${theme.value === 'light' ? 'on' : ''}
|
||||
@click=${() => theme.value = 'light'}>light</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div>
|
||||
<div class="lbl">Density</div>
|
||||
<div class="desc">Affects panel padding and font scale (15 / 14 / 13 px). Choose what your eyes prefer.</div>
|
||||
</div>
|
||||
<div class="seg">
|
||||
<button class=${density.value === 'comfy' ? 'on' : ''}
|
||||
@click=${() => density.value = 'comfy'}>comfy</button>
|
||||
<button class=${density.value === 'default' ? 'on' : ''}
|
||||
@click=${() => density.value = 'default'}>default</button>
|
||||
<button class=${density.value === 'compact' ? 'on' : ''}
|
||||
@click=${() => density.value = 'compact'}>compact</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div>
|
||||
<div class="lbl">Reduce motion</div>
|
||||
<div class="desc">Stops the rotating diamond, animated field lines, and chart easing. Auto-on if your system has the prefers-reduced-motion preference set.</div>
|
||||
</div>
|
||||
<span class="toggle ${motionReduced.value ? 'on' : ''}"
|
||||
role="switch" aria-checked=${motionReduced.value}
|
||||
@click=${() => motionReduced.value = !motionReduced.value}></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<h4>Pipeline</h4>
|
||||
<div class="row">
|
||||
<div>
|
||||
<div class="lbl">Auto-rerun on edit</div>
|
||||
<div class="desc">When you change a Tunables slider or load a new scene, push the change to the worker without a manual restart.</div>
|
||||
</div>
|
||||
<span class="toggle ${autoUpdate.value ? 'on' : ''}"
|
||||
role="switch" aria-checked=${autoUpdate.value}
|
||||
@click=${() => autoUpdate.value = !autoUpdate.value}></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<h4>Transport</h4>
|
||||
<div class="row">
|
||||
<div>
|
||||
<div class="lbl">Mode</div>
|
||||
<div class="desc">WASM runs nvsim in your browser (default, no server). WS connects to a host-supplied nvsim-server (REST + binary WebSocket); see ADR-092 §6.2.</div>
|
||||
</div>
|
||||
<div class="seg">
|
||||
<button class=${transport.value === 'wasm' ? 'on' : ''}
|
||||
@click=${() => transport.value = 'wasm'}>WASM</button>
|
||||
<button class=${transport.value === 'ws' ? 'on' : ''}
|
||||
@click=${() => transport.value = 'ws'}>WS</button>
|
||||
</div>
|
||||
</div>
|
||||
${transport.value === 'ws' ? html`
|
||||
<div class="row">
|
||||
<div>
|
||||
<div class="lbl">WS URL</div>
|
||||
<div class="desc">Where your nvsim-server is listening. The server defaults to 127.0.0.1:7878.</div>
|
||||
</div>
|
||||
<input type="text" placeholder="ws://localhost:7878" .value=${wsUrl.value}
|
||||
@input=${(e: Event) => wsUrl.value = (e.target as HTMLInputElement).value} />
|
||||
</div>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<h4>Help</h4>
|
||||
<div class="row">
|
||||
<div>
|
||||
<div class="lbl">Open help center</div>
|
||||
<div class="desc">Quickstart, glossary, FAQ, and shortcuts. Press <kbd style="font-family:var(--mono);font-size:10.5px;padding:1px 4px;background:var(--bg-3);border:1px solid var(--line);border-radius:3px;">?</kbd> any time.</div>
|
||||
</div>
|
||||
<button class="seg"
|
||||
@click=${() => { this.close(); window.dispatchEvent(new CustomEvent('nv-show-help')); }}
|
||||
style="padding:6px 12px;cursor:pointer;background:var(--bg-3);border:1px solid var(--line);border-radius:6px;color:var(--ink);">
|
||||
Open
|
||||
</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div>
|
||||
<div class="lbl">Replay welcome tour</div>
|
||||
<div class="desc">Re-show the 6-step first-run walkthrough.</div>
|
||||
</div>
|
||||
<button class="seg"
|
||||
@click=${() => { this.close(); window.dispatchEvent(new CustomEvent('nv-show-tour')); }}
|
||||
style="padding:6px 12px;cursor:pointer;background:var(--bg-3);border:1px solid var(--line);border-radius:6px;color:var(--ink);">
|
||||
Replay
|
||||
</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div>
|
||||
<div class="lbl">Reset all preferences</div>
|
||||
<div class="desc">Wipe theme, density, motion, scene drag positions, REPL history, and the onboarding-seen flag.</div>
|
||||
</div>
|
||||
<button class="seg"
|
||||
@click=${() => this.resetPrefs()}
|
||||
style="padding:6px 12px;cursor:pointer;background:var(--bg-3);border:1px solid oklch(0.65 0.22 25 / 0.4);border-radius:6px;color:var(--bad);">
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<h4>About</h4>
|
||||
<div class="row" style="border-bottom:0;">
|
||||
<div>
|
||||
<div class="lbl">nvsim · v0.3.0</div>
|
||||
<div class="desc">Open-source NV-diamond simulator. Apache-2.0 OR MIT.<br>
|
||||
<a style="color:var(--accent-2); text-decoration:underline dotted; cursor:pointer;"
|
||||
@click=${() => { this.close(); window.dispatchEvent(new CustomEvent('nv-show-help', { detail: { section: 'about' } })); }}>
|
||||
More info →
|
||||
</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
/* Sidebar — Scene panel, NV sensor panel, Tunables, Pipeline diagram. */
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import { fs, fmod, dtMs, noiseEnabled, running, getClient, pushLog } from '../store/appStore';
|
||||
|
||||
let configPushTimer: number | null = null;
|
||||
function pushConfigDebounced(): void {
|
||||
if (configPushTimer !== null) window.clearTimeout(configPushTimer);
|
||||
configPushTimer = window.setTimeout(async () => {
|
||||
const c = getClient();
|
||||
if (!c) return;
|
||||
try {
|
||||
await c.setConfig({
|
||||
digitiser: { f_s_hz: fs.value, f_mod_hz: fmod.value },
|
||||
sensor: {
|
||||
gamma_fwhm_hz: 1.0e6,
|
||||
t1_s: 5.0e-3,
|
||||
t2_s: 1.0e-6,
|
||||
t2_star_s: 200e-9,
|
||||
contrast: 0.03,
|
||||
n_spins: 1.0e12,
|
||||
shot_noise_disabled: !noiseEnabled.value,
|
||||
},
|
||||
dt_s: dtMs.value * 1e-3,
|
||||
});
|
||||
pushLog('dbg', `config pushed · fs=${fs.value} f_mod=${fmod.value} dt=${dtMs.value.toFixed(1)}ms noise=${noiseEnabled.value ? 'on' : 'off'}`);
|
||||
} catch (e) {
|
||||
pushLog('warn', `config push failed: ${(e as Error).message}`);
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
@customElement('nv-sidebar')
|
||||
export class NvSidebar extends LitElement {
|
||||
static styles = css`
|
||||
:host {
|
||||
display: flex; flex-direction: column; gap: 14px;
|
||||
padding: 14px; overflow-y: auto;
|
||||
background: var(--bg-1); border-right: 1px solid var(--line);
|
||||
}
|
||||
.panel {
|
||||
background: var(--bg-2); border: 1px solid var(--line);
|
||||
border-radius: var(--radius); padding: 12px;
|
||||
}
|
||||
.panel-h {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
font-size: 11px; font-weight: 600; color: var(--ink-3);
|
||||
text-transform: uppercase; letter-spacing: 0.08em;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.panel-help {
|
||||
font-size: 11.5px; color: var(--ink-3);
|
||||
margin: 0 0 10px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.help-link {
|
||||
color: var(--accent-2);
|
||||
cursor: pointer;
|
||||
text-decoration: underline dotted;
|
||||
}
|
||||
.help-link:hover { color: var(--accent); }
|
||||
.count {
|
||||
background: var(--bg-3); color: var(--ink-2);
|
||||
padding: 1px 6px; border-radius: 999px;
|
||||
font-family: var(--mono); font-size: 10px;
|
||||
text-transform: none; letter-spacing: 0;
|
||||
}
|
||||
.scene-item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.scene-item:hover { background: var(--bg-3); }
|
||||
.scene-item .swatch { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.scene-item .name { font-size: 13px; flex: 1; }
|
||||
.scene-item .meta { font-family: var(--mono); font-size: 10.5px; color: var(--ink-3); }
|
||||
.field-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 6px 0; font-size: 12.5px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.field-row:last-child { border-bottom: 0; }
|
||||
.field-row .lbl { color: var(--ink-3); }
|
||||
.field-row .val { font-family: var(--mono); color: var(--ink); font-size: 12px; }
|
||||
.slider-row { padding: 8px 0; border-bottom: 1px solid var(--line); }
|
||||
.slider-row:last-child { border-bottom: 0; padding-bottom: 0; }
|
||||
.slider-row .top { display: flex; justify-content: space-between; margin-bottom: 6px; font-size: 12px; }
|
||||
.slider-row .top .lbl { color: var(--ink-3); }
|
||||
.slider-row .top .val { font-family: var(--mono); color: var(--ink); }
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none; appearance: none;
|
||||
width: 100%; height: 4px;
|
||||
background: var(--bg-3); border-radius: 2px; outline: none;
|
||||
}
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none; appearance: none;
|
||||
width: 14px; height: 14px; border-radius: 50%;
|
||||
background: var(--accent); cursor: pointer;
|
||||
border: 2px solid var(--bg-2);
|
||||
box-shadow: 0 0 0 1px var(--line-2);
|
||||
}
|
||||
.pipeline { display: flex; gap: 4px; align-items: center; flex-wrap: wrap; margin-top: 6px; }
|
||||
.stage {
|
||||
flex: 1; min-width: 50px;
|
||||
padding: 4px 6px;
|
||||
background: var(--bg-3); border: 1px solid var(--line);
|
||||
border-radius: 6px; font-size: 9.5px; text-align: center;
|
||||
color: var(--ink-2); font-family: var(--mono);
|
||||
}
|
||||
.stage.live { border-color: var(--accent-2); color: var(--accent-2); }
|
||||
.stage-arrow { color: var(--ink-4); font-size: 10px; }
|
||||
`;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
effect(() => { fs.value; fmod.value; dtMs.value; noiseEnabled.value; running.value; this.requestUpdate(); });
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="panel">
|
||||
<div class="panel-h">Scene <span class="count">4 sources</span></div>
|
||||
<div class="panel-help">
|
||||
Magnetic primitives in the simulated environment. Drag any in the
|
||||
canvas to reposition; positions persist across reloads.
|
||||
</div>
|
||||
<div class="scene-item">
|
||||
<span class="swatch" style="background:oklch(0.72 0.18 330)"></span>
|
||||
<span class="name">rebar.steel.coil</span>
|
||||
<span class="meta">χ=5000</span>
|
||||
</div>
|
||||
<div class="scene-item">
|
||||
<span class="swatch" style="background:oklch(0.78 0.14 195)"></span>
|
||||
<span class="name">heart_proxy</span>
|
||||
<span class="meta">1e-6 A·m²</span>
|
||||
</div>
|
||||
<div class="scene-item">
|
||||
<span class="swatch" style="background:oklch(0.72 0.18 330)"></span>
|
||||
<span class="name">mains_60Hz</span>
|
||||
<span class="meta">2 A · 60 Hz</span>
|
||||
</div>
|
||||
<div class="scene-item">
|
||||
<span class="swatch" style="background:oklch(0.78 0.14 145)"></span>
|
||||
<span class="name">door.steel</span>
|
||||
<span class="meta">eddy</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-h">NV sensor <span class="count">COTS</span></div>
|
||||
<div class="panel-help">
|
||||
Element Six DNV-B1 reference: 1 mm³ diamond, ~10¹² NV centers.
|
||||
Floor δB ≈ 1.18 pT/√Hz per Barry 2020 §III.A.
|
||||
<span class="help-link" title="Open glossary"
|
||||
@click=${() => window.dispatchEvent(new CustomEvent('nv-show-help', { detail: { section: 'glossary' } }))}>What's NV?</span>
|
||||
</div>
|
||||
<div class="field-row" title="Sensing volume (cubic millimetres)"><span class="lbl">V</span><span class="val">1 mm³</span></div>
|
||||
<div class="field-row" title="Number of NV centers contributing to readout"><span class="lbl">N</span><span class="val">1e12 NV</span></div>
|
||||
<div class="field-row" title="ODMR contrast — fractional dip at resonance"><span class="lbl">C</span><span class="val">0.030</span></div>
|
||||
<div class="field-row" title="Inhomogeneous dephasing time T₂*"><span class="lbl">T₂*</span><span class="val">200 ns</span></div>
|
||||
<div class="field-row" title="Shot-noise-limited field sensitivity"><span class="lbl">δB</span><span class="val">1.18 pT/√Hz</span></div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-h">Tunables</div>
|
||||
<div class="panel-help">
|
||||
Live pipeline parameters. Edits debounce 300 ms then rebuild the
|
||||
WASM pipeline without restarting the frame stream.
|
||||
</div>
|
||||
<div class="slider-row" title="Digitiser sample rate — frames per second emitted by the pipeline">
|
||||
<div class="top"><span class="lbl">Sample rate</span><span class="val">${(fs.value / 1000).toFixed(1)} kHz</span></div>
|
||||
<input type="range" min="1000" max="100000" .value=${String(fs.value)}
|
||||
aria-label="Sample rate in Hz"
|
||||
@input=${(e: Event) => { fs.value = +(e.target as HTMLInputElement).value; pushConfigDebounced(); }} />
|
||||
</div>
|
||||
<div class="slider-row" title="Microwave modulation frequency for lock-in demodulation">
|
||||
<div class="top"><span class="lbl">Lockin f_mod</span><span class="val">${(fmod.value / 1000).toFixed(3)} kHz</span></div>
|
||||
<input type="range" min="100" max="5000" .value=${String(fmod.value)}
|
||||
aria-label="Lock-in modulation frequency in Hz"
|
||||
@input=${(e: Event) => { fmod.value = +(e.target as HTMLInputElement).value; pushConfigDebounced(); }} />
|
||||
</div>
|
||||
<div class="slider-row" title="Per-sample integration time">
|
||||
<div class="top"><span class="lbl">Integration t</span><span class="val">${dtMs.value.toFixed(1)} ms</span></div>
|
||||
<input type="range" min="0.1" max="10" step="0.1" .value=${String(dtMs.value)}
|
||||
aria-label="Integration time in milliseconds"
|
||||
@input=${(e: Event) => { dtMs.value = +(e.target as HTMLInputElement).value; pushConfigDebounced(); }} />
|
||||
</div>
|
||||
<div class="slider-row" title="Toggle shot-noise sampling. OFF = analytic noise-free output (debug only)">
|
||||
<div class="top"><span class="lbl">Shot noise</span><span class="val">${noiseEnabled.value ? 'ON' : 'OFF'}</span></div>
|
||||
<input type="range" min="0" max="1" .value=${noiseEnabled.value ? '1' : '0'}
|
||||
aria-label="Shot-noise sampling enabled"
|
||||
@input=${(e: Event) => { noiseEnabled.value = (e.target as HTMLInputElement).value === '1'; pushConfigDebounced(); }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-h">Pipeline</div>
|
||||
<div class="panel-help">
|
||||
Forward simulator stages, left to right. Stages glow cyan while
|
||||
the pipeline is running.
|
||||
</div>
|
||||
<div class="pipeline">
|
||||
<span class="stage ${running.value ? 'live' : ''}">scene</span>
|
||||
<span class="stage-arrow">→</span>
|
||||
<span class="stage ${running.value ? 'live' : ''}">B-S</span>
|
||||
<span class="stage-arrow">→</span>
|
||||
<span class="stage ${running.value ? 'live' : ''}">prop</span>
|
||||
<span class="stage-arrow">→</span>
|
||||
<span class="stage ${running.value ? 'live' : ''}">NV</span>
|
||||
<span class="stage-arrow">→</span>
|
||||
<span class="stage ${running.value ? 'live' : ''}">ADC</span>
|
||||
<span class="stage-arrow">→</span>
|
||||
<span class="stage ${running.value ? 'live' : ''}">frame</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
/* Toast notification — shown briefly via window.dispatchEvent('nv-toast', detail). */
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
|
||||
@customElement('nv-toast')
|
||||
export class NvToast extends LitElement {
|
||||
@state() private visible = false;
|
||||
@state() private msg = '';
|
||||
@state() private icon = '✓';
|
||||
private timer: number | null = null;
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: fixed; bottom: 24px; left: 50%;
|
||||
transform: translateX(-50%) translateY(80px);
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 14px;
|
||||
font-size: 12.5px;
|
||||
box-shadow: var(--shadow);
|
||||
z-index: 100;
|
||||
opacity: 0; pointer-events: none;
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
:host([visible]) {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
.icon { color: var(--accent); }
|
||||
`;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('nv-toast', this.onToast as EventListener);
|
||||
}
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener('nv-toast', this.onToast as EventListener);
|
||||
}
|
||||
|
||||
private onToast = (e: Event): void => {
|
||||
const detail = (e as CustomEvent).detail as { msg?: string; icon?: string };
|
||||
this.msg = detail.msg ?? 'Done';
|
||||
this.icon = detail.icon ?? '✓';
|
||||
this.visible = true;
|
||||
this.setAttribute('visible', '');
|
||||
if (this.timer !== null) window.clearTimeout(this.timer);
|
||||
this.timer = window.setTimeout(() => {
|
||||
this.visible = false;
|
||||
this.removeAttribute('visible');
|
||||
}, 1800);
|
||||
};
|
||||
|
||||
override render() {
|
||||
return html`<span class="icon">${this.icon}</span><span>${this.msg}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
export function toast(msg: string, icon = '✓'): void {
|
||||
window.dispatchEvent(new CustomEvent('nv-toast', { detail: { msg, icon } }));
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
/* Topbar — breadcrumbs, transport pill, FPS pill, seed pill, controls. */
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import {
|
||||
fps, transportLabel, seed, theme, sceneName,
|
||||
running, getClient, pushLog,
|
||||
} from '../store/appStore';
|
||||
import { openModal } from './nv-modal';
|
||||
import { toast } from './nv-toast';
|
||||
|
||||
@customElement('nv-topbar')
|
||||
export class NvTopbar extends LitElement {
|
||||
static styles = css`
|
||||
:host {
|
||||
display: flex; align-items: center;
|
||||
padding: 0 16px; gap: 12px;
|
||||
background: var(--bg-1);
|
||||
border-bottom: 1px solid var(--line);
|
||||
z-index: 10;
|
||||
}
|
||||
.crumbs { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--ink-3); }
|
||||
.crumbs .sep { color: var(--ink-4); }
|
||||
.crumbs .cur { color: var(--ink); font-weight: 500; }
|
||||
.spacer { flex: 1; }
|
||||
.pill {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 5px 10px;
|
||||
background: var(--bg-2); border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
font-size: 12px; color: var(--ink-2);
|
||||
font-family: var(--mono); font-weight: 500;
|
||||
}
|
||||
.pill .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 6px var(--ok); animation: pulse 2s infinite; }
|
||||
.pill.wasm .dot { background: var(--accent-2); box-shadow: 0 0 6px var(--accent-2); }
|
||||
.pill.seed { color: var(--ink-3); cursor: pointer; }
|
||||
.pill.seed:hover { border-color: var(--line-2); }
|
||||
.pill.seed b { color: var(--accent); font-weight: 600; }
|
||||
.pill.wasm { cursor: pointer; }
|
||||
.pill.wasm:hover { border-color: var(--line-2); }
|
||||
button {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: var(--bg-2); border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
font-size: 12.5px; font-weight: 500; color: var(--ink);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
button:hover { border-color: var(--line-2); background: var(--bg-3); }
|
||||
button.primary { background: var(--accent); border-color: var(--accent); color: #1a0f00; }
|
||||
button.primary:hover { filter: brightness(1.08); }
|
||||
button.ghost { background: transparent; }
|
||||
`;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
effect(() => { fps.value; transportLabel.value; seed.value; theme.value; sceneName.value; running.value; this.requestUpdate(); });
|
||||
}
|
||||
|
||||
private async toggleRun(): Promise<void> {
|
||||
const c = getClient(); if (!c) return;
|
||||
if (running.value) { await c.pause(); running.value = false; }
|
||||
else { await c.run(); running.value = true; }
|
||||
}
|
||||
private async reset(): Promise<void> {
|
||||
const c = getClient(); if (!c) return;
|
||||
await c.reset();
|
||||
}
|
||||
private toggleTheme(): void {
|
||||
theme.value = theme.value === 'dark' ? 'light' : 'dark';
|
||||
}
|
||||
private async openSeedModal(): Promise<void> {
|
||||
const cur = `0x${seed.value.toString(16).toUpperCase().padStart(8, '0')}`;
|
||||
openModal({
|
||||
title: 'Set seed',
|
||||
body: `<p>Set the 32-bit hex seed for the shot-noise PRNG. Same <code>(scene, config, seed)</code> → byte-identical witness.</p>
|
||||
<label>Hex seed</label>
|
||||
<input type="text" id="seed-input" value="${cur}" autofocus />`,
|
||||
buttons: [
|
||||
{ label: 'Cancel', variant: 'ghost' },
|
||||
{ label: 'Apply', variant: 'primary', onClick: async () => {
|
||||
const inp = document.querySelector('nv-modal')?.shadowRoot?.querySelector<HTMLInputElement>('#seed-input');
|
||||
if (!inp) return;
|
||||
const raw = inp.value.trim().replace(/^0x/i, '');
|
||||
const v = BigInt('0x' + raw);
|
||||
seed.value = v;
|
||||
await getClient()?.setSeed(v);
|
||||
pushLog('ok', `seed → 0x${v.toString(16).toUpperCase()}`);
|
||||
toast(`Seed → 0x${v.toString(16).toUpperCase().slice(0, 8)}`, '⟳');
|
||||
} },
|
||||
],
|
||||
});
|
||||
}
|
||||
private openTransportSettings(): void {
|
||||
window.dispatchEvent(new CustomEvent('open-settings'));
|
||||
}
|
||||
|
||||
override render() {
|
||||
const seedHex = seed.value.toString(16).toUpperCase().padStart(8, '0');
|
||||
return html`
|
||||
<div class="crumbs">
|
||||
<span class="home">RuView</span><span class="sep">/</span>
|
||||
<span>nvsim</span><span class="sep">/</span>
|
||||
<span class="cur" id="scene-name">${sceneName.value}</span>
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
<span class="pill" id="fps-pill">
|
||||
<span class="dot"></span>
|
||||
<span id="fps-val">${fps.value > 0 ? (fps.value / 1000).toFixed(2) + ' kHz' : 'idle'}</span>
|
||||
</span>
|
||||
<span class="pill wasm" id="transport-pill" title="Transport settings"
|
||||
@click=${this.openTransportSettings}>
|
||||
<span class="dot"></span>${transportLabel.value}
|
||||
</span>
|
||||
<span class="pill seed" id="seed-pill" title="Set seed"
|
||||
@click=${this.openSeedModal}>
|
||||
seed: <b>0x${seedHex}</b>
|
||||
</span>
|
||||
<button class="ghost" id="tour-btn" title="Replay the 10-step welcome tour"
|
||||
aria-label="Replay welcome tour"
|
||||
@click=${() => window.dispatchEvent(new CustomEvent('nv-show-tour'))}>
|
||||
★ Tour
|
||||
</button>
|
||||
<button class="ghost" id="help-btn" title="Help (press ? any time)" aria-label="Open help"
|
||||
@click=${() => window.dispatchEvent(new CustomEvent('nv-show-help'))}>
|
||||
?
|
||||
</button>
|
||||
<button class="ghost" id="theme-btn" title="Toggle theme" aria-label="Toggle theme"
|
||||
@click=${this.toggleTheme}>
|
||||
${theme.value === 'dark' ? '☼' : '☾'}
|
||||
</button>
|
||||
<button id="reset-btn" @click=${this.reset}>↺ Reset</button>
|
||||
<button class="primary" id="run-btn" @click=${this.toggleRun}>
|
||||
${running.value ? '❚❚ Pause' : '▶ Run'}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
/* nvsim dashboard entry — boots the WasmClient, mounts <nv-app>. */
|
||||
import './app.css';
|
||||
import './components/nv-app';
|
||||
import { effect } from '@preact/signals-core';
|
||||
|
||||
import { WasmClient } from './transport/WasmClient';
|
||||
import { WsClient } from './transport/WsClient';
|
||||
import type { NvsimClient, MagFrameBatch } from './transport/NvsimClient';
|
||||
import {
|
||||
setClient, transport, wsUrl, connected, transportError,
|
||||
theme, density, motionReduced,
|
||||
pushLog, expectedWitness, framesEmitted, fps, lastB, bMag,
|
||||
pushTrace, pushStripBar, lastFrame, sceneJson, witnessHex,
|
||||
replHistory, scenePositions, type SceneItemPos,
|
||||
activeAppIds, pushAppEvent,
|
||||
} from './store/appStore';
|
||||
import { APP_RUNTIMES, type AppRuntimeContext } from './store/appRuntimes';
|
||||
import { kvGet, kvSet } from './store/persistence';
|
||||
|
||||
function applyTheme(t: string): void {
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
}
|
||||
function applyDensity(d: string): void {
|
||||
document.body.classList.remove('density-comfy', 'density-default', 'density-compact');
|
||||
document.body.classList.add(`density-${d}`);
|
||||
}
|
||||
function applyMotion(reduced: boolean): void {
|
||||
document.body.classList.toggle('reduce-motion', reduced);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
// Restore persisted prefs
|
||||
const t = (await kvGet<'dark' | 'light'>('theme')) ?? 'dark';
|
||||
const d = (await kvGet<'comfy' | 'default' | 'compact'>('density')) ?? 'default';
|
||||
const sysMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
|
||||
const m = (await kvGet<boolean>('motionReduced')) ?? sysMotion;
|
||||
theme.value = t; applyTheme(t);
|
||||
density.value = d; applyDensity(d);
|
||||
motionReduced.value = m; applyMotion(m);
|
||||
|
||||
// React to changes → persist
|
||||
effect(() => { applyTheme(theme.value); kvSet('theme', theme.value); });
|
||||
effect(() => { applyDensity(density.value); kvSet('density', density.value); });
|
||||
effect(() => { applyMotion(motionReduced.value); kvSet('motionReduced', motionReduced.value); });
|
||||
|
||||
// REPL history + scene drag positions persistence (P0.10, P1.7)
|
||||
const histSaved = await kvGet<string[]>('repl-history');
|
||||
if (histSaved && Array.isArray(histSaved)) replHistory.value = histSaved;
|
||||
effect(() => { void kvSet('repl-history', replHistory.value); });
|
||||
const positionsSaved = await kvGet<SceneItemPos[]>('scene-positions');
|
||||
if (positionsSaved && Array.isArray(positionsSaved)) scenePositions.value = positionsSaved;
|
||||
effect(() => { void kvSet('scene-positions', scenePositions.value); });
|
||||
|
||||
// Restore WS URL preference + transport mode
|
||||
const savedWsUrl = (await kvGet<string>('wsUrl')) ?? '';
|
||||
if (savedWsUrl) wsUrl.value = savedWsUrl;
|
||||
const savedTransport = (await kvGet<'wasm' | 'ws'>('transport')) ?? 'wasm';
|
||||
transport.value = savedTransport;
|
||||
effect(() => { void kvSet('wsUrl', wsUrl.value); });
|
||||
effect(() => { void kvSet('transport', transport.value); });
|
||||
|
||||
// Per-app runtime scratch state + history buffer (defined first so the
|
||||
// onFrames callback can close over them).
|
||||
const appState: Record<string, Record<string, number>> = {};
|
||||
const bMagHistory: number[] = [];
|
||||
const runtimeStartTs = performance.now();
|
||||
|
||||
const onFrames = (batch: MagFrameBatch): void => {
|
||||
if (batch.frames.length === 0) return;
|
||||
const last = batch.frames[batch.frames.length - 1];
|
||||
lastFrame.value = last;
|
||||
const bx = last.bPt[0] * 1e-12;
|
||||
const by = last.bPt[1] * 1e-12;
|
||||
const bz = last.bPt[2] * 1e-12;
|
||||
lastB.value = [bx, by, bz];
|
||||
const bmagT = Math.sqrt(bx * bx + by * by + bz * bz);
|
||||
bMag.value = bmagT;
|
||||
pushTrace([bx * 1e9, by * 1e9, bz * 1e9]);
|
||||
pushStripBar(Math.min(1, Math.abs(bz * 1e9) / 5 + 0.3));
|
||||
bMagHistory.push(bmagT);
|
||||
while (bMagHistory.length > 256) bMagHistory.shift();
|
||||
|
||||
const activeIds = activeAppIds.value;
|
||||
if (activeIds.size === 0) return;
|
||||
const elapsedS = (performance.now() - runtimeStartTs) / 1000;
|
||||
for (const id of activeIds) {
|
||||
const fn = APP_RUNTIMES[id];
|
||||
if (!fn) continue;
|
||||
if (!appState[id]) appState[id] = {};
|
||||
const ctx: AppRuntimeContext = {
|
||||
frame: last,
|
||||
bMagT: bmagT,
|
||||
bRecoveredT: [bx, by, bz],
|
||||
bHistory: bMagHistory,
|
||||
elapsedS,
|
||||
state: appState[id],
|
||||
};
|
||||
try {
|
||||
const result = fn(ctx);
|
||||
if (!result) continue;
|
||||
const evs = Array.isArray(result) ? result : [result];
|
||||
for (const ev of evs) {
|
||||
pushAppEvent(ev);
|
||||
pushLog('info',
|
||||
`<span class="k">[${ev.appId}]</span> <span class="s">${ev.eventName}</span> <span class="n">(${ev.eventId})</span>${ev.detail ? ' · ' + ev.detail : ''}`);
|
||||
}
|
||||
} catch (e) {
|
||||
pushLog('warn', `[${id}] runtime error: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Boot transport (WASM by default, WS if user previously selected it)
|
||||
let activeClient: NvsimClient | null = null;
|
||||
async function bootTransport(): Promise<void> {
|
||||
try {
|
||||
if (activeClient) await activeClient.close();
|
||||
const want = transport.value;
|
||||
if (want === 'ws' && wsUrl.value.trim()) {
|
||||
const c = new WsClient(wsUrl.value.trim());
|
||||
const info = await c.boot();
|
||||
activeClient = c;
|
||||
connected.value = true;
|
||||
transportError.value = null;
|
||||
expectedWitness.value = info.expectedWitnessHex;
|
||||
wireClient(c);
|
||||
pushLog('ok', `transport WS · ${wsUrl.value} · nvsim@${info.buildVersion}`);
|
||||
} else {
|
||||
if (want === 'ws') {
|
||||
pushLog('warn', 'WS transport selected but no URL set — falling back to WASM');
|
||||
}
|
||||
const c = new WasmClient();
|
||||
const info = await c.boot();
|
||||
activeClient = c;
|
||||
connected.value = true;
|
||||
transportError.value = null;
|
||||
expectedWitness.value = info.expectedWitnessHex;
|
||||
wireClient(c);
|
||||
pushLog('ok', `transport WASM · nvsim@${info.buildVersion} · magic=0x${info.frameMagic.toString(16).toUpperCase()}`);
|
||||
}
|
||||
setClient(activeClient);
|
||||
} catch (e) {
|
||||
const msg = (e as Error).message;
|
||||
transportError.value = msg;
|
||||
connected.value = false;
|
||||
pushLog('err', `transport boot failed: ${msg}`);
|
||||
}
|
||||
}
|
||||
function wireClient(c: NvsimClient): void {
|
||||
c.onEvent((ev) => {
|
||||
if (ev.type === 'log') pushLog(ev.level, ev.msg);
|
||||
if (ev.type === 'fps') fps.value = ev.value;
|
||||
if (ev.type === 'state') framesEmitted.value = BigInt(ev.framesEmitted);
|
||||
});
|
||||
c.onFrames(onFrames);
|
||||
}
|
||||
|
||||
// React to transport-mode flips: tear down + re-boot.
|
||||
let bootInProgress = false;
|
||||
effect(() => {
|
||||
transport.value; wsUrl.value;
|
||||
if (bootInProgress) return;
|
||||
bootInProgress = true;
|
||||
void bootTransport().finally(() => { bootInProgress = false; });
|
||||
});
|
||||
|
||||
pushLog('info', 'nvsim — booting transport');
|
||||
|
||||
// Initial boot — handled by the effect() above.
|
||||
// Auto-verify witness whenever a fresh transport boot completes.
|
||||
let verifiedFor: string | null = null;
|
||||
effect(() => {
|
||||
const exp = expectedWitness.value;
|
||||
const isConn = connected.value;
|
||||
if (!exp || !isConn) return;
|
||||
if (verifiedFor === exp) return;
|
||||
verifiedFor = exp;
|
||||
void (async () => {
|
||||
const c = activeClient;
|
||||
if (!c) return;
|
||||
try {
|
||||
const expBytes = new Uint8Array(32);
|
||||
for (let i = 0; i < 32; i++) expBytes[i] = parseInt(exp.slice(i * 2, i * 2 + 2), 16);
|
||||
const r = await c.verifyWitness(expBytes);
|
||||
if (r.ok) {
|
||||
witnessHex.value = exp;
|
||||
pushLog('ok', `witness verified · determinism gate ✓ · transport=${transport.value}`);
|
||||
} else {
|
||||
const actual = Array.from(r.actual).map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
witnessHex.value = actual;
|
||||
pushLog('err', `WITNESS MISMATCH · expected ${exp.slice(0, 16)}… got ${actual.slice(0, 16)}…`);
|
||||
}
|
||||
} catch (e) {
|
||||
pushLog('warn', `witness verify skipped: ${(e as Error).message}`);
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
sceneJson.value = '(reference scene)';
|
||||
})();
|
||||
@@ -1,236 +0,0 @@
|
||||
/* In-browser simulated runtimes for App Store apps.
|
||||
*
|
||||
* Each runtime takes the most recent nvsim MagFrame + a short rolling
|
||||
* history and decides whether to emit one or more app events. Outputs are
|
||||
* illustrative: nvsim produces magnetic-field samples, the wasm-edge
|
||||
* algorithms expect WiFi CSI subcarriers — different physical modalities.
|
||||
* The simulated runtime preserves *event-emission semantics* (the same
|
||||
* i32 event IDs, the same trigger logic shape) so users can see the
|
||||
* cards working without an ESP32 mesh.
|
||||
*
|
||||
* For engineering-grade output, deploy the real `wifi-densepose-wasm-edge`
|
||||
* crate to ESP32 firmware over the WS transport — see ADR-040 / ADR-092 §6.2.
|
||||
*/
|
||||
|
||||
import type { MagFrameRecord } from '../transport/NvsimClient';
|
||||
|
||||
export interface AppEvent {
|
||||
/** Wall-clock timestamp (ms). */
|
||||
ts: number;
|
||||
/** App id that emitted. */
|
||||
appId: string;
|
||||
/** i32 event id from `event_types` mod in wifi-densepose-wasm-edge. */
|
||||
eventId: number;
|
||||
/** Human-readable event name (matches the constant name). */
|
||||
eventName: string;
|
||||
/** Numeric value the app reports (units app-specific). */
|
||||
value: number;
|
||||
/** Optional extra context for the console line. */
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface AppRuntimeContext {
|
||||
frame: MagFrameRecord;
|
||||
bMagT: number;
|
||||
bRecoveredT: [number, number, number];
|
||||
/** Rolling history of |B| in T. Most recent last. */
|
||||
bHistory: number[];
|
||||
/** Time since the runtime was activated (s). */
|
||||
elapsedS: number;
|
||||
/** Per-app scratch state — runtimes can persist counters here. */
|
||||
state: Record<string, number>;
|
||||
}
|
||||
|
||||
export type AppRuntimeFn = (ctx: AppRuntimeContext) => AppEvent | AppEvent[] | null;
|
||||
|
||||
/** Welford-style running-stat helper. */
|
||||
function rollingMean(arr: number[]): number {
|
||||
if (arr.length === 0) return 0;
|
||||
let s = 0;
|
||||
for (const v of arr) s += v;
|
||||
return s / arr.length;
|
||||
}
|
||||
function rollingStd(arr: number[]): number {
|
||||
if (arr.length < 2) return 0;
|
||||
const m = rollingMean(arr);
|
||||
let s = 0;
|
||||
for (const v of arr) s += (v - m) * (v - m);
|
||||
return Math.sqrt(s / (arr.length - 1));
|
||||
}
|
||||
|
||||
/** vital_trend — periodic 1-Hz HR/BR estimate from the B_z oscillation. */
|
||||
const vitalTrend: AppRuntimeFn = (ctx) => {
|
||||
if (ctx.bHistory.length < 64) return null;
|
||||
const last = ctx.state['lastEmitS'] ?? 0;
|
||||
if (ctx.elapsedS - last < 1.0) return null;
|
||||
ctx.state['lastEmitS'] = ctx.elapsedS;
|
||||
|
||||
// Crude HR estimate: count zero-crossings of detrended B_z over the last
|
||||
// 64 samples; treat each crossing pair as one cardiac cycle.
|
||||
const tail = ctx.bHistory.slice(-64);
|
||||
const m = rollingMean(tail);
|
||||
let crossings = 0;
|
||||
for (let i = 1; i < tail.length; i++) {
|
||||
if ((tail[i] - m) * (tail[i - 1] - m) < 0) crossings++;
|
||||
}
|
||||
// 64 samples ≈ 0.65 s at the worker's 32-frame batches × 16 ms tick.
|
||||
const cycles = crossings / 2;
|
||||
const hr = Math.max(40, Math.min(180, Math.round((cycles / 0.65) * 60)));
|
||||
const br = Math.max(8, Math.min(30, Math.round(hr / 4))); // crude proxy
|
||||
|
||||
const evs: AppEvent[] = [
|
||||
{ ts: Date.now(), appId: 'vital_trend', eventId: 100, eventName: 'VITAL_TREND', value: hr, detail: `HR≈${hr} BPM, BR≈${br} br/min` },
|
||||
];
|
||||
if (hr < 60) evs.push({ ts: Date.now(), appId: 'vital_trend', eventId: 103, eventName: 'BRADYCARDIA', value: hr, detail: `HR=${hr} BPM` });
|
||||
else if (hr > 100) evs.push({ ts: Date.now(), appId: 'vital_trend', eventId: 104, eventName: 'TACHYCARDIA', value: hr, detail: `HR=${hr} BPM` });
|
||||
if (br < 12) evs.push({ ts: Date.now(), appId: 'vital_trend', eventId: 101, eventName: 'BRADYPNEA', value: br, detail: `BR=${br} br/min` });
|
||||
else if (br > 24) evs.push({ ts: Date.now(), appId: 'vital_trend', eventId: 102, eventName: 'TACHYPNEA', value: br, detail: `BR=${br} br/min` });
|
||||
return evs;
|
||||
};
|
||||
|
||||
/** occupancy — variance threshold on |B| over a 5-second window. */
|
||||
const occupancy: AppRuntimeFn = (ctx) => {
|
||||
if (ctx.bHistory.length < 32) return null;
|
||||
const last = ctx.state['lastEmitS'] ?? 0;
|
||||
if (ctx.elapsedS - last < 2.0) return null;
|
||||
const std = rollingStd(ctx.bHistory.slice(-128)) * 1e9; // T → nT
|
||||
const occupied = std > 0.01; // empirical threshold for the demo
|
||||
const wasOccupied = (ctx.state['occ'] ?? 0) > 0.5;
|
||||
if (occupied !== wasOccupied) {
|
||||
ctx.state['occ'] = occupied ? 1 : 0;
|
||||
ctx.state['lastEmitS'] = ctx.elapsedS;
|
||||
return {
|
||||
ts: Date.now(),
|
||||
appId: 'occupancy',
|
||||
eventId: occupied ? 300 : 302,
|
||||
eventName: occupied ? 'ZONE_OCCUPIED' : 'ZONE_TRANSITION',
|
||||
value: std,
|
||||
detail: occupied ? `σ(|B|)=${std.toFixed(3)} nT — entered` : `σ(|B|)=${std.toFixed(3)} nT — left`,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/** intrusion — |B| above ambient + dwell timer. */
|
||||
const intrusion: AppRuntimeFn = (ctx) => {
|
||||
const ambient = ctx.state['ambient'] ?? ctx.bMagT;
|
||||
ctx.state['ambient'] = 0.95 * ambient + 0.05 * ctx.bMagT;
|
||||
const exceeds = ctx.bMagT > ambient * 1.5 && ctx.bMagT > 1e-12;
|
||||
const dwellStart = ctx.state['dwellStart'] ?? 0;
|
||||
if (exceeds && dwellStart === 0) {
|
||||
ctx.state['dwellStart'] = ctx.elapsedS;
|
||||
} else if (!exceeds) {
|
||||
ctx.state['dwellStart'] = 0;
|
||||
}
|
||||
if (exceeds && dwellStart > 0 && ctx.elapsedS - dwellStart > 0.5 && (ctx.state['lastEmitS'] ?? 0) < dwellStart) {
|
||||
ctx.state['lastEmitS'] = ctx.elapsedS;
|
||||
return {
|
||||
ts: Date.now(),
|
||||
appId: 'intrusion',
|
||||
eventId: 200,
|
||||
eventName: 'INTRUSION_ALERT',
|
||||
value: ctx.bMagT * 1e9,
|
||||
detail: `|B|=${(ctx.bMagT * 1e9).toFixed(2)} nT > 1.5× ambient (${(ambient * 1e9).toFixed(2)} nT) for ${(ctx.elapsedS - dwellStart).toFixed(1)} s`,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/** coherence — z-score of recent |B| against a longer baseline. */
|
||||
const coherence: AppRuntimeFn = (ctx) => {
|
||||
if (ctx.bHistory.length < 64) return null;
|
||||
const last = ctx.state['lastEmitS'] ?? 0;
|
||||
if (ctx.elapsedS - last < 0.5) return null;
|
||||
ctx.state['lastEmitS'] = ctx.elapsedS;
|
||||
|
||||
const recent = ctx.bHistory.slice(-32);
|
||||
const baseline = ctx.bHistory.slice(-128, -32);
|
||||
if (baseline.length < 32) return null;
|
||||
const mu = rollingMean(baseline);
|
||||
const sd = rollingStd(baseline);
|
||||
if (sd === 0) return null;
|
||||
const recentMean = rollingMean(recent);
|
||||
const z = Math.abs(recentMean - mu) / sd;
|
||||
return {
|
||||
ts: Date.now(),
|
||||
appId: 'coherence',
|
||||
eventId: 2,
|
||||
eventName: 'COHERENCE_SCORE',
|
||||
value: z,
|
||||
detail: `z=${z.toFixed(2)} σ ${z > 3 ? '· DRIFT' : z > 1.5 ? '· marginal' : '· stable'}`,
|
||||
};
|
||||
};
|
||||
|
||||
/** adversarial — detect physically-impossible 1/r³ violation. */
|
||||
const adversarial: AppRuntimeFn = (ctx) => {
|
||||
if (ctx.bHistory.length < 32) return null;
|
||||
const last = ctx.state['lastEmitS'] ?? 0;
|
||||
if (ctx.elapsedS - last < 3.0) return null;
|
||||
|
||||
// Fake "multi-link consistency": compare instantaneous |B| with the
|
||||
// smoothed |B|. A sharp factor-of-N step violates dipole physics
|
||||
// (real 1/r³ source moves continuously).
|
||||
const tail = ctx.bHistory.slice(-32);
|
||||
let maxJump = 0;
|
||||
for (let i = 1; i < tail.length; i++) {
|
||||
const j = Math.abs(Math.log(Math.max(tail[i], 1e-15)) - Math.log(Math.max(tail[i - 1], 1e-15)));
|
||||
if (j > maxJump) maxJump = j;
|
||||
}
|
||||
if (maxJump > 5) {
|
||||
ctx.state['lastEmitS'] = ctx.elapsedS;
|
||||
return {
|
||||
ts: Date.now(),
|
||||
appId: 'adversarial',
|
||||
eventId: 3,
|
||||
eventName: 'ANOMALY_DETECTED',
|
||||
value: maxJump,
|
||||
detail: `log-jump ${maxJump.toFixed(1)} — physically implausible step in |B|`,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/** exo_ghost_hunter — empty-room CSI anomaly detector adapted to the
|
||||
* magnetic noise floor: flag impulsive / periodic / drift / random
|
||||
* patterns and a hidden-presence sub-detector at 0.15-0.5 Hz. */
|
||||
const exoGhostHunter: AppRuntimeFn = (ctx) => {
|
||||
if (ctx.bHistory.length < 128) return null;
|
||||
const last = ctx.state['lastEmitS'] ?? 0;
|
||||
if (ctx.elapsedS - last < 4.0) return null;
|
||||
ctx.state['lastEmitS'] = ctx.elapsedS;
|
||||
|
||||
const tail = ctx.bHistory.slice(-128);
|
||||
const std = rollingStd(tail) * 1e9;
|
||||
// Detect impulsive: max - mean > 4σ
|
||||
const m = rollingMean(tail);
|
||||
let maxDev = 0;
|
||||
for (const v of tail) {
|
||||
const d = Math.abs(v - m);
|
||||
if (d > maxDev) maxDev = d;
|
||||
}
|
||||
const cls: 1 | 3 | 4 = maxDev > 4 * (std * 1e-9) ? 1 // impulsive
|
||||
: ctx.elapsedS > 10 ? 3 // drift bias as a default after warmup
|
||||
: 4; // random
|
||||
const clsName = cls === 1 ? 'impulsive' : cls === 3 ? 'drift' : 'random';
|
||||
return {
|
||||
ts: Date.now(),
|
||||
appId: 'exo_ghost_hunter',
|
||||
eventId: 651,
|
||||
eventName: 'ANOMALY_CLASS',
|
||||
value: cls,
|
||||
detail: `class=${clsName} · σ=${std.toFixed(3)} nT`,
|
||||
};
|
||||
};
|
||||
|
||||
export const APP_RUNTIMES: Record<string, AppRuntimeFn> = {
|
||||
vital_trend: vitalTrend,
|
||||
occupancy,
|
||||
intrusion,
|
||||
coherence,
|
||||
adversarial,
|
||||
exo_ghost_hunter: exoGhostHunter,
|
||||
};
|
||||
|
||||
export function hasRuntime(appId: string): boolean {
|
||||
return appId in APP_RUNTIMES;
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
/* Application-wide reactive state.
|
||||
*
|
||||
* One signal per logical observable; components subscribe to only the
|
||||
* signals they read. Keeps re-renders surgical even at 1 kHz frame rates.
|
||||
* Persistence lives in `persistence.ts`; this module is pure state.
|
||||
*/
|
||||
import { signal, computed } from '@preact/signals-core';
|
||||
import type { NvsimClient, MagFrameRecord, NvsimEvent } from '../transport/NvsimClient';
|
||||
|
||||
export type Theme = 'dark' | 'light';
|
||||
export type Density = 'comfy' | 'default' | 'compact';
|
||||
export type TransportMode = 'wasm' | 'ws';
|
||||
|
||||
export const transport = signal<TransportMode>('wasm');
|
||||
export const wsUrl = signal<string>('');
|
||||
export const connected = signal<boolean>(false);
|
||||
export const transportError = signal<string | null>(null);
|
||||
|
||||
export const running = signal<boolean>(false);
|
||||
export const paused = signal<boolean>(true);
|
||||
export const speed = signal<number>(1.0);
|
||||
export const t = signal<number>(0); // sim time (s)
|
||||
export const framesEmitted = signal<bigint>(0n);
|
||||
|
||||
export const seed = signal<bigint>(0xCAFEBABEn);
|
||||
|
||||
export const fs = signal<number>(10000); // sample rate Hz
|
||||
export const fmod = signal<number>(1000); // lockin Hz
|
||||
export const dtMs = signal<number>(1.0);
|
||||
export const noiseEnabled = signal<boolean>(true);
|
||||
|
||||
export const theme = signal<Theme>('dark');
|
||||
export const density = signal<Density>('default');
|
||||
export const motionReduced = signal<boolean>(false);
|
||||
export const autoUpdate = signal<boolean>(true);
|
||||
|
||||
export const lastB = signal<[number, number, number]>([0, 0, 0]); // T
|
||||
export const bMag = signal<number>(0);
|
||||
export const snr = signal<number>(0);
|
||||
export const fps = signal<number>(0);
|
||||
|
||||
export const witnessHex = signal<string>('');
|
||||
export const witnessVerified = signal<'pending' | 'ok' | 'fail' | 'idle'>('idle');
|
||||
export const expectedWitness = signal<string>('');
|
||||
|
||||
export const lastFrame = signal<MagFrameRecord | null>(null);
|
||||
export const traceX = signal<number[]>([]);
|
||||
export const traceY = signal<number[]>([]);
|
||||
export const traceZ = signal<number[]>([]);
|
||||
export const stripBars = signal<number[]>([]);
|
||||
|
||||
export const sceneName = signal<string>('rebar-walkby-01');
|
||||
export const sceneJson = signal<string>('');
|
||||
|
||||
export const consolePaused = signal<boolean>(false);
|
||||
export const consoleFilter = signal<'all' | 'info' | 'warn' | 'err' | 'dbg' | 'ok'>('all');
|
||||
|
||||
/** REPL command history, persisted via persistence.ts (kvSet 'repl-history'). */
|
||||
export const replHistory = signal<string[]>([]);
|
||||
export function pushReplHistory(cmd: string): void {
|
||||
const next = replHistory.value.slice();
|
||||
next.push(cmd);
|
||||
while (next.length > 200) next.shift();
|
||||
replHistory.value = next;
|
||||
}
|
||||
|
||||
/** Scene drag positions, persisted via persistence.ts (kvSet 'scene-positions'). */
|
||||
export interface SceneItemPos { id: string; x: number; y: number }
|
||||
export const scenePositions = signal<SceneItemPos[]>([]);
|
||||
|
||||
/** App-runtime emitted events. See appRuntimes.ts. */
|
||||
import type { AppEvent } from './appRuntimes';
|
||||
export const appEvents = signal<AppEvent[]>([]);
|
||||
export const appEventCounts = signal<Record<string, number>>({});
|
||||
|
||||
export function pushAppEvent(ev: AppEvent): void {
|
||||
const next = appEvents.value.slice();
|
||||
next.push(ev);
|
||||
while (next.length > 200) next.shift();
|
||||
appEvents.value = next;
|
||||
|
||||
const c = { ...appEventCounts.value };
|
||||
c[ev.appId] = (c[ev.appId] ?? 0) + 1;
|
||||
appEventCounts.value = c;
|
||||
}
|
||||
|
||||
/** Active app activations — driven by the App Store toggles. Mirrored
|
||||
* from `apps.ts` but exposed as a signal here so `main.ts` can dispatch
|
||||
* frames to active runtimes without importing the App Store component. */
|
||||
export const activeAppIds = signal<Set<string>>(new Set());
|
||||
|
||||
export const transportLabel = computed<string>(() =>
|
||||
transport.value === 'wasm' ? 'wasm' : 'ws',
|
||||
);
|
||||
|
||||
let _client: NvsimClient | null = null;
|
||||
export function setClient(c: NvsimClient): void { _client = c; }
|
||||
export function getClient(): NvsimClient | null { return _client; }
|
||||
|
||||
export interface ConsoleLine {
|
||||
ts: number;
|
||||
level: 'info' | 'warn' | 'err' | 'dbg' | 'ok';
|
||||
msg: string;
|
||||
}
|
||||
export const consoleLines = signal<ConsoleLine[]>([]);
|
||||
const MAX_LINES = 200;
|
||||
|
||||
export function pushLog(level: ConsoleLine['level'], msg: string): void {
|
||||
if (consolePaused.value) return;
|
||||
const next = consoleLines.value.slice();
|
||||
next.push({ ts: Date.now(), level, msg });
|
||||
while (next.length > MAX_LINES) next.shift();
|
||||
consoleLines.value = next;
|
||||
}
|
||||
|
||||
export function pushTrace(b: [number, number, number]): void {
|
||||
const cap = 200;
|
||||
const x = traceX.value.slice(); x.push(b[0]); if (x.length > cap) x.shift();
|
||||
const y = traceY.value.slice(); y.push(b[1]); if (y.length > cap) y.shift();
|
||||
const z = traceZ.value.slice(); z.push(b[2]); if (z.length > cap) z.shift();
|
||||
traceX.value = x;
|
||||
traceY.value = y;
|
||||
traceZ.value = z;
|
||||
}
|
||||
|
||||
export function pushStripBar(amp: number): void {
|
||||
const cap = 48;
|
||||
const next = stripBars.value.slice();
|
||||
next.push(Math.max(0, Math.min(1, amp)));
|
||||
while (next.length > cap) next.shift();
|
||||
stripBars.value = next;
|
||||
}
|
||||
|
||||
export function recordEvent(_ev: NvsimEvent): void {
|
||||
// future: route NvsimEvent into store updates per type. For V1 the
|
||||
// worker pushes B-vector / frame data directly via the data plane.
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
/* RuView Edge App Store registry.
|
||||
*
|
||||
* Catalog of every WASM edge module shipping in the workspace plus the
|
||||
* `nvsim` simulator itself. Each entry maps to a hot-loadable algorithm
|
||||
* the dashboard can run in-browser (WASM transport) or push to a real
|
||||
* ESP32-S3 mesh (WS transport, deployed via WASM3 — ADR-040 Tier 3).
|
||||
*
|
||||
* Categories (ADR-041 event-ID ranges):
|
||||
* med 100–199 Medical & health
|
||||
* sec 200–299 Security & safety
|
||||
* bld 300–399 Smart building
|
||||
* ret 400–499 Retail & hospitality
|
||||
* ind 500–599 Industrial
|
||||
* sig 600–619 Signal-processing primitives
|
||||
* lrn 620–639 Online learning
|
||||
* spt 640–659 Spatial / graph
|
||||
* tmp 640–660 Temporal logic / planning
|
||||
* ais 700–719 AI safety
|
||||
* qnt 720–739 Quantum-flavoured signal
|
||||
* aut 740–759 Autonomy / mesh
|
||||
* exo 650–699 Exotic / research
|
||||
* sim — Pipeline simulators (nvsim)
|
||||
*
|
||||
* The `crate` field names the Cargo crate that owns the implementation.
|
||||
* `wasmEdge` apps are compiled out of `wifi-densepose-wasm-edge`;
|
||||
* `nvsim` apps come from `nvsim`. Future apps may target other crates.
|
||||
*/
|
||||
|
||||
export type AppCategory =
|
||||
| 'sim'
|
||||
| 'med'
|
||||
| 'sec'
|
||||
| 'bld'
|
||||
| 'ret'
|
||||
| 'ind'
|
||||
| 'sig'
|
||||
| 'lrn'
|
||||
| 'spt'
|
||||
| 'tmp'
|
||||
| 'ais'
|
||||
| 'qnt'
|
||||
| 'aut'
|
||||
| 'exo';
|
||||
|
||||
/** What actually happens when a card's toggle is on.
|
||||
* - `running` — the algorithm is genuinely running in the browser right now
|
||||
* (e.g. `nvsim` itself, which is the simulator the dashboard fronts).
|
||||
* - `simulated` — a pared-down version of the algorithm runs against nvsim's
|
||||
* live magnetic frame stream as a *proxy* for its native CSI input.
|
||||
* Emits real i32 event IDs into the console feed; output is illustrative,
|
||||
* not engineering-grade. Listed apps' Rust source is real, builds for
|
||||
* wasm32-unknown-unknown, and passes its native unit tests.
|
||||
* - `mesh-only` — algorithm needs CSI subcarrier data from a real ESP32-S3
|
||||
* mesh (or a future CSI simulator). Toggling persists the selection so
|
||||
* the WS transport can push activation when connected. */
|
||||
export type AppRuntime = 'running' | 'simulated' | 'mesh-only';
|
||||
|
||||
export interface AppManifest {
|
||||
/** Stable kebab-case id; matches the wasm-edge module name (e.g. `med_sleep_apnea`). */
|
||||
id: string;
|
||||
/** Human-readable name. */
|
||||
name: string;
|
||||
/** Category short-code. */
|
||||
category: AppCategory;
|
||||
/** Cargo crate the implementation lives in. */
|
||||
crate: 'nvsim' | 'wifi-densepose-wasm-edge' | string;
|
||||
/** One-liner description. */
|
||||
summary: string;
|
||||
/** Optional longer markdown body. */
|
||||
body?: string;
|
||||
/** Numeric event IDs this app emits (i32 codes from `event_types` mod). */
|
||||
events?: number[];
|
||||
/** Compute budget tier the module advertises. S=<5ms, M=<15ms, L=<50ms. */
|
||||
budget?: 'S' | 'M' | 'L';
|
||||
/** Default activation state when listed. */
|
||||
active?: boolean;
|
||||
/** Tags for fuzzy search and filtering. */
|
||||
tags?: string[];
|
||||
/** "Available", "Beta", or "Research" maturity. */
|
||||
status: 'available' | 'beta' | 'research';
|
||||
/** ADR back-reference. */
|
||||
adr?: string;
|
||||
/** What actually happens when active — see AppRuntime docs. */
|
||||
runtime?: AppRuntime;
|
||||
}
|
||||
|
||||
export const APPS: AppManifest[] = [
|
||||
// ── Pipeline simulators ──────────────────────────────────────────────────
|
||||
{
|
||||
id: 'nvsim',
|
||||
name: 'nvsim — NV-diamond magnetometer',
|
||||
category: 'sim',
|
||||
crate: 'nvsim',
|
||||
summary:
|
||||
'Deterministic forward simulator: scene → Biot–Savart → NV ensemble → ADC → MagFrame stream + SHA-256 witness.',
|
||||
budget: 'L',
|
||||
active: true,
|
||||
status: 'available',
|
||||
tags: ['quantum', 'magnetometer', 'simulator', 'witness', 'wasm'],
|
||||
adr: 'ADR-089',
|
||||
runtime: 'running',
|
||||
},
|
||||
|
||||
// ── Core sensing primitives (ADR-014/040 flagship modules) ───────────────
|
||||
{
|
||||
id: 'gesture',
|
||||
name: 'Gesture (DTW)',
|
||||
category: 'sig',
|
||||
crate: 'wifi-densepose-wasm-edge',
|
||||
summary: 'Dynamic-Time-Warping gesture classifier from CSI motion templates.',
|
||||
events: [1],
|
||||
budget: 'M',
|
||||
status: 'available',
|
||||
tags: ['hci', 'csi', 'classifier', 'dtw'],
|
||||
adr: 'ADR-014',
|
||||
runtime: 'mesh-only',
|
||||
},
|
||||
{
|
||||
id: 'coherence',
|
||||
name: 'Coherence gate',
|
||||
category: 'sig',
|
||||
crate: 'wifi-densepose-wasm-edge',
|
||||
summary: 'Z-score coherence scoring + Accept/PredictOnly/Reject/Recalibrate gate.',
|
||||
events: [2],
|
||||
budget: 'S',
|
||||
status: 'available',
|
||||
tags: ['gate', 'csi', 'coherence', 'drift'],
|
||||
adr: 'ADR-029',
|
||||
runtime: 'simulated',
|
||||
},
|
||||
{
|
||||
id: 'adversarial',
|
||||
name: 'Adversarial-signal detector',
|
||||
category: 'ais',
|
||||
crate: 'wifi-densepose-wasm-edge',
|
||||
summary:
|
||||
'Physically-impossible-signal detector — multi-link consistency, used to flag spoofed CSI.',
|
||||
events: [3],
|
||||
budget: 'M',
|
||||
status: 'available',
|
||||
tags: ['security', 'csi', 'spoofing', 'mesh'],
|
||||
adr: 'ADR-032',
|
||||
runtime: 'simulated',
|
||||
},
|
||||
{
|
||||
id: 'rvf',
|
||||
name: 'RVF — Rust Verified Feature stream',
|
||||
category: 'sig',
|
||||
crate: 'wifi-densepose-wasm-edge',
|
||||
summary: 'Verified-frame builder with SHA-256 hash + version metadata for the feature stream.',
|
||||
budget: 'S',
|
||||
status: 'available',
|
||||
tags: ['witness', 'csi', 'hash'],
|
||||
adr: 'ADR-040',
|
||||
},
|
||||
{
|
||||
id: 'occupancy',
|
||||
name: 'Occupancy estimator',
|
||||
category: 'bld',
|
||||
crate: 'wifi-densepose-wasm-edge',
|
||||
summary: 'Through-wall presence + person-count via CSI amplitude perturbation.',
|
||||
events: [300, 301, 302],
|
||||
budget: 'S',
|
||||
status: 'available',
|
||||
tags: ['csi', 'building', 'presence'],
|
||||
runtime: 'simulated',
|
||||
},
|
||||
{
|
||||
id: 'vital_trend',
|
||||
name: 'Vital-trend monitor',
|
||||
category: 'med',
|
||||
crate: 'wifi-densepose-wasm-edge',
|
||||
summary: 'HR + BR trend tracking with bradycardia/tachycardia/apnea events.',
|
||||
events: [100, 101, 102, 103, 104, 105],
|
||||
budget: 'S',
|
||||
status: 'available',
|
||||
tags: ['medical', 'vitals', 'csi'],
|
||||
adr: 'ADR-021',
|
||||
runtime: 'simulated',
|
||||
},
|
||||
{
|
||||
id: 'intrusion',
|
||||
name: 'Intrusion detector',
|
||||
category: 'sec',
|
||||
crate: 'wifi-densepose-wasm-edge',
|
||||
summary: 'Zone-based intrusion alert from CSI motion patterns.',
|
||||
events: [200, 201],
|
||||
budget: 'S',
|
||||
status: 'available',
|
||||
tags: ['security', 'zone', 'csi'],
|
||||
runtime: 'simulated',
|
||||
},
|
||||
|
||||
// ── Medical & Health (100-series) ────────────────────────────────────────
|
||||
{ id: 'med_sleep_apnea', name: 'Sleep-apnea detector', category: 'med', crate: 'wifi-densepose-wasm-edge', summary: 'Episodic respiratory pause detection during sleep cycles.', events: [105], budget: 'S', status: 'available', tags: ['medical', 'sleep', 'breathing'] },
|
||||
{ id: 'med_cardiac_arrhythmia', name: 'Cardiac arrhythmia', category: 'med', crate: 'wifi-densepose-wasm-edge', summary: 'Beat-to-beat irregularity classifier from cardiac micro-Doppler.', events: [103, 104], budget: 'M', status: 'available', tags: ['medical', 'cardiac', 'arrhythmia'] },
|
||||
{ id: 'med_respiratory_distress', name: 'Respiratory distress', category: 'med', crate: 'wifi-densepose-wasm-edge', summary: 'Distress signature: rapid shallow breathing + accessory-muscle motion.', events: [101, 102], budget: 'S', status: 'available', tags: ['medical', 'breathing', 'icu'] },
|
||||
{ id: 'med_gait_analysis', name: 'Gait analysis', category: 'med', crate: 'wifi-densepose-wasm-edge', summary: 'Stride length, cadence, asymmetry from through-wall CSI pose tracking.', budget: 'M', status: 'available', tags: ['medical', 'gait', 'pose'] },
|
||||
{ id: 'med_seizure_detect', name: 'Seizure detector', category: 'med', crate: 'wifi-densepose-wasm-edge', summary: 'Tonic-clonic seizure motion signature.', budget: 'M', status: 'beta', tags: ['medical', 'neuro'] },
|
||||
|
||||
// ── Security (200-series) ────────────────────────────────────────────────
|
||||
{ id: 'sec_perimeter_breach', name: 'Perimeter breach', category: 'sec', crate: 'wifi-densepose-wasm-edge', summary: 'Approach/departure detection at user-defined boundary segments.', events: [210, 211, 212, 213], budget: 'S', status: 'available', tags: ['security', 'perimeter'] },
|
||||
{ id: 'sec_weapon_detect', name: 'Metal anomaly / weapon', category: 'sec', crate: 'wifi-densepose-wasm-edge', summary: 'Metal-perturbation flag in CSI; potential weapon presence (research).', events: [220, 221, 222], budget: 'M', status: 'research', tags: ['security', 'metal', 'csi'] },
|
||||
{ id: 'sec_tailgating', name: 'Tailgating detector', category: 'sec', crate: 'wifi-densepose-wasm-edge', summary: 'Detect 2+ persons crossing a single-passage threshold.', events: [230, 231, 232], budget: 'S', status: 'available', tags: ['security', 'access-control'] },
|
||||
{ id: 'sec_loitering', name: 'Loitering detector', category: 'sec', crate: 'wifi-densepose-wasm-edge', summary: 'Stationary occupancy past a configurable dwell threshold.', events: [240, 241, 242], budget: 'S', status: 'available', tags: ['security', 'dwell'] },
|
||||
{ id: 'sec_panic_motion', name: 'Panic motion', category: 'sec', crate: 'wifi-densepose-wasm-edge', summary: 'High-energy distress motion: struggle / fleeing pattern.', events: [250, 251, 252], budget: 'S', status: 'beta', tags: ['security', 'distress'] },
|
||||
|
||||
// ── Smart Building (300-series) ──────────────────────────────────────────
|
||||
{ id: 'bld_hvac_presence', name: 'HVAC presence', category: 'bld', crate: 'wifi-densepose-wasm-edge', summary: 'Occupied/activity-level/departure-countdown for HVAC zones.', events: [310, 311, 312], budget: 'S', status: 'available', tags: ['hvac', 'building', 'energy'] },
|
||||
{ id: 'bld_lighting_zones', name: 'Lighting zones', category: 'bld', crate: 'wifi-densepose-wasm-edge', summary: 'Per-zone light on/dim/off cues from occupancy.', events: [320, 321, 322], budget: 'S', status: 'available', tags: ['lighting', 'building'] },
|
||||
{ id: 'bld_elevator_count', name: 'Elevator count', category: 'bld', crate: 'wifi-densepose-wasm-edge', summary: 'Person count inside elevator car from CSI.', events: [330], budget: 'S', status: 'available', tags: ['elevator', 'building'] },
|
||||
{ id: 'bld_meeting_room', name: 'Meeting-room utilization', category: 'bld', crate: 'wifi-densepose-wasm-edge', summary: 'Meeting size + duration analytics for booking systems.', budget: 'S', status: 'available', tags: ['meeting', 'analytics'] },
|
||||
{ id: 'bld_energy_audit', name: 'Energy audit', category: 'bld', crate: 'wifi-densepose-wasm-edge', summary: 'Continuous occupancy-vs-HVAC-state audit for energy savings.', budget: 'M', status: 'available', tags: ['energy', 'audit'] },
|
||||
|
||||
// ── Retail (400-series) ──────────────────────────────────────────────────
|
||||
{ id: 'ret_queue_length', name: 'Queue length', category: 'ret', crate: 'wifi-densepose-wasm-edge', summary: 'Live queue-length tracking for checkout / kiosks.', budget: 'S', status: 'available', tags: ['retail', 'queue'] },
|
||||
{ id: 'ret_dwell_heatmap', name: 'Dwell heatmap', category: 'ret', crate: 'wifi-densepose-wasm-edge', summary: 'Per-zone dwell time accumulation; analytics-only export.', budget: 'M', status: 'available', tags: ['retail', 'heatmap'] },
|
||||
{ id: 'ret_customer_flow', name: 'Customer flow', category: 'ret', crate: 'wifi-densepose-wasm-edge', summary: 'Origin-destination flow graph through a store layout.', budget: 'M', status: 'available', tags: ['retail', 'flow'] },
|
||||
{ id: 'ret_table_turnover', name: 'Table turnover', category: 'ret', crate: 'wifi-densepose-wasm-edge', summary: 'Restaurant table seat / vacate transitions.', budget: 'S', status: 'available', tags: ['retail', 'restaurant'] },
|
||||
{ id: 'ret_shelf_engagement', name: 'Shelf engagement', category: 'ret', crate: 'wifi-densepose-wasm-edge', summary: 'Reach-to-shelf gestures and dwell at product zones.', budget: 'M', status: 'available', tags: ['retail', 'shelf'] },
|
||||
|
||||
// ── Industrial (500-series) ──────────────────────────────────────────────
|
||||
{ id: 'ind_forklift_proximity', name: 'Forklift proximity', category: 'ind', crate: 'wifi-densepose-wasm-edge', summary: 'Worker-near-forklift safety alert.', budget: 'S', status: 'available', tags: ['industrial', 'safety'] },
|
||||
{ id: 'ind_confined_space', name: 'Confined-space monitor', category: 'ind', crate: 'wifi-densepose-wasm-edge', summary: 'Last-person-out detection + presence audit for OSHA confined-space entries.', budget: 'S', status: 'available', tags: ['industrial', 'osha'] },
|
||||
{ id: 'ind_clean_room', name: 'Clean-room PPE / motion', category: 'ind', crate: 'wifi-densepose-wasm-edge', summary: 'Motion patterns consistent with proper PPE-clad movement.', budget: 'M', status: 'beta', tags: ['industrial', 'cleanroom'] },
|
||||
{ id: 'ind_livestock_monitor', name: 'Livestock monitor', category: 'ind', crate: 'wifi-densepose-wasm-edge', summary: 'Vital-sign + activity tracking for stall-bound livestock.', budget: 'M', status: 'beta', tags: ['agriculture', 'livestock'] },
|
||||
{ id: 'ind_structural_vibration', name: 'Structural vibration', category: 'ind', crate: 'wifi-densepose-wasm-edge', summary: 'Building/equipment micro-vibration via CSI phase derivative.', budget: 'M', status: 'research', tags: ['industrial', 'vibration'] },
|
||||
|
||||
// ── Signal primitives (600-series) ───────────────────────────────────────
|
||||
{ id: 'sig_coherence_gate', name: 'Coherence gate (extended)', category: 'sig', crate: 'wifi-densepose-wasm-edge', summary: 'Hysteresis + multi-state coherence gate driving downstream apps.', budget: 'S', status: 'available', tags: ['gate', 'csi'] },
|
||||
{ id: 'sig_flash_attention', name: 'Flash attention (CSI)', category: 'sig', crate: 'wifi-densepose-wasm-edge', summary: 'Edge-friendly attention block for CSI subcarrier weighting.', budget: 'M', status: 'beta', tags: ['attention', 'csi'] },
|
||||
{ id: 'sig_temporal_compress', name: 'Temporal-tensor compress', category: 'sig', crate: 'wifi-densepose-wasm-edge', summary: 'RuVector temporal-tensor compression on the CSI buffer.', budget: 'M', status: 'available', tags: ['compress', 'tensor'] },
|
||||
{ id: 'sig_sparse_recovery', name: 'Sparse recovery', category: 'sig', crate: 'wifi-densepose-wasm-edge', summary: '114→56 subcarrier sparse interpolation via L1 solver.', budget: 'M', status: 'available', tags: ['sparse', 'csi'] },
|
||||
{ id: 'sig_mincut_person_match', name: 'Mincut person-match', category: 'sig', crate: 'wifi-densepose-wasm-edge', summary: 'Min-cut person assignment across multistatic frames.', budget: 'M', status: 'available', tags: ['mincut', 'matching'] },
|
||||
{ id: 'sig_optimal_transport', name: 'Optimal transport', category: 'sig', crate: 'wifi-densepose-wasm-edge', summary: 'OT-based feature alignment between mesh nodes.', budget: 'M', status: 'beta', tags: ['ot', 'alignment'] },
|
||||
|
||||
// ── Online learning ──────────────────────────────────────────────────────
|
||||
{ id: 'lrn_dtw_gesture_learn', name: 'DTW gesture learn', category: 'lrn', crate: 'wifi-densepose-wasm-edge', summary: 'On-device template learning for personalized gesture libraries.', budget: 'M', status: 'beta', tags: ['lifelong', 'gesture'] },
|
||||
{ id: 'lrn_anomaly_attractor', name: 'Anomaly attractor', category: 'lrn', crate: 'wifi-densepose-wasm-edge', summary: 'Novelty detector with dynamic-attractor recall.', budget: 'M', status: 'research', tags: ['novelty', 'lifelong'] },
|
||||
{ id: 'lrn_meta_adapt', name: 'Meta-adapt', category: 'lrn', crate: 'wifi-densepose-wasm-edge', summary: 'Meta-learning adapter for fast site-to-site transfer.', budget: 'L', status: 'research', tags: ['meta-learning'] },
|
||||
{ id: 'lrn_ewc_lifelong', name: 'EWC++ lifelong', category: 'lrn', crate: 'wifi-densepose-wasm-edge', summary: 'Elastic-weight-consolidation gate to avoid catastrophic forgetting.', budget: 'M', status: 'beta', tags: ['lifelong', 'ewc'] },
|
||||
|
||||
// ── Spatial / graph ──────────────────────────────────────────────────────
|
||||
{ id: 'spt_pagerank_influence', name: 'PageRank influence', category: 'spt', crate: 'wifi-densepose-wasm-edge', summary: 'Graph-influence ranking on the multistatic mesh.', budget: 'M', status: 'beta', tags: ['graph', 'pagerank'] },
|
||||
{ id: 'spt_micro_hnsw', name: 'µHNSW vector index', category: 'spt', crate: 'wifi-densepose-wasm-edge', summary: 'Tiny HNSW index for AETHER re-ID embeddings on-device.', budget: 'M', status: 'available', tags: ['hnsw', 'reid'] },
|
||||
{ id: 'spt_spiking_tracker', name: 'Spiking tracker', category: 'spt', crate: 'wifi-densepose-wasm-edge', summary: 'Spiking-network multi-target tracker.', budget: 'L', status: 'research', tags: ['snn', 'tracker'] },
|
||||
|
||||
// ── Temporal / planning ──────────────────────────────────────────────────
|
||||
{ id: 'tmp_pattern_sequence', name: 'Pattern sequence', category: 'tmp', crate: 'wifi-densepose-wasm-edge', summary: 'Sequence-of-events pattern matcher (e.g. ingress→linger→egress).', budget: 'M', status: 'available', tags: ['temporal', 'pattern'] },
|
||||
{ id: 'tmp_temporal_logic_guard', name: 'Temporal logic guard', category: 'tmp', crate: 'wifi-densepose-wasm-edge', summary: 'LTL/MTL safety-property guard over event streams.', budget: 'M', status: 'beta', tags: ['ltl', 'safety'] },
|
||||
{ id: 'tmp_goap_autonomy', name: 'GOAP autonomy', category: 'tmp', crate: 'wifi-densepose-wasm-edge', summary: 'Goal-oriented action planning for adaptive routines.', budget: 'L', status: 'research', tags: ['planning', 'autonomy'] },
|
||||
|
||||
// ── AI safety ────────────────────────────────────────────────────────────
|
||||
{ id: 'ais_prompt_shield', name: 'Prompt shield', category: 'ais', crate: 'wifi-densepose-wasm-edge', summary: 'Edge-side LLM prompt-injection guard for on-device assistants.', budget: 'M', status: 'beta', tags: ['security', 'llm'] },
|
||||
{ id: 'ais_behavioral_profiler', name: 'Behavioral profiler', category: 'ais', crate: 'wifi-densepose-wasm-edge', summary: 'Anomalous-behaviour profiler (drift in motion habits).', budget: 'M', status: 'beta', tags: ['anomaly', 'behaviour'] },
|
||||
|
||||
// ── Quantum-flavoured ────────────────────────────────────────────────────
|
||||
{ id: 'qnt_quantum_coherence', name: 'Quantum coherence', category: 'qnt', crate: 'wifi-densepose-wasm-edge', summary: 'Coherence diagnostics adapted for quantum-sensor signals.', budget: 'M', status: 'research', tags: ['quantum', 'coherence'] },
|
||||
{ id: 'qnt_interference_search', name: 'Interference search', category: 'qnt', crate: 'wifi-densepose-wasm-edge', summary: 'Interferometric anomaly search across mesh viewpoints.', budget: 'L', status: 'research', tags: ['quantum', 'interference'] },
|
||||
|
||||
// ── Autonomy / mesh ──────────────────────────────────────────────────────
|
||||
{ id: 'aut_psycho_symbolic', name: 'Psycho-symbolic agent', category: 'aut', crate: 'wifi-densepose-wasm-edge', summary: 'Symbolic-rule + neural-feature hybrid for low-power autonomy loops.', budget: 'L', status: 'research', tags: ['autonomy', 'symbolic'] },
|
||||
{ id: 'aut_self_healing_mesh', name: 'Self-healing mesh', category: 'aut', crate: 'wifi-densepose-wasm-edge', summary: 'Mesh-topology repair with per-node health gossip.', budget: 'M', status: 'beta', tags: ['mesh', 'health'] },
|
||||
|
||||
// ── Exotic / Research (650-series) ───────────────────────────────────────
|
||||
{ id: 'exo_ghost_hunter', name: 'Ghost hunter (anomaly)', category: 'exo', crate: 'wifi-densepose-wasm-edge', summary: 'Empty-room CSI anomaly detector — impulsive/periodic/drift/random + hidden-presence sub-detector.', events: [650, 651, 652, 653], budget: 'S', status: 'available', tags: ['anomaly', 'paranormal', 'csi'], adr: 'ADR-041', runtime: 'simulated' },
|
||||
{ id: 'exo_breathing_sync', name: 'Breathing sync', category: 'exo', crate: 'wifi-densepose-wasm-edge', summary: 'Multi-person breathing synchrony analytics.', budget: 'M', status: 'beta', tags: ['breathing', 'sync'] },
|
||||
{ id: 'exo_dream_stage', name: 'Dream-stage classifier', category: 'exo', crate: 'wifi-densepose-wasm-edge', summary: 'NREM/REM stage classification from breathing + micro-motion.', budget: 'M', status: 'research', tags: ['sleep', 'rem'] },
|
||||
{ id: 'exo_emotion_detect', name: 'Emotion detector', category: 'exo', crate: 'wifi-densepose-wasm-edge', summary: 'Coarse arousal/valence from breathing + heart-rate variability.', budget: 'M', status: 'research', tags: ['affect'] },
|
||||
{ id: 'exo_gesture_language', name: 'Gesture language', category: 'exo', crate: 'wifi-densepose-wasm-edge', summary: 'Sign-language pattern recognition.', budget: 'L', status: 'research', tags: ['hci', 'sign'] },
|
||||
{ id: 'exo_happiness_score', name: 'Happiness score', category: 'exo', crate: 'wifi-densepose-wasm-edge', summary: 'Aggregate well-being score from co-occupancy + activity dynamics.', budget: 'M', status: 'research', tags: ['affect', 'wellbeing'] },
|
||||
{ id: 'exo_hyperbolic_space', name: 'Hyperbolic space embed', category: 'exo', crate: 'wifi-densepose-wasm-edge', summary: 'Hyperbolic embeddings for hierarchical scene structure.', budget: 'L', status: 'research', tags: ['embedding', 'hyperbolic'] },
|
||||
{ id: 'exo_music_conductor', name: 'Music conductor', category: 'exo', crate: 'wifi-densepose-wasm-edge', summary: 'Map gesture energy to MIDI tempo/dynamics.', budget: 'M', status: 'research', tags: ['midi', 'art'] },
|
||||
{ id: 'exo_plant_growth', name: 'Plant-growth tracker', category: 'exo', crate: 'wifi-densepose-wasm-edge', summary: 'Slow CSI drift tracking for greenhouse foliage growth.', budget: 'L', status: 'research', tags: ['agriculture'] },
|
||||
{ id: 'exo_rain_detect', name: 'Rain detector', category: 'exo', crate: 'wifi-densepose-wasm-edge', summary: 'Outdoor CSI signature of rainfall.', budget: 'M', status: 'research', tags: ['weather'] },
|
||||
{ id: 'exo_time_crystal', name: 'Time-crystal periodicity', category: 'exo', crate: 'wifi-densepose-wasm-edge', summary: 'Periodicity diagnostics with anti-aliasing harmonics.', budget: 'M', status: 'research', tags: ['periodicity'] },
|
||||
];
|
||||
|
||||
export const CATEGORIES: Record<AppCategory, { label: string; color: string; range: string }> = {
|
||||
sim: { label: 'Simulators', color: 'oklch(0.78 0.14 70)', range: '—' },
|
||||
med: { label: 'Medical & Health', color: 'oklch(0.65 0.22 25)', range: '100–199' },
|
||||
sec: { label: 'Security & Safety', color: 'oklch(0.7 0.18 35)', range: '200–299' },
|
||||
bld: { label: 'Smart Building', color: 'oklch(0.78 0.12 195)', range: '300–399' },
|
||||
ret: { label: 'Retail & Hospitality', color: 'oklch(0.78 0.14 145)', range: '400–499' },
|
||||
ind: { label: 'Industrial', color: 'oklch(0.72 0.18 330)', range: '500–599' },
|
||||
sig: { label: 'Signal Processing', color: 'oklch(0.78 0.14 70)', range: '600–619' },
|
||||
lrn: { label: 'Online Learning', color: 'oklch(0.78 0.12 260)', range: '620–639' },
|
||||
spt: { label: 'Spatial / Graph', color: 'oklch(0.7 0.18 100)', range: '640–659' },
|
||||
tmp: { label: 'Temporal / Planning', color: 'oklch(0.7 0.16 50)', range: '660–679' },
|
||||
ais: { label: 'AI Safety', color: 'oklch(0.65 0.22 25)', range: '700–719' },
|
||||
qnt: { label: 'Quantum', color: 'oklch(0.72 0.18 290)', range: '720–739' },
|
||||
aut: { label: 'Autonomy', color: 'oklch(0.78 0.14 145)', range: '740–759' },
|
||||
exo: { label: 'Exotic / Research', color: 'oklch(0.72 0.18 330)', range: '650–699' },
|
||||
};
|
||||
|
||||
export interface AppActivation {
|
||||
id: string;
|
||||
/** Active in the current session. */
|
||||
active: boolean;
|
||||
/** Last activation timestamp. */
|
||||
lastActivatedAt?: number;
|
||||
/** Last event count seen (for the cards' counter). */
|
||||
eventCount?: number;
|
||||
}
|
||||
|
||||
export function defaultActivations(): AppActivation[] {
|
||||
return APPS.map((a) => ({ id: a.id, active: a.active === true, eventCount: 0 }));
|
||||
}
|
||||
|
||||
export function appsByCategory(): Record<AppCategory, AppManifest[]> {
|
||||
const map = {} as Record<AppCategory, AppManifest[]>;
|
||||
for (const c of Object.keys(CATEGORIES) as AppCategory[]) map[c] = [];
|
||||
for (const a of APPS) map[a.category].push(a);
|
||||
return map;
|
||||
}
|
||||
|
||||
export function findApp(id: string): AppManifest | undefined {
|
||||
return APPS.find((a) => a.id === id);
|
||||
}
|
||||
|
||||
export function fuzzyMatch(query: string, app: AppManifest): number {
|
||||
if (!query) return 1;
|
||||
const q = query.toLowerCase();
|
||||
let score = 0;
|
||||
if (app.id.toLowerCase().includes(q)) score += 3;
|
||||
if (app.name.toLowerCase().includes(q)) score += 3;
|
||||
if (app.summary.toLowerCase().includes(q)) score += 1;
|
||||
if (app.tags?.some((t) => t.toLowerCase().includes(q))) score += 2;
|
||||
if (app.category === q) score += 5;
|
||||
return score;
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
/* IndexedDB-backed persistence for settings and saved scenes.
|
||||
* Mirrors the mockup's `nvsim/kv` store. */
|
||||
|
||||
const DB_NAME = 'nvsim';
|
||||
const DB_VER = 1;
|
||||
const STORE = 'kv';
|
||||
|
||||
let dbPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
function openDb(): Promise<IDBDatabase> {
|
||||
if (dbPromise) return dbPromise;
|
||||
dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VER);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE);
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
export async function kvGet<T = unknown>(key: string): Promise<T | undefined> {
|
||||
const db = await openDb();
|
||||
return await new Promise<T | undefined>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE, 'readonly');
|
||||
const r = tx.objectStore(STORE).get(key);
|
||||
r.onsuccess = () => resolve(r.result as T | undefined);
|
||||
r.onerror = () => reject(r.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function kvSet(key: string, value: unknown): Promise<void> {
|
||||
const db = await openDb();
|
||||
return await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE, 'readwrite');
|
||||
tx.objectStore(STORE).put(value, key);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function kvDelete(key: string): Promise<void> {
|
||||
const db = await openDb();
|
||||
return await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE, 'readwrite');
|
||||
tx.objectStore(STORE).delete(key);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
/* Common NvsimClient interface — both WasmClient and WsClient implement it.
|
||||
* Dashboard binds to this interface and never to a concrete client.
|
||||
* Aligns with ADR-092 §5.2.
|
||||
*/
|
||||
|
||||
export interface PipelineConfigJson {
|
||||
digitiser?: {
|
||||
f_s_hz: number;
|
||||
f_mod_hz: number;
|
||||
lp_cutoff_hz?: number;
|
||||
};
|
||||
sensor?: {
|
||||
gamma_fwhm_hz?: number;
|
||||
t1_s?: number;
|
||||
t2_s?: number;
|
||||
t2_star_s?: number;
|
||||
contrast?: number;
|
||||
n_spins?: number;
|
||||
n_centers?: number;
|
||||
shot_noise_disabled?: boolean;
|
||||
};
|
||||
dt_s?: number | null;
|
||||
}
|
||||
|
||||
export interface SceneJson {
|
||||
dipoles: { position: [number, number, number]; moment: [number, number, number] }[];
|
||||
loops: {
|
||||
centre: [number, number, number];
|
||||
normal: [number, number, number];
|
||||
radius: number;
|
||||
current: number;
|
||||
n_segments: number;
|
||||
}[];
|
||||
ferrous: {
|
||||
position: [number, number, number];
|
||||
volume: number;
|
||||
susceptibility: number;
|
||||
}[];
|
||||
eddy: unknown[];
|
||||
sensors: [number, number, number][];
|
||||
ambient_field: [number, number, number];
|
||||
}
|
||||
|
||||
export interface MagFrameRecord {
|
||||
magic: number;
|
||||
version: number;
|
||||
flags: number;
|
||||
sensorId: number;
|
||||
tUs: bigint;
|
||||
bPt: [number, number, number];
|
||||
sigmaPt: [number, number, number];
|
||||
noiseFloorPtSqrtHz: number;
|
||||
temperatureK: number;
|
||||
raw: Uint8Array;
|
||||
}
|
||||
|
||||
export interface MagFrameBatch {
|
||||
frames: MagFrameRecord[];
|
||||
bytes: Uint8Array;
|
||||
}
|
||||
|
||||
export type NvsimEvent =
|
||||
| { type: 'log'; level: 'info' | 'warn' | 'err' | 'dbg' | 'ok'; msg: string }
|
||||
| { type: 'witness'; hex: string }
|
||||
| { type: 'fps'; value: number }
|
||||
| { type: 'state'; running: boolean; t: number; framesEmitted: number };
|
||||
|
||||
export interface RunOpts { frames?: number }
|
||||
|
||||
/** One-shot pipeline run for "what would the sensor recover at this scene?"
|
||||
* use cases. Doesn't disturb the running pipeline. */
|
||||
export interface TransientRunResult {
|
||||
bRecoveredT: [number, number, number];
|
||||
bMagT: number;
|
||||
noiseFloorPtSqrtHz: number;
|
||||
sigmaPt: [number, number, number];
|
||||
nFrames: number;
|
||||
witnessHex: string;
|
||||
}
|
||||
|
||||
export interface NvsimClient {
|
||||
loadScene(scene: SceneJson): Promise<void>;
|
||||
setConfig(cfg: PipelineConfigJson): Promise<void>;
|
||||
setSeed(seed: bigint): Promise<void>;
|
||||
reset(): Promise<void>;
|
||||
run(opts?: RunOpts): Promise<void>;
|
||||
pause(): Promise<void>;
|
||||
step(direction: 'fwd' | 'back', dtMs: number): Promise<void>;
|
||||
|
||||
onFrames(cb: (batch: MagFrameBatch) => void): void;
|
||||
onEvent(cb: (ev: NvsimEvent) => void): void;
|
||||
|
||||
generateWitness(samples: number): Promise<Uint8Array>;
|
||||
verifyWitness(expected: Uint8Array): Promise<{ ok: true } | { ok: false; actual: Uint8Array }>;
|
||||
exportProofBundle(): Promise<Blob>;
|
||||
runTransient(scene: SceneJson, config: PipelineConfigJson, seed: bigint, samples: number): Promise<TransientRunResult>;
|
||||
|
||||
buildId(): Promise<string>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
/** Parse one MagFrame from a 60-byte slice. Layout matches `nvsim::frame`. */
|
||||
export function parseMagFrame(view: DataView, offset: number, raw: Uint8Array): MagFrameRecord {
|
||||
// v1 layout: magic(u32) | version(u16) | flags(u16) | sensor_id(u16) | _reserved(u16) |
|
||||
// t_us(u64) | b_pt[3](f32) | sigma_pt[3](f32) | noise_floor_pt_sqrt_hz(f32) |
|
||||
// temperature_k(f32) — 60 bytes total. All little-endian.
|
||||
const magic = view.getUint32(offset + 0, true);
|
||||
const version = view.getUint16(offset + 4, true);
|
||||
const flags = view.getUint16(offset + 6, true);
|
||||
const sensorId = view.getUint16(offset + 8, true);
|
||||
// skip 2 bytes reserved at offset+10
|
||||
const tUs = view.getBigUint64(offset + 12, true);
|
||||
const bx = view.getFloat32(offset + 20, true);
|
||||
const by = view.getFloat32(offset + 24, true);
|
||||
const bz = view.getFloat32(offset + 28, true);
|
||||
const sx = view.getFloat32(offset + 32, true);
|
||||
const sy = view.getFloat32(offset + 36, true);
|
||||
const sz = view.getFloat32(offset + 40, true);
|
||||
const noiseFloorPtSqrtHz = view.getFloat32(offset + 44, true);
|
||||
const temperatureK = view.getFloat32(offset + 48, true);
|
||||
return {
|
||||
magic,
|
||||
version,
|
||||
flags,
|
||||
sensorId,
|
||||
tUs,
|
||||
bPt: [bx, by, bz],
|
||||
sigmaPt: [sx, sy, sz],
|
||||
noiseFloorPtSqrtHz,
|
||||
temperatureK,
|
||||
raw: raw.subarray(offset, offset + 60),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseFrameBatch(bytes: Uint8Array): MagFrameRecord[] {
|
||||
const frameSize = 60;
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const out: MagFrameRecord[] = [];
|
||||
for (let off = 0; off + frameSize <= bytes.byteLength; off += frameSize) {
|
||||
out.push(parseMagFrame(view, off, bytes));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
/* Default `NvsimClient` implementation. Talks to the Web Worker that
|
||||
* hosts the nvsim WASM module. ADR-092 §5.4 + §6.3. */
|
||||
|
||||
import {
|
||||
type NvsimClient,
|
||||
type SceneJson,
|
||||
type PipelineConfigJson,
|
||||
type RunOpts,
|
||||
type MagFrameBatch,
|
||||
type NvsimEvent,
|
||||
type TransientRunResult,
|
||||
parseFrameBatch,
|
||||
} from './NvsimClient';
|
||||
|
||||
interface PendingRequest<T = unknown> {
|
||||
resolve: (v: T) => void;
|
||||
reject: (err: Error) => void;
|
||||
}
|
||||
|
||||
export interface WasmBootInfo {
|
||||
buildVersion: string;
|
||||
frameMagic: number;
|
||||
frameBytes: number;
|
||||
expectedWitnessHex: string;
|
||||
}
|
||||
|
||||
export class WasmClient implements NvsimClient {
|
||||
private worker: Worker;
|
||||
private nextId = 1;
|
||||
private pending = new Map<number, PendingRequest<unknown>>();
|
||||
private frameSubs = new Set<(b: MagFrameBatch) => void>();
|
||||
private eventSubs = new Set<(e: NvsimEvent) => void>();
|
||||
private bootInfo: WasmBootInfo | null = null;
|
||||
|
||||
constructor() {
|
||||
this.worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
|
||||
this.worker.addEventListener('message', (ev) => this.onMessage(ev));
|
||||
this.worker.addEventListener('error', (e) =>
|
||||
this.eventSubs.forEach((s) => s({ type: 'log', level: 'err', msg: String(e.message) })),
|
||||
);
|
||||
}
|
||||
|
||||
private onMessage(ev: MessageEvent): void {
|
||||
const m = ev.data as { type: string; id?: number; [k: string]: unknown };
|
||||
if (m.type === 'frames') {
|
||||
const buf = m.batch as ArrayBuffer;
|
||||
const bytes = new Uint8Array(buf);
|
||||
const frames = parseFrameBatch(bytes);
|
||||
const batch: MagFrameBatch = { frames, bytes };
|
||||
this.frameSubs.forEach((s) => s(batch));
|
||||
const fps = m.fps as number;
|
||||
if (fps > 0) {
|
||||
this.eventSubs.forEach((s) => s({ type: 'fps', value: fps }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (m.type === 'state') {
|
||||
this.eventSubs.forEach((s) =>
|
||||
s({
|
||||
type: 'state',
|
||||
running: Boolean(m.running),
|
||||
t: 0,
|
||||
framesEmitted: Number(m.framesEmitted ?? 0),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (m.type === 'ready') {
|
||||
return;
|
||||
}
|
||||
if (m.type === 'err' && m.id == null) {
|
||||
this.eventSubs.forEach((s) =>
|
||||
s({ type: 'log', level: 'err', msg: String(m.msg) }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (typeof m.id === 'number' && this.pending.has(m.id)) {
|
||||
const p = this.pending.get(m.id)!;
|
||||
this.pending.delete(m.id);
|
||||
if (m.type === 'err') p.reject(new Error(String(m.msg)));
|
||||
else p.resolve(m);
|
||||
}
|
||||
}
|
||||
|
||||
private rpc<T = unknown>(msg: Record<string, unknown>, transfer: Transferable[] = []): Promise<T> {
|
||||
const id = this.nextId++;
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject });
|
||||
this.worker.postMessage({ ...msg, id }, transfer);
|
||||
});
|
||||
}
|
||||
|
||||
async boot(): Promise<WasmBootInfo> {
|
||||
if (this.bootInfo) return this.bootInfo;
|
||||
// Pass Vite's resolved BASE_URL so the worker can locate /nvsim-pkg/
|
||||
// under the same prefix the dashboard is served from (e.g. /RuView/nvsim/
|
||||
// on GitHub Pages, "/" in dev).
|
||||
const base = import.meta.env.BASE_URL ?? '/';
|
||||
const r = await this.rpc<{ buildVersion: string; frameMagic: number; frameBytes: number; expectedWitnessHex: string }>(
|
||||
{ type: 'boot', base },
|
||||
);
|
||||
this.bootInfo = {
|
||||
buildVersion: r.buildVersion,
|
||||
frameMagic: r.frameMagic,
|
||||
frameBytes: r.frameBytes,
|
||||
expectedWitnessHex: r.expectedWitnessHex,
|
||||
};
|
||||
return this.bootInfo;
|
||||
}
|
||||
|
||||
async loadScene(scene: SceneJson): Promise<void> {
|
||||
await this.rpc({ type: 'setScene', json: JSON.stringify(scene) });
|
||||
}
|
||||
|
||||
async setConfig(cfg: PipelineConfigJson): Promise<void> {
|
||||
await this.rpc({ type: 'setConfig', json: JSON.stringify(cfg) });
|
||||
}
|
||||
|
||||
async setSeed(seed: bigint): Promise<void> {
|
||||
await this.rpc({ type: 'setSeed', seed: Number(seed & 0xFFFFFFFFn) });
|
||||
}
|
||||
|
||||
async reset(): Promise<void> {
|
||||
await this.rpc({ type: 'reset' });
|
||||
}
|
||||
|
||||
async run(_opts?: RunOpts): Promise<void> {
|
||||
await this.rpc({ type: 'run' });
|
||||
}
|
||||
|
||||
async pause(): Promise<void> {
|
||||
await this.rpc({ type: 'pause' });
|
||||
}
|
||||
|
||||
async step(_direction: 'fwd' | 'back', _dtMs: number): Promise<void> {
|
||||
await this.rpc({ type: 'step' });
|
||||
}
|
||||
|
||||
onFrames(cb: (batch: MagFrameBatch) => void): void { this.frameSubs.add(cb); }
|
||||
onEvent(cb: (ev: NvsimEvent) => void): void { this.eventSubs.add(cb); }
|
||||
|
||||
async generateWitness(samples: number): Promise<Uint8Array> {
|
||||
const r = await this.rpc<{ witness: ArrayBuffer; hex: string }>({ type: 'witnessGenerate', samples });
|
||||
return new Uint8Array(r.witness);
|
||||
}
|
||||
|
||||
async verifyWitness(expected: Uint8Array): Promise<{ ok: true } | { ok: false; actual: Uint8Array }> {
|
||||
const buf = expected.slice().buffer;
|
||||
const r = await this.rpc<{ ok: boolean; actual: ArrayBuffer; actualHex: string }>(
|
||||
{ type: 'witnessVerify', samples: 256, expected: buf },
|
||||
[buf],
|
||||
);
|
||||
if (r.ok) return { ok: true };
|
||||
return { ok: false, actual: new Uint8Array(r.actual) };
|
||||
}
|
||||
|
||||
async runTransient(
|
||||
scene: SceneJson,
|
||||
config: PipelineConfigJson,
|
||||
seed: bigint,
|
||||
samples: number,
|
||||
): Promise<TransientRunResult> {
|
||||
const r = await this.rpc<{
|
||||
bRecoveredT: number[];
|
||||
bMagT: number;
|
||||
noiseFloorPtSqrtHz: number;
|
||||
sigmaPt: number[];
|
||||
nFrames: number;
|
||||
witnessHex: string;
|
||||
}>({
|
||||
type: 'runTransient',
|
||||
scene: JSON.stringify(scene),
|
||||
config: JSON.stringify(config),
|
||||
seed: Number(seed & 0xFFFFFFFFn),
|
||||
samples,
|
||||
});
|
||||
return {
|
||||
bRecoveredT: [r.bRecoveredT[0], r.bRecoveredT[1], r.bRecoveredT[2]],
|
||||
bMagT: r.bMagT,
|
||||
noiseFloorPtSqrtHz: r.noiseFloorPtSqrtHz,
|
||||
sigmaPt: [r.sigmaPt[0], r.sigmaPt[1], r.sigmaPt[2]],
|
||||
nFrames: r.nFrames,
|
||||
witnessHex: r.witnessHex,
|
||||
};
|
||||
}
|
||||
|
||||
async exportProofBundle(): Promise<Blob> {
|
||||
// Bundle = REFERENCE_SCENE_JSON + computed witness hex + version. Wraps
|
||||
// the same artifacts `Proof::generate` produces natively. ADR-092 §6.1.
|
||||
const w = await this.generateWitness(256);
|
||||
const hex = Array.from(w).map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
const info = this.bootInfo ?? (await this.boot());
|
||||
const manifest = JSON.stringify(
|
||||
{
|
||||
kind: 'nvsim-proof-bundle',
|
||||
version: info.buildVersion,
|
||||
seed: '0x0000002A',
|
||||
nSamples: 256,
|
||||
witness: hex,
|
||||
expected: info.expectedWitnessHex,
|
||||
ok: hex === info.expectedWitnessHex,
|
||||
ts: new Date().toISOString(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
return new Blob([manifest], { type: 'application/json' });
|
||||
}
|
||||
|
||||
async buildId(): Promise<string> {
|
||||
const r = await this.rpc<{ buildId: string }>({ type: 'buildId' });
|
||||
return r.buildId;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.worker.terminate();
|
||||
}
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
/* WebSocket transport client — talks to a `nvsim-server` Axum host
|
||||
* (v2/crates/nvsim-server). REST for control plane, binary WebSocket
|
||||
* for the MagFrame stream. Mirrors the WasmClient interface so the
|
||||
* dashboard can swap transports at runtime without code changes.
|
||||
*
|
||||
* ADR-092 §5.2 / §6.2.
|
||||
*/
|
||||
|
||||
import {
|
||||
type NvsimClient,
|
||||
type SceneJson,
|
||||
type PipelineConfigJson,
|
||||
type RunOpts,
|
||||
type MagFrameBatch,
|
||||
type NvsimEvent,
|
||||
type TransientRunResult,
|
||||
parseFrameBatch,
|
||||
} from './NvsimClient';
|
||||
|
||||
interface HealthBody {
|
||||
nvsim_version: string;
|
||||
magic: number;
|
||||
frame_bytes: number;
|
||||
expected_witness_hex: string;
|
||||
}
|
||||
|
||||
interface VerifyBody {
|
||||
ok: boolean;
|
||||
actual_hex: string;
|
||||
expected_hex: string;
|
||||
}
|
||||
|
||||
interface WitnessBody {
|
||||
witness_hex: string;
|
||||
samples: number;
|
||||
seed_hex: string;
|
||||
}
|
||||
|
||||
export interface WsBootInfo {
|
||||
buildVersion: string;
|
||||
frameMagic: number;
|
||||
frameBytes: number;
|
||||
expectedWitnessHex: string;
|
||||
}
|
||||
|
||||
/** Convert a base URL (e.g. `http://host:7878`) to its WebSocket peer (`ws://host:7878`). */
|
||||
function toWsUrl(baseUrl: string): string {
|
||||
if (baseUrl.startsWith('ws://') || baseUrl.startsWith('wss://')) return baseUrl;
|
||||
return baseUrl.replace(/^http/, 'ws');
|
||||
}
|
||||
|
||||
export class WsClient implements NvsimClient {
|
||||
private baseUrl: string;
|
||||
private wsUrl: string;
|
||||
private ws: WebSocket | null = null;
|
||||
private bootInfo: WsBootInfo | null = null;
|
||||
private frameSubs = new Set<(b: MagFrameBatch) => void>();
|
||||
private eventSubs = new Set<(e: NvsimEvent) => void>();
|
||||
private running = false;
|
||||
private framesEmitted = 0;
|
||||
private fpsLast = performance.now();
|
||||
private fpsCount = 0;
|
||||
|
||||
/** @param baseUrl e.g. `http://localhost:7878` */
|
||||
constructor(baseUrl: string) {
|
||||
this.baseUrl = baseUrl.replace(/\/$/, '');
|
||||
this.wsUrl = `${toWsUrl(this.baseUrl)}/ws/stream`;
|
||||
}
|
||||
|
||||
private async json<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||
...init,
|
||||
headers: { 'content-type': 'application/json', ...(init?.headers ?? {}) },
|
||||
});
|
||||
if (!res.ok) throw new Error(`${path}: ${res.status} ${res.statusText}`);
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
async boot(): Promise<WsBootInfo> {
|
||||
if (this.bootInfo) return this.bootInfo;
|
||||
const h = await this.json<HealthBody>('/api/health');
|
||||
this.bootInfo = {
|
||||
buildVersion: h.nvsim_version,
|
||||
frameMagic: h.magic,
|
||||
frameBytes: h.frame_bytes,
|
||||
expectedWitnessHex: h.expected_witness_hex,
|
||||
};
|
||||
this.openWs();
|
||||
return this.bootInfo;
|
||||
}
|
||||
|
||||
private openWs(): void {
|
||||
if (this.ws) return;
|
||||
const ws = new WebSocket(this.wsUrl);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
ws.onopen = () => {
|
||||
this.eventSubs.forEach((s) =>
|
||||
s({ type: 'log', level: 'ok', msg: `ws/stream connected · ${this.wsUrl}` }),
|
||||
);
|
||||
};
|
||||
ws.onclose = () => {
|
||||
this.ws = null;
|
||||
this.eventSubs.forEach((s) =>
|
||||
s({ type: 'log', level: 'warn', msg: 'ws/stream closed' }),
|
||||
);
|
||||
};
|
||||
ws.onerror = () => {
|
||||
this.eventSubs.forEach((s) =>
|
||||
s({ type: 'log', level: 'err', msg: `ws/stream error · ${this.wsUrl}` }),
|
||||
);
|
||||
};
|
||||
ws.onmessage = (ev: MessageEvent) => {
|
||||
if (!(ev.data instanceof ArrayBuffer)) return;
|
||||
const bytes = new Uint8Array(ev.data);
|
||||
const frames = parseFrameBatch(bytes);
|
||||
if (frames.length === 0) return;
|
||||
const batch: MagFrameBatch = { frames, bytes };
|
||||
this.frameSubs.forEach((s) => s(batch));
|
||||
this.framesEmitted += frames.length;
|
||||
this.fpsCount += frames.length;
|
||||
const now = performance.now();
|
||||
if (now - this.fpsLast >= 1000) {
|
||||
const fps = (this.fpsCount * 1000) / (now - this.fpsLast);
|
||||
this.eventSubs.forEach((s) => s({ type: 'fps', value: fps }));
|
||||
this.fpsLast = now;
|
||||
this.fpsCount = 0;
|
||||
}
|
||||
};
|
||||
this.ws = ws;
|
||||
}
|
||||
|
||||
async loadScene(scene: SceneJson): Promise<void> {
|
||||
await this.json('/api/scene', { method: 'PUT', body: JSON.stringify(scene) });
|
||||
}
|
||||
async setConfig(cfg: PipelineConfigJson): Promise<void> {
|
||||
await this.json('/api/config', { method: 'PUT', body: JSON.stringify(cfg) });
|
||||
}
|
||||
async setSeed(seed: bigint): Promise<void> {
|
||||
await this.json('/api/seed', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ seed_hex: '0x' + seed.toString(16).toUpperCase().padStart(16, '0') }),
|
||||
});
|
||||
}
|
||||
async reset(): Promise<void> {
|
||||
await this.json('/api/reset', { method: 'POST' });
|
||||
this.running = false;
|
||||
this.framesEmitted = 0;
|
||||
this.eventSubs.forEach((s) => s({ type: 'state', running: false, t: 0, framesEmitted: 0 }));
|
||||
}
|
||||
async run(_opts?: RunOpts): Promise<void> {
|
||||
await this.json('/api/run', { method: 'POST' });
|
||||
this.running = true;
|
||||
this.eventSubs.forEach((s) =>
|
||||
s({ type: 'state', running: true, t: 0, framesEmitted: this.framesEmitted }),
|
||||
);
|
||||
}
|
||||
async pause(): Promise<void> {
|
||||
await this.json('/api/pause', { method: 'POST' });
|
||||
this.running = false;
|
||||
this.eventSubs.forEach((s) =>
|
||||
s({ type: 'state', running: false, t: 0, framesEmitted: this.framesEmitted }),
|
||||
);
|
||||
}
|
||||
async step(direction: 'fwd' | 'back', dtMs: number): Promise<void> {
|
||||
await this.json('/api/step', { method: 'POST', body: JSON.stringify({ direction, dt_ms: dtMs }) });
|
||||
}
|
||||
|
||||
onFrames(cb: (b: MagFrameBatch) => void): void { this.frameSubs.add(cb); }
|
||||
onEvent(cb: (e: NvsimEvent) => void): void { this.eventSubs.add(cb); }
|
||||
|
||||
async generateWitness(samples: number): Promise<Uint8Array> {
|
||||
const r = await this.json<WitnessBody>('/api/witness/generate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ samples }),
|
||||
});
|
||||
const out = new Uint8Array(32);
|
||||
for (let i = 0; i < 32; i++) out[i] = parseInt(r.witness_hex.slice(i * 2, i * 2 + 2), 16);
|
||||
return out;
|
||||
}
|
||||
|
||||
async verifyWitness(expected: Uint8Array): Promise<{ ok: true } | { ok: false; actual: Uint8Array }> {
|
||||
const expected_hex = Array.from(expected).map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
const r = await this.json<VerifyBody>('/api/witness/verify', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ expected_hex, samples: 256 }),
|
||||
});
|
||||
if (r.ok) return { ok: true };
|
||||
const actual = new Uint8Array(32);
|
||||
for (let i = 0; i < 32; i++) actual[i] = parseInt(r.actual_hex.slice(i * 2, i * 2 + 2), 16);
|
||||
return { ok: false, actual };
|
||||
}
|
||||
|
||||
async exportProofBundle(): Promise<Blob> {
|
||||
const text = await fetch(`${this.baseUrl}/api/export-proof`, { method: 'POST' }).then((r) => r.text());
|
||||
return new Blob([text], { type: 'application/json' });
|
||||
}
|
||||
|
||||
async runTransient(
|
||||
scene: SceneJson,
|
||||
config: PipelineConfigJson,
|
||||
_seed: bigint,
|
||||
samples: number,
|
||||
): Promise<TransientRunResult> {
|
||||
// Server doesn't expose a transient route in V1 — the dashboard's
|
||||
// Ghost Murmur sandbox falls back to the WASM client when transport
|
||||
// is WS. Stub here returns a zero-result so the caller can detect.
|
||||
void scene; void config; void samples;
|
||||
return {
|
||||
bRecoveredT: [0, 0, 0],
|
||||
bMagT: 0,
|
||||
noiseFloorPtSqrtHz: 0,
|
||||
sigmaPt: [0, 0, 0],
|
||||
nFrames: 0,
|
||||
witnessHex: '(transient route not available in WS transport — V1 limitation)',
|
||||
};
|
||||
}
|
||||
|
||||
async buildId(): Promise<string> {
|
||||
const info = this.bootInfo ?? (await this.boot());
|
||||
return `nvsim@${info.buildVersion} (ws)`;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.ws?.close();
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
/* Web Worker hosting the nvsim WASM module.
|
||||
*
|
||||
* Boots `/nvsim-pkg/nvsim.js`, instantiates `WasmPipeline`, then
|
||||
* postMessage-RPCs with the main thread. Frame batches are returned
|
||||
* as `ArrayBuffer` transfers so we don't pay a copy on the hot path.
|
||||
*
|
||||
* ADR-092 §5.4.
|
||||
*/
|
||||
|
||||
/// <reference lib="WebWorker" />
|
||||
|
||||
const ws = self as unknown as DedicatedWorkerGlobalScope;
|
||||
|
||||
interface WasmPipelineApi {
|
||||
run(n: number): Uint8Array;
|
||||
runWithWitness(n: number): { frames: Uint8Array; witness: Uint8Array; frameCount: number };
|
||||
free?: () => void;
|
||||
}
|
||||
type WasmPipelineCtor = new (sceneJson: string, configJson: string, seed: number) => WasmPipelineApi;
|
||||
type WasmPipelineStatic = WasmPipelineCtor & {
|
||||
buildVersion(): string;
|
||||
frameMagic(): number;
|
||||
frameBytes(): number;
|
||||
};
|
||||
|
||||
interface TransientResult {
|
||||
bRecoveredT: Float64Array;
|
||||
bMagT: number;
|
||||
noiseFloorPtSqrtHz: number;
|
||||
sigmaPt: Float64Array;
|
||||
nFrames: number;
|
||||
witnessHex: string;
|
||||
}
|
||||
|
||||
interface NvsimPkg {
|
||||
default: (input?: unknown) => Promise<unknown>;
|
||||
WasmPipeline: WasmPipelineStatic;
|
||||
referenceSceneJson: () => string;
|
||||
expectedReferenceWitnessHex: () => string;
|
||||
hexWitness: (b: Uint8Array) => string;
|
||||
referenceWitness: () => Uint8Array;
|
||||
runTransient: (sceneJson: string, configJson: string, seed: number, nSamples: number) => TransientResult;
|
||||
}
|
||||
|
||||
let _WasmPipeline!: WasmPipelineStatic;
|
||||
let referenceSceneJson!: () => string;
|
||||
let expectedReferenceWitnessHex!: () => string;
|
||||
let hexWitness!: (b: Uint8Array) => string;
|
||||
let referenceWitness!: () => Uint8Array;
|
||||
let runTransient!: (sceneJson: string, configJson: string, seed: number, nSamples: number) => TransientResult;
|
||||
|
||||
async function loadPkg(base: string): Promise<void> {
|
||||
// `base` is the dashboard's BASE_URL injected by Vite, prefixed with the
|
||||
// origin so we get an absolute URL the dynamic import can resolve. In dev
|
||||
// this is "/", in prod under GitHub Pages it's "/RuView/nvsim/".
|
||||
const absoluteBase = new URL(base, ws.location.origin).href;
|
||||
const pkgUrl = new URL('nvsim-pkg/nvsim.js', absoluteBase).href;
|
||||
const pkg = (await import(/* @vite-ignore */ pkgUrl)) as NvsimPkg;
|
||||
await pkg.default();
|
||||
_WasmPipeline = pkg.WasmPipeline;
|
||||
referenceSceneJson = pkg.referenceSceneJson;
|
||||
expectedReferenceWitnessHex = pkg.expectedReferenceWitnessHex;
|
||||
hexWitness = pkg.hexWitness;
|
||||
referenceWitness = pkg.referenceWitness;
|
||||
runTransient = pkg.runTransient;
|
||||
}
|
||||
|
||||
let pipeline: WasmPipelineApi | null = null;
|
||||
let configJson = '';
|
||||
let sceneJson = '';
|
||||
let seed = BigInt(0xCAFEBABE);
|
||||
|
||||
let running = false;
|
||||
let timer: number | null = null;
|
||||
let framesEmitted = 0;
|
||||
let tStart = 0;
|
||||
|
||||
function ensureRebuild(): void {
|
||||
if (!sceneJson) sceneJson = referenceSceneJson();
|
||||
if (!configJson) {
|
||||
configJson = JSON.stringify({
|
||||
digitiser: { f_s_hz: 10000, f_mod_hz: 1000 },
|
||||
sensor: {
|
||||
gamma_fwhm_hz: 1.0e6,
|
||||
t1_s: 5.0e-3,
|
||||
t2_s: 1.0e-6,
|
||||
t2_star_s: 200e-9,
|
||||
contrast: 0.03,
|
||||
n_spins: 1.0e12,
|
||||
shot_noise_disabled: false,
|
||||
},
|
||||
dt_s: null,
|
||||
});
|
||||
}
|
||||
pipeline?.free?.();
|
||||
pipeline = new _WasmPipeline(sceneJson, configJson, Number(seed & 0xFFFFFFFFn));
|
||||
}
|
||||
|
||||
function post(msg: unknown, transfer: Transferable[] = []): void {
|
||||
// postMessage Transferable overload: pass transfer list as 2nd arg
|
||||
(ws.postMessage as (msg: unknown, t: Transferable[]) => void)(msg, transfer);
|
||||
}
|
||||
|
||||
function startTimer(): void {
|
||||
if (timer !== null) return;
|
||||
tStart = performance.now();
|
||||
framesEmitted = 0;
|
||||
const tick = (): void => {
|
||||
if (!running || !pipeline) return;
|
||||
// Per-tick: simulate 32 frames; push as one batch.
|
||||
const n = 32;
|
||||
const bytes = pipeline.run(n);
|
||||
framesEmitted += n;
|
||||
const elapsed = (performance.now() - tStart) / 1000;
|
||||
const fps = elapsed > 0 ? framesEmitted / elapsed : 0;
|
||||
post(
|
||||
{ type: 'frames', batch: bytes.buffer, count: n, fps, framesEmitted },
|
||||
[bytes.buffer],
|
||||
);
|
||||
timer = ws.setTimeout(tick, 16);
|
||||
};
|
||||
timer = ws.setTimeout(tick, 0);
|
||||
}
|
||||
|
||||
function stopTimer(): void {
|
||||
if (timer !== null) {
|
||||
ws.clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
ws.addEventListener('message', async (ev: MessageEvent): Promise<void> => {
|
||||
const m = ev.data as { type: string; id?: number; [k: string]: unknown };
|
||||
try {
|
||||
switch (m.type) {
|
||||
case 'boot': {
|
||||
const base = (m.base as string | undefined) ?? '/';
|
||||
await loadPkg(base);
|
||||
ensureRebuild();
|
||||
post({
|
||||
type: 'booted',
|
||||
id: m.id,
|
||||
buildVersion: _WasmPipeline.buildVersion(),
|
||||
frameMagic: _WasmPipeline.frameMagic(),
|
||||
frameBytes: _WasmPipeline.frameBytes(),
|
||||
expectedWitnessHex: expectedReferenceWitnessHex(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'setScene': {
|
||||
sceneJson = m.json as string;
|
||||
ensureRebuild();
|
||||
post({ type: 'ack', id: m.id });
|
||||
break;
|
||||
}
|
||||
case 'setConfig': {
|
||||
configJson = m.json as string;
|
||||
ensureRebuild();
|
||||
post({ type: 'ack', id: m.id });
|
||||
break;
|
||||
}
|
||||
case 'setSeed': {
|
||||
seed = BigInt(m.seed as string | number | bigint);
|
||||
ensureRebuild();
|
||||
post({ type: 'ack', id: m.id });
|
||||
break;
|
||||
}
|
||||
case 'reset': {
|
||||
stopTimer();
|
||||
running = false;
|
||||
ensureRebuild();
|
||||
framesEmitted = 0;
|
||||
post({ type: 'ack', id: m.id });
|
||||
post({ type: 'state', running: false, framesEmitted });
|
||||
break;
|
||||
}
|
||||
case 'run': {
|
||||
if (!pipeline) ensureRebuild();
|
||||
running = true;
|
||||
startTimer();
|
||||
post({ type: 'ack', id: m.id });
|
||||
post({ type: 'state', running: true, framesEmitted });
|
||||
break;
|
||||
}
|
||||
case 'pause': {
|
||||
running = false;
|
||||
stopTimer();
|
||||
post({ type: 'ack', id: m.id });
|
||||
post({ type: 'state', running: false, framesEmitted });
|
||||
break;
|
||||
}
|
||||
case 'step': {
|
||||
if (!pipeline) ensureRebuild();
|
||||
const bytes = pipeline!.run(1);
|
||||
framesEmitted += 1;
|
||||
post(
|
||||
{ type: 'frames', batch: bytes.buffer, count: 1, fps: 0, framesEmitted },
|
||||
[bytes.buffer],
|
||||
);
|
||||
post({ type: 'ack', id: m.id });
|
||||
break;
|
||||
}
|
||||
case 'witnessGenerate': {
|
||||
if (!pipeline) ensureRebuild();
|
||||
const samples = (m.samples as number) ?? 256;
|
||||
const result = pipeline!.runWithWitness(samples) as {
|
||||
frames: Uint8Array;
|
||||
witness: Uint8Array;
|
||||
frameCount: number;
|
||||
};
|
||||
const hex = hexWitness(result.witness);
|
||||
post(
|
||||
{
|
||||
type: 'witness',
|
||||
id: m.id,
|
||||
witness: result.witness.buffer,
|
||||
hex,
|
||||
frameCount: result.frameCount,
|
||||
},
|
||||
[result.witness.buffer],
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'witnessVerify': {
|
||||
// Verify always runs the *canonical* reference scene at seed=42, N=256
|
||||
// so the witness matches Proof::EXPECTED_WITNESS_HEX byte-for-byte.
|
||||
// The user's working scene/config/seed don't affect the witness.
|
||||
const expectedBuf = m.expected as ArrayBuffer;
|
||||
const expected = new Uint8Array(expectedBuf);
|
||||
const actual = referenceWitness();
|
||||
let ok = actual.length === expected.length;
|
||||
if (ok) {
|
||||
for (let i = 0; i < expected.length; i++) {
|
||||
if (actual[i] !== expected[i]) { ok = false; break; }
|
||||
}
|
||||
}
|
||||
const actualBuf = actual.slice().buffer;
|
||||
post(
|
||||
{
|
||||
type: 'verify',
|
||||
id: m.id,
|
||||
ok,
|
||||
actual: actualBuf,
|
||||
actualHex: hexWitness(actual),
|
||||
},
|
||||
[actualBuf],
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'runTransient': {
|
||||
const sceneJson = m.scene as string;
|
||||
const configJson = m.config as string;
|
||||
const seed = (m.seed as number) ?? 0;
|
||||
const samples = (m.samples as number) ?? 64;
|
||||
const r = runTransient(sceneJson, configJson, seed, samples);
|
||||
post({
|
||||
type: 'transient',
|
||||
id: m.id,
|
||||
bRecoveredT: Array.from(r.bRecoveredT),
|
||||
bMagT: r.bMagT,
|
||||
noiseFloorPtSqrtHz: r.noiseFloorPtSqrtHz,
|
||||
sigmaPt: Array.from(r.sigmaPt),
|
||||
nFrames: r.nFrames,
|
||||
witnessHex: r.witnessHex,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'buildId': {
|
||||
post({
|
||||
type: 'buildId',
|
||||
id: m.id,
|
||||
buildId: `nvsim@${_WasmPipeline.buildVersion()}`,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
post({ type: 'err', id: m.id, msg: `unknown op ${m.type}` });
|
||||
}
|
||||
} catch (e) {
|
||||
post({ type: 'err', id: m.id, msg: (e as Error).message ?? String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
post({ type: 'ready' });
|
||||
@@ -1,56 +0,0 @@
|
||||
/* axe-core accessibility smoke against the built dashboard.
|
||||
* Closes ADR-092 §11.5 — formal axe scan.
|
||||
*
|
||||
* Runs against `npm run preview` (Vite preview server). Validates each
|
||||
* primary view (home / scene / apps / inspector / witness / ghost-murmur)
|
||||
* and asserts 0 critical/serious violations.
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
|
||||
const VIEWS = ['home', 'scene', 'apps', 'inspector', 'witness', 'ghost-murmur'] as const;
|
||||
|
||||
test.describe('axe-core a11y smoke', () => {
|
||||
for (const view of VIEWS) {
|
||||
test(`view: ${view}`, async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// Dismiss the welcome modal if it auto-shows.
|
||||
await page.evaluate(() => {
|
||||
const sr = (document.querySelector('nv-app') as HTMLElement & { shadowRoot: ShadowRoot }).shadowRoot;
|
||||
const ob = sr.querySelector('nv-onboarding') as HTMLElement | null;
|
||||
if (ob?.hasAttribute('open')) {
|
||||
(ob.shadowRoot?.querySelector('.skip') as HTMLElement | null)?.click();
|
||||
}
|
||||
});
|
||||
// Navigate to the view via the rail button (except for home which is default).
|
||||
if (view !== 'home') {
|
||||
await page.evaluate((v) => {
|
||||
const sr = (document.querySelector('nv-app') as HTMLElement & { shadowRoot: ShadowRoot }).shadowRoot;
|
||||
const rail = sr.querySelector('nv-rail') as HTMLElement & { shadowRoot: ShadowRoot };
|
||||
const btn = rail.shadowRoot.querySelector(`button[data-id=${v}-btn]`) as HTMLElement | null;
|
||||
btn?.click();
|
||||
}, view);
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
const results = await new AxeBuilder({ page })
|
||||
.options({ runOnly: ['wcag2a', 'wcag2aa'] })
|
||||
.analyze();
|
||||
|
||||
const critical = results.violations.filter((v) => v.impact === 'critical');
|
||||
const serious = results.violations.filter((v) => v.impact === 'serious');
|
||||
|
||||
// Logging the violation summary makes CI failures readable.
|
||||
if (critical.length || serious.length) {
|
||||
for (const v of [...critical, ...serious]) {
|
||||
console.error(`[${view}] ${v.impact} · ${v.id} · ${v.help}`);
|
||||
for (const node of v.nodes) console.error(` ${node.target.join(' >> ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
expect(critical.length, 'no critical violations').toBe(0);
|
||||
expect(serious.length, 'no serious violations').toBe(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"],
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noImplicitOverride": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"exactOptionalPropertyTypes": false,
|
||||
"useDefineForClassFields": false,
|
||||
"experimentalDecorators": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/**/*", "vite.config.ts"],
|
||||
"exclude": ["node_modules", "dist", "public/nvsim-pkg"]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user