Skip to content

Commit 5d6419a

Browse files
author
Ravi Singh
committed
feat(ui): full design-faithful port of all 7 screens + every control wired to firmware
Comprehensive port from frontend/design-source/. Same look/feel/sizes/ placements as Claude Design handoff; every button, dropdown, field, and toggle calls a real /api endpoint and persists in NVS or has hardware effect. Verified all 11 endpoints live on the C3. Frontend (src/screens.tsx full rewrite + atoms.tsx additions): LEDs screen — 11 mode cards each with the Claude-design description ("Distance-driven cluster with directional fade", etc), animated mini LedPreview thumbnails, color preset row + hex input, NumberAndSlider for brightness/effect speed/intensity/trail, dual-handle distance window slider, light span / center shift / strip length sliders. Optimistic writes via /api/settings POST with toast confirms. Motion screen — large smoothing toggle, real-time raw-vs-smoothed LineChart (SVG, dashed gridlines, 80-sample ring buffer), filter sliders (position/velocity/prediction smoothing) + PI gains panel with explanatory hint card. All values round-trip via /api/settings. Mesh screen — topology cards with SVG diagrams (straight/L/U/custom, ported verbatim from design), pair-new-device button opening a 30 s window with animated pairing card, devices list with healthy dots + RSSI + lost%, sensor priority cards (most_recent / slave_first / master_first / zone_based) with descriptions. Wires to /api/topology and /api/mesh. Hardware screen — board profile cards (not dropdown), radar driver cards, pin map with field-label icons + dropdowns filtering unsafe GPIOs. "Save & reboot" button when reboot is needed; reboots via /api/reboot. Network screen — connected status hero card with wifi icon, scan button + signal-bars indicator + Join/current chip per network, inline password prompt on Join, hostname input with .local suffix, AP-mode selector (auto/always/sta_only) + Reset Wi-Fi danger button with two-click confirmation. All endpoints wired. System screen — firmware card with version + reboot button, drop-zone file input with drag-drop or click-to-select, progress bar during upload, "Flash & reboot" button. Diagnostics with heap/min-heap/ uptime/MAC. Auth section with show/hide password toggle and explicit "Set password" / "Disable auth" button. JSON config Export downloads /api/settings JSON. Factory reset with type-the-hostname confirmation posts to new /api/factory_reset (erases NVS + reboots). src/atoms.tsx adds: Icon (24 SVG paths from design), Sparkline (gradient fill + line), NumberAndSlider, DualHandleRange (touch-friendly with pointermove), TopologyDiagram (4 SVGs), LineChart (raw vs smoothed), hsv2rgb / rgb2hex / hex2rgb helpers, fmtUptime, useRing. Firmware: - /api/factory_reset endpoint: nvs_flash_erase() + esp_restart() in deferred task so the JSON response can flush first. - /api/system GET/POST already shipped. Bug fixes: - Set Password now shows real error when < 8 chars (was silent). - OTA upload uses postBinary() with progress callback (was generic POST). Build: 1.16 MB binary, 19%% free in app slot. UI bundle 81.8 KB raw / 24.1 KB gzipped. Both C3s flashed. Verified live on hardware at 192.168.0.216: - /api/ping, /api/version, /api/distance, /api/system, /api/wifi, /api/mesh, /api/topology, /api/board/profiles, /api/radar/kinds, /api/radar/diag, /api/settings — all returning expected payloads. - Radar diag: driver=ld2410, 867 frames parsed in 30 s, last frame 31 ms ago. - Distance live: 39 cm.
1 parent b61a838 commit 5d6419a

5 files changed

Lines changed: 894 additions & 520 deletions

File tree

firmware/components/webui/ui.html

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.
3.3 KB
Binary file not shown.

firmware/components/webui/webui.c

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
#include "esp_log.h"
99
#include "esp_http_server.h"
1010
#include "esp_wifi.h"
11+
#include "nvs_flash.h"
1112
#include "esp_app_desc.h"
1213
#include "esp_system.h"
1314
#include "esp_idf_version.h"
@@ -285,6 +286,24 @@ static esp_err_t handle_system_post(httpd_req_t *req) {
285286
return handle_system_get(req);
286287
}
287288

289+
/* /api/factory_reset — erase NVS, then reboot. Used by System tab's
290+
* "type-the-hostname" confirmation. */
291+
static void _factory_task(void *arg) {
292+
(void)arg;
293+
vTaskDelay(pdMS_TO_TICKS(500));
294+
nvs_flash_erase();
295+
esp_restart();
296+
}
297+
static esp_err_t handle_factory_reset(httpd_req_t *req) {
298+
if (!gate_auth(req)) return ESP_OK;
299+
cJSON *r = cJSON_CreateObject();
300+
cJSON_AddBoolToObject(r, "ok", true);
301+
cJSON_AddStringToObject(r, "note", "erasing NVS and rebooting");
302+
send_json(req, r);
303+
xTaskCreate(_factory_task, "factory", 2048, NULL, 5, NULL);
304+
return ESP_OK;
305+
}
306+
288307
/* /api/reboot — schedule restart so the response can flush first. */
289308
static void _reboot_task(void *arg) { (void)arg; vTaskDelay(pdMS_TO_TICKS(500)); esp_restart(); }
290309
static esp_err_t handle_reboot(httpd_req_t *req) {
@@ -1030,6 +1049,7 @@ static const httpd_uri_t k_routes[] = {
10301049
{ "/api/reboot", HTTP_POST, handle_reboot, NULL },
10311050
{ "/api/system", HTTP_GET, handle_system_get, NULL },
10321051
{ "/api/system", HTTP_POST, handle_system_post, NULL },
1052+
{ "/api/factory_reset", HTTP_POST, handle_factory_reset, NULL },
10331053
{ "/api/version", HTTP_GET, handle_version, NULL },
10341054
{ "/api/wifi/scan", HTTP_GET, handle_wifi_scan, NULL },
10351055
{ "/api/wifi", HTTP_GET, handle_wifi_get, NULL },

frontend/src/atoms.tsx

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,128 @@ export function useRing(size: number, seed: number = 0) {
9393
const push = (v: number) => setBuf(b => [...b.slice(1), v]);
9494
return [buf, push] as const;
9595
}
96+
97+
/* Number+Slider field used across LEDs and Motion screens. */
98+
export function NumberAndSlider({ label, value, onChange, min, max, step = 1, suffix = '' }: {
99+
label: string; value: number; onChange: (v: number) => void; min: number; max: number; step?: number; suffix?: string;
100+
}) {
101+
return (
102+
<div>
103+
<div style="display: flex; justify-content: space-between; margin-bottom: 6px;">
104+
<span class="field-label" style="margin-bottom: 0;">{label}</span>
105+
<input type="number" value={value} min={min} max={max} step={step}
106+
onChange={(e) => onChange(Math.max(min, Math.min(max, Number((e.target as HTMLInputElement).value))))}
107+
style="width: 80px; background: var(--bg-1); border: 1px solid var(--line); border-radius: 6px; padding: 2px 6px; font-family: var(--font-mono); font-size: 12px; color: var(--text-0); text-align: right; outline: none;"/>
108+
</div>
109+
<input type="range" class="range" value={value} min={min} max={max} step={step}
110+
onInput={(e) => onChange(Number((e.target as HTMLInputElement).value))}/>
111+
{suffix && <div style="font-size: 10px; color: var(--text-3); text-align: right; margin-top: 2px;">{suffix}</div>}
112+
</div>
113+
);
114+
}
115+
116+
/* Dual-handle range slider for the distance window. */
117+
export function DualHandleRange({ minVal, maxVal, onChange, min = 0, max = 500 }: {
118+
minVal: number; maxVal: number; onChange: (v: { minVal: number; maxVal: number }) => void; min?: number; max?: number;
119+
}) {
120+
const ref = useRef<HTMLDivElement>(null);
121+
const [drag, setDrag] = useState<'min' | 'max' | null>(null);
122+
const handle = (e: PointerEvent) => {
123+
if (!drag || !ref.current) return;
124+
const rect = ref.current.getBoundingClientRect();
125+
const t = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
126+
const v = Math.round(min + t * (max - min));
127+
if (drag === 'min') onChange({ minVal: Math.min(v, maxVal - 5), maxVal });
128+
else onChange({ minVal, maxVal: Math.max(v, minVal + 5) });
129+
};
130+
useEffect(() => {
131+
if (!drag) return;
132+
const up = () => setDrag(null);
133+
window.addEventListener('pointermove', handle);
134+
window.addEventListener('pointerup', up);
135+
return () => { window.removeEventListener('pointermove', handle); window.removeEventListener('pointerup', up); };
136+
}, [drag, minVal, maxVal]);
137+
const tMin = (minVal - min) / (max - min);
138+
const tMax = (maxVal - min) / (max - min);
139+
return (
140+
<div ref={ref} style="position: relative; height: 28px; padding: 12px 0; cursor: pointer; touch-action: none;">
141+
<div style="position: absolute; left: 0; right: 0; top: 50%; height: 4px; background: var(--bg-3); border-radius: 999px;"/>
142+
<div style={`position: absolute; top: 50%; height: 4px; transform: translateY(-2px); left: ${tMin*100}%; width: ${(tMax-tMin)*100}%; background: var(--acc-grad); border-radius: 999px;`}/>
143+
<div onPointerDown={() => 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);`}/>
144+
<div onPointerDown={() => 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);`}/>
145+
</div>
146+
);
147+
}
148+
149+
/* SVG topology diagrams used by Mesh screen. */
150+
export function TopologyDiagram({ kind, size = 96 }: { kind: 'straight'|'l_shape'|'u_shape'|'custom'; size?: number }) {
151+
const stroke = 'var(--text-2)';
152+
const acc = 'var(--acc-orange)';
153+
if (kind === 'straight') return (
154+
<svg viewBox="0 0 100 60" width="100%" style={`height: ${size*0.6}px;`}>
155+
<line x1="10" y1="30" x2="90" y2="30" stroke={stroke} stroke-width="2"/>
156+
{[10, 50, 90].map((x, i) => <circle key={i} cx={x} cy="30" r="4" fill={acc}/>)}
157+
</svg>
158+
);
159+
if (kind === 'l_shape') return (
160+
<svg viewBox="0 0 100 100" width="100%" style={`height: ${size}px;`}>
161+
<polyline points="20,20 20,80 80,80" stroke={stroke} stroke-width="2" fill="none"/>
162+
{[[20,20],[20,50],[20,80],[50,80],[80,80]].map(([x,y], i) => <circle key={i} cx={x} cy={y} r="4" fill={acc}/>)}
163+
</svg>
164+
);
165+
if (kind === 'u_shape') return (
166+
<svg viewBox="0 0 100 100" width="100%" style={`height: ${size}px;`}>
167+
<polyline points="15,20 15,80 85,80 85,20" stroke={stroke} stroke-width="2" fill="none"/>
168+
{[[15,20],[15,50],[15,80],[50,80],[85,80],[85,50],[85,20]].map(([x,y], i) => <circle key={i} cx={x} cy={y} r="4" fill={acc}/>)}
169+
</svg>
170+
);
171+
return (
172+
<svg viewBox="0 0 100 100" width="100%" style={`height: ${size}px;`}>
173+
<path d="M 15 30 Q 40 10 55 40 T 85 70" stroke={stroke} stroke-width="2" fill="none" stroke-dasharray="3 3"/>
174+
{[[15,30],[40,22],[55,40],[70,52],[85,70]].map(([x,y], i) => <circle key={i} cx={x} cy={y} r="4" fill={acc}/>)}
175+
</svg>
176+
);
177+
}
178+
179+
/* Two-line chart for Motion screen — raw vs smoothed distance. */
180+
export function LineChart({ raw, smooth, width = 600, height = 180 }: {
181+
raw: number[]; smooth: number[]; width?: number; height?: number;
182+
}) {
183+
const lo = 0, hi = 300;
184+
const range = hi - lo;
185+
const toPath = (data: number[]) => data.map((v, i) => {
186+
const x = (i / Math.max(1, data.length - 1)) * width;
187+
const y = height - ((v - lo) / range) * height;
188+
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`;
189+
}).join(' ');
190+
return (
191+
<svg width="100%" viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="none" style={`display: block; height: ${height}px;`}>
192+
{[0, 0.25, 0.5, 0.75, 1].map(t => (
193+
<line key={t} x1="0" y1={height * t} x2={width} y2={height * t} stroke="var(--line-soft)" stroke-dasharray="2 4" stroke-width="1"/>
194+
))}
195+
<path d={toPath(raw)} stroke="var(--text-3)" stroke-width="1" fill="none" opacity="0.7"/>
196+
<path d={toPath(smooth)} stroke="var(--acc-orange)" stroke-width="2" fill="none" stroke-linecap="round"/>
197+
</svg>
198+
);
199+
}
200+
201+
/* HSV ↔ RGB conversion for the color wheel. */
202+
export function hsv2rgb(h: number, s: number, v: number): [number, number, number] {
203+
h = ((h % 360) + 360) % 360;
204+
const c = v * s, x = c * (1 - Math.abs(((h / 60) % 2) - 1)), m = v - c;
205+
let r = 0, g = 0, b = 0;
206+
if (h < 60) [r, g, b] = [c, x, 0];
207+
else if (h < 120) [r, g, b] = [x, c, 0];
208+
else if (h < 180) [r, g, b] = [0, c, x];
209+
else if (h < 240) [r, g, b] = [0, x, c];
210+
else if (h < 300) [r, g, b] = [x, 0, c];
211+
else [r, g, b] = [c, 0, x];
212+
return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)];
213+
}
214+
export function rgb2hex(r: number, g: number, b: number) {
215+
return '#' + [r, g, b].map(n => Math.max(0, Math.min(255, n)).toString(16).padStart(2, '0')).join('').toUpperCase();
216+
}
217+
export function hex2rgb(hex: string): [number, number, number] {
218+
const h = hex.replace('#', '').padEnd(6, '0').slice(0, 6);
219+
return [parseInt(h.slice(0,2), 16), parseInt(h.slice(2,4), 16), parseInt(h.slice(4,6), 16)];
220+
}

0 commit comments

Comments
 (0)