feat(pointcloud): integrate ESP32 CSI as optional data stream from hosted viewer
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=<url> 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 <ruv@ruv.net>
This commit is contained in:
parent
e3021c777c
commit
ad41a89960
|
|
@ -45,8 +45,15 @@ jobs:
|
||||||
- Default — synthetic in-browser demo (no backend, no network calls).
|
- Default — synthetic in-browser demo (no backend, no network calls).
|
||||||
- `?backend=auto` — fetch from `/api/splats` on the same origin
|
- `?backend=auto` — fetch from `/api/splats` on the same origin
|
||||||
(only works when the viewer is served by `ruview-pointcloud serve`).
|
(only works when the viewer is served by `ruview-pointcloud serve`).
|
||||||
- `?backend=<url>` — fetch from `<url>/api/splats` on a CORS-permitting
|
- `?backend=<url>` — fetch from `<url>/api/splats`. The intended
|
||||||
host (e.g. `?backend=https://my-ruview.example.com`).
|
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
|
- `?live=1` — require a live backend; show an offline message instead
|
||||||
of falling back to the synthetic demo.
|
of falling back to the synthetic demo.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
serves the viewer and the API together). On any failure (404, network
|
||||||
error, CORS), fall back silently to synthetic-demo rendering so the
|
error, CORS), fall back silently to synthetic-demo rendering so the
|
||||||
tab never dies.
|
tab never dies.
|
||||||
3. **Remote mode** (`?backend=<url>`) — fetch from `<url>/api/splats`. The
|
3. **Remote mode** (`?backend=<url>`) — fetch from `<url>/api/splats`.
|
||||||
user supplies a CORS-permitting host running their own
|
This is the **integrated-ESP32** path: the user runs
|
||||||
`ruview-pointcloud serve` (e.g. a Tailscale-exposed home node). Badge
|
`ruview-pointcloud serve --bind 127.0.0.1:9880` locally with an
|
||||||
reads `● REMOTE <url>`. Same silent demo fallback on failure.
|
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 <url>`. Same silent
|
||||||
|
demo fallback on failure.
|
||||||
4. **Strict-live mode** (`?live=1`) — disable the demo fallback. If the
|
4. **Strict-live mode** (`?live=1`) — disable the demo fallback. If the
|
||||||
chosen transport fails, replace the info panel with an explicit offline
|
chosen transport fails, replace the info panel with an explicit offline
|
||||||
message (`● OFFLINE — Live backend required but unreachable`). Useful
|
message (`● OFFLINE — Live backend required but unreachable`). Useful
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ serde_json = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
axum = { workspace = true }
|
axum = { workspace = true }
|
||||||
|
tower-http = { workspace = true }
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
dirs = "5"
|
dirs = "5"
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,13 @@ use crate::fusion;
|
||||||
use crate::pointcloud;
|
use crate::pointcloud;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::State,
|
extract::State,
|
||||||
|
http::{HeaderValue, Method},
|
||||||
response::Html,
|
response::Html,
|
||||||
routing::get,
|
routing::get,
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
use tower_http::cors::{AllowOrigin, CorsLayer};
|
||||||
|
|
||||||
struct AppState {
|
struct AppState {
|
||||||
latest_cloud: Mutex<pointcloud::PointCloud>,
|
latest_cloud: Mutex<pointcloud::PointCloud>,
|
||||||
|
|
@ -108,12 +110,36 @@ pub async fn serve(bind: &str, _brain: Option<&str>) -> anyhow::Result<()> {
|
||||||
if has_camera { eprintln!(" Camera: LIVE (/dev/video0)"); }
|
if has_camera { eprintln!(" Camera: LIVE (/dev/video0)"); }
|
||||||
else { eprintln!(" Camera: DEMO"); }
|
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()
|
let app = Router::new()
|
||||||
.route("/", get(index))
|
.route("/", get(index))
|
||||||
.route("/api/cloud", get(api_cloud))
|
.route("/api/cloud", get(api_cloud))
|
||||||
.route("/api/splats", get(api_splats))
|
.route("/api/splats", get(api_splats))
|
||||||
.route("/api/status", get(api_status))
|
.route("/api/status", get(api_status))
|
||||||
.route("/health", get(api_health))
|
.route("/health", get(api_health))
|
||||||
|
.layer(cors)
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
println!("╔══════════════════════════════════════════════╗");
|
println!("╔══════════════════════════════════════════════╗");
|
||||||
|
|
|
||||||
|
|
@ -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 { 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:hover { background: #ffc04d; }
|
||||||
#cam-cta.hidden { display: none; }
|
#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; }
|
.live { color: #4f4; } .demo { color: #f44; }
|
||||||
.face { color: #4cf; }
|
.face { color: #4cf; }
|
||||||
.section { margin-top: 6px; padding-top: 6px; border-top: 1px solid #333; }
|
.section { margin-top: 6px; padding-top: 6px; border-top: 1px solid #333; }
|
||||||
|
|
@ -32,6 +35,7 @@
|
||||||
<div id="stats">Loading...</div>
|
<div id="stats">Loading...</div>
|
||||||
</div>
|
</div>
|
||||||
<button id="cam-cta">▶ Project Subject — render your face into the Vault</button>
|
<button id="cam-cta">▶ Project Subject — render your face into the Vault</button>
|
||||||
|
<button id="esp-cta" title="Stream live CSI from a local ruview-pointcloud serve instance (e.g. http://127.0.0.1:9880)">📡 Connect ESP32…</button>
|
||||||
<script>
|
<script>
|
||||||
var scene = new THREE.Scene();
|
var scene = new THREE.Scene();
|
||||||
scene.background = new THREE.Color(0x0a0a0a);
|
scene.background = new THREE.Color(0x0a0a0a);
|
||||||
|
|
@ -600,6 +604,42 @@
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// Wire the ESP32 backend CTA: prompts for a ruview-pointcloud serve URL,
|
||||||
|
// persists last-used value in localStorage, and reloads with the
|
||||||
|
// ?backend=<url> query so the existing remote-mode path takes over.
|
||||||
|
// Disconnect by clicking again when already connected.
|
||||||
|
(function wireEspCta() {
|
||||||
|
var btn = document.getElementById("esp-cta");
|
||||||
|
if (!btn) return;
|
||||||
|
var connected = backendArg.startsWith("http");
|
||||||
|
if (connected) {
|
||||||
|
btn.classList.add("connected");
|
||||||
|
btn.textContent = "📡 ESP32 connected · disconnect";
|
||||||
|
}
|
||||||
|
btn.addEventListener("click", function() {
|
||||||
|
if (connected) {
|
||||||
|
// Strip ?backend= from current URL and reload — return to demo.
|
||||||
|
var u = new URL(window.location.href);
|
||||||
|
u.searchParams.delete("backend");
|
||||||
|
window.location.href = u.toString();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var stored;
|
||||||
|
try { stored = localStorage.getItem("ruview.backendUrl"); } catch (_) { stored = null; }
|
||||||
|
var def = stored || "http://127.0.0.1:9880";
|
||||||
|
var url = window.prompt(
|
||||||
|
"Enter the ruview-pointcloud serve URL (run `ruview-pointcloud serve` locally with your ESP32 streaming CSI to UDP port 3333):",
|
||||||
|
def
|
||||||
|
);
|
||||||
|
if (!url) return;
|
||||||
|
url = url.replace(/\/+$/, "");
|
||||||
|
try { localStorage.setItem("ruview.backendUrl", url); } catch (_) {}
|
||||||
|
var u2 = new URL(window.location.href);
|
||||||
|
u2.searchParams.set("backend", url);
|
||||||
|
window.location.href = u2.toString();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
fetchCloud();
|
fetchCloud();
|
||||||
setInterval(fetchCloud, 100); // 10 Hz — denser updates so face mesh feels live and the spiral animates smoothly
|
setInterval(fetchCloud, 100); // 10 Hz — denser updates so face mesh feels live and the spiral animates smoothly
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue