setDrag('min')} style={`position: absolute; top: 50%; left: calc(${tMin*100}% - 9px); transform: translateY(-50%); width: 18px; height: 18px; border-radius: 50%; background: var(--text-0); border: 3px solid var(--acc-orange); cursor: grab; box-shadow: var(--shadow-1);`}/>
+
setDrag('max')} style={`position: absolute; top: 50%; left: calc(${tMax*100}% - 9px); transform: translateY(-50%); width: 18px; height: 18px; border-radius: 50%; background: var(--text-0); border: 3px solid var(--acc-orange); cursor: grab; box-shadow: var(--shadow-1);`}/>
+
+ );
+}
+
+/* SVG topology diagrams used by Mesh screen. */
+export function TopologyDiagram({ kind, size = 96 }: { kind: 'straight'|'l_shape'|'u_shape'|'custom'; size?: number }) {
+ const stroke = 'var(--text-2)';
+ const acc = 'var(--acc-orange)';
+ if (kind === 'straight') return (
+
+ );
+ if (kind === 'l_shape') return (
+
+ );
+ if (kind === 'u_shape') return (
+
+ );
+ return (
+
+ );
+}
+
+/* Two-line chart for Motion screen β raw vs smoothed distance. */
+export function LineChart({ raw, smooth, width = 600, height = 180 }: {
+ raw: number[]; smooth: number[]; width?: number; height?: number;
+}) {
+ const lo = 0, hi = 300;
+ const range = hi - lo;
+ const toPath = (data: number[]) => data.map((v, i) => {
+ const x = (i / Math.max(1, data.length - 1)) * width;
+ const y = height - ((v - lo) / range) * height;
+ return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`;
+ }).join(' ');
+ return (
+
+ );
+}
+
+/* HSV β RGB conversion for the color wheel. */
+export function hsv2rgb(h: number, s: number, v: number): [number, number, number] {
+ h = ((h % 360) + 360) % 360;
+ const c = v * s, x = c * (1 - Math.abs(((h / 60) % 2) - 1)), m = v - c;
+ let r = 0, g = 0, b = 0;
+ if (h < 60) [r, g, b] = [c, x, 0];
+ else if (h < 120) [r, g, b] = [x, c, 0];
+ else if (h < 180) [r, g, b] = [0, c, x];
+ else if (h < 240) [r, g, b] = [0, x, c];
+ else if (h < 300) [r, g, b] = [x, 0, c];
+ else [r, g, b] = [c, 0, x];
+ return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)];
+}
+export function rgb2hex(r: number, g: number, b: number) {
+ return '#' + [r, g, b].map(n => Math.max(0, Math.min(255, n)).toString(16).padStart(2, '0')).join('').toUpperCase();
+}
+export function hex2rgb(hex: string): [number, number, number] {
+ const h = hex.replace('#', '').padEnd(6, '0').slice(0, 6);
+ return [parseInt(h.slice(0,2), 16), parseInt(h.slice(2,4), 16), parseInt(h.slice(4,6), 16)];
+}
+
+/* Animated logo mark β triangle "A" with concentric pulse rings.
+ * Pulse intensity scales with proximity (closer target = brighter).
+ * Faithful port of frontend/design-source/project/app.jsx LogoMark. */
+export function LogoMark({ size = 36, distance = 0 }: { size?: number; distance?: number }) {
+ const pulseStrength = Math.max(0.2, Math.min(1, (250 - distance) / 200));
+ return (
+
+
+
+
+ );
+}
+
+/* Wordmark with gradient "Sense" β paired with LogoMark in sidebar/header. */
+export function Wordmark({ font = 16, sub = 10, mono = false, version, target }: {
+ font?: number; sub?: number; mono?: boolean; version?: string; target?: string;
+}) {
+ if (mono) return null;
+ return (
+
+
+ AmbiSense
+
+
+ {version || ''} Β· {target || ''}
+
+
+ );
+}
diff --git a/frontend/src/components.tsx b/frontend/src/components.tsx
new file mode 100644
index 0000000..a569c4e
--- /dev/null
+++ b/frontend/src/components.tsx
@@ -0,0 +1,130 @@
+/** Shared UI atoms. */
+import { ComponentChildren, JSX } from 'preact';
+import { useEffect, useState } from 'preact/hooks';
+
+export function Card({ title, right, children }: {
+ title?: string; right?: ComponentChildren; children: ComponentChildren;
+}) {
+ return (
+
+ {title && (
+
+
{title}
+ {right}
+
+ )}
+
{children}
+
+ );
+}
+
+export function Toggle({ value, onChange, large }: {
+ value: boolean; onChange: (v: boolean) => void; large?: boolean;
+}) {
+ return (
+
onChange(!value)}
+ role="switch"
+ aria-checked={value}
+ />
+ );
+}
+
+export function Field({ label, children }: { label: string; children: ComponentChildren }) {
+ return (
+
+
+ {children}
+
+ );
+}
+
+export function Slider({ value, min, max, onChange, suffix }: {
+ value: number; min: number; max: number; onChange: (v: number) => void; suffix?: string;
+}) {
+ return (
+
+ onChange(parseInt((e.target as HTMLInputElement).value))}
+ />
+
+ {value}{suffix || ''}
+
+
+ );
+}
+
+export function Toast({ msg, kind, onDone }: { msg: string; kind: 'ok'|'err'; onDone: () => void }) {
+ useEffect(() => {
+ const t = setTimeout(onDone, kind === 'err' ? 4500 : 2200);
+ return () => clearTimeout(t);
+ }, []);
+ return
{msg}
;
+}
+
+export function Row({ k, v }: { k: string; v: ComponentChildren }) {
+ return (
+
+ {k}
+ {v}
+
+ );
+}
+
+export function Dot({ kind }: { kind: 'ok'|'warn'|'err'|'off' }) {
+ return
;
+}
+
+/* Optimistic save helper: shows a toast based on a promise. */
+export function useToaster() {
+ const [t, setT] = useState<{ msg: string; kind: 'ok'|'err' } | null>(null);
+ return {
+ toast: t,
+ set: (msg: string, kind: 'ok'|'err' = 'ok') => setT({ msg, kind }),
+ clear: () => setT(null),
+ track: async
(p: Promise, okMsg = 'Saved'): Promise => {
+ try { const r = await p; setT({ msg: okMsg, kind: 'ok' }); return r; }
+ catch (e: any) { setT({ msg: e.message || 'Failed', kind: 'err' }); return undefined; }
+ }
+ };
+}
+
+/* HSV color picker β simple wheel + lightness slider. Returns r/g/b. */
+export function ColorPicker({ rgb, onChange }: { rgb: [number,number,number]; onChange: (r:number,g:number,b:number)=>void }) {
+ const presets: [number,number,number][] = [
+ [255,255,255], [255,170,80], [255,80,80], [255,80,180],
+ [180,80,255], [80,180,255], [80,255,180], [255,255,80],
+ ];
+ return (
+
+ {presets.map(p => {
+ const on = p[0]===rgb[0] && p[1]===rgb[1] && p[2]===rgb[2];
+ return (
+
onChange(p[0], p[1], p[2])}
+ />
+ );
+ })}
+ v.toString(16).padStart(2,'0')).join('')}`}
+ onInput={(e) => {
+ const hex = (e.target as HTMLInputElement).value;
+ const r = parseInt(hex.slice(1,3),16);
+ const g = parseInt(hex.slice(3,5),16);
+ const b = parseInt(hex.slice(5,7),16);
+ onChange(r,g,b);
+ }}
+ style="width: 36px; height: 36px; border: 1px solid var(--line); border-radius: 8px; padding: 2px; background: var(--bg-1); cursor: pointer;"
+ />
+
+ );
+}
diff --git a/frontend/src/led_preview.tsx b/frontend/src/led_preview.tsx
new file mode 100644
index 0000000..ccdd872
--- /dev/null
+++ b/frontend/src/led_preview.tsx
@@ -0,0 +1,198 @@
+/** LED strip canvas preview. Mirrors the firmware's mode logic so the
+ on-screen animation matches what a real strip would show. */
+import { useEffect, useRef } from 'preact/hooks';
+
+export const LED_MODE_NAMES = [
+ 'Standard','Rainbow','Color Wave','Breathing','Solid',
+ 'Comet','Pulse','Fire','Theater Chase','Dual Scan','Motion Particles',
+];
+
+interface PreviewProps {
+ mode: number;
+ rgb: [number, number, number];
+ count: number;
+ brightness: number;
+ span: number;
+ distance?: number;
+ minD?: number;
+ maxD?: number;
+ height?: number;
+ speed?: number;
+ intensity?: number;
+}
+
+interface RGB { r: number; g: number; b: number; }
+
+const wheel = (p: number): RGB => {
+ if (p < 85) return { r: p*3, g: 255-p*3, b: 0 };
+ if (p < 170) { p -= 85; return { r: 255-p*3, g: 0, b: p*3 }; }
+ p -= 170; return { r: 0, g: p*3, b: 255-p*3 };
+};
+const dim = (c: RGB, k: number): RGB => ({ r: (c.r*k)|0, g: (c.g*k)|0, b: (c.b*k)|0 });
+
+export function LedPreview(p: PreviewProps) {
+ const ref = useRef
(null);
+ const stateRef = useRef<{ step: number; particles: any[]; heat: number[]; prev: RGB[] }>({
+ step: 0, particles: [], heat: [], prev: [],
+ });
+
+ useEffect(() => {
+ const canvas = ref.current;
+ if (!canvas) return;
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return;
+
+ let raf = 0;
+ let stop = false;
+
+ const draw = () => {
+ if (stop) return;
+ /* Pause when tab is hidden β phones don't need to burn battery on
+ * an off-screen canvas. The next visibilitychange resumes us. */
+ if (document.hidden) {
+ raf = requestAnimationFrame(draw);
+ return;
+ }
+ const w = canvas.width = canvas.clientWidth * window.devicePixelRatio;
+ const h = canvas.height = (p.height || 80) * window.devicePixelRatio;
+ const n = Math.max(1, Math.min(p.count, 300)); /* preview cap */
+ const pxW = w / n;
+ ctx.clearRect(0, 0, w, h);
+
+ const s = stateRef.current;
+ if (s.heat.length !== n) s.heat = new Array(n).fill(0);
+ if (s.prev.length !== n) s.prev = new Array(n).fill({r:0,g:0,b:0});
+
+ const base: RGB = { r: p.rgb[0], g: p.rgb[1], b: p.rgb[2] };
+ const br = (p.brightness ?? 255) / 255;
+ const minD = p.minD ?? 30, maxD = p.maxD ?? 300;
+ const span = Math.max(1, p.span);
+ const avail = Math.max(0, n - span);
+ const dist = p.distance ?? minD + Math.abs(Math.sin(s.step * 0.01)) * (maxD - minD);
+ const start = ((dist - minD) / (maxD - minD) * avail) | 0;
+ const espd = (p.speed ?? 50) / 50;
+ const eint = (p.intensity ?? 50) / 100;
+
+ const paint = (i: number, c: RGB) => {
+ if (i < 0 || i >= n) return;
+ ctx.fillStyle = `rgb(${(c.r*br)|0},${(c.g*br)|0},${(c.b*br)|0})`;
+ ctx.fillRect(i * pxW, 0, pxW + 1, h);
+ };
+
+ switch (p.mode) {
+ case 0: { /* standard */
+ for (let i = 0; i < n; i++) paint(i, dim(base, 0.05));
+ for (let i = start; i < Math.min(start + span, n); i++) paint(i, base);
+ break;
+ }
+ case 1: { /* rainbow */
+ const off = (s.step * espd) & 0xFF;
+ for (let i = 0; i < n; i++) paint(i, wheel(((i*256/n + off) | 0) & 0xFF));
+ break;
+ }
+ case 2: { /* color_wave */
+ for (let i = 0; i < n; i++) {
+ const phase = i / n * 6.28 + s.step * 0.05 * espd;
+ let k = (Math.sin(phase) + 1) * 0.5;
+ k = k * eint + (1 - eint) * 0.4;
+ paint(i, dim(wheel(((i*256/n + s.step) | 0) & 0xFF), k));
+ }
+ break;
+ }
+ case 3: { /* breathing */
+ let k = (Math.sin(s.step * 0.05 * espd) + 1) * 0.5;
+ k = 0.1 + 0.9 * k * eint;
+ for (let i = 0; i < n; i++) paint(i, dim(base, k));
+ break;
+ }
+ case 4: { /* solid */
+ for (let i = 0; i < n; i++) paint(i, base);
+ break;
+ }
+ case 5: { /* comet */
+ const fade = 0.85;
+ for (let i = 0; i < n; i++) {
+ s.prev[i] = dim(s.prev[i], fade);
+ paint(i, s.prev[i]);
+ }
+ for (let i = 0; i < 3; i++) {
+ const px = start + i;
+ if (px < n) { s.prev[px] = base; paint(px, base); }
+ }
+ break;
+ }
+ case 6: { /* pulse */
+ for (let i = 0; i < n; i++) paint(i, {r:0,g:0,b:0});
+ const max_r = n / 4;
+ for (let p2 = 0; p2 < 3; p2++) {
+ const phase = p2 * 2;
+ const r = ((s.step * 0.2 + phase) % max_r);
+ for (let off = -((r)|0); off <= ((r)|0); off++) {
+ const k = (1 - Math.abs(off / Math.max(0.1, r)) ** 2) * eint;
+ const idx = start + off;
+ if (idx >= 0 && idx < n && k > 0) paint(idx, dim(base, k));
+ }
+ }
+ break;
+ }
+ case 7: { /* fire */
+ for (let i = 0; i < n; i++) s.heat[i] = Math.max(0, s.heat[i] - (Math.random()*55 + 2)|0);
+ for (let i = n-1; i >= 2; i--) s.heat[i] = ((s.heat[i-1] + s.heat[i-2]*2) / 3)|0;
+ if (Math.random() < 0.5) { const y = (Math.random()*7)|0; s.heat[y] = Math.min(255, s.heat[y] + 160 + ((Math.random()*96)|0)); }
+ for (let i = 0; i < n; i++) {
+ const t = ((s.heat[i] * 191) / 255)|0;
+ let c: RGB;
+ if (t < 64) c = {r: t*4, g: 0, b: 0};
+ else if (t < 128) c = {r: 255, g: (t-64)*4, b: 0};
+ else c = {r: 255, g: 255, b: (t-128)*4};
+ paint(i, c);
+ }
+ break;
+ }
+ case 8: { /* theater_chase */
+ const gap = 3, ph = s.step % gap;
+ for (let i = 0; i < n; i++) paint(i, ((i+ph) % gap === 0) ? base : {r:0,g:0,b:0});
+ break;
+ }
+ case 9: { /* dual_scan */
+ for (let i = 0; i < n; i++) paint(i, {r:0,g:0,b:0});
+ const sw = 4, p1 = (s.step|0) % n, p2 = n - 1 - ((s.step|0) % n);
+ const inv: RGB = { r: 255-base.r, g: 255-base.g, b: 255-base.b };
+ for (let off = -sw; off <= sw; off++) {
+ const k = 1 - Math.abs(off/sw);
+ if (p1+off >= 0 && p1+off < n) paint(p1+off, dim(base, k));
+ if (p2+off >= 0 && p2+off < n) paint(p2+off, dim(inv, k));
+ }
+ if (start >= 0 && start < n) paint(start, {r:255,g:255,b:255});
+ break;
+ }
+ case 10: { /* motion_particles */
+ for (const part of s.particles) {
+ if (!part.active) continue;
+ part.pos += part.vel; part.bright -= 0.02;
+ if (part.bright <= 0 || part.pos < 0 || part.pos >= n) part.active = false;
+ }
+ const spawn = 1 + (eint * 4)|0;
+ for (let i = 0; i < spawn; i++) {
+ if (s.particles.length < 50) s.particles.push({ active: true, pos: start + (Math.random()*7-3), vel: (Math.random()*2-1)*espd*0.6, bright: 1 });
+ else for (const part of s.particles) if (!part.active) { part.active = true; part.pos = start + (Math.random()*7-3); part.vel = (Math.random()*2-1)*espd*0.6; part.bright = 1; break; }
+ }
+ for (let i = 0; i < n; i++) paint(i, {r:0,g:0,b:0});
+ for (const part of s.particles) {
+ if (!part.active) continue;
+ const px = part.pos|0;
+ if (px >= 0 && px < n) paint(px, dim(base, part.bright));
+ }
+ break;
+ }
+ }
+
+ s.step++;
+ raf = requestAnimationFrame(draw);
+ };
+ raf = requestAnimationFrame(draw);
+ return () => { stop = true; cancelAnimationFrame(raf); };
+ }, [p.mode, p.rgb[0], p.rgb[1], p.rgb[2], p.count, p.brightness, p.span, p.distance, p.speed, p.intensity]);
+
+ return ;
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
new file mode 100644
index 0000000..c40b855
--- /dev/null
+++ b/frontend/src/main.tsx
@@ -0,0 +1,134 @@
+/** App shell β sidebar (logo + nav + IP/board footer) + sticky header
+ * (page name Β· hostname.local + live distance chip + RSSI + theme).
+ * Faithful port of frontend/design-source/project/app.jsx. */
+import { render } from 'preact';
+import { useEffect, useState } from 'preact/hooks';
+import { getJSON, liveSocket } from './api';
+import { Toast, useToaster } from './components';
+import { Icon, LogoMark, Wordmark } from './atoms';
+import { ScreenLive, ScreenLeds, ScreenMotion, ScreenMesh, ScreenHardware, ScreenNetwork, ScreenSystem } from './screens';
+import './styles.css';
+
+type Tab = 'live'|'leds'|'motion'|'mesh'|'hardware'|'network'|'system';
+
+const TABS: { id: Tab; name: string; icon: string }[] = [
+ { id: 'live', name: 'Live', icon: 'dashboard' },
+ { id: 'leds', name: 'LEDs', icon: 'led' },
+ { id: 'motion', name: 'Motion', icon: 'motion' },
+ { id: 'mesh', name: 'Mesh', icon: 'mesh' },
+ { id: 'hardware', name: 'Hardware', icon: 'chip' },
+ { id: 'network', name: 'Network', icon: 'wifi' },
+ { id: 'system', name: 'System', icon: 'settings' },
+];
+
+function App() {
+ const [tab, setTab] = useState(localStorage.getItem('ambitab') as Tab || 'live');
+ const [version, setVersion] = useState({});
+ const [settings, setSettings] = useState({});
+ const [live, setLive] = useState({ distance: 0, raw: 0, direction: 0, rssi: 0, heap: 0, uptime: 0, peers: 0, healthy: 0 });
+ const [theme, setTheme] = useState<'dark'|'light'>(localStorage.getItem('ambitheme') as any || 'dark');
+ const [wsConnected, setWsConnected] = useState(false);
+ const t = useToaster();
+
+ const reload = () => {
+ getJSON('/api/version').then(setVersion).catch(() => {});
+ getJSON('/api/settings').then(setSettings).catch(() => {});
+ };
+
+ useEffect(() => { reload(); }, []);
+ useEffect(() => {
+ const id = setInterval(() => { if (!document.hidden) getJSON('/api/version').then(setVersion).catch(() => {}); }, 30000);
+ return () => clearInterval(id);
+ }, []);
+ useEffect(() => {
+ const close = liveSocket((data) => { setLive(data); setWsConnected(true); });
+ return () => { close(); setWsConnected(false); };
+ }, []);
+ useEffect(() => { document.documentElement.setAttribute('data-theme', theme); localStorage.setItem('ambitheme', theme); }, [theme]);
+ useEffect(() => { localStorage.setItem('ambitab', tab); }, [tab]);
+
+ const state = { live, settings, version, toast: t.toast, setToast: t.set, reload };
+ const switcher: Record = { live: ScreenLive, leds: ScreenLeds, motion: ScreenMotion, mesh: ScreenMesh, hardware: ScreenHardware, network: ScreenNetwork, system: ScreenSystem };
+ const Screen = switcher[tab];
+ const tabName = TABS.find(x => x.id === tab)?.name || '';
+ const dist = Math.round(live.distance || 0);
+ const ipShort = version.ip || 'β';
+ const radarKind = settings.radar_kind ? settings.radar_kind.toUpperCase() : '';
+ const boardSub = `${(version.target || 'esp32').toUpperCase()}${radarKind ? ` Β· ${radarKind}` : ''}`;
+ const fwVer = version.version || 'v6.x';
+ const fwTarget = (version.target || 'esp32').toUpperCase();
+
+ return (
+
+ {/* Sidebar β desktop only */}
+
+
+ {/* Main column */}
+
+ {/* Sticky header */}
+
+
+ {/* Page content */}
+
+
+
+
+ {/* Mobile bottom-tabs */}
+
+
+ {t.toast &&
}
+
+ );
+}
+
+render(, document.getElementById('root')!);
diff --git a/frontend/src/screens.tsx b/frontend/src/screens.tsx
new file mode 100644
index 0000000..ee077ba
--- /dev/null
+++ b/frontend/src/screens.tsx
@@ -0,0 +1,1166 @@
+/** All seven screens: Live, LEDs, Motion, Mesh, Hardware, Network, System.
+ * Faithful port of frontend/design-source/. Every control wires to a real
+ * /api/* endpoint with optimistic updates + toast confirmation. */
+import { useEffect, useRef, useState } from 'preact/hooks';
+import { Card, Toggle, Field, Slider, Row, Dot, ColorPicker as PaletteColorPicker, useToaster } from './components';
+import { LedPreview, LED_MODE_NAMES } from './led_preview';
+import { Icon, Sparkline, fmtUptime, NumberAndSlider, DualHandleRange, TopologyDiagram, LineChart, hsv2rgb, rgb2hex, hex2rgb } from './atoms';
+import { getJSON, postJSON, postBinary } from './api';
+
+interface Live { distance: number; direction: number; rssi: number; heap: number; uptime: number; peers: number; healthy: number; }
+export interface AppState {
+ live: Live;
+ settings: any;
+ version: any;
+ toast: any;
+ setToast: (m: string, k?: 'ok'|'err') => void;
+ reload: () => void;
+}
+
+const MODE_DESCRIPTIONS = [
+ 'Distance-driven cluster with directional fade',
+ 'Full-strip hue cycle',
+ 'Sine wave of color across strip',
+ 'Strip gently inhales and exhales',
+ 'All pixels one color',
+ 'Tail chasing across the strip',
+ 'Center pulse expands outward',
+ 'Flickering ember simulation',
+ 'Marquee dot pattern',
+ 'Two scanners meet in the middle',
+ 'Particles spawn from your position',
+];
+
+function PageHead({ title, sub, right }: any) {
+ return (
+
+
+
{title}
+ {sub &&
{sub}
}
+
+ {right}
+
+ );
+}
+
+/* Shared debounced save β every screen with sliders uses this. Sliders fire
+ * onChange ~30Γ/s while dragging; without debouncing each tick fires a POST
+ * /api/settings + 2Γ GET (reload), which overwhelms the C3's single-core
+ * httpd (max_open_sockets=7) and triggers ERR_CONNECTION_RESET. With a
+ * 300 ms tail, only the *final* slider value POSTs once the user stops
+ * moving. Multiple keys touched within the window get coalesced into a
+ * single JSON body β the firmware /api/settings POST handler already
+ * iterates the whole object looking for known keys, so one batched call
+ * is identical in effect to N individual calls. */
+function useDebouncedSave(reload: () => void, setToast: (m: string, k?: 'ok'|'err') => void, delay = 300) {
+ const pending = useRef({});
+ const timer = useRef(null);
+ return (patch: any) => {
+ pending.current = { ...pending.current, ...patch };
+ if (timer.current) clearTimeout(timer.current);
+ timer.current = setTimeout(async () => {
+ const body = pending.current;
+ pending.current = {};
+ timer.current = null;
+ try { await postJSON('/api/settings', body); reload(); }
+ catch (e: any) { setToast(e.message || 'Save failed', 'err'); }
+ }, delay);
+ };
+}
+
+/* ================================================================= */
+/* A. LIVE */
+/* ================================================================= */
+export function ScreenLive({ live, version, settings, setToast }: AppState) {
+ const dist = Math.round(live.distance || 0);
+ const minD = settings.min_distance ?? 30;
+ const maxD = settings.max_distance ?? 300;
+ const inWindow = dist >= minD && dist <= maxD;
+ /* Push a sample on EVERY live update (now 20 Hz) instead of only when
+ * the integer cm value changes. Otherwise the sparkline freezes during
+ * stationary-but-real-time periods, which looks broken. */
+ const histRef = useRef(Array(80).fill(0));
+ const [hist, setHist] = useState(histRef.current);
+ useEffect(() => {
+ histRef.current = [...histRef.current.slice(1), dist];
+ setHist(histRef.current);
+ }, [live]);
+
+ const [sysEn, setSysEn] = useState(true);
+ useEffect(() => { getJSON('/api/system').then(r => setSysEn(!!r.enabled)).catch(() => {}); }, []);
+ const toggleSys = async () => {
+ const next = !sysEn;
+ setSysEn(next);
+ try { await postJSON('/api/system', { enabled: next }); }
+ catch (e: any) { setSysEn(!next); setToast(e.message || 'Toggle failed', 'err'); }
+ };
+
+ return (
+ <>
+ WS connected Β· 5 Hz}/>
+
+
+
+
+
+
+
System {sysEn ? 'active' : 'paused'}
+
{sysEn ? 'Radar, mesh, and LED output running' : 'All output muted, mesh idle'}
+
+
+
+
+
+
+
+
+
+
Distance
+
+ {dist}cm
+
+
+
+ {inWindow ? 'in window' : 'outside'}
+
+ min {minD}
+ max {maxD}
+ {live.direction === 0 ? 'still' : live.direction < 0 ? 'closer β' : 'away β'}
+
+
+
+
+
+
+
+
+
Live LED preview Β· {LED_MODE_NAMES[settings.light_mode ?? 0]}
+
distance {dist} cm
+
+
+
+
+
+
+ -65 ? 'dBm Β· excellent' : (live.rssi||0) > -75 ? 'dBm Β· good' : 'dBm Β· weak'}/>
+
+
+
+
+
+
+
+
+
Mesh{live.peers > 0 ? 'peer' : 'standalone'}
+
+
{live.peers || 0} peer{(live.peers||0) === 1 ? '' : 's'} Β· {live.healthy||0} healthy
+ {(live.peers || 0) === 0 && (
+
+ No peers discovered. Open Mesh tab to start a 30 s pairing window.
+
+ )}
+
+
+
+
+ >
+ );
+}
+
+function StatTile({ label, value, sub, accent }: any) {
+ return (
+
+
{label}
+
{value}
+ {sub &&
{sub}
}
+
+ );
+}
+
+function DevField({ k, v }: any) {
+ return (
+
+ );
+}
+
+/* ================================================================= */
+/* B. LEDs */
+/* ================================================================= */
+export function ScreenLeds({ settings, live, reload, setToast }: AppState) {
+ const [s, setS] = useState(settings);
+ useEffect(() => setS(settings), [JSON.stringify(settings)]);
+ const debouncedSave = useDebouncedSave(reload, setToast);
+ /* `save` updates local state immediately so the slider/preview feel
+ * instantaneous, then queues the network write under the debouncer. */
+ const save = (patch: any) => {
+ setS((prev: any) => ({ ...prev, ...patch }));
+ debouncedSave(patch);
+ };
+ const mode = s.light_mode ?? 0;
+ const showColor = [0,2,3,4,5,6,9,10].includes(mode);
+ const showSpeed = mode !== 4;
+ const showTrail = mode === 0 || mode === 5;
+ const showDirection = mode === 0;
+ const showIntensity = ![0,4].includes(mode);
+
+ const hex = rgb2hex(s.r ?? 255, s.g ?? 255, s.b ?? 255);
+ const presets = ['#FFB54A', '#FF7A3D', '#FF3D82', '#5BC7FF', '#4ADE80', '#9D5BFF', '#FF5470', '#FFFFFF'];
+
+ return (
+ <>
+
+
+
+
+ Live preview Β· {LED_MODE_NAMES[mode]}
+ distance {Math.round(live.distance||0)} cm
+
+
+
+
+
+
+
Mode
+
+
+ {LED_MODE_NAMES.map((name, i) => (
+
+ ))}
+
+
+
+
+
+ {showColor && (
+
+
Color
+
+
+
+
{
+ const h = (e.target as HTMLInputElement).value;
+ const [r, g, b] = hex2rgb(h);
+ save({ r, g, b });
+ }}/>
+
+
Presets
+
+ {presets.map(p => {
+ const [r, g, b] = hex2rgb(p);
+ const on = hex.toUpperCase() === p.toUpperCase();
+ return (
+
+
+
+ )}
+
+
+
{LED_MODE_NAMES[mode]} parameters
+
+
save({ brightness: v })} min={0} max={255}/>
+ {showSpeed && save({ effect_speed: v })} min={0} max={100}/>}
+ {showIntensity && save({ effect_intensity: v })} min={0} max={100}/>}
+ {showTrail && save({ trail: v })} min={0} max={20}/>}
+ {showDirection && (
+
+
+
Directional light
+
Brighter side leads movement
+
+
save({ dir_light: v ? 1 : 0 })}/>
+
+ )}
+
+
+
Background mode
+
Faint always-on color when idle
+
+
save({ bg_mode: v ? 1 : 0 })}/>
+
+
+
+
+
+
Layout
+
+
+
+ Distance window
+ {s.min_distance ?? 30}β{s.max_distance ?? 300} cm
+
+
save({ min_distance: minVal, max_distance: maxVal })} min={0} max={500}/>
+
+
save({ span: v })} min={1} max={150}/>
+ save({ center_shift: v })} min={-100} max={100}/>
+ save({ led_count: v })} min={1} max={1500}/>
+
+
+
+
+ >
+ );
+}
+
+/* ================================================================= */
+/* C. MOTION */
+/* ================================================================= */
+export function ScreenMotion({ settings, live, reload, setToast }: AppState) {
+ const [s, setS] = useState(settings);
+ const [showAdvanced, setShowAdvanced] = useState(false);
+ useEffect(() => setS(settings), [JSON.stringify(settings)]);
+ const debouncedSave = useDebouncedSave(reload, setToast);
+ const save = (patch: any) => {
+ setS((prev: any) => ({ ...prev, ...patch }));
+ debouncedSave(patch);
+ };
+
+ /* Both buffers come straight from the firmware (raw_cm + distance_cm over
+ * WS at 20 Hz). No client-side alpha simulation β the chart shows what
+ * the firmware actually feeds the LED engine. */
+ const rawRef = useRef(Array(80).fill(0));
+ const smoothRef = useRef(Array(80).fill(0));
+ const [raw, setRaw] = useState(rawRef.current);
+ const [smooth, setSmooth] = useState(smoothRef.current);
+ useEffect(() => {
+ const r = (live as any).raw ?? live.distance;
+ const sm = live.distance;
+ rawRef.current = [...rawRef.current.slice(1), r];
+ smoothRef.current = [...smoothRef.current.slice(1), sm];
+ setRaw([...rawRef.current]);
+ setSmooth([...smoothRef.current]);
+ }, [live]);
+
+ const enabled = !!s.motion_enabled;
+ const mode: 'kalman'|'pi' = (s.motion_mode === 'pi') ? 'pi' : 'kalman';
+ const response = s.response ?? 50;
+ const lookAhead = s.look_ahead_ms ?? 0;
+ const outlier = s.outlier_strength ?? 1;
+
+ /* Tooltip text under each main slider β explains *what* the knob does
+ * physically, not just its numeric value. Helps users without filtering
+ * theory background pick a setting that matches their installation. */
+ const responseHint =
+ response < 25 ? 'Heavy filtering. Drift is invisible, but fast walk-throughs lag noticeably.' :
+ response < 65 ? 'Balanced. Walks render smoothly; jitter is suppressed.' :
+ 'Snappy. The strip tracks subtle motion but radar noise leaks through.';
+ const lookHint =
+ lookAhead === 0 ? 'No predictive lead. The strip lights where the radar last saw you.' :
+ lookAhead < 200 ? 'Slight predictive lead β masks ~50 ms render latency.' :
+ 'Aggressive prediction. Great for fast stairs, may overshoot near corners.';
+ const outlierLabel = ['Off', 'Soft (3-sample)', 'Strong (7-sample)'][outlier] || 'Soft';
+
+ return (
+ <>
+ setShowAdvanced(!showAdvanced)}>
+ {showAdvanced ? 'Hide' : 'Show'} advanced
+ }/>
+
+
+
+
+
Motion smoothing
+
{enabled ? 'Filters jitter and predicts velocity' : 'Disabled β strip follows raw radar'}
+
+
save({ motion_enabled: v ? 1 : 0 })}/>
+
+
+
+
+ Raw vs smoothed Β· last 16 s
+
+ raw
+
+
+ smoothed
+
+ {Math.round(live.distance||0)} cm
+
+
+
+
+
+ {/* Algorithm picker */}
+
+
Algorithm
+
+
+ {[
+ { id: 'kalman', name: 'Kalman', desc: 'Default. Estimates position + velocity together; energy-aware noise model. Best for stairs.' },
+ { id: 'pi', name: 'Legacy PI', desc: 'EMA + PI controller from v5. Five tunables; familiar if you tuned the Arduino build.' },
+ ].map(a => {
+ const active = mode === a.id;
+ return (
+
+ );
+ })}
+
+
+
+
+ {/* Two main sliders: Response + Look-ahead. These map onto either
+ * Kalman process noise (Q_pos, Q_vel) or PI alpha+predict in the
+ * firmware β the user shouldn't need to know which. */}
+
+
Tuning
+
+
+
+
+ Calm β Snappy Β· {response}
+
+
save({ response: +e.target.value })}/>
+
{responseHint}
+
+
+
+
+ {lookAhead} ms
+
+
save({ look_ahead_ms: +e.target.value })}/>
+
{lookHint}
+
+
+
+
+ {outlierLabel}
+
+
+ {[
+ { v: 0, name: 'Off', note: 'Trust radar 1:1' },
+ { v: 1, name: 'Soft', note: 'Median of last 3' },
+ { v: 2, name: 'Strong', note: 'Median of last 7' },
+ ].map(o => {
+ const active = outlier === o.v;
+ return (
+
+ );
+ })}
+
+
Stronger rejection masks single-sample radar glitches but adds 1-2 frames of lag.
+
+
+
+
+ {/* Advanced (collapsed by default) β exposes the v5 PI knobs for power
+ * users. Has visible effect only when Algorithm = Legacy PI. */}
+ {showAdvanced && (
+
+
+ Advanced β Legacy PI gains
+
+ {mode === 'pi' ? 'Active' : 'Inactive in Kalman mode'}
+
+
+
+
save({ pos_smooth_x1k: v })} min={10} max={800} suffix="Γ 1/1000"/>
+ save({ vel_smooth_x1k: v })} min={10} max={500} suffix="Γ 1/1000"/>
+ save({ predict_x1k: v })} min={0} max={2000} suffix="Γ 1/1000"/>
+ save({ p_gain_x1k: v })} min={0} max={1000} suffix="Γ 1/1000"/>
+ save({ i_gain_x1k: v })} min={0} max={200} suffix="Γ 1/1000"/>
+
+
+ These are the v5 firmware knobs. In Kalman mode the Response slider above replaces them. Higher P responds faster but overshoots; higher I corrects steady-state drift over time.
+
+
+
+ )}
+ >
+ );
+}
+
+/* ================================================================= */
+/* D. MESH */
+/* ================================================================= */
+export function ScreenMesh({ live, settings, setToast, reload }: AppState) {
+ const [topology, setTopology] = useState({ kind: 'straight', segments: [], total_leds: 30 });
+ const [mesh, setMesh] = useState({ peers: [], fusion: 'most_recent', coordinator: true, pairing: false, pairing_ms_left: 0, my_mac: '' });
+ const [identifying, setIdentifying] = useState(null);
+
+ const refresh = () => Promise.all([
+ getJSON('/api/topology').then(setTopology),
+ getJSON('/api/mesh').then(setMesh),
+ ]).catch(() => {});
+
+ /* Two polling cadences: 4 s when idle (cheap), 500 ms while pairing
+ * window is open (so the countdown ring updates in real time and we
+ * spot a new peer joining within half a second). The /api/mesh
+ * response carries pairing_ms_left from firmware β we never run a
+ * client-side countdown, so cancellation, OTA reboots, and pair-on-
+ * other-device events are all reflected accurately. */
+ useEffect(() => {
+ refresh();
+ const fast = mesh.pairing;
+ const id = setInterval(refresh, fast ? 500 : 4000);
+ return () => clearInterval(id);
+ }, [mesh.pairing]);
+
+ const startPair = async () => {
+ try { await postJSON('/api/mesh', { pair: true }); setToast('Pairing window open Β· 30 s'); refresh(); }
+ catch (e: any) { setToast(e.message || 'Pair failed', 'err'); }
+ };
+
+ const identifyPeer = async (mac: string) => {
+ setIdentifying(mac);
+ try {
+ await postJSON('/api/mesh/identify', { mac });
+ setToast(`Identifying ${mac.slice(-5)}β¦`);
+ } catch (e: any) {
+ setToast(e.message || 'Identify failed', 'err');
+ }
+ setTimeout(() => setIdentifying(null), 5000);
+ };
+
+ const setTopo = async (kind: string) => {
+ try { await postJSON('/api/topology', { kind }); setToast('Topology saved'); refresh(); }
+ catch (e: any) { setToast(e.message || 'Save failed', 'err'); }
+ };
+
+ const setFusion = async (f: string) => {
+ try { await postJSON('/api/mesh', { fusion: f }); setToast('Priority saved'); refresh(); }
+ catch (e: any) { setToast(e.message || 'Save failed', 'err'); }
+ };
+
+ const topologies = [
+ { id: 'straight', name: 'Straight', desc: 'Single hallway run' },
+ { id: 'l_shape', name: 'L-shape', desc: 'One corner, two flights' },
+ { id: 'u_shape', name: 'U-shape', desc: 'Two corners, three flights' },
+ { id: 'custom', name: 'Custom', desc: 'Position pixels manually' },
+ ];
+ const priorities = [
+ { id: 'most_recent', name: 'Most recent', desc: 'Whichever device just saw motion' },
+ { id: 'slave_first', name: 'Slave first', desc: 'Slaves win unless silent for 2 s' },
+ { id: 'master_first', name: 'Master first', desc: 'Master wins unless silent for 2 s' },
+ { id: 'zone_based', name: 'Zone based', desc: 'Each device owns its segment range' },
+ ];
+
+ const myMac: string = mesh.my_mac || '';
+ const pairSecsLeft = Math.ceil((mesh.pairing_ms_left || 0) / 1000);
+ const pairProgress = Math.max(0, Math.min(1, (mesh.pairing_ms_left || 0) / 30000));
+
+ /* Rendering "this device + all peers" as one unified list keeps the
+ * mental model "every device is equal" β coordinator is just whoever
+ * has the lowest MAC at any given moment. */
+ const allDevices = [
+ { mac: myMac || 'β', name: settings.device_name || 'this device', role: mesh.coordinator ? 'coordinator' : 'follower', rssi: live.rssi || -50, healthy: true, self: true },
+ ...(mesh.peers || []).map((p: any) => ({ ...p, role: 'follower', name: p.mac.slice(-5).toUpperCase(), self: false })),
+ ];
+
+ return (
+ <>
+
+ {mesh.pairing ? <> Listening Β· {pairSecsLeft}s> : <> Pair new device>}
+ }/>
+
+ {/* Pairing card with circular SVG countdown β visual anchor that
+ * communicates "the device is actively listening RIGHT NOW" much
+ * more clearly than a number. */}
+ {mesh.pairing && (
+
+
+
+
+
Pairing window open
+
+ On the other device, either hold the BOOT button for 3 seconds or open its web UI and click Pair new device. They auto-connect β no need to click on both.
+
+
+
+
+ )}
+
+
+
Topology
+
+
+ {topologies.map(t => {
+ const active = topology.kind === t.id;
+ return (
+
+ );
+ })}
+
+
+
+
+
+
+ Devices
+ Click Identify to make a device blink β useful while wiring topology
+
+
+ {allDevices.map((d: any) => {
+ const isIdentifying = identifying === d.mac;
+ return (
+
+
+
+
+ {d.name}
+ {d.role}
+ {d.self && this device}
+
+
{d.mac}
+
+
+
{d.rssi || 'β'} dBm
+
+ {!d.self && (
+
+ )}
+
+ );
+ })}
+ {allDevices.length <= 1 && (
+
+ No peers paired yet. Click Pair new device above to start a 30-second pairing window.
+
+ )}
+
+
+
+
+
Sensor priority
+
+
+ {priorities.map(p => {
+ const active = mesh.fusion === p.id;
+ return (
+
+ );
+ })}
+
+
+
+ >
+ );
+}
+
+/* ================================================================= */
+/* E. HARDWARE */
+/* ================================================================= */
+export function ScreenHardware({ setToast, reload }: AppState) {
+ const [profiles, setProfiles] = useState(null);
+ const [kinds, setKinds] = useState(null);
+ const [activeBoard, setActiveBoard] = useState('');
+ const [activeRadar, setActiveRadar] = useState('');
+ const [pins, setPins] = useState({});
+ const [busy, setBusy] = useState(false);
+ const [needsReboot, setNeedsReboot] = useState(false);
+
+ useEffect(() => {
+ Promise.all([getJSON('/api/board/profiles'), getJSON('/api/radar/kinds'), getJSON('/api/settings')])
+ .then(([p, k, st]) => {
+ setProfiles(p); setKinds(k); setActiveBoard(p.active); setActiveRadar(k.active);
+ const prof = p.profiles.find((x: any) => x.id === p.active) || p.profiles[0];
+ setPins({
+ led_pin: st.led_pin ?? prof.led_pin,
+ radar_rx: st.radar_rx ?? prof.radar_rx,
+ radar_tx: st.radar_tx ?? prof.radar_tx,
+ button_pin: st.button_pin ?? prof.button,
+ status_led_pin: st.status_led_pin ?? prof.status_led,
+ });
+ }).catch((e) => setToast(e.message || 'Load failed', 'err'));
+ }, []);
+
+ if (!profiles || !kinds) return <>>;
+ const profile = profiles.profiles.find((p: any) => p.id === activeBoard) || profiles.profiles[0];
+ const unsafe: number[] = profile.unsafe || [];
+
+ const onBoardChange = (id: string) => {
+ setActiveBoard(id);
+ const np = profiles.profiles.find((p: any) => p.id === id);
+ if (np) setPins({ led_pin: np.led_pin, radar_rx: np.radar_rx, radar_tx: np.radar_tx, button_pin: np.button, status_led_pin: np.status_led });
+ setNeedsReboot(true);
+ };
+
+ const onRadarChange = (id: string) => { setActiveRadar(id); setNeedsReboot(true); };
+ const onPinChange = (k: string, v: number) => { setPins({ ...pins, [k]: v }); setNeedsReboot(true); };
+
+ const save = async () => {
+ setBusy(true);
+ try {
+ await postJSON('/api/board', { id: activeBoard, radar_kind: activeRadar, ...pins });
+ setToast('Saved');
+ } catch (e: any) { setToast(e.message || 'Save failed', 'err'); }
+ finally { setBusy(false); }
+ };
+
+ const saveReboot = async () => {
+ if (!confirm('Save and reboot device now? You will lose connection for ~10 seconds.')) return;
+ setBusy(true);
+ try {
+ await postJSON('/api/board', { id: activeBoard, radar_kind: activeRadar, ...pins });
+ await postJSON('/api/reboot', {});
+ setToast('Rebooting β refresh in 10 s');
+ } catch (e: any) { setToast(e.message || 'Save failed', 'err'); }
+ finally { setBusy(false); setNeedsReboot(false); }
+ };
+
+ const PINS: [string, string, string, string][] = [
+ ['led_pin', 'LED data', 'led', 'led_pin'],
+ ['radar_rx', 'Radar RX', 'radar', 'radar_rx'],
+ ['radar_tx', 'Radar TX', 'radar', 'radar_tx'],
+ ['button_pin', 'Button', 'pin', 'button'],
+ ['status_led_pin', 'Status LED', 'bolt', 'status_led'],
+ ];
+
+ return (
+ <>
+ Save & reboot : }/>
+
+
+
+
Board profile
+
+
+ {profiles.profiles.map((p: any) => {
+ const on = activeBoard === p.id;
+ return (
+
+ );
+ })}
+
+
+
+
+
+
Radar
+
+ {kinds.kinds.map((r: any) => {
+ const on = activeRadar === r.id;
+ return (
+
+ );
+ })}
+
+
+
+
+
+
Pin map{profile.display}
+
+
+ {PINS.map(([key, label, icon, profKey]) => {
+ const cur = pins[key] ?? (profile as any)[profKey];
+ return (
+
+
+
+ {label}
+
+
+
+ );
+ })}
+
+
+
+ Strapping pins are disabled β they affect boot mode and shouldn't drive an LED strip or radar UART. {unsafe.length} hidden ({unsafe.join(', ')}).
+
+
+
+ >
+ );
+}
+
+/* ================================================================= */
+/* F. NETWORK */
+/* ================================================================= */
+export function ScreenNetwork({ setToast }: AppState) {
+ const [wifi, setWifi] = useState(null);
+ const [scan, setScan] = useState(null);
+ const [pwd, setPwd] = useState('');
+ const [host, setHost] = useState('');
+ const [apMode, setApMode] = useState('auto');
+ const [confirm, setConfirm] = useState(false);
+ const [scanning, setScanning] = useState(false);
+ const [joinSsid, setJoinSsid] = useState(null);
+
+ const refresh = () => getJSON('/api/wifi').then(w => { setWifi(w); setApMode(w.ap_mode); setHost(w.hostname || ''); });
+ const doScan = async () => {
+ setScanning(true);
+ try { const r = await getJSON('/api/wifi/scan'); setScan(r.networks); }
+ catch (e: any) { setToast(e.message || 'Scan failed', 'err'); }
+ finally { setScanning(false); }
+ };
+ useEffect(() => { refresh(); }, []);
+
+ const join = async () => {
+ if (!joinSsid) return;
+ try { await postJSON('/api/wifi', { ssid: joinSsid, pass: pwd, hostname: host || undefined }); setToast('Saved Β· reconnecting'); setJoinSsid(null); setPwd(''); setTimeout(refresh, 4000); }
+ catch (e: any) { setToast(e.message || 'Join failed', 'err'); }
+ };
+ const saveApMode = async (m: string) => { setApMode(m); try { await postJSON('/api/wifi', { ap_mode: m }); setToast('AP mode saved'); refresh(); } catch (e: any) { setToast(e.message, 'err'); } };
+ const saveHost = async () => { try { await postJSON('/api/wifi', { hostname: host }); setToast('Hostname saved'); } catch (e: any) { setToast(e.message, 'err'); } };
+ const forget = async () => {
+ if (!confirm) { setConfirm(true); return; }
+ try { await postJSON('/api/wifi', { forget_sta: true }); setToast('Reset Β· device returns to AP mode'); setConfirm(false); refresh(); }
+ catch (e: any) { setToast(e.message, 'err'); }
+ };
+
+ if (!wifi) return <>>;
+
+ return (
+ <>
+
+
+ {/* connected card */}
+
+
+
+
+
+
+
+ {wifi.sta_connected ? wifi.ssid : 'Not connected'}
+ {wifi.sta_connected && connected}
+ {wifi.ap_active && AP up}
+
+
+ {wifi.ip || 'β'} Β· {wifi.hostname}.local Β· {wifi.rssi} dBm
+
+
+ {wifi.sta_configured &&
}
+
+
+
+
+
Available networks
+
+
+
+ {scan === null &&
Click Scan to discover networks.
}
+ {scan?.length === 0 &&
No networks found.
}
+ {scan?.map(n => {
+ const isCurrent = wifi.sta_connected && n.ssid === wifi.ssid;
+ const bars = n.rssi > -60 ? 4 : n.rssi > -68 ? 3 : n.rssi > -76 ? 2 : 1;
+ return (
+
+
+ {[1,2,3,4].map(i =>
)}
+
+
+
{n.ssid}
+
{n.rssi} dBm Β· {n.secure ? 'WPA2' : 'open'}
+
+ {isCurrent ?
current :
}
+
+ );
+ })}
+
+
+
+ {joinSsid && (
+
+
Join "{joinSsid}"
+
setPwd((e.target as HTMLInputElement).value)} style="margin-bottom: 8px;"/>
+
+
+
+
+
+ )}
+
+
+
+
Hostname
+
+
mDNS name
+
+ setHost((e.target as HTMLInputElement).value.replace(/[^a-z0-9-]/g, ''))}/>
+ .local
+
+
+
+
+
+
+
AP behaviour
+
+ {[
+ { id: 'auto', name: 'Auto', desc: 'AP off when STA connected' },
+ { id: 'always', name: 'Always on', desc: 'AP up at all times β local fallback' },
+ { id: 'sta_only', name: 'STA only', desc: 'AP off, ESP-NOW uses STA channel' },
+ ].map(m => {
+ const on = apMode === m.id;
+ return (
+
+ );
+ })}
+
+
+
+ >
+ );
+}
+
+/* ================================================================= */
+/* G. SYSTEM */
+/* ================================================================= */
+export function ScreenSystem({ version, setToast }: AppState) {
+ const [pwd, setPwd] = useState('');
+ const [showPwd, setShowPwd] = useState(false);
+ const [authReq, setAuthReq] = useState(false);
+ const [otaName, setOtaName] = useState(null);
+ const [otaFile, setOtaFile] = useState(null);
+ const [otaProg, setOtaProg] = useState(-1);
+ const [confirmText, setConfirmText] = useState('');
+ const fileRef = useRef(null);
+
+ useEffect(() => setAuthReq(!!version.auth_enabled), [version.auth_enabled]);
+
+ const setPassword = async () => {
+ if (pwd && pwd.length < 8) { setToast('Password must be β₯ 8 characters', 'err'); return; }
+ try {
+ const r = await postJSON('/api/auth/password', { password: pwd });
+ setToast(pwd ? 'Password set Β· auth enabled' : 'Password cleared Β· auth disabled');
+ setPwd('');
+ setAuthReq(!!r.auth_enabled);
+ } catch (e: any) { setToast(e.message || 'Failed', 'err'); }
+ };
+
+ const onFile = (f: File | null | undefined) => {
+ if (!f) return;
+ if (!f.name.endsWith('.bin')) { setToast('Pick a .bin file', 'err'); return; }
+ setOtaFile(f); setOtaName(f.name); setOtaProg(-1);
+ };
+
+ const flash = async () => {
+ if (!otaFile) return;
+ setOtaProg(0);
+ try {
+ await postBinary('/api/ota', otaFile, p => setOtaProg(p));
+ setToast('Flashed Β· device rebooting');
+ setOtaProg(1);
+ setTimeout(() => { setOtaName(null); setOtaFile(null); setOtaProg(-1); }, 3000);
+ } catch (e: any) { setToast(e.message || 'OTA failed', 'err'); setOtaProg(-1); }
+ };
+
+ const reboot = async () => {
+ if (!confirm('Reboot device? You will lose connection for ~10 seconds.')) return;
+ try { await postJSON('/api/reboot', {}); setToast('Rebooting'); }
+ catch (e: any) { setToast(e.message, 'err'); }
+ };
+
+ const exportConfig = async () => {
+ try {
+ const r = await getJSON('/api/settings');
+ const blob = new Blob([JSON.stringify(r, null, 2)], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url; a.download = `${version.hostname || 'ambisense'}-config.json`;
+ a.click();
+ URL.revokeObjectURL(url);
+ setToast('Exported');
+ } catch (e: any) { setToast(e.message, 'err'); }
+ };
+
+ return (
+ <>
+
+
+
+
+
Firmware{version.version || 'β'}
+
+
+
+
+
{version.version} Β· {version.target}
+
ESP-IDF {version.idf_version} Β· built {version.build_date}
+
+
+
+
+
+
+ {otaProg >= 0 && otaProg < 1 && (
+
+
+
Uploading {(otaProg*100).toFixed(0)}%
+
+ )}
+
+ {otaName && otaProg < 0 && (
+
+ )}
+
+
+
+
+
+
+
+
Auth
+
+ {authReq ? 'enabled' : 'disabled'}
+
+
+
+
Password (β₯ 8 chars Β· empty disables auth)
+
+ setPwd((e.target as HTMLInputElement).value)}/>
+
+
+
+
+ Required only on this network. Local mDNS access uses cookie sessions.
+
+
+
+
+
+
+
JSON config
+
+
+
+
+
+
+
+
Factory reset
+
+
+ Type {version.hostname} to confirm
+
+
+ setConfirmText((e.target as HTMLInputElement).value)}/>
+
+
+
+
+
+ >
+ );
+}
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
new file mode 100644
index 0000000..64c28b2
--- /dev/null
+++ b/frontend/src/styles.css
@@ -0,0 +1,311 @@
+/* AmbiSense v6 β design tokens (lifted from frontend/design-source/project/tokens.css) */
+:root {
+ --bg-0: #0B0D10;
+ --bg-1: #101317;
+ --bg-2: #15181C;
+ --bg-3: #1B1F24;
+ --bg-4: #232830;
+ --line: #232830;
+ --line-soft: #1B1F24;
+ --text-0: #F2F4F7;
+ --text-1: #C7CDD6;
+ --text-2: #8A929E;
+ --text-3: #5C636E;
+ --text-4: #3D434C;
+
+ --acc-amber: #FFB54A;
+ --acc-orange: #FF7A3D;
+ --acc-pink: #FF3D82;
+ --acc-grad: linear-gradient(135deg, #FFB54A 0%, #FF7A3D 45%, #FF3D82 100%);
+
+ --ok: #4ADE80;
+ --warn: #FFB54A;
+ --err: #FF5470;
+ --info: #5BC7FF;
+
+ --r-xs: 6px; --r-sm: 8px; --r-md: 12px; --r-lg: 16px; --r-xl: 22px; --r-pill: 999px;
+ --s-1: 4px; --s-2: 8px; --s-3: 12px; --s-4: 16px; --s-5: 24px; --s-6: 32px; --s-7: 48px;
+ --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
+ --font-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
+ --shadow-1: 0 1px 2px rgba(0,0,0,.35);
+ --shadow-2: 0 6px 24px rgba(0,0,0,.4);
+}
+
+[data-theme="light"] {
+ --bg-0: #F6F5F2; --bg-1: #EDECE7; --bg-2: #FFFFFF; --bg-3: #F2F1ED; --bg-4: #E6E4DE;
+ --line: #DEDCD5; --line-soft: #E8E6DF;
+ --text-0: #15181C; --text-1: #2D333B; --text-2: #5C636E; --text-3: #8A929E; --text-4: #B5BBC4;
+}
+
+* { box-sizing: border-box; }
+html, body, #root { margin: 0; padding: 0; height: 100%; }
+body {
+ background: var(--bg-0); color: var(--text-0);
+ font-family: var(--font-sans); font-size: 14px; line-height: 1.5; letter-spacing: -0.005em;
+ -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility;
+ overscroll-behavior: none;
+}
+.mono { font-family: var(--font-mono); }
+.smallcaps { text-transform: uppercase; letter-spacing: .14em; font-size: 11px; font-weight: 500; color: var(--text-2); }
+button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
+input, select, textarea { font: inherit; color: inherit; }
+::selection { background: rgba(255,122,61,.35); color: var(--text-0); }
+
+/* Layout */
+.app { display: grid; grid-template-columns: 240px 1fr; min-height: 100%; }
+.sidebar {
+ background: var(--bg-1); border-right: 1px solid var(--line);
+ padding: 24px 14px; display: flex; flex-direction: column; gap: 4px;
+ position: sticky; top: 0; height: 100vh; overflow-y: auto;
+}
+.brand { display: flex; align-items: center; gap: 10px; padding: 0 8px 24px; }
+.brand-mark {
+ width: 28px; height: 28px; border-radius: 8px; background: var(--acc-grad);
+ display: grid; place-items: center; font-weight: 700; color: #1A0F08; font-size: 13px;
+}
+.brand-name { font-weight: 600; letter-spacing: -0.01em; font-size: 15px; }
+.brand-ver { font-size: 11px; color: var(--text-3); margin-left: 4px; }
+
+.navlink {
+ display: flex; align-items: center; gap: 10px;
+ padding: 9px 12px; border-radius: 8px; font-size: 13px;
+ color: var(--text-1); text-decoration: none; transition: background .12s, color .12s;
+}
+.navlink:hover { background: var(--bg-2); color: var(--text-0); }
+.navlink.on { background: var(--bg-3); color: var(--text-0); box-shadow: var(--shadow-1); }
+.navlink-icon { width: 16px; height: 16px; opacity: .9; flex-shrink: 0; }
+
+.main { padding: 28px 32px 96px; max-width: 1200px; }
+.page-head { display: flex; align-items: end; justify-content: space-between; margin-bottom: 24px; gap: 16px; flex-wrap: wrap; }
+.page-head h1 { font-size: 22px; font-weight: 600; margin: 0; letter-spacing: -0.02em; }
+.page-head .sub { color: var(--text-2); font-size: 13px; margin-top: 2px; }
+
+/* Responsive helpers used inline by the layout. Without these the desktop
+ * sidebar and mobile bottom-tab nav both render at once on phones, which
+ * is how the UI looked "scrambled" in the v6.0 report. */
+.hide-mobile { /* visible on desktop, hidden via media query below */ }
+.show-mobile { display: none; }
+
+@media (max-width: 760px) {
+ .hide-mobile { display: none !important; }
+ .show-mobile { display: flex !important; }
+ .app { grid-template-columns: 1fr; }
+ .sidebar {
+ position: fixed; bottom: 0; left: 0; right: 0; top: auto; height: auto;
+ flex-direction: row; justify-content: space-around; padding: 8px 6px;
+ border-right: none; border-top: 1px solid var(--line); z-index: 10;
+ overflow-x: auto;
+ }
+ .brand { display: none; }
+ .navlink { flex-direction: column; gap: 3px; padding: 6px 8px; font-size: 10px; }
+ .main { padding: 14px 12px 88px; max-width: 100%; }
+ .page-head { gap: 8px; margin-bottom: 16px; }
+ .page-head h1 { font-size: 19px; }
+ .app-header { padding: 10px 12px; }
+ /* Cards lose their inner indent on phones β saves ~14 px per card * 4 cards. */
+ .card-body { padding: 12px 12px 14px; }
+ .card-head { padding: 12px 12px 0; }
+ /* Tame inline grids that hardcode min 280 px columns β they'd overflow
+ * on a 360 px viewport once card padding is subtracted. */
+ .card .card-body > div[style*="grid-template-columns: repeat(auto-fit"] {
+ grid-template-columns: 1fr !important;
+ }
+ /* LED screen's primary 2-col grid (Mode list | Color/Layout column) β
+ * stacks vertically on phones. */
+ .led-grid { grid-template-columns: 1fr !important; gap: 12px !important; }
+ /* Top-level grid wrappers used by Motion advanced + Hardware pin maps. */
+ div[style*="grid-template-columns: repeat(auto-fit, minmax(280px, 1fr))"],
+ div[style*="grid-template-columns: repeat(auto-fit, minmax(220px, 1fr))"],
+ div[style*="grid-template-columns: repeat(auto-fit, minmax(170px, 1fr))"] {
+ grid-template-columns: 1fr 1fr !important;
+ }
+ div[style*="grid-template-columns: repeat(auto-fit, minmax(155px, 1fr))"] {
+ grid-template-columns: 1fr 1fr !important;
+ }
+}
+@media (max-width: 480px) {
+ /* Single column on the smallest phones. */
+ div[style*="grid-template-columns: repeat(auto-fit"] { grid-template-columns: 1fr !important; }
+}
+
+/* Atoms */
+.card { background: var(--bg-2); border: 1px solid var(--line); border-radius: 16px; box-shadow: var(--shadow-1); }
+.card-head { display: flex; align-items: center; justify-content: space-between; padding: 16px 18px 0; }
+.card-body { padding: 14px 18px 18px; }
+.card-title { font-size: 13px; color: var(--text-2); text-transform: uppercase; letter-spacing: .14em; font-weight: 500; margin: 0; }
+.divider { height: 1px; background: var(--line); }
+
+.dot { width: 8px; height: 8px; border-radius: 999px; display: inline-block; }
+.dot-ok { background: var(--ok); box-shadow: 0 0 0 3px rgba(74,222,128,.15); }
+.dot-warn { background: var(--warn); box-shadow: 0 0 0 3px rgba(255,181,74,.18); }
+.dot-err { background: var(--err); box-shadow: 0 0 0 3px rgba(255,84,112,.18); }
+.dot-off { background: var(--text-4); }
+
+.btn {
+ display: inline-flex; align-items: center; gap: 8px;
+ padding: 9px 14px; border-radius: 8px;
+ background: var(--bg-3); border: 1px solid var(--line);
+ color: var(--text-0); font-weight: 500; font-size: 13px;
+ transition: background .12s, border-color .12s, transform .08s;
+ white-space: nowrap;
+}
+.btn:hover { background: var(--bg-4); border-color: var(--text-4); }
+.btn:active { transform: translateY(1px); }
+.btn:disabled { opacity: .5; cursor: not-allowed; }
+.btn-primary { background: var(--acc-grad); border: 0; color: #1A0F08; font-weight: 600; }
+.btn-primary:hover { filter: brightness(1.06); }
+.btn-ghost { background: transparent; border: 1px solid transparent; }
+.btn-ghost:hover { background: var(--bg-3); border-color: var(--line); }
+.btn-danger { background: rgba(255,84,112,.08); border: 1px solid rgba(255,84,112,.35); color: var(--err); }
+.btn-danger:hover { background: rgba(255,84,112,.14); border-color: rgba(255,84,112,.55); }
+.btn-sm { padding: 6px 10px; font-size: 12px; }
+
+.field-label { font-size: 12px; color: var(--text-2); margin-bottom: 6px; display: block; }
+.input, .select {
+ width: 100%; padding: 9px 12px;
+ background: var(--bg-1); border: 1px solid var(--line); border-radius: 8px;
+ color: var(--text-0); font-size: 13px; outline: none;
+ transition: border-color .12s, background .12s;
+}
+.input:focus, .select:focus { border-color: var(--acc-orange); background: var(--bg-0); }
+.input.mono { font-family: var(--font-mono); }
+.select { appearance: none; padding-right: 32px;
+ background-image: url("data:image/svg+xml;utf8,");
+ background-repeat: no-repeat; background-position: right 10px center; }
+
+.range { -webkit-appearance: none; width: 100%; height: 4px; background: var(--bg-3); border-radius: 999px; outline: none; }
+.range::-webkit-slider-thumb {
+ -webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%;
+ background: var(--text-0); border: 3px solid var(--acc-orange); cursor: pointer;
+}
+.range::-moz-range-thumb { width: 16px; height: 16px; border-radius: 50%;
+ background: var(--text-0); border: 3px solid var(--acc-orange); cursor: pointer; }
+
+.toggle { position: relative; width: 36px; height: 20px; background: var(--bg-3);
+ border-radius: 999px; border: 1px solid var(--line); transition: background .15s; flex-shrink: 0; }
+.toggle::after { content: ""; position: absolute; top: 2px; left: 2px;
+ width: 14px; height: 14px; background: var(--text-1); border-radius: 50%; transition: left .15s, background .15s; }
+.toggle.on { background: var(--acc-grad); border-color: transparent; }
+.toggle.on::after { left: 18px; background: white; }
+
+.chip { display: inline-flex; align-items: center; gap: 6px; padding: 4px 9px;
+ border-radius: 999px; background: var(--bg-3); border: 1px solid var(--line);
+ font-size: 11px; color: var(--text-1); font-family: var(--font-mono); }
+
+.row { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; font-size: 13px; }
+.row .lbl { color: var(--text-2); font-size: 12px; }
+.row .val { font-family: var(--font-mono); color: var(--text-0); }
+
+.grid-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 14px; }
+
+.kv { display: grid; grid-template-columns: max-content 1fr; gap: 6px 14px; font-size: 13px; }
+.kv .k { color: var(--text-2); font-size: 12px; }
+.kv .v { font-family: var(--font-mono); color: var(--text-0); }
+
+.toast {
+ position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
+ background: var(--bg-3); border: 1px solid var(--line); border-radius: 10px;
+ padding: 10px 16px; font-size: 13px; box-shadow: var(--shadow-2);
+ z-index: 100; animation: fade-up .25s ease-out;
+}
+.toast.ok { border-color: rgba(74,222,128,.4); color: var(--ok); }
+.toast.err { border-color: rgba(255,84,112,.4); color: var(--err); }
+
+@keyframes fade-up { from { opacity: 0; transform: translate(-50%, 6px); } to { opacity: 1; transform: translate(-50%, 0); } }
+@keyframes pulse-acc { 0%,100% { opacity: 1; } 50% { opacity: 0.45; } }
+@keyframes logo-pulse {
+ 0% { r: 4; opacity: 0.9; stroke-width: 1.4; }
+ 70% { r: 18; opacity: 0; stroke-width: 0.4; }
+ 100% { r: 18; opacity: 0; }
+}
+@keyframes logo-breath {
+ 0%, 100% { transform: scale(1); }
+ 50% { transform: scale(1.08); }
+}
+
+/* App shell β sticky header bar + sidebar with logo + bottom IP chip */
+.app-header {
+ position: sticky; top: 0; z-index: 50;
+ display: flex; align-items: center; gap: 14px;
+ padding: 12px 24px;
+ background: color-mix(in srgb, var(--bg-0) 88%, transparent);
+ backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
+ border-bottom: 1px solid var(--line);
+}
+.brand-block {
+ display: flex; align-items: center; gap: 12px;
+ padding: 4px 6px 16px;
+}
+.sidebar-foot {
+ padding: 10px 12px; font-size: 10px; color: var(--text-3); line-height: 1.5;
+ border-top: 1px solid var(--line-soft); margin-top: 6px;
+}
+.btn-icon { padding: 8px; }
+@media (max-width: 760px) {
+ .app-header { padding: 10px 14px; }
+}
+@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.45; } }
+
+.led-canvas {
+ width: 100%; height: 80px; border-radius: 12px;
+ background: linear-gradient(180deg, var(--bg-1) 0%, var(--bg-0) 100%);
+ border: 1px solid var(--line); display: block;
+}
+.distance-meter {
+ background: var(--bg-1); border-radius: 12px; padding: 18px;
+ border: 1px solid var(--line);
+}
+.distance-num { font-size: 44px; font-weight: 600; font-family: var(--font-mono); letter-spacing: -0.02em; }
+.distance-unit { font-size: 14px; color: var(--text-2); margin-left: 4px; font-family: var(--font-sans); }
+.bar { height: 6px; background: var(--bg-3); border-radius: 3px; overflow: hidden; margin-top: 10px; }
+.bar-fill { height: 100%; background: var(--acc-grad); transition: width .18s ease; }
+
+/* Live dashboard β design-faithful */
+.dist-big {
+ font-size: 64px; font-weight: 500; letter-spacing: -0.04em;
+ background: var(--acc-grad); -webkit-background-clip: text; -webkit-text-fill-color: transparent;
+ background-clip: text; line-height: 1;
+}
+.distance-row {
+ display: flex; justify-content: space-between; align-items: start; gap: 24px;
+}
+.dist-spark { flex: 1; align-self: end; max-width: 420px; }
+@media (max-width: 900px) {
+ .dash-grid { grid-template-columns: 1fr !important; }
+ .stat-row { grid-template-columns: repeat(2, 1fr) !important; }
+ .distance-row { flex-direction: column; align-items: stretch; }
+ .dist-spark { max-width: 100%; }
+ .dist-big { font-size: 48px; }
+}
+
+.tab-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 8px; }
+.mode-card {
+ border: 1px solid var(--line); border-radius: 10px; padding: 10px;
+ background: var(--bg-1); cursor: pointer; transition: border-color .12s, background .12s, transform .08s;
+ display: flex; flex-direction: column; gap: 6px;
+}
+.mode-card:hover { background: var(--bg-3); border-color: var(--text-4); }
+.mode-card:active { transform: translateY(1px); }
+.mode-card.on { border-color: var(--acc-orange); background: rgba(255,122,61,.08); }
+.mode-card .preview { height: 32px; border-radius: 6px; }
+.mode-card .name { font-size: 12px; font-weight: 500; }
+
+/* Static mode-card thumbnails β one CSS gradient per mode. Cheap, beautiful,
+ * no canvas paint cost. Only the active mode gets the live canvas preview
+ * in the hero card above. */
+.thumb-standard { background: linear-gradient(90deg, #15181C 0%, #15181C 30%, var(--text-0) 32%, var(--text-0) 38%, #15181C 40%, #15181C 100%); }
+.thumb-rainbow { background: linear-gradient(90deg, #FF3D82, #FFB54A, #4ADE80, #5BC7FF, #B57BFF, #FF3D82); }
+.thumb-color-wave { background: linear-gradient(90deg, #FF7A3D 0%, #FFB54A 50%, #FF3D82 100%); position: relative; }
+.thumb-color-wave::after { content: ""; position: absolute; inset: 0; background: linear-gradient(90deg, transparent, rgba(0,0,0,.4), transparent); }
+.thumb-breathing { background: var(--acc-grad); position: relative; opacity: .85; }
+.thumb-solid { background: var(--acc-grad); }
+.thumb-comet { background: linear-gradient(90deg, #15181C 0%, #15181C 60%, rgba(255,122,61,.4) 75%, var(--text-0) 90%, var(--text-0) 100%); }
+.thumb-pulse { background: radial-gradient(circle at 50% 50%, var(--acc-orange) 0%, var(--acc-pink) 30%, transparent 70%), var(--bg-3); }
+.thumb-fire { background: linear-gradient(0deg, #FF3D82 0%, #FF7A3D 30%, #FFB54A 65%, #FFF7E0 95%); }
+.thumb-theater-chase { background: repeating-linear-gradient(90deg, var(--acc-orange) 0 10%, transparent 10% 20%, var(--acc-orange) 20% 30%, transparent 30% 40%, var(--acc-orange) 40% 50%, transparent 50% 60%, var(--acc-orange) 60% 70%, transparent 70% 80%, var(--acc-orange) 80% 90%, transparent 90% 100%); }
+.thumb-dual-scan { background: linear-gradient(90deg, var(--acc-orange) 0 10%, transparent 15% 85%, #5BC7FF 90% 100%); }
+.thumb-particles { background: radial-gradient(circle at 30% 50%, var(--acc-pink) 0%, transparent 8%), radial-gradient(circle at 60% 50%, var(--acc-orange) 0%, transparent 8%), radial-gradient(circle at 78% 50%, var(--acc-amber) 0%, transparent 8%), var(--bg-3); }
+
+.color-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
+.swatch { width: 28px; height: 28px; border-radius: 8px; cursor: pointer; border: 2px solid transparent; }
+.swatch.on { border-color: var(--text-0); box-shadow: 0 0 0 2px var(--bg-0), 0 0 0 4px var(--acc-orange); }
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..8923fe2
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "jsx": "react-jsx",
+ "jsxImportSource": "preact",
+ "strict": false,
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "isolatedModules": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"]
+ },
+ "include": ["src"]
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 0000000..5fd7018
--- /dev/null
+++ b/frontend/vite.config.ts
@@ -0,0 +1,24 @@
+import { defineConfig } from 'vite';
+import preact from '@preact/preset-vite';
+import { viteSingleFile } from 'vite-plugin-singlefile';
+
+export default defineConfig({
+ plugins: [preact(), viteSingleFile()],
+ build: {
+ target: 'es2020',
+ minify: 'terser',
+ cssCodeSplit: false,
+ assetsInlineLimit: 100000000,
+ chunkSizeWarningLimit: 100000000,
+ outDir: 'dist',
+ rollupOptions: {
+ output: { inlineDynamicImports: true },
+ },
+ },
+ server: {
+ port: 5173,
+ proxy: {
+ '/api': 'http://192.168.4.1',
+ },
+ },
+});
diff --git a/AmbiSense/AmbiSense.ino b/legacy/AmbiSense/AmbiSense.ino
similarity index 100%
rename from AmbiSense/AmbiSense.ino
rename to legacy/AmbiSense/AmbiSense.ino
diff --git a/AmbiSense/compressed_html_full.h b/legacy/AmbiSense/compressed_html_full.h
similarity index 100%
rename from AmbiSense/compressed_html_full.h
rename to legacy/AmbiSense/compressed_html_full.h
diff --git a/AmbiSense/config.h b/legacy/AmbiSense/config.h
similarity index 100%
rename from AmbiSense/config.h
rename to legacy/AmbiSense/config.h
diff --git a/AmbiSense/eeprom_manager.cpp b/legacy/AmbiSense/eeprom_manager.cpp
similarity index 100%
rename from AmbiSense/eeprom_manager.cpp
rename to legacy/AmbiSense/eeprom_manager.cpp
diff --git a/AmbiSense/eeprom_manager.h b/legacy/AmbiSense/eeprom_manager.h
similarity index 100%
rename from AmbiSense/eeprom_manager.h
rename to legacy/AmbiSense/eeprom_manager.h
diff --git a/AmbiSense/espnow_manager.cpp b/legacy/AmbiSense/espnow_manager.cpp
similarity index 100%
rename from AmbiSense/espnow_manager.cpp
rename to legacy/AmbiSense/espnow_manager.cpp
diff --git a/AmbiSense/espnow_manager.h b/legacy/AmbiSense/espnow_manager.h
similarity index 100%
rename from AmbiSense/espnow_manager.h
rename to legacy/AmbiSense/espnow_manager.h
diff --git a/AmbiSense/led_controller.cpp b/legacy/AmbiSense/led_controller.cpp
similarity index 100%
rename from AmbiSense/led_controller.cpp
rename to legacy/AmbiSense/led_controller.cpp
diff --git a/AmbiSense/led_controller.h b/legacy/AmbiSense/led_controller.h
similarity index 100%
rename from AmbiSense/led_controller.h
rename to legacy/AmbiSense/led_controller.h
diff --git a/AmbiSense/memory_analysis.h b/legacy/AmbiSense/memory_analysis.h
similarity index 100%
rename from AmbiSense/memory_analysis.h
rename to legacy/AmbiSense/memory_analysis.h
diff --git a/AmbiSense/radar_manager.cpp b/legacy/AmbiSense/radar_manager.cpp
similarity index 100%
rename from AmbiSense/radar_manager.cpp
rename to legacy/AmbiSense/radar_manager.cpp
diff --git a/AmbiSense/radar_manager.h b/legacy/AmbiSense/radar_manager.h
similarity index 100%
rename from AmbiSense/radar_manager.h
rename to legacy/AmbiSense/radar_manager.h
diff --git a/AmbiSense/resources.h b/legacy/AmbiSense/resources.h
similarity index 100%
rename from AmbiSense/resources.h
rename to legacy/AmbiSense/resources.h
diff --git a/AmbiSense/stair_wizard_fixed.cpp b/legacy/AmbiSense/stair_wizard_fixed.cpp
similarity index 100%
rename from AmbiSense/stair_wizard_fixed.cpp
rename to legacy/AmbiSense/stair_wizard_fixed.cpp
diff --git a/AmbiSense/stair_wizard_fixed.h b/legacy/AmbiSense/stair_wizard_fixed.h
similarity index 100%
rename from AmbiSense/stair_wizard_fixed.h
rename to legacy/AmbiSense/stair_wizard_fixed.h
diff --git a/AmbiSense/web_interface.cpp b/legacy/AmbiSense/web_interface.cpp
similarity index 100%
rename from AmbiSense/web_interface.cpp
rename to legacy/AmbiSense/web_interface.cpp
diff --git a/AmbiSense/web_interface.h b/legacy/AmbiSense/web_interface.h
similarity index 100%
rename from AmbiSense/web_interface.h
rename to legacy/AmbiSense/web_interface.h
diff --git a/AmbiSense/wifi_manager.cpp b/legacy/AmbiSense/wifi_manager.cpp
similarity index 100%
rename from AmbiSense/wifi_manager.cpp
rename to legacy/AmbiSense/wifi_manager.cpp
diff --git a/AmbiSense/wifi_manager.h b/legacy/AmbiSense/wifi_manager.h
similarity index 100%
rename from AmbiSense/wifi_manager.h
rename to legacy/AmbiSense/wifi_manager.h