From ad41a8996004ef88067dd8765642b87bb980a06f Mon Sep 17 00:00:00 2001 From: ruv Date: Wed, 29 Apr 2026 20:33:00 -0400 Subject: [PATCH] feat(pointcloud): integrate ESP32 CSI as optional data stream from hosted viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hosted GitHub Pages viewer can now act as a thin client for a locally-running ruview-pointcloud serve instance โ€” flip a button, the ESP32's CSI fusion (camera depth + WiFi CSI + mmWave) renders inside the same Three.js scene that previously only showed the face mesh demo. No clone, no rebuild, no toolchain on the visitor's side. Server (stream.rs): - Add tower_http::cors::CorsLayer with a deliberate allowlist: https://ruvnet.github.io, http://localhost:*, http://127.0.0.1:*, and 'null' (for file:// origins). Anything else is denied โ€” not a wildcard CORS. Modern browsers (Chrome 94+, Firefox 116+, Safari 16.4+) treat 127.0.0.1 as a "potentially trustworthy" origin so HTTPS Pages โ†’ HTTP loopback is permitted. The new layer wraps the existing /api/cloud, /api/splats, /api/status, /health routes. - Cargo.toml: pull in workspace tower-http (cors feature already on). Viewer: - New "๐Ÿ“ก Connect ESP32โ€ฆ" CTA bottom-right. Clicking prompts for a ruview-pointcloud serve URL (default http://127.0.0.1:9880), persists the last-used value in localStorage, and reloads with ?backend= so the existing remote-mode fetch path takes over. When already connected the button toggles to "disconnect" and reloads back to the demo. - Reuses the existing transport selector โ€” no new code path to maintain. The face mesh / synthetic demo render path is unaffected; this is purely an additive UI affordance over the ?backend= query. Docs: - ADR-094 ยง2.3 expanded with the local-ESP32 workflow and the CORS posture rationale. - Workflow README documents ?backend=http://127.0.0.1:9880 as the intended local-ESP32 path. Tests: cargo test -p wifi-densepose-pointcloud โ†’ 15/15 passed. Co-Authored-By: claude-flow --- .github/workflows/pointcloud-pages.yml | 11 ++++- ...-094-pointcloud-github-pages-deployment.md | 30 ++++++++++++-- .../wifi-densepose-pointcloud/Cargo.toml | 1 + .../wifi-densepose-pointcloud/src/stream.rs | 26 ++++++++++++ .../wifi-densepose-pointcloud/src/viewer.html | 40 +++++++++++++++++++ 5 files changed, 102 insertions(+), 6 deletions(-) diff --git a/.github/workflows/pointcloud-pages.yml b/.github/workflows/pointcloud-pages.yml index 74f33deb..8b3eb51e 100644 --- a/.github/workflows/pointcloud-pages.yml +++ b/.github/workflows/pointcloud-pages.yml @@ -45,8 +45,15 @@ jobs: - 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=` โ€” fetch from `/api/splats` on a CORS-permitting - host (e.g. `?backend=https://my-ruview.example.com`). + - `?backend=` โ€” fetch from `/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. diff --git a/docs/adr/ADR-094-pointcloud-github-pages-deployment.md b/docs/adr/ADR-094-pointcloud-github-pages-deployment.md index 2f59afd6..4b6a125f 100644 --- a/docs/adr/ADR-094-pointcloud-github-pages-deployment.md +++ b/docs/adr/ADR-094-pointcloud-github-pages-deployment.md @@ -66,10 +66,32 @@ and publish it to `gh-pages/pointcloud/` alongside the other demos: serves the viewer and the API together). On any failure (404, network error, CORS), fall back silently to synthetic-demo rendering so the tab never dies. -3. **Remote mode** (`?backend=`) โ€” fetch from `/api/splats`. The - user supplies a CORS-permitting host running their own - `ruview-pointcloud serve` (e.g. a Tailscale-exposed home node). Badge - reads `โ— REMOTE `. Same silent demo fallback on failure. +3. **Remote mode** (`?backend=`) โ€” fetch from `/api/splats`. + This is the **integrated-ESP32** path: the user runs + `ruview-pointcloud serve --bind 127.0.0.1:9880` locally with an + ESP32-S3 streaming CSI to UDP port 3333, then opens + `https://ruvnet.github.io/RuView/pointcloud/?backend=http://127.0.0.1:9880`. + The hosted Pages viewer becomes a thin client for the local Rust + fusion pipeline (camera depth + WiFi CSI + mmWave) without a clone + or rebuild. The viewer also exposes a "๐Ÿ“ก Connect ESP32" button that + prompts for the URL, persists it in `localStorage`, and reloads + with the query param. + + For this to work the local server must answer the browser's CORS + preflight. `stream.rs` therefore installs a `tower_http` `CorsLayer` + that allows three origin classes: + + - `https://ruvnet.github.io` โ€” the published Pages demo. + - `http://localhost:*` and `http://127.0.0.1:*` โ€” developer running + the bundled `viewer.html` directly. + - `null` โ€” `file://` origins. + + Mixed-content (HTTPS Pages โ†’ HTTP loopback) is permitted because + modern browsers (Chrome 94+, Firefox 116+, Safari 16.4+) classify + `127.0.0.1` and `localhost` as "potentially trustworthy" origins. + Any other origin (a public hostname, etc.) is denied โ€” this is not + a wildcard CORS posture. Badge reads `โ— REMOTE `. Same silent + demo fallback on failure. 4. **Strict-live mode** (`?live=1`) โ€” disable the demo fallback. If the chosen transport fails, replace the info panel with an explicit offline message (`โ— OFFLINE โ€” Live backend required but unreachable`). Useful diff --git a/v2/crates/wifi-densepose-pointcloud/Cargo.toml b/v2/crates/wifi-densepose-pointcloud/Cargo.toml index a6d2700f..371b855d 100644 --- a/v2/crates/wifi-densepose-pointcloud/Cargo.toml +++ b/v2/crates/wifi-densepose-pointcloud/Cargo.toml @@ -14,6 +14,7 @@ serde_json = { workspace = true } tokio = { workspace = true } anyhow = { workspace = true } axum = { workspace = true } +tower-http = { workspace = true } clap = { version = "4", features = ["derive"] } chrono = "0.4" dirs = "5" diff --git a/v2/crates/wifi-densepose-pointcloud/src/stream.rs b/v2/crates/wifi-densepose-pointcloud/src/stream.rs index 83f988e2..808f6231 100644 --- a/v2/crates/wifi-densepose-pointcloud/src/stream.rs +++ b/v2/crates/wifi-densepose-pointcloud/src/stream.rs @@ -8,11 +8,13 @@ use crate::fusion; use crate::pointcloud; use axum::{ extract::State, + http::{HeaderValue, Method}, response::Html, routing::get, Json, Router, }; use std::sync::{Arc, Mutex}; +use tower_http::cors::{AllowOrigin, CorsLayer}; struct AppState { latest_cloud: Mutex, @@ -108,12 +110,36 @@ pub async fn serve(bind: &str, _brain: Option<&str>) -> anyhow::Result<()> { if has_camera { eprintln!(" Camera: LIVE (/dev/video0)"); } else { eprintln!(" Camera: DEMO"); } + // CORS โ€” allow the hosted GitHub Pages viewer to fetch /api/splats from a + // locally-running instance of this server. Modern browsers treat + // 127.0.0.1/localhost as a "potentially trustworthy" origin so the HTTPS + // page can reach a plain-HTTP loopback backend without mixed-content + // blocking. Origins permitted: + // - https://ruvnet.github.io (the published RuView Pages demo) + // - http://localhost:* / http://127.0.0.1:* (developer running the + // viewer.html bundled with this binary) + // Anything else is denied, so this is not a "wildcard" CORS. + let cors = CorsLayer::new() + .allow_origin(AllowOrigin::predicate(|origin: &HeaderValue, _req| { + let s = match origin.to_str() { + Ok(v) => v, + Err(_) => return false, + }; + s == "https://ruvnet.github.io" + || s.starts_with("http://localhost") + || s.starts_with("http://127.0.0.1") + || s == "null" // file:// origins + })) + .allow_methods([Method::GET, Method::OPTIONS]) + .allow_headers([axum::http::header::CONTENT_TYPE]); + let app = Router::new() .route("/", get(index)) .route("/api/cloud", get(api_cloud)) .route("/api/splats", get(api_splats)) .route("/api/status", get(api_status)) .route("/health", get(api_health)) + .layer(cors) .with_state(state); println!("โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—"); diff --git a/v2/crates/wifi-densepose-pointcloud/src/viewer.html b/v2/crates/wifi-densepose-pointcloud/src/viewer.html index c8ac95b3..be142a49 100644 --- a/v2/crates/wifi-densepose-pointcloud/src/viewer.html +++ b/v2/crates/wifi-densepose-pointcloud/src/viewer.html @@ -14,6 +14,9 @@ #cam-cta { position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%); padding: 10px 18px; background: #e8a634; color: #0a0a0a; border: none; border-radius: 4px; font-family: monospace; font-size: 14px; font-weight: bold; cursor: pointer; z-index: 10; } #cam-cta:hover { background: #ffc04d; } #cam-cta.hidden { display: none; } + #esp-cta { position: absolute; bottom: 16px; right: 16px; padding: 8px 14px; background: transparent; color: #e8a634; border: 1px solid #e8a634; border-radius: 4px; font-family: monospace; font-size: 12px; cursor: pointer; z-index: 10; } + #esp-cta:hover { background: rgba(232, 166, 52, 0.12); } + #esp-cta.connected { background: #4f4; color: #0a0a0a; border-color: #4f4; } .live { color: #4f4; } .demo { color: #f44; } .face { color: #4cf; } .section { margin-top: 6px; padding-top: 6px; border-top: 1px solid #333; } @@ -32,6 +35,7 @@
Loading...
+