f}V;dPf0Q-=>|N6?VP$ZGuJ7$)hLLr&^omihoby
z533gUPM_D3>9tm8+tyFF!i&7){(Vw=;TAAEqAXr;A&PW9H?IcwkZ-Q^39cVlt+m+o{mVp-e+
z4|FsJrD;?_uH7T7du~=<(MsKAGp9|szevAh?u)B^m3~(qUdi@&u{HjFg~1U_I=MHha+-9)pO>Y<^Nr`^|@eR}@p
z7winSF9cq-CA_<{)GekF1{7{vTOSWp_S%&TS~*fs3*Kn)!2LQX<6UvvWch6
zCS5OkvXjwTGu8Ljwf#G-Ib%zw&dU3B+u`-e-8ajoZ08J{GIb4bUx(ZW#fy{La&@zR
zRm_}awf3;Kcj>w`aO}KT?dZCdRr~p=b(i-)cy3zyfw9e-B1YLKn`=lBc^dV~ba@GX3k}H|_jq**m7@5rVxwtK2Y4yuT5M#+J
z;eiXKpwsLs9J#tr(&PANdRlK|*jM^#U$WKDc%ugH=keOljMq>7tTC(vMJ7K8U|h*$
zb(BFJxXeU34wxt!wgBgVjwS+EHZqiYGlv5-CBA3LT#rI1%~%Og+SjX3(PHtwH0=le$wQ!k0;-C5eQxa52G
zXy5^W1sT`
z6?yrc`n5aHR&Hk3;*K9)Re=j8b$!fLyZtfexOB_%_QlO}WM;4TwpsVxdh2opiB}?;
z&b&6pD;|c;o_s6nd(f2X?RggG{?r5=EUEu(^q=9k8hB8H!Pal`f#8G3vLt+Bf->E<
zdX}pGXSiCod|H*P;eUpm;k$FwqT-vFZoCoYvGoY<$zJvZSky1)+F1Qm*5uts{oWV9
zL^2<#`^{6DwJPdn4IM=c)1jK
z62mL!M=>F(YL_Cn>ocf}Hy^sVv}NU_y&BgmmumWkc-iOx_W<=2hG^*M%DBmyCam%c
zI2q*Sd+XM+Ngu7BH!qj+=GmO{^pdpgucg_UXCA!N+Vtb5#-53b)w!~)G*rH92aeF$
z=-rp}>h-zMGjH9kn^|vs{_$P`F5_80@vh&TW$O=J+Pb%|uV;O<@TDK|9czQuKArY?
z@?_7K_j21`{IR*mWz=1ryRE!_Y6iQ$*Tc*Q9|e~Bi6(tg*ZCCW`&U=B^vFY7o|+H?
XTe<1AmbUfR&I1odM(x+s|Gx");
+ background-repeat: no-repeat;
+ background-position: right 10px center;
+}
+.select:focus { border-color: var(--acc-orange); }
+
+/* segmented control */
+.seg {
+ display: inline-flex;
+ background: var(--bg-1);
+ border: 1px solid var(--line);
+ border-radius: var(--r-sm);
+ padding: 3px;
+ gap: 2px;
+}
+.seg button {
+ padding: 6px 12px;
+ font-size: 12px;
+ border-radius: 5px;
+ color: var(--text-2);
+ font-weight: 500;
+}
+.seg button.on { background: var(--bg-3); color: var(--text-0); box-shadow: var(--shadow-1); }
+.seg button:hover:not(.on) { color: var(--text-1); }
+
+/* slider */
+.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;
+ box-shadow: var(--shadow-1);
+ transition: transform .1s;
+}
+.range::-webkit-slider-thumb:hover { transform: scale(1.1); }
+.range::-moz-range-thumb {
+ width: 16px; height: 16px;
+ border-radius: 50%;
+ background: var(--text-0);
+ border: 3px solid var(--acc-orange);
+ cursor: pointer;
+}
+
+/* toggle */
+.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; }
+
+.toggle-lg { width: 52px; height: 30px; }
+.toggle-lg::after { width: 24px; height: 24px; top: 2px; left: 2px; }
+.toggle-lg.on::after { left: 26px; }
+
+/* chip */
+.chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 9px;
+ border-radius: var(--r-pill);
+ background: var(--bg-3);
+ border: 1px solid var(--line);
+ font-size: 11px;
+ color: var(--text-1);
+ font-family: var(--font-mono);
+}
+
+/* page container */
+.page { padding: 28px 32px 80px; max-width: 1280px; margin: 0 auto; }
+.page-header { display: flex; align-items: end; justify-content: space-between; margin-bottom: 24px; gap: 16px; }
+.page-header h1 { font-size: 22px; font-weight: 600; margin: 0; letter-spacing: -0.02em; }
+.page-header .sub { color: var(--text-2); font-size: 13px; margin-top: 2px; }
+
+@media (max-width: 760px) {
+ .page { padding: 16px 14px 96px; }
+ .page-header h1 { font-size: 19px; }
+}
+
+/* utility */
+.hide-mobile { }
+.show-mobile { display: none !important; }
+@media (max-width: 760px) {
+ .hide-mobile { display: none !important; }
+ .show-mobile { display: revert !important; }
+}
+
+@keyframes pulse-acc { 0%,100% { opacity: 1; } 50% { opacity: 0.45; } }
+@keyframes spin-slow { to { transform: rotate(360deg); } }
+@keyframes fade-up { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
+
+@media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after {
+ animation-duration: 0.001ms !important;
+ transition-duration: 0.001ms !important;
+ }
+}
diff --git a/frontend/design-source/project/tweaks-panel.jsx b/frontend/design-source/project/tweaks-panel.jsx
new file mode 100644
index 0000000..5f8f95a
--- /dev/null
+++ b/frontend/design-source/project/tweaks-panel.jsx
@@ -0,0 +1,425 @@
+
+// tweaks-panel.jsx
+// Reusable Tweaks shell + form-control helpers.
+//
+// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode,
+// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so
+// individual prototypes don't re-roll it. Ships a consistent set of controls so you
+// don't hand-draw , segmented radios, steppers, etc.
+//
+// Usage (in an HTML file that loads React + Babel):
+//
+// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
+// "primaryColor": "#D97757",
+// "fontSize": 16,
+// "density": "regular",
+// "dark": false
+// }/*EDITMODE-END*/;
+//
+// function App() {
+// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
+// return (
+//
+// Hello
+//
+//
+// setTweak('fontSize', v)} />
+// setTweak('density', v)} />
+//
+// setTweak('primaryColor', v)} />
+// setTweak('dark', v)} />
+//
+//
+// );
+// }
+//
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+const __TWEAKS_STYLE = `
+ .twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
+ max-height:calc(100vh - 32px);display:flex;flex-direction:column;
+ background:rgba(250,249,247,.78);color:#29261b;
+ -webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
+ border:.5px solid rgba(255,255,255,.6);border-radius:14px;
+ box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
+ font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
+ .twk-hd{display:flex;align-items:center;justify-content:space-between;
+ padding:10px 8px 10px 14px;cursor:move;user-select:none}
+ .twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
+ .twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
+ width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
+ .twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
+ .twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
+ overflow-y:auto;overflow-x:hidden;min-height:0;
+ scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
+ .twk-body::-webkit-scrollbar{width:8px}
+ .twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
+ .twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
+ border:2px solid transparent;background-clip:content-box}
+ .twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
+ border:2px solid transparent;background-clip:content-box}
+ .twk-row{display:flex;flex-direction:column;gap:5px}
+ .twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
+ .twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
+ color:rgba(41,38,27,.72)}
+ .twk-lbl>span:first-child{font-weight:500}
+ .twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
+
+ .twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
+ color:rgba(41,38,27,.45);padding:10px 0 0}
+ .twk-sect:first-child{padding-top:0}
+
+ .twk-field{appearance:none;width:100%;height:26px;padding:0 8px;
+ border:.5px solid rgba(0,0,0,.1);border-radius:7px;
+ background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
+ .twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
+ select.twk-field{padding-right:22px;
+ background-image:url("data:image/svg+xml;utf8,");
+ background-repeat:no-repeat;background-position:right 8px center}
+
+ .twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
+ border-radius:999px;background:rgba(0,0,0,.12);outline:none}
+ .twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
+ width:14px;height:14px;border-radius:50%;background:#fff;
+ border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
+ .twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
+ background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
+
+ .twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
+ background:rgba(0,0,0,.06);user-select:none}
+ .twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
+ background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
+ transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
+ .twk-seg.dragging .twk-seg-thumb{transition:none}
+ .twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
+ background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px;
+ border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2;
+ overflow-wrap:anywhere}
+
+ .twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
+ background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
+ .twk-toggle[data-on="1"]{background:#34c759}
+ .twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
+ background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
+ .twk-toggle[data-on="1"] i{transform:translateX(14px)}
+
+ .twk-num{display:flex;align-items:center;height:26px;padding:0 0 0 8px;
+ border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
+ .twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
+ user-select:none;padding-right:8px}
+ .twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
+ font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
+ outline:none;color:inherit;-moz-appearance:textfield}
+ .twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
+ -webkit-appearance:none;margin:0}
+ .twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}
+
+ .twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
+ background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
+ .twk-btn:hover{background:rgba(0,0,0,.88)}
+ .twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
+ .twk-btn.secondary:hover{background:rgba(0,0,0,.1)}
+
+ .twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
+ border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
+ background:transparent;flex-shrink:0}
+ .twk-swatch::-webkit-color-swatch-wrapper{padding:0}
+ .twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
+ .twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}
+`;
+
+// ββ useTweaks βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+// Single source of truth for tweak values. setTweak persists via the host
+// (__edit_mode_set_keys β host rewrites the EDITMODE block on disk).
+function useTweaks(defaults) {
+ const [values, setValues] = React.useState(defaults);
+ // Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a
+ // useState-style call doesn't write a "[object Object]" key into the persisted
+ // JSON block.
+ const setTweak = React.useCallback((keyOrEdits, val) => {
+ const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null
+ ? keyOrEdits : { [keyOrEdits]: val };
+ setValues((prev) => ({ ...prev, ...edits }));
+ window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*');
+ }, []);
+ return [values, setTweak];
+}
+
+// ββ TweaksPanel βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+// Floating shell. Registers the protocol listener BEFORE announcing
+// availability β if the announce ran first, the host's activate could land
+// before our handler exists and the toolbar toggle would silently no-op.
+// The close button posts __edit_mode_dismissed so the host's toolbar toggle
+// flips off in lockstep; the host echoes __deactivate_edit_mode back which
+// is what actually hides the panel.
+function TweaksPanel({ title = 'Tweaks', children }) {
+ const [open, setOpen] = React.useState(false);
+ const dragRef = React.useRef(null);
+ const offsetRef = React.useRef({ x: 16, y: 16 });
+ const PAD = 16;
+
+ const clampToViewport = React.useCallback(() => {
+ const panel = dragRef.current;
+ if (!panel) return;
+ const w = panel.offsetWidth, h = panel.offsetHeight;
+ const maxRight = Math.max(PAD, window.innerWidth - w - PAD);
+ const maxBottom = Math.max(PAD, window.innerHeight - h - PAD);
+ offsetRef.current = {
+ x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
+ y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
+ };
+ panel.style.right = offsetRef.current.x + 'px';
+ panel.style.bottom = offsetRef.current.y + 'px';
+ }, []);
+
+ React.useEffect(() => {
+ if (!open) return;
+ clampToViewport();
+ if (typeof ResizeObserver === 'undefined') {
+ window.addEventListener('resize', clampToViewport);
+ return () => window.removeEventListener('resize', clampToViewport);
+ }
+ const ro = new ResizeObserver(clampToViewport);
+ ro.observe(document.documentElement);
+ return () => ro.disconnect();
+ }, [open, clampToViewport]);
+
+ React.useEffect(() => {
+ const onMsg = (e) => {
+ const t = e?.data?.type;
+ if (t === '__activate_edit_mode') setOpen(true);
+ else if (t === '__deactivate_edit_mode') setOpen(false);
+ };
+ window.addEventListener('message', onMsg);
+ window.parent.postMessage({ type: '__edit_mode_available' }, '*');
+ return () => window.removeEventListener('message', onMsg);
+ }, []);
+
+ const dismiss = () => {
+ setOpen(false);
+ window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*');
+ };
+
+ const onDragStart = (e) => {
+ const panel = dragRef.current;
+ if (!panel) return;
+ const r = panel.getBoundingClientRect();
+ const sx = e.clientX, sy = e.clientY;
+ const startRight = window.innerWidth - r.right;
+ const startBottom = window.innerHeight - r.bottom;
+ const move = (ev) => {
+ offsetRef.current = {
+ x: startRight - (ev.clientX - sx),
+ y: startBottom - (ev.clientY - sy),
+ };
+ clampToViewport();
+ };
+ const up = () => {
+ window.removeEventListener('mousemove', move);
+ window.removeEventListener('mouseup', up);
+ };
+ window.addEventListener('mousemove', move);
+ window.addEventListener('mouseup', up);
+ };
+
+ if (!open) return null;
+ return (
+ <>
+
+
+
+ {title}
+
+
+
{children}
+
+ >
+ );
+}
+
+// ββ Layout helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+function TweakSection({ label, children }) {
+ return (
+ <>
+ {label}
+ {children}
+ >
+ );
+}
+
+function TweakRow({ label, value, children, inline = false }) {
+ return (
+
+
+ {label}
+ {value != null && {value}}
+
+ {children}
+
+ );
+}
+
+// ββ Controls ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
+ return (
+
+ onChange(Number(e.target.value))} />
+
+ );
+}
+
+function TweakToggle({ label, value, onChange }) {
+ return (
+
+
{label}
+
+
+ );
+}
+
+function TweakRadio({ label, value, options, onChange }) {
+ const trackRef = React.useRef(null);
+ const [dragging, setDragging] = React.useState(false);
+ const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
+ const idx = Math.max(0, opts.findIndex((o) => o.value === value));
+ const n = opts.length;
+
+ // The active value is read by pointer-move handlers attached for the lifetime
+ // of a drag β ref it so a stale closure doesn't fire onChange for every move.
+ const valueRef = React.useRef(value);
+ valueRef.current = value;
+
+ const segAt = (clientX) => {
+ const r = trackRef.current.getBoundingClientRect();
+ const inner = r.width - 4;
+ const i = Math.floor(((clientX - r.left - 2) / inner) * n);
+ return opts[Math.max(0, Math.min(n - 1, i))].value;
+ };
+
+ const onPointerDown = (e) => {
+ setDragging(true);
+ const v0 = segAt(e.clientX);
+ if (v0 !== valueRef.current) onChange(v0);
+ const move = (ev) => {
+ if (!trackRef.current) return;
+ const v = segAt(ev.clientX);
+ if (v !== valueRef.current) onChange(v);
+ };
+ const up = () => {
+ setDragging(false);
+ window.removeEventListener('pointermove', move);
+ window.removeEventListener('pointerup', up);
+ };
+ window.addEventListener('pointermove', move);
+ window.addEventListener('pointerup', up);
+ };
+
+ return (
+
+
+
+ {opts.map((o) => (
+
+ ))}
+
+
+ );
+}
+
+function TweakSelect({ label, value, options, onChange }) {
+ return (
+
+
+
+ );
+}
+
+function TweakText({ label, value, placeholder, onChange }) {
+ return (
+
+ onChange(e.target.value)} />
+
+ );
+}
+
+function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
+ const clamp = (n) => {
+ if (min != null && n < min) return min;
+ if (max != null && n > max) return max;
+ return n;
+ };
+ const startRef = React.useRef({ x: 0, val: 0 });
+ const onScrubStart = (e) => {
+ e.preventDefault();
+ startRef.current = { x: e.clientX, val: value };
+ const decimals = (String(step).split('.')[1] || '').length;
+ const move = (ev) => {
+ const dx = ev.clientX - startRef.current.x;
+ const raw = startRef.current.val + dx * step;
+ const snapped = Math.round(raw / step) * step;
+ onChange(clamp(Number(snapped.toFixed(decimals))));
+ };
+ const up = () => {
+ window.removeEventListener('pointermove', move);
+ window.removeEventListener('pointerup', up);
+ };
+ window.addEventListener('pointermove', move);
+ window.addEventListener('pointerup', up);
+ };
+ return (
+
+ {label}
+ onChange(clamp(Number(e.target.value)))} />
+ {unit && {unit}}
+
+ );
+}
+
+function TweakColor({ label, value, onChange }) {
+ return (
+
+
{label}
+
onChange(e.target.value)} />
+
+ );
+}
+
+function TweakButton({ label, onClick, secondary = false }) {
+ return (
+
+ );
+}
+
+Object.assign(window, {
+ useTweaks, TweaksPanel, TweakSection, TweakRow,
+ TweakSlider, TweakToggle, TweakRadio, TweakSelect,
+ TweakText, TweakNumber, TweakColor, TweakButton,
+});
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
From e3ea8f9ffec95ead49e3bd66a5b0c8b7956e1329 Mon Sep 17 00:00:00 2001
From: Ravi Singh
Date: Tue, 5 May 2026 18:43:18 +0530
Subject: [PATCH 02/17] docs(v6): architecture, roadmap, and hardware
troubleshooting
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Captures the locked architecture decisions, the 5-PR plan with
current status, and the hardware reference + flash troubleshooting
ladder so future sessions/contributors can pick up the v6 rewrite
without reconstructing context.
- docs/V6-ARCHITECTURE.md β locked decisions (peer mesh, modular
radar drivers, NVS schema, board profiles, FreeRTOS task model,
HTTP API surface). Single source of truth for "why is it built
this way".
- docs/V6-ROADMAP.md β PR-by-PR plan (PR #1 done; PR #2-5 scoped),
done criteria, tag/release cadence (v6.0.0-alpha.N β v6.0.0).
- docs/HARDWARE.md β C3 SuperMini reference wiring, sensor pinouts
for LD2410/LD2412/LD2420/LD2450, and a tested flash-fails-to-
connect troubleshooting ladder including the macOS USB-CDC
stuck-state recovery (Mac restart).
- README pointers to the three new docs.
---
README.md | 8 ++
docs/HARDWARE.md | 159 ++++++++++++++++++++++++++
docs/V6-ARCHITECTURE.md | 246 ++++++++++++++++++++++++++++++++++++++++
docs/V6-ROADMAP.md | 187 ++++++++++++++++++++++++++++++
4 files changed, 600 insertions(+)
create mode 100644 docs/HARDWARE.md
create mode 100644 docs/V6-ARCHITECTURE.md
create mode 100644 docs/V6-ROADMAP.md
diff --git a/README.md b/README.md
index 4947093..f28c9bc 100644
--- a/README.md
+++ b/README.md
@@ -48,6 +48,14 @@ VSCode users: install Espressif's ESP-IDF extension to get IntelliSense for
IDF headers β without it, clangd will report `'esp_err.h' file not found`
and similar; the code still builds correctly via `idf.py`.
+## v6 documentation
+
+| Document | Read when |
+|---|---|
+| [docs/V6-ARCHITECTURE.md](docs/V6-ARCHITECTURE.md) | Picking up the v6 rewrite cold β this captures the locked architectural decisions (peer mesh, modular radar drivers, NVS schema, board profiles). Read first before proposing any structural change. |
+| [docs/V6-ROADMAP.md](docs/V6-ROADMAP.md) | Planning what to build next β the 5-PR plan with status per PR and the tag/release cadence. |
+| [docs/HARDWARE.md](docs/HARDWARE.md) | Building or debugging hardware β reference wiring for C3 SuperMini, board profiles, sensor pinouts, and a flash-fails-to-connect troubleshooting ladder. |
+
## Repo layout
```
diff --git a/docs/HARDWARE.md b/docs/HARDWARE.md
new file mode 100644
index 0000000..9f9425f
--- /dev/null
+++ b/docs/HARDWARE.md
@@ -0,0 +1,159 @@
+# AmbiSense v6 β Hardware Setup & Troubleshooting
+
+For locked architectural decisions about which boards/sensors are
+supported, see [`V6-ARCHITECTURE.md`](V6-ARCHITECTURE.md). This file
+covers the practical "I have hardware in front of me" stuff.
+
+## Reference wiring (ESP32-C3 SuperMini)
+
+This is the validated reference build for v6.0.
+
+| Function | C3 GPIO | Note |
+| -------------- | ------- | ----------------------------------- |
+| LED data (DIN) | GPIO 10 | WS2812 / NeoPixel |
+| Radar RX (MCU) | GPIO 20 | from radar's TX |
+| Radar TX (MCU) | GPIO 21 | to radar's RX |
+| Reset / mode | GPIO 4 | momentary button to GND |
+| Status LED | GPIO 8 | onboard (active-low, on most clones)|
+| 5 V power | 5V pin | for radar VCC |
+| Ground | GND | radar, LED logic ground |
+
+**Pin defaults match the firmware out of the box** β flash the C3
+SuperMini build, wire to spec, no NVS pin override needed. If you
+remap pins from the web UI later, the unsafe-pin guard refuses
+strapping/USB-JTAG/flash pins (GPIOs 9, 11β19 on C3).
+
+### Power supply
+
+- **Logic**: USB power is fine for radar + MCU + onboard status LED.
+- **LED strip**: drive from a separate 5 V PSU sized to the strip
+ (60 mA per WS2812 LED at full white). **Inject power on both ends**
+ for runs above ~50 LEDs to prevent voltage droop and rainbow
+ desaturation. The C3's 3.3 V LDO cannot power more than ~30 LEDs at
+ full brightness β don't try.
+- **Common ground**: tie the LED PSU's GND to the C3's GND so the
+ data signal references correctly.
+
+## Sensor reference wiring
+
+Both LD2410 and LD2450 use 256000 baud UART, 5 V VCC, identical pinout:
+
+```
+Sensor MCU (C3 SuperMini)
+------ ------------------
+TX β GPIO 20 (radar_rx)
+RX β GPIO 21 (radar_tx)
+VCC β 5V
+GND β GND
+```
+
+LD2450 adds OUT pin (digital presence indicator) β leave unconnected
+for v6, the firmware reads everything via UART.
+
+## Supported boards (v6.0)
+
+| Profile | Status | LED pin | Radar RX | Radar TX | Button | Status LED |
+| --------------------- | -------- | ------- | -------- | -------- | ------ | ------------- |
+| `esp32c3-supermini` | β
valid | 10 | 20 | 21 | 4 | 8 (active-low)|
+| `esp32-devkit` | builds | 5 | 16 | 17 | 4 | 2 |
+| `esp32s3-zero` | builds | 21 | 4 | 5 | 9 | 21 |
+| `esp32c6-devkit` | builds | 8 | 4 | 5 | 9 | 15 |
+
+Profiles defined in
+[`firmware/components/board/board.c`](../firmware/components/board/board.c).
+Adding a new board = +1 entry there + a build target in
+`.github/workflows/firmware.yml`.
+
+## Supported sensors (v6.0)
+
+| Driver id | Sensor | Targets | x/y? |
+| ---------- | --------------- | ------- | ---- |
+| `ld2410` | HiLink LD2410(B/C) | 1 | no |
+| `ld2412` | HiLink LD2412 | 1 | no |
+| `ld2420` | HiLink LD2420 | 1 (presence only) | no |
+| `ld2450` | HiLink LD2450 | up to 3 | yes (LD2450 only) |
+| `sim` | Synthetic | scripted | optional |
+
+Switch sensors at runtime via the web UI without reflashing β the
+driver registry compiles every driver in and selects one from NVS at
+boot.
+
+## Troubleshooting
+
+### Flash fails with "Failed to connect to ESP32-C3: No serial data received"
+
+**Symptom**: `idf.py flash` fails repeatedly. The chip enumerates as
+`/dev/cu.usbmodem*` (so USB-CDC is up) but esptool's SYNC packets
+go unanswered. Sometimes the chip feels warm.
+
+**Diagnosis ladder** β try in order:
+
+1. **Check the port isn't being held by another process.**
+ ```sh
+ lsof /dev/cu.usbmodem*
+ ```
+ Common culprits on macOS: `LG Calibration` (LG monitor calibration
+ daemon), Arduino IDE serial monitor, VSCode serial monitor. Kill
+ any holders before retrying.
+
+2. **Force the C3 into ROM download mode.** On most C3 SuperMini
+ clones with two surface-mount buttons (RST + BOOT):
+ - Hold `BOOT`.
+ - While holding `BOOT`, tap `RST`.
+ - Wait 1 s. Release `BOOT`.
+ - Retry `idf.py flash`.
+
+3. **Hold BOOT throughout the flash.** Some clones need `BOOT` held
+ continuously, not just sampled at reset. Run `idf.py flash` with
+ `BOOT` still pressed; release only after flash completes.
+
+4. **For single-button boards**: `BOOT` is often a tiny solder pad on
+ the back, or `BOOT = GPIO 9`. Bridge GPIO 9 to GND with tweezers
+ while pressing `RST`.
+
+5. **`--before usb_reset`** (the C3's USB-Serial-JTAG-specific reset).
+ ```sh
+ python -m esptool --chip esp32c3 -p /dev/cu.usbmodem... \
+ -b 460800 --before usb_reset --after hard_reset \
+ write_flash @flash_args
+ ```
+
+6. **macOS USB-CDC stuck state β restart the Mac.** This is the known
+ final-resort fix when the chip enumerates but esptool can't sync
+ despite all of the above. macOS sometimes caches a stale USB-CDC
+ endpoint state for the C3's USB-Serial-JTAG and won't release it
+ until reboot. Confirmed by Ravi as the working recovery on
+ 2026-05-05.
+
+### Onboard LED stays solid after boot
+
+Expected during PR #1 β `app_main` finishes setup and switches to
+`STATUS_LED_AP_MODE` (slow 1 Hz blink). If it stays solid, you're on
+PR #1's `BOOT` pattern still, which means `app_main` crashed before
+reaching the `set_pattern(AP_MODE)` call. Check the serial log over
+USB-Serial-JTAG (the C3's onboard USB IS the serial console).
+
+### Where do I see logs?
+
+The C3 SuperMini routes ESP-IDF console over its built-in USB-Serial-
+JTAG peripheral. Connect via:
+
+```sh
+. $IDF_PATH/export.sh
+idf.py -p /dev/cu.usbmodem... monitor
+```
+
+or any serial terminal at 115200 baud. Logs are also mirrored to a
+16 KB in-RAM ring buffer accessible at `GET /api/logs` once PR #2's
+web server is up.
+
+### Brownout reset loop
+
+The C3's brownout detector is configured at threshold level 7 (~2.7 V).
+Brownouts on a powered-only-by-USB C3 driving a long LED strip from
+the same rail are common. v6 expects the **LED strip on its own PSU**;
+running them off the C3's 5 V/3.3 V rails will trigger the brownout
+detector and reboot you in a loop.
+
+If you must run a few LEDs from USB power for testing, keep `count β€ 30`
+and `brightness β€ 100/255`.
diff --git a/docs/V6-ARCHITECTURE.md b/docs/V6-ARCHITECTURE.md
new file mode 100644
index 0000000..7ff9e3c
--- /dev/null
+++ b/docs/V6-ARCHITECTURE.md
@@ -0,0 +1,246 @@
+# AmbiSense v6 β Architecture (locked decisions)
+
+Status: **active** as of 2026-05-05. This document captures decisions that
+must not be relitigated without an explicit conversation. If you're picking
+up the v6 rewrite cold, read this first.
+
+## Why v6 exists
+
+v5.1.1 (Arduino) works but is structurally constrained:
+
+- Every subsystem runs cooperatively in `loop()` β radar reads, LED
+ rendering, HTTP serving, ESP-NOW. A slow web request stalls the LEDs;
+ a heavy Fire-mode frame stalls radar reads.
+- EEPROM byte-offset layout (320 bytes across 6 sections) β fragile, no
+ versioning, no atomicity.
+- One radar driver hard-coded; no path to LD2450 multi-target tracking.
+- Master/slave ESP-NOW with implicit topology β equal LED-segment splits
+ break asymmetric stairs (3-step landing + 12-step main flight).
+- WiFi channel pinned to 6 even when the master joins a router on
+ channel 11 β silent failure.
+- No OTA, no captive portal, no auth, plaintext WiFi creds in EEPROM.
+
+v6 is an ESP-IDF + FreeRTOS rewrite that fixes all of the above while
+preserving the v5 visual modes, the user-facing API surface (most
+endpoints kept compatible), and the master-slave-style coordination
+behaviour for U/L stair installs.
+
+---
+
+## Target hardware
+
+| Profile | Validated | Default LED pin | Default radar pins |
+| --------------------- | --------- | --------------- | ------------------ |
+| `esp32c3-supermini` | β
yes | GPIO 10 | RX 20 / TX 21 |
+| `esp32-devkit` | builds | GPIO 5 | RX 16 / TX 17 |
+| `esp32s3-zero` | builds | GPIO 21 | RX 4 / TX 5 |
+| `esp32c6-devkit` | builds | GPIO 8 | RX 4 / TX 5 |
+
+Profiles ship in `firmware/components/board/board.c`. Each declares an
+`unsafe_pin_mask` covering strapping pins, USB-Serial-JTAG D-/D+, and
+internal SPI flash. The web UI (PR #5) will refuse pin remaps that fall
+on unsafe pins.
+
+The C3 SuperMini is the primary target for v6.0. Other boards are
+"compile-clean" until the user gets hardware in hand.
+
+## Target sensors (radar abstraction layer)
+
+A driver registry compiled into `components/radar/`. All drivers linked
+in; one selected at runtime via the `board.radar_kind` NVS key. Adding a
+new sensor = +1 file + 1 dropdown entry, no firmware reflash needed.
+
+| Driver id | Sensor | Tier | Notes |
+| ---------- | ---------------- | ----- | ----------------------------------- |
+| `ld2410` | HiLink LD2410(B/C)| v1 | Single target; existing v5 hardware |
+| `ld2412` | HiLink LD2412 | v1 | Per-gate sensitivity |
+| `ld2420` | HiLink LD2420 | v1 | Presence only (no distance) |
+| `ld2450` | HiLink LD2450 | v1 | Up to 3 targets, x/y/speed |
+| `sim` | Simulator | v1 | Synthetic traces for desk testing |
+
+**One radar per device.** The C3 has only one usable UART beyond the
+console, and the user explicitly confirmed this constraint. Multi-radar
+fusion happens at the mesh level (each device contributes its sensor).
+
+## Mesh model: peer mesh, NOT master/slave
+
+Every device:
+
+1. Has the **full topology config** in NVS (which segments exist, which
+ device owns each segment, sensor positions in cm along the strip).
+2. **Broadcasts its radar reading at 5 Hz** to all peers via ESP-NOW.
+3. Runs the **same fusion algorithm locally** on the merged peer stream
+ and arrives at the same global "active person position".
+4. Renders **only its own LED segment** on its **own local strip**.
+
+Properties:
+
+- **No leader required for control.** Any device can drop and the others
+ keep working at degraded fusion accuracy.
+- **Web UI host = elected coordinator** (lowest-MAC peer). User browses
+ to `ambisense.local`, mDNS resolves to whoever is elected. No "which
+ IP do I open?" confusion.
+- **Config gossip.** Any device accepts a config write; gossip propagates
+ with a `config_version` stamp. Lex tiebreaker on MAC for simultaneous
+ writes.
+
+### Critical physical fact: each device has its OWN local strip
+
+LED strips need power injection on both ends for long runs and the data
+signal degrades over distance. We do **not** chain a single strip across
+multiple devices. Each device drives its own physical strip; the "global
+LED address space" is purely virtual:
+
+```
+device A (segment 0): physical pixels 0..99 == virtual 0..99
+device B (segment 1): physical pixels 0..99 == virtual 100..199
+device C (segment 2): physical pixels 0..99 == virtual 200..299
+```
+
+When the active person is at virtual position 250:
+
+- A renders background only (250 not in 0..99).
+- B renders background only (250 not in 100..199).
+- C renders the moving light at *local* pixel 50 (250 β 200).
+
+This shrinks ESP-NOW bandwidth dramatically β peers exchange ~24-byte
+`global_state_t` frames (active position + mode + color + effect tick),
+not pixel-level commands. ~600 B/s mesh chatter for a 5-device install.
+
+### Wire protocol (PR #4 will implement)
+
+```c
+typedef struct { // ESP-NOW, broadcast every 200 ms
+ uint8_t msg_type; // 1=TARGET, 2=HEARTBEAT, 3=CONFIG_GOSSIP, 4=CHAN_ANNOUNCE
+ uint8_t device_idx;
+ uint16_t config_version;
+ uint16_t distance_cm;
+ int16_t x_cm, y_cm; // valid for LD2450, else 0
+ int16_t velocity_cms;
+ uint8_t energy;
+ uint8_t flags; // bit0=primary_target_present, bit1=is_coordinator
+ uint64_t ts_us;
+} peer_msg_t;
+```
+
+Channel-follow-STA: when any device joins a router, it broadcasts
+`CHAN_ANNOUNCE`; peers update via `esp_wifi_set_channel()`. Solves the
+v5 "channel 6 forced, router on 11, mesh dies" silent failure.
+
+Encrypted ESP-NOW: PMK/LMK pairing during a 3-second-button-hold
+pairing window. Stops a neighbour with the same firmware from joining
+your mesh accidentally.
+
+---
+
+## Software structure
+
+```
+firmware/
+βββ CMakeLists.txt # IDF project root
+βββ partitions.csv # NVS + 2x OTA + LittleFS + coredump
+βββ sdkconfig.defaults # Common knobs (WDT, brownout, coredump, β¦)
+βββ sdkconfig.defaults.esp32c3 # C3-specific (USB-Serial-JTAG console, brownout)
+βββ main/main.c # app_main: bring up tasks; no business logic
+βββ components/
+ βββ board/ # PR #1 β β board profile struct + 4 profiles
+ βββ settings/ # PR #1 β β NVS facade replacing v5 EEPROM
+ βββ status_led/ # PR #1 β β pattern-driven LED in own task
+ βββ netmgr/ # PR #2 β Wi-Fi STA/AP + mDNS + captive DNS
+ βββ webui/ # PR #2 β esp_http_server + LittleFS + auth
+ βββ ota/ # PR #2 β esp_https_ota wrapper
+ βββ radar/ # PR #3 β driver registry (LD2410/2412/2420/2450/sim)
+ βββ motion/ # PR #3 β PI smoother (port of v5 algorithm)
+ βββ led_engine/ # PR #3 β 11 modes via led_strip RMT
+ βββ mesh/ # PR #4 β ESP-NOW peer mesh
+ βββ topology/ # PR #4 β explicit L/U/asymmetric stair model
+```
+
+### FreeRTOS task model
+
+| Task | Pri | Stack | Period | Responsibility |
+| ----------------- | --- | ----- | ----------- | ----------------------------------- |
+| `radar_task` | 6 | 3 KB | UART event | Read radar bytes β `radar_frame_t` |
+| `motion_task` | 5 | 4 KB | 50 Hz | PI smoother β publishes `target_t` |
+| `mesh_rx_task` | 5 | 4 KB | event | ESP-NOW callbacks β `target_q` |
+| `mesh_tx_task` | 4 | 3 KB | 5 Hz | Broadcast our reading; topology gossip |
+| `led_render_task` | 4 | 6 KB | 60 Hz | Read fused target β framebuffer β `led_strip_refresh()` |
+| `web_task` | 3 | 6 KB | event | HTTPD handler thread |
+| `health_task` | 2 | 2 KB | 1 Hz | Heap watch, peer timeout, status LED |
+
+The single integration point is `target_q` β radar local + ESP-NOW
+remote both push into it; motion fusion + LED render both consume it.
+This collapses v5's four ad-hoc paths in `processRadarReading()`
+(master / slave / standalone / no-slaves) into one uniform pipeline.
+
+---
+
+## NVS schema (replacing v5's 320-byte EEPROM map)
+
+| Namespace | Keys (representative) |
+| --------- | --------------------- |
+| `sys` | `device_name` |
+| `board` | `id`, `led_pin`, `radar_rx`, `radar_tx`, `button`, `status_led`, `radar_kind` |
+| `led` | `count`, `brightness`, `r/g/b`, `mode`, `span`, `center_shift`, `trail`, `dir_light`, `bg_mode`, `effect_speed`, `effect_intensity` |
+| `dist` | `min_cm`, `max_cm` |
+| `motion` | `enabled`, `pos_smooth`, `vel_smooth`, `predict`, `p_gain`, `i_gain` |
+| `mesh` | `peers` (blob: count + 6ΓN MAC array), `pmk`, `channel`, `priority_mode` |
+| `topo` | `kind` (straight/L/U/custom), `total_leds`, `segments` (blob) |
+| `wifi` | `ssid`, `pass` (encrypted w/ chip-derived key), `static_ip` (optional), `mdns_name` |
+| `auth` | `admin_pass_hash` (PBKDF2-SHA256, 250k rounds) |
+
+NVS is journaled (atomic per-key writes), wear-levelled, typed (no
+manual hi/lo byte unpacking), and versionable. Replaces v5's manual
+XOR-CRC sectioned layout entirely.
+
+No migration shim from v5 EEPROM β the user explicitly confirmed v6 is
+a clean cutover (no backwards compatibility with Arduino fleet).
+
+---
+
+## HTTP API
+
+The PR #1 design preserves every existing v5 endpoint (compatibility for
+external integrations like Home Assistant), and adds the missing v6
+surface. See `docs/V6-ROADMAP.md` for the per-PR endpoint deliveries.
+
+New endpoints summary:
+
+```
+POST /api/auth/login β cookie session
+WS /api/live β distance + RSSI + heap @ 5 Hz, replaces XHR polling
+GET /api/board/profiles β board dropdown
+POST /api/board β save board id + pin overrides; reboot
+GET /api/radar/kinds β ld2410 | ld2412 | ld2420 | ld2450 | sim
+POST /api/topology β persist L/U/custom + per-segment LED ranges
+POST /api/ota β multipart firmware upload
+POST /api/sim/trace β simulator-driver: replay a recorded distance trace
+GET /api/version β app version + git sha + idf version + free heap
+GET /api/logs β ring buffer of recent ESP_LOG output
+```
+
+---
+
+## Open decisions (locked for v6.0)
+
+These were resolved by the user with "decide for me":
+
+| Decision | Choice |
+| ------------ | ------------------------------------------------------------ |
+| Boards in v1 | All four profiles ship; only C3 validated, others build-clean |
+| Auth default | OFF; banner until configured; PBKDF2-SHA256 hash |
+| OTA signing | Unsigned for v6.0; signed-OTA on the v6.x roadmap |
+| UI framework | Preact + Tailwind + Vite + TypeScript; bundle target <80 KB gz |
+| Mesh model | Peer mesh with elected coordinator (lowest-MAC) |
+| MQTT | Off by default; HA auto-discovery format when enabled |
+| Repo strategy | Same repo; `legacy/v5-arduino` archive; `v6-idf-rewrite` working branch; tagged releases `v6.0.0-alpha.N` |
+
+## What's NOT in v6.0 (deferred)
+
+- BLE Mesh / Thread / Matter (would require C6/S3 only β rules out C3)
+- Simultaneous LD2410 + LD2450 on one board (C3 has only one usable UART)
+- Signed OTA (defer until update flow is exercised)
+- Multi-room / cross-house mesh (β€5 nodes per install is the design point)
+- Anonymous telemetry / phone-home (never)
+
+These can be added later without breaking the architecture.
diff --git a/docs/V6-ROADMAP.md b/docs/V6-ROADMAP.md
new file mode 100644
index 0000000..1e8fd2b
--- /dev/null
+++ b/docs/V6-ROADMAP.md
@@ -0,0 +1,187 @@
+# AmbiSense v6 β PR-by-PR roadmap
+
+Five PRs. Each is independently flashable and validated on real C3
+hardware before the next starts. Releases tagged `v6.0.0-alpha.N` from
+PR #2 onwards; final `v6.0.0` ships with PR #5.
+
+For locked architectural decisions see [`V6-ARCHITECTURE.md`](V6-ARCHITECTURE.md).
+
+---
+
+## PR #1 β IDF skeleton
+
+**Status: β
MERGED on `v6-idf-rewrite`, pushed to origin (2026-05-05).**
+
+Delivered:
+- Repo housekeeping: legacy v5 moved under `legacy/AmbiSense/`,
+ branches `legacy/v5-arduino` and `v6-idf-rewrite` created.
+- ESP-IDF v5.3-compatible project scaffold under `firmware/`.
+- Custom partition table: 16 KB NVS + 8 KB OTA data + 1408 KB Γ 2 OTA
+ app slots + 960 KB LittleFS + 64 KB coredump (4 MB total).
+- `sdkconfig.defaults` with task watchdog, brownout detector,
+ bootloader rollback, coredump-to-flash, esp_http_server WS support.
+- `components/board/` β profile struct + 4 ship-ready profiles
+ (`esp32c3-supermini` validated; others build-clean, untested).
+ `unsafe_pin_mask` blocks UI pin remap to strapping/USB/flash GPIOs.
+- `components/settings/` β NVS facade replacing v5's 320-byte EEPROM
+ layout. PR #1 only exposes the `board` namespace; other namespaces
+ populated as their owners come online.
+- `components/status_led/` β pattern-driven LED in its own FreeRTOS task.
+ Six patterns (BOOT, AP_MODE, STA_MODE, OTA, ERROR, PANIC) cover every
+ user-visible state.
+- `main/main.c` β resolves board profile (NVS override β compile-time
+ default), applies per-pin NVS overrides while rejecting unsafe pins,
+ spawns status_led task.
+- `frontend/design-source/` β full Claude Design handoff bundle
+ preserved for PR #5 to build against.
+- `.github/workflows/firmware.yml` β IDF build matrix across all four
+ targets on every push/PR; uploads tagged firmware artifacts.
+
+**Build numbers** (C3 target):
+- `ambisense.bin`: 0x30CF0 bytes (~195 KB)
+- App slot free: 0x12F310 bytes (86%)
+- Bootloader free: 35%
+
+**Hardware validation status**: blocked on a one-off macOS USB-CDC
+enumeration issue (the chip enumerates but esptool can't sync). Not a
+firmware bug β see [`HARDWARE.md`](HARDWARE.md). Will be flashed once
+the user retries after a Mac restart.
+
+---
+
+## PR #2 β Wi-Fi, web shell, OTA, auth scaffold
+
+**Status: not started.**
+
+Scope:
+- `components/netmgr/` β Wi-Fi STA with fallback to AP, mDNS hostname,
+ captive-portal DNS responder for AP mode (auto-pop on iOS/Android/
+ Win11), Wi-Fi event loop integration.
+- `components/webui/` β esp_http_server, LittleFS-served static UI
+ (placeholder bundle), JSON settings round-trip for every NVS key,
+ cookie-based auth (off by default, PBKDF2-SHA256 password hash),
+ WebSocket endpoint stub at `/api/live`.
+- `components/ota/` β `esp_https_ota` wrapper, `POST /api/ota` multipart
+ upload, two-stage commit (write β verify β mark valid β reboot).
+- Status LED transitions: BOOT β AP_MODE while waiting for credentials,
+ STA_MODE heartbeat once connected.
+- `GET /api/version`, `GET /api/logs` (ring buffer mirror of `ESP_LOG*`).
+
+**Done criterion**: from a stock C3, connect phone to `AmbiSense-XXXX`
+AP, captive portal pops, configure home Wi-Fi, device reboots into STA
+mode, browse to `http://ambisense.local`, see placeholder dashboard,
+upload a `.bin` firmware via OTA, device reboots into new firmware.
+
+**Tag on completion**: `v6.0.0-alpha.1`.
+
+---
+
+## PR #3 β Radar driver registry, motion, LED engine
+
+**Status: not started.**
+
+Scope:
+- `components/radar/` β driver registry pattern with one driver per
+ sensor: `ld2410.c`, `ld2412.c`, `ld2420.c`, `ld2450.c`, `sim.c`.
+ Selected at runtime via `board.radar_kind` NVS key. Common
+ `radar_frame_t` API; LD2450's x/y/multi-target fields ignored by
+ drivers that don't provide them.
+- `components/motion/` β port of v5's PI smoother
+ (`legacy/AmbiSense/radar_manager.cpp:38-198`). Same algorithm, runs
+ in its own task at 50 Hz, output published to `target_q`.
+- `components/led_engine/` β port of all 11 visual modes
+ (`legacy/AmbiSense/led_controller.cpp` lines 273β650). RMT-backed
+ `led_strip` driver replaces `Adafruit_NeoPixel`. Render task at
+ 60 Hz consumes `target_q`, writes framebuffer, refreshes strip
+ asynchronously (non-blocking, unlike v5's `strip.show()`).
+- `POST /api/sim/trace` β accept JSON-encoded distance traces for the
+ simulator driver. Lets you debug LED modes without standing in front
+ of a sensor.
+- UI placeholder gets the LED preview canvas wired to live state.
+
+**Done criterion**: standalone C3 + LD2450 + 30 LED test strip mimics
+v5 visual behaviour at parity for all 11 modes; web request load no
+longer glitches LED rendering; simulator replays a recorded trace
+identically to a live one.
+
+**Tag on completion**: `v6.0.0-alpha.2`.
+
+---
+
+## PR #4 β Peer mesh, topology, ESP-NOW
+
+**Status: not started.**
+
+Scope:
+- `components/mesh/` β ESP-NOW peer mesh per the wire protocol in
+ [`V6-ARCHITECTURE.md`](V6-ARCHITECTURE.md#wire-protocol).
+ Channel-follow-STA, encrypted ESP-NOW (PMK/LMK), 3-second-button-
+ hold pairing window, active heartbeat, 10 s health timeout.
+- `components/topology/` β explicit topology config in NVS (straight /
+ L / U / asymmetric custom) with per-segment device + LED-range
+ assignments. Replaces v5's implicit-from-count model that broke
+ asymmetric stairs.
+- All 4 sensor priority modes preserved as fusion algorithms (MOST_RECENT,
+ SLAVE_FIRST, MASTER_FIRST, ZONE_BASED) β but reframed as peer-fusion
+ policies rather than master-decides logic.
+- Coordinator election (lowest-MAC peer serves the web UI; mDNS and
+ captive portal point at coordinator's IP).
+- Config gossip with version-stamped `CONFIG_GOSSIP` packets; lex
+ tiebreaker on MAC for simultaneous edits.
+
+**Done criterion**: 2 devices on a U-stair, kill any one β the others
+keep tracking; configure asymmetric segments and verify visually;
+coordinator survives; rebooting the coordinator triggers a new election
+within 5 s.
+
+**Tag on completion**: `v6.0.0-alpha.3`.
+
+---
+
+## PR #5 β Real UI, MQTT, polish
+
+**Status: not started.** Design source already in repo at
+`frontend/design-source/` (Claude Design handoff: tokens.css + 7
+screen JSXs).
+
+Scope:
+- `frontend/` β Vite + Preact + Tailwind + TypeScript scaffold. Lift
+ the design tokens from `frontend/design-source/project/tokens.css`
+ into `tailwind.config.ts`. Port each of the 7 screens from JSX into
+ Preact components. Bundle target <80 KB gzipped.
+- WebSocket live data wired to `/api/live` (replace polling-based
+ placeholder from PR #2).
+- OTA UI: drag-drop `.bin`, progress bar, 30 s reboot overlay polling
+ `/api/version` to know when to dismiss.
+- Pin remap UI with guard rails β unsafe pins shown disabled with
+ tooltip, "reboot to apply" affordance.
+- LD2450 2-D zone editor (only visible when `radar_kind=ld2450`) β
+ drag-define exclusion zones in x/y space.
+- Factory reset flow with type-device-name confirmation.
+- MQTT publisher: off by default, system-tab toggle, Home Assistant
+ auto-discovery payload format. Publishes `{distance, mode, state}`,
+ subscribes to `cmd/*`.
+- README rewrite for v6, hardware photos, install guide, HA integration
+ re-validation against v6 endpoints.
+
+**Done criterion**: full UI works on phone + desktop; all flows from
+the design covered; MQTT integration tested with a real Home Assistant;
+clean release notes.
+
+**Tag on completion**: `v6.0.0`. Promote `v6-idf-rewrite` β `main`.
+Move v5 README content fully behind a "v5 legacy" link.
+
+---
+
+## Tag/release cadence
+
+| Tag | Trigger | Contents |
+| ------------------ | -------------- | --------------------- |
+| `v5.1.1` | (already exists) | last Arduino release |
+| `v6.0.0-alpha.0` | PR #1 merge | skeleton boots only |
+| `v6.0.0-alpha.1` | PR #2 merge | + Wi-Fi, web, OTA |
+| `v6.0.0-alpha.2` | PR #3 merge | + radar + LED engine |
+| `v6.0.0-alpha.3` | PR #4 merge | + peer mesh |
+| `v6.0.0` | PR #5 merge | feature complete |
+
+CI auto-attaches per-board firmware artifacts to tag pushes.
From 096d40521aaa65d18b6a5882b77ac9728f577152 Mon Sep 17 00:00:00 2001
From: Ravi Singh
Date: Tue, 5 May 2026 19:07:28 +0530
Subject: [PATCH 03/17] =?UTF-8?q?feat(v6):=20PR=20#2=20=E2=80=94=20always-?=
=?UTF-8?q?on=20AP,=20STA,=20captive=20portal,=20web=20UI=20shell,=20auth,?=
=?UTF-8?q?=20OTA?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds the full networking + web layer. AP and STA both run simultaneously
so the device works with OR without an external router β many installs
have no Wi-Fi at all and the AP becomes the only access point.
- components/netmgr β WIFI_MODE_APSTA always on. AP "AmbiSense-XXXX" up
for the lifetime of the device on a configurable channel (default 6),
open or WPA2 (NVS-configurable). STA additive: if creds saved, joins
user's router; on failure, AP stays available so the user can fix
creds via the captive portal. Captive-portal DNS responder steers
every query at the device IP so iOS/Android/Win11 auto-pop setup.
mDNS publishes .local + _ambisense._tcp service.
- components/auth β PBKDF2-SHA256 (250k rounds) password hash, 32-byte
random session tokens, 8-slot in-RAM session store, 24h TTL. Off by
default; banner in placeholder UI nudges user to set a password.
- components/ota β esp_https_ota wrapper streaming an octet-stream
upload into the inactive OTA partition, validates, marks for boot,
reboots in 1s. Bootloader rollback armed via sdkconfig β failed boot
reverts automatically. ota_mark_valid() called early in main to defuse
rollback once the running image proves it boots clean.
- components/webui β esp_http_server with full route surface:
GET / placeholder HTML (PR #5 replaces)
GET /generate_204, /hotspot-detect.html, /connecttest.txt, ...
captive portal redirects (302 β /)
GET /api/version firmware/idf/build/uptime/heap/ip/etc.
GET /api/wifi/scan list nearby APs
POST /api/wifi set creds, deferred reconnect
POST /api/auth/login | logout | password
GET /api/board/profiles 4 board profiles + unsafe pin masks
POST /api/board save board id + per-pin overrides
GET /api/radar/kinds 5 radar drivers + active selection
GET /api/settings flat read of every NVS namespace
POST /api/settings partial update (any subset)
GET /api/distance placeholder (PR #3 wires real value)
POST /api/ota octet-stream firmware upload
WS /api/live 5 Hz JSON: distance/rssi/heap/peers
- components/settings extended: typed get/set for u32/i32/u8/blob/str
across any namespace + wifi/sys/auth shortcuts.
- main.c brings them all up in order: nvs β board β status_led β auth
β netmgr β webui β ota_mark_valid.
Built clean; binary 950 KB (33%% free in 1.4 MB app slot). Flashed to
both C3s. AP visible from phone; captive portal expected to pop.
Refs v6 epic. Next: PR #3 β radar driver registry + LED engine.
---
firmware/components/auth/CMakeLists.txt | 5 +
firmware/components/auth/auth.c | 210 +++++
firmware/components/auth/include/auth.h | 50 ++
firmware/components/netmgr/CMakeLists.txt | 5 +
firmware/components/netmgr/idf_component.yml | 2 +
firmware/components/netmgr/include/netmgr.h | 62 ++
firmware/components/netmgr/netmgr.c | 367 +++++++++
firmware/components/ota/CMakeLists.txt | 5 +
firmware/components/ota/include/ota.h | 44 +
firmware/components/ota/ota.c | 99 +++
.../components/settings/include/settings.h | 39 +
firmware/components/settings/settings.c | 155 ++++
firmware/components/webui/CMakeLists.txt | 7 +
firmware/components/webui/include/webui.h | 43 +
firmware/components/webui/webui.c | 767 ++++++++++++++++++
firmware/main/CMakeLists.txt | 3 +-
firmware/main/main.c | 23 +-
frontend/design-source/chats/chat1.md | 41 +
18 files changed, 1920 insertions(+), 7 deletions(-)
create mode 100644 firmware/components/auth/CMakeLists.txt
create mode 100644 firmware/components/auth/auth.c
create mode 100644 firmware/components/auth/include/auth.h
create mode 100644 firmware/components/netmgr/CMakeLists.txt
create mode 100644 firmware/components/netmgr/idf_component.yml
create mode 100644 firmware/components/netmgr/include/netmgr.h
create mode 100644 firmware/components/netmgr/netmgr.c
create mode 100644 firmware/components/ota/CMakeLists.txt
create mode 100644 firmware/components/ota/include/ota.h
create mode 100644 firmware/components/ota/ota.c
create mode 100644 firmware/components/webui/CMakeLists.txt
create mode 100644 firmware/components/webui/include/webui.h
create mode 100644 firmware/components/webui/webui.c
diff --git a/firmware/components/auth/CMakeLists.txt b/firmware/components/auth/CMakeLists.txt
new file mode 100644
index 0000000..4f987d9
--- /dev/null
+++ b/firmware/components/auth/CMakeLists.txt
@@ -0,0 +1,5 @@
+idf_component_register(
+ SRCS "auth.c"
+ INCLUDE_DIRS "include"
+ REQUIRES settings mbedtls log esp_system freertos
+)
diff --git a/firmware/components/auth/auth.c b/firmware/components/auth/auth.c
new file mode 100644
index 0000000..1c2bdc4
--- /dev/null
+++ b/firmware/components/auth/auth.c
@@ -0,0 +1,210 @@
+#include "auth.h"
+
+#include
+#include
+#include
+
+#include "esp_log.h"
+#include "esp_random.h"
+#include "freertos/FreeRTOS.h"
+#include "freertos/semphr.h"
+#include "mbedtls/pkcs5.h"
+#include "mbedtls/md.h"
+
+#include "settings.h"
+
+static const char *TAG = "auth";
+
+#define PBKDF2_ROUNDS 250000
+#define SALT_LEN 16
+#define HASH_LEN 32
+#define MAX_SESSIONS 8
+#define SESSION_TTL_MS (24ULL * 60ULL * 60ULL * 1000ULL) /* 24h */
+
+typedef struct {
+ bool active;
+ uint8_t token[AUTH_TOKEN_LEN];
+ uint64_t expires_ms;
+} session_t;
+
+static struct {
+ bool inited;
+ bool enabled;
+ uint8_t hash[HASH_LEN];
+ uint8_t salt[SALT_LEN];
+ SemaphoreHandle_t lock;
+ session_t sessions[MAX_SESSIONS];
+} s_auth;
+
+static uint64_t now_ms(void) {
+ return (uint64_t)xTaskGetTickCount() * portTICK_PERIOD_MS;
+}
+
+static void hex_encode(const uint8_t *in, size_t n, char *out) {
+ static const char hex[] = "0123456789abcdef";
+ for (size_t i = 0; i < n; ++i) {
+ out[i*2] = hex[in[i] >> 4];
+ out[i*2+1] = hex[in[i] & 0x0f];
+ }
+ out[n*2] = '\0';
+}
+
+static int hex_decode(const char *in, uint8_t *out, size_t n) {
+ for (size_t i = 0; i < n; ++i) {
+ char hi = in[i*2], lo = in[i*2+1];
+ if (!hi || !lo) return -1;
+ int v = 0;
+ for (int k = 0; k < 2; ++k) {
+ char c = (k == 0) ? hi : lo;
+ int d;
+ if (c >= '0' && c <= '9') d = c - '0';
+ else if (c >= 'a' && c <= 'f') d = c - 'a' + 10;
+ else if (c >= 'A' && c <= 'F') d = c - 'A' + 10;
+ else return -1;
+ v = (v << 4) | d;
+ }
+ out[i] = (uint8_t)v;
+ }
+ return 0;
+}
+
+static int pbkdf2(const char *plaintext, const uint8_t salt[SALT_LEN], uint8_t out[HASH_LEN]) {
+ const mbedtls_md_info_t *info = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
+ if (!info) return -1;
+#if defined(MBEDTLS_VERSION_MAJOR) && MBEDTLS_VERSION_MAJOR >= 3
+ return mbedtls_pkcs5_pbkdf2_hmac_ext(MBEDTLS_MD_SHA256,
+ (const unsigned char *)plaintext, strlen(plaintext),
+ salt, SALT_LEN, PBKDF2_ROUNDS, HASH_LEN, out);
+#else
+ mbedtls_md_context_t ctx;
+ mbedtls_md_init(&ctx);
+ int rc = mbedtls_md_setup(&ctx, info, 1);
+ if (rc == 0) {
+ rc = mbedtls_pkcs5_pbkdf2_hmac(&ctx,
+ (const unsigned char *)plaintext, strlen(plaintext),
+ salt, SALT_LEN, PBKDF2_ROUNDS, HASH_LEN, out);
+ }
+ mbedtls_md_free(&ctx);
+ return rc;
+#endif
+}
+
+esp_err_t auth_init(void) {
+ if (s_auth.inited) return ESP_OK;
+ s_auth.lock = xSemaphoreCreateMutex();
+ s_auth.inited = true;
+
+ if (settings_get_auth_hash(s_auth.hash, s_auth.salt) == ESP_OK) {
+ s_auth.enabled = true;
+ ESP_LOGI(TAG, "Auth enabled (password configured)");
+ } else {
+ s_auth.enabled = false;
+ ESP_LOGI(TAG, "Auth DISABLED (no password set β open access on local network)");
+ }
+ return ESP_OK;
+}
+
+bool auth_is_enabled(void) {
+ return s_auth.enabled;
+}
+
+esp_err_t auth_set_password(const char *plaintext) {
+ if (!plaintext || !plaintext[0]) {
+ settings_clear_auth();
+ s_auth.enabled = false;
+ memset(s_auth.hash, 0, HASH_LEN);
+ memset(s_auth.salt, 0, SALT_LEN);
+ auth_revoke_all();
+ ESP_LOGI(TAG, "Password cleared; auth disabled");
+ return ESP_OK;
+ }
+ if (strlen(plaintext) < 8) return ESP_ERR_INVALID_ARG;
+
+ uint8_t salt[SALT_LEN];
+ esp_fill_random(salt, SALT_LEN);
+ uint8_t hash[HASH_LEN];
+ if (pbkdf2(plaintext, salt, hash) != 0) return ESP_FAIL;
+
+ esp_err_t err = settings_set_auth_hash(hash, salt);
+ if (err != ESP_OK) return err;
+ memcpy(s_auth.hash, hash, HASH_LEN);
+ memcpy(s_auth.salt, salt, SALT_LEN);
+ s_auth.enabled = true;
+ auth_revoke_all(); /* invalidate any pre-existing sessions */
+ ESP_LOGI(TAG, "Password set; auth enabled");
+ return ESP_OK;
+}
+
+bool auth_check_password(const char *plaintext) {
+ if (!s_auth.enabled || !plaintext) return false;
+ uint8_t cand[HASH_LEN];
+ if (pbkdf2(plaintext, s_auth.salt, cand) != 0) return false;
+ /* Constant-time compare */
+ uint8_t diff = 0;
+ for (size_t i = 0; i < HASH_LEN; ++i) diff |= cand[i] ^ s_auth.hash[i];
+ return diff == 0;
+}
+
+esp_err_t auth_issue_session(char token_hex_out[AUTH_TOKEN_HEX_LEN]) {
+ xSemaphoreTake(s_auth.lock, portMAX_DELAY);
+ int slot = -1;
+ uint64_t now = now_ms();
+ for (int i = 0; i < MAX_SESSIONS; ++i) {
+ if (!s_auth.sessions[i].active || s_auth.sessions[i].expires_ms < now) {
+ slot = i; break;
+ }
+ }
+ if (slot < 0) {
+ /* All slots occupied & valid β evict oldest. */
+ slot = 0;
+ for (int i = 1; i < MAX_SESSIONS; ++i) {
+ if (s_auth.sessions[i].expires_ms < s_auth.sessions[slot].expires_ms) slot = i;
+ }
+ }
+ esp_fill_random(s_auth.sessions[slot].token, AUTH_TOKEN_LEN);
+ s_auth.sessions[slot].expires_ms = now + SESSION_TTL_MS;
+ s_auth.sessions[slot].active = true;
+ hex_encode(s_auth.sessions[slot].token, AUTH_TOKEN_LEN, token_hex_out);
+ xSemaphoreGive(s_auth.lock);
+ return ESP_OK;
+}
+
+bool auth_check_session(const char *token_hex) {
+ if (!token_hex || strlen(token_hex) < AUTH_TOKEN_LEN * 2) return false;
+ uint8_t tok[AUTH_TOKEN_LEN];
+ if (hex_decode(token_hex, tok, AUTH_TOKEN_LEN) < 0) return false;
+
+ xSemaphoreTake(s_auth.lock, portMAX_DELAY);
+ uint64_t now = now_ms();
+ bool ok = false;
+ for (int i = 0; i < MAX_SESSIONS; ++i) {
+ if (!s_auth.sessions[i].active) continue;
+ if (s_auth.sessions[i].expires_ms < now) { s_auth.sessions[i].active = false; continue; }
+ uint8_t diff = 0;
+ for (size_t k = 0; k < AUTH_TOKEN_LEN; ++k) diff |= tok[k] ^ s_auth.sessions[i].token[k];
+ if (diff == 0) { ok = true; break; }
+ }
+ xSemaphoreGive(s_auth.lock);
+ return ok;
+}
+
+void auth_revoke(const char *token_hex) {
+ if (!token_hex) return;
+ uint8_t tok[AUTH_TOKEN_LEN];
+ if (hex_decode(token_hex, tok, AUTH_TOKEN_LEN) < 0) return;
+ xSemaphoreTake(s_auth.lock, portMAX_DELAY);
+ for (int i = 0; i < MAX_SESSIONS; ++i) {
+ if (s_auth.sessions[i].active &&
+ memcmp(tok, s_auth.sessions[i].token, AUTH_TOKEN_LEN) == 0) {
+ s_auth.sessions[i].active = false;
+ }
+ }
+ xSemaphoreGive(s_auth.lock);
+}
+
+void auth_revoke_all(void) {
+ if (!s_auth.lock) return;
+ xSemaphoreTake(s_auth.lock, portMAX_DELAY);
+ for (int i = 0; i < MAX_SESSIONS; ++i) s_auth.sessions[i].active = false;
+ xSemaphoreGive(s_auth.lock);
+}
diff --git a/firmware/components/auth/include/auth.h b/firmware/components/auth/include/auth.h
new file mode 100644
index 0000000..1deb9b2
--- /dev/null
+++ b/firmware/components/auth/include/auth.h
@@ -0,0 +1,50 @@
+#pragma once
+
+/*
+ * AmbiSense v6 β auth.
+ *
+ * Off by default. When a password is set, every /api/... endpoint requires
+ * a valid session cookie. Login takes a password, hashes it with PBKDF2-
+ * SHA256 (250k rounds), compares against the stored hash, and issues a
+ * 32-byte random session token returned as `Set-Cookie: ambisense=...`.
+ *
+ * Session storage is in-RAM (8 slots, 24 h TTL). Restart wipes sessions β
+ * acceptable for a hobbyist device.
+ */
+
+#include
+#include
+#include
+#include "esp_err.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#define AUTH_TOKEN_LEN 32
+#define AUTH_TOKEN_HEX_LEN (AUTH_TOKEN_LEN * 2 + 1)
+
+esp_err_t auth_init(void);
+
+/* True if a password is configured (auth required for /api/...). */
+bool auth_is_enabled(void);
+
+/* Set the admin password. Empty/NULL clears it (disables auth). */
+esp_err_t auth_set_password(const char *plaintext);
+
+/* Verify a plaintext password against the stored hash. */
+bool auth_check_password(const char *plaintext);
+
+/* Issue a session token (returned in token_hex_out, NUL-terminated). */
+esp_err_t auth_issue_session(char token_hex_out[AUTH_TOKEN_HEX_LEN]);
+
+/* Validate a token string. Returns true if active session exists. */
+bool auth_check_session(const char *token_hex);
+
+/* Revoke a single session (logout) or all (factory reset). */
+void auth_revoke(const char *token_hex);
+void auth_revoke_all(void);
+
+#ifdef __cplusplus
+}
+#endif
diff --git a/firmware/components/netmgr/CMakeLists.txt b/firmware/components/netmgr/CMakeLists.txt
new file mode 100644
index 0000000..652fd5a
--- /dev/null
+++ b/firmware/components/netmgr/CMakeLists.txt
@@ -0,0 +1,5 @@
+idf_component_register(
+ SRCS "netmgr.c"
+ INCLUDE_DIRS "include"
+ REQUIRES settings esp_wifi esp_netif esp_event nvs_flash mdns lwip log
+)
diff --git a/firmware/components/netmgr/idf_component.yml b/firmware/components/netmgr/idf_component.yml
new file mode 100644
index 0000000..f964bd7
--- /dev/null
+++ b/firmware/components/netmgr/idf_component.yml
@@ -0,0 +1,2 @@
+dependencies:
+ espressif/mdns: "^1.5.0"
diff --git a/firmware/components/netmgr/include/netmgr.h b/firmware/components/netmgr/include/netmgr.h
new file mode 100644
index 0000000..492f2ea
--- /dev/null
+++ b/firmware/components/netmgr/include/netmgr.h
@@ -0,0 +1,62 @@
+#pragma once
+
+/*
+ * AmbiSense v6 β network manager.
+ *
+ * Owns the Wi-Fi state machine: tries STA with stored credentials, falls
+ * back to AP "AmbiSense-XXXX" if STA fails or no credentials are saved.
+ * Brings up mDNS (`.local`), and runs a captive-portal DNS responder
+ * while in AP mode so phones auto-pop the setup page.
+ *
+ * State changes are exposed via callback so the status_led component can
+ * mirror them as blink patterns.
+ */
+
+#include
+#include
+#include
+#include "esp_err.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef enum {
+ NETMGR_STATE_BOOT = 0,
+ NETMGR_STATE_STA_CONNECTING,
+ NETMGR_STATE_STA_CONNECTED,
+ NETMGR_STATE_AP_FALLBACK, /* AP because STA failed or no creds */
+ NETMGR_STATE_OTA,
+ NETMGR_STATE_ERROR,
+} netmgr_state_t;
+
+typedef void (*netmgr_state_cb_t)(netmgr_state_t state, void *ctx);
+
+/* Bring up the Wi-Fi stack, mDNS, and captive portal as needed. Reads
+ * stored credentials from NVS (`wifi.ssid` / `wifi.pass`). Non-blocking;
+ * connection happens on the IDF event loop. */
+esp_err_t netmgr_init(void);
+
+/* Connect with new credentials and persist them on success. Pass NULL ssid
+ * to clear creds and force AP fallback. */
+esp_err_t netmgr_set_credentials(const char *ssid, const char *pass);
+
+/* Currently in STA mode? */
+bool netmgr_is_sta_connected(void);
+
+/* IP/hostname accessors β caller-provided buffers. */
+esp_err_t netmgr_get_ip(char *out, size_t max);
+esp_err_t netmgr_get_hostname(char *out, size_t max);
+
+/* RSSI in STA mode, 0 in AP mode. */
+int8_t netmgr_get_rssi(void);
+
+/* Register a callback fired on every state transition. */
+void netmgr_on_state_change(netmgr_state_cb_t cb, void *ctx);
+
+/* Set device hostname (lower-case, alnum/hyphen). Persists to NVS. */
+esp_err_t netmgr_set_hostname(const char *name);
+
+#ifdef __cplusplus
+}
+#endif
diff --git a/firmware/components/netmgr/netmgr.c b/firmware/components/netmgr/netmgr.c
new file mode 100644
index 0000000..5b210d4
--- /dev/null
+++ b/firmware/components/netmgr/netmgr.c
@@ -0,0 +1,367 @@
+#include "netmgr.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "esp_log.h"
+#include "esp_event.h"
+#include "esp_wifi.h"
+#include "esp_netif.h"
+#include "esp_mac.h"
+#include "esp_random.h"
+#include "freertos/FreeRTOS.h"
+#include "freertos/task.h"
+#include "freertos/event_groups.h"
+#include "lwip/err.h"
+#include "lwip/sys.h"
+#include "mdns.h"
+
+#include "settings.h"
+
+static const char *TAG = "netmgr";
+
+#define STA_RETRY_MAX 3
+#define STA_RETRY_BACKOFF_MS 3000
+
+static struct {
+ netmgr_state_t state;
+ netmgr_state_cb_t cb;
+ void *cb_ctx;
+ EventGroupHandle_t evt;
+ int sta_retry;
+ esp_netif_t *sta_netif;
+ esp_netif_t *ap_netif;
+ char hostname[33];
+ bool inited;
+ bool dns_running;
+ TaskHandle_t dns_task;
+} s_net;
+
+#define EVT_GOT_IP BIT0
+#define EVT_FAIL BIT1
+
+static void notify_state(netmgr_state_t st) {
+ s_net.state = st;
+ if (s_net.cb) s_net.cb(st, s_net.cb_ctx);
+}
+
+/* Sanitize a free-form name into an mDNS-safe hostname:
+ * lowercase, alnum + hyphen, leading "ambisense-" if not present.
+ * Falls back to "ambisense-XXXX" using the last 16 bits of MAC. */
+static void sanitize_hostname(const char *in, char *out, size_t max) {
+ char buf[33] = {0};
+ size_t bi = 0;
+ if (in) {
+ for (size_t i = 0; in[i] && bi < sizeof(buf) - 1; ++i) {
+ char c = in[i];
+ if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a');
+ if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-') {
+ buf[bi++] = c;
+ } else if (c == ' ' || c == '.' || c == '_') {
+ buf[bi++] = '-';
+ }
+ }
+ }
+ if (bi == 0 || strncmp(buf, "ambisense", 9) != 0) {
+ uint8_t mac[6];
+ esp_read_mac(mac, ESP_MAC_WIFI_STA);
+ snprintf(out, max, "ambisense-%02x%02x", mac[4], mac[5]);
+ } else {
+ snprintf(out, max, "%s", buf);
+ }
+}
+
+static esp_err_t bring_up_mdns(void) {
+ esp_err_t err = mdns_init();
+ if (err != ESP_OK) {
+ ESP_LOGW(TAG, "mdns_init: 0x%x", err);
+ return err;
+ }
+ mdns_hostname_set(s_net.hostname);
+ mdns_instance_name_set("AmbiSense");
+ mdns_service_add("_ambisense", "_http", "_tcp", 80, NULL, 0);
+ ESP_LOGI(TAG, "mDNS up: %s.local", s_net.hostname);
+ return ESP_OK;
+}
+
+/* Captive-portal DNS responder. Resolves every query to our AP IP so any
+ * hostname a phone tries (apple.com/library/test/success.html, msftconnecttest,
+ * captive.apple.com, ...) gets steered at the device. iOS / Android / Win11
+ * detect this and pop the setup page automatically. */
+static void dns_task(void *arg) {
+ (void)arg;
+ int sock = socket(AF_INET, SOCK_DGRAM, 0);
+ if (sock < 0) { ESP_LOGE(TAG, "dns: socket"); vTaskDelete(NULL); }
+
+ struct sockaddr_in srv = {
+ .sin_family = AF_INET,
+ .sin_port = htons(53),
+ .sin_addr.s_addr = htonl(INADDR_ANY),
+ };
+ if (bind(sock, (struct sockaddr *)&srv, sizeof(srv)) < 0) {
+ ESP_LOGE(TAG, "dns: bind 53"); close(sock); vTaskDelete(NULL);
+ }
+
+ /* Our AP IP. Default IDF AP is 192.168.4.1. We'll fetch live. */
+ esp_netif_ip_info_t ip;
+ esp_netif_get_ip_info(s_net.ap_netif, &ip);
+ uint32_t ap_ip = ip.ip.addr; /* network-order */
+
+ uint8_t buf[512];
+ while (s_net.dns_running) {
+ struct sockaddr_in src;
+ socklen_t slen = sizeof(src);
+ int n = recvfrom(sock, buf, sizeof(buf), 0, (struct sockaddr *)&src, &slen);
+ if (n < 12) continue;
+ /* Build a minimal answer: copy the question, set QR=1, AA=1, ANCOUNT=1,
+ * append an A-record pointing to ap_ip, TTL 60. */
+ buf[2] |= 0x80; /* QR */
+ buf[2] |= 0x04; /* AA */
+ buf[3] = 0x80; /* RA + RCODE=0 */
+ buf[6] = 0; buf[7] = 1; /* ANCOUNT = 1 */
+ buf[8] = 0; buf[9] = 0; /* NSCOUNT */
+ buf[10] = 0; buf[11] = 0; /* ARCOUNT */
+
+ /* Find end of question (NUL-terminated label sequence + 4 bytes type/class). */
+ int p = 12;
+ while (p < n && buf[p] != 0) p += buf[p] + 1;
+ p += 1 + 4; /* skip null label + qtype + qclass */
+ if (p + 16 > (int)sizeof(buf)) continue;
+
+ /* Answer: pointer to question name (0xC00C), TYPE=A, CLASS=IN, TTL=60, RDLEN=4, IP. */
+ buf[p++] = 0xC0; buf[p++] = 0x0C;
+ buf[p++] = 0x00; buf[p++] = 0x01;
+ buf[p++] = 0x00; buf[p++] = 0x01;
+ buf[p++] = 0x00; buf[p++] = 0x00; buf[p++] = 0x00; buf[p++] = 0x3C;
+ buf[p++] = 0x00; buf[p++] = 0x04;
+ buf[p++] = (ap_ip >> 0) & 0xFF;
+ buf[p++] = (ap_ip >> 8) & 0xFF;
+ buf[p++] = (ap_ip >> 16) & 0xFF;
+ buf[p++] = (ap_ip >> 24) & 0xFF;
+
+ sendto(sock, buf, p, 0, (struct sockaddr *)&src, slen);
+ }
+ close(sock);
+ vTaskDelete(NULL);
+}
+
+static void start_captive_dns(void) {
+ if (s_net.dns_running) return;
+ s_net.dns_running = true;
+ xTaskCreate(dns_task, "captive_dns", 3072, NULL, 3, &s_net.dns_task);
+}
+
+static void on_wifi_event(void *arg, esp_event_base_t base, int32_t id, void *data) {
+ if (base == WIFI_EVENT && id == WIFI_EVENT_STA_START) {
+ esp_wifi_connect();
+ } else if (base == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED) {
+ if (s_net.sta_retry < STA_RETRY_MAX) {
+ s_net.sta_retry++;
+ ESP_LOGW(TAG, "STA disconnected; retry %d/%d", s_net.sta_retry, STA_RETRY_MAX);
+ vTaskDelay(pdMS_TO_TICKS(STA_RETRY_BACKOFF_MS));
+ esp_wifi_connect();
+ } else {
+ ESP_LOGW(TAG, "STA failed after %d retries; falling back to AP", STA_RETRY_MAX);
+ xEventGroupSetBits(s_net.evt, EVT_FAIL);
+ }
+ } else if (base == IP_EVENT && id == IP_EVENT_STA_GOT_IP) {
+ ip_event_got_ip_t *e = (ip_event_got_ip_t *)data;
+ ESP_LOGI(TAG, "STA got IP: " IPSTR, IP2STR(&e->ip_info.ip));
+ s_net.sta_retry = 0;
+ xEventGroupSetBits(s_net.evt, EVT_GOT_IP);
+ } else if (base == WIFI_EVENT && id == WIFI_EVENT_AP_STACONNECTED) {
+ wifi_event_ap_staconnected_t *e = (wifi_event_ap_staconnected_t *)data;
+ ESP_LOGI(TAG, "AP client joined: " MACSTR, MAC2STR(e->mac));
+ }
+}
+
+/* Configure both AP and STA interfaces. The AP stays up for the
+ * entire device lifetime β many installs have no router at all, and
+ * peer-mesh devices need a stable channel to find each other. */
+static esp_err_t configure_ap(void) {
+ char ap_ssid[32];
+ uint8_t mac[6];
+ esp_read_mac(mac, ESP_MAC_WIFI_STA);
+ snprintf(ap_ssid, sizeof(ap_ssid), "AmbiSense-%02X%02X", mac[4], mac[5]);
+
+ /* Optional WPA2 password from NVS; default is open for first-setup. */
+ char ap_pass[64] = {0};
+ settings_get_str("wifi", "ap_pass", ap_pass, sizeof(ap_pass));
+
+ /* Channel: prefer NVS pin (so peers can be co-channeled even off-router);
+ * default 6. PR #4's mesh uses this same channel. */
+ uint8_t channel = 6;
+ uint8_t saved_ch = 0;
+ if (settings_get_u8("wifi", "ap_ch", &saved_ch) == ESP_OK && saved_ch >= 1 && saved_ch <= 13) {
+ channel = saved_ch;
+ }
+
+ wifi_config_t cfg = {0};
+ snprintf((char *)cfg.ap.ssid, sizeof(cfg.ap.ssid), "%s", ap_ssid);
+ cfg.ap.ssid_len = strlen(ap_ssid);
+ cfg.ap.channel = channel;
+ cfg.ap.max_connection = 6; /* up to 5 mesh peers + 1 phone */
+ if (ap_pass[0] && strlen(ap_pass) >= 8) {
+ snprintf((char *)cfg.ap.password, sizeof(cfg.ap.password), "%s", ap_pass);
+ cfg.ap.authmode = WIFI_AUTH_WPA2_PSK;
+ } else {
+ cfg.ap.authmode = WIFI_AUTH_OPEN;
+ }
+ cfg.ap.pmf_cfg.required = false;
+
+ ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_AP, &cfg));
+ ESP_LOGI(TAG, "AP configured: SSID=%s%s channel=%u", ap_ssid,
+ cfg.ap.authmode == WIFI_AUTH_OPEN ? " (open)" : " (wpa2)", channel);
+ return ESP_OK;
+}
+
+static esp_err_t configure_sta(const char *ssid, const char *pass) {
+ wifi_config_t cfg = {0};
+ snprintf((char *)cfg.sta.ssid, sizeof(cfg.sta.ssid), "%s", ssid);
+ if (pass && pass[0]) snprintf((char *)cfg.sta.password, sizeof(cfg.sta.password), "%s", pass);
+ cfg.sta.threshold.authmode = WIFI_AUTH_OPEN;
+ cfg.sta.scan_method = WIFI_FAST_SCAN;
+ cfg.sta.pmf_cfg.capable = true;
+ return esp_wifi_set_config(WIFI_IF_STA, &cfg);
+}
+
+esp_err_t netmgr_init(void) {
+ if (s_net.inited) return ESP_OK;
+
+ s_net.evt = xEventGroupCreate();
+
+ ESP_ERROR_CHECK(esp_netif_init());
+ ESP_ERROR_CHECK(esp_event_loop_create_default());
+ s_net.sta_netif = esp_netif_create_default_wifi_sta();
+ s_net.ap_netif = esp_netif_create_default_wifi_ap();
+
+ /* Hostname: NVS wifi.host > derived from MAC. */
+ char raw[33] = {0};
+ settings_get_hostname(raw, sizeof(raw));
+ sanitize_hostname(raw, s_net.hostname, sizeof(s_net.hostname));
+ esp_netif_set_hostname(s_net.sta_netif, s_net.hostname);
+
+ wifi_init_config_t wcfg = WIFI_INIT_CONFIG_DEFAULT();
+ ESP_ERROR_CHECK(esp_wifi_init(&wcfg));
+ ESP_ERROR_CHECK(esp_wifi_set_storage(WIFI_STORAGE_RAM));
+ ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &on_wifi_event, NULL));
+ ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, IP_EVENT_STA_GOT_IP, &on_wifi_event, NULL));
+
+ /* AP+STA always-on. AP serves the web UI for direct connect (no router
+ * needed), and ESP-NOW peers find each other on the AP channel. STA is
+ * additive: if creds are saved, we also join the user's router. */
+ ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_APSTA));
+ configure_ap();
+
+ char ssid[33] = {0}, pass[65] = {0};
+ settings_get_wifi_ssid(ssid, sizeof(ssid));
+ settings_get_wifi_pass(pass, sizeof(pass));
+ bool have_sta_creds = ssid[0] != 0;
+ if (have_sta_creds) {
+ configure_sta(ssid, pass);
+ s_net.sta_retry = 0;
+ xEventGroupClearBits(s_net.evt, EVT_GOT_IP | EVT_FAIL);
+ notify_state(NETMGR_STATE_STA_CONNECTING);
+ } else {
+ notify_state(NETMGR_STATE_AP_FALLBACK);
+ }
+
+ ESP_ERROR_CHECK(esp_wifi_start());
+ ESP_LOGI(TAG, "Wi-Fi up: AP%s%s", have_sta_creds ? "+STA (joining " : " only", have_sta_creds ? ssid : "");
+ if (have_sta_creds) ESP_LOGI(TAG, "STA: trying %s", ssid);
+
+ /* Captive DNS responder runs on AP interface β always up so phones
+ * connecting to AmbiSense-XXXX get auto-popped to the setup page,
+ * regardless of STA state. */
+ start_captive_dns();
+ bring_up_mdns();
+
+ /* Wait briefly for STA to either connect or fail; either way, the AP
+ * remains available so this never blocks the device from being usable. */
+ if (have_sta_creds) {
+ EventBits_t bits = xEventGroupWaitBits(
+ s_net.evt, EVT_GOT_IP | EVT_FAIL, pdFALSE, pdFALSE,
+ pdMS_TO_TICKS(15000));
+ if (bits & EVT_GOT_IP) notify_state(NETMGR_STATE_STA_CONNECTED);
+ else notify_state(NETMGR_STATE_AP_FALLBACK);
+ }
+
+ s_net.inited = true;
+ return ESP_OK;
+}
+
+esp_err_t netmgr_set_credentials(const char *ssid, const char *pass) {
+ if (!ssid || !ssid[0]) {
+ settings_set_wifi_ssid("");
+ settings_set_wifi_pass("");
+ esp_wifi_disconnect();
+ notify_state(NETMGR_STATE_AP_FALLBACK);
+ ESP_LOGI(TAG, "Cleared STA creds; AP remains up");
+ return ESP_OK;
+ }
+ settings_set_wifi_ssid(ssid);
+ settings_set_wifi_pass(pass ? pass : "");
+
+ /* AP keeps running throughout. Just retarget STA. */
+ esp_wifi_disconnect();
+ s_net.sta_retry = 0;
+ xEventGroupClearBits(s_net.evt, EVT_GOT_IP | EVT_FAIL);
+ configure_sta(ssid, pass);
+ notify_state(NETMGR_STATE_STA_CONNECTING);
+ esp_wifi_connect();
+
+ EventBits_t bits = xEventGroupWaitBits(
+ s_net.evt, EVT_GOT_IP | EVT_FAIL, pdFALSE, pdFALSE,
+ pdMS_TO_TICKS(15000));
+ if (bits & EVT_GOT_IP) {
+ notify_state(NETMGR_STATE_STA_CONNECTED);
+ return ESP_OK;
+ }
+ notify_state(NETMGR_STATE_AP_FALLBACK);
+ return ESP_FAIL;
+}
+
+bool netmgr_is_sta_connected(void) {
+ return s_net.state == NETMGR_STATE_STA_CONNECTED;
+}
+
+esp_err_t netmgr_get_ip(char *out, size_t max) {
+ if (!out || max == 0) return ESP_ERR_INVALID_ARG;
+ esp_netif_ip_info_t ip;
+ esp_netif_t *if_ = (s_net.state == NETMGR_STATE_STA_CONNECTED) ? s_net.sta_netif : s_net.ap_netif;
+ esp_err_t err = esp_netif_get_ip_info(if_, &ip);
+ if (err != ESP_OK) return err;
+ snprintf(out, max, IPSTR, IP2STR(&ip.ip));
+ return ESP_OK;
+}
+
+esp_err_t netmgr_get_hostname(char *out, size_t max) {
+ if (!out || max == 0) return ESP_ERR_INVALID_ARG;
+ snprintf(out, max, "%s", s_net.hostname);
+ return ESP_OK;
+}
+
+int8_t netmgr_get_rssi(void) {
+ if (!netmgr_is_sta_connected()) return 0;
+ wifi_ap_record_t info = {0};
+ if (esp_wifi_sta_get_ap_info(&info) != ESP_OK) return 0;
+ return info.rssi;
+}
+
+void netmgr_on_state_change(netmgr_state_cb_t cb, void *ctx) {
+ s_net.cb = cb;
+ s_net.cb_ctx = ctx;
+}
+
+esp_err_t netmgr_set_hostname(const char *name) {
+ if (!name) return ESP_ERR_INVALID_ARG;
+ sanitize_hostname(name, s_net.hostname, sizeof(s_net.hostname));
+ settings_set_hostname(s_net.hostname);
+ if (s_net.sta_netif) esp_netif_set_hostname(s_net.sta_netif, s_net.hostname);
+ mdns_hostname_set(s_net.hostname);
+ return ESP_OK;
+}
diff --git a/firmware/components/ota/CMakeLists.txt b/firmware/components/ota/CMakeLists.txt
new file mode 100644
index 0000000..28cce9d
--- /dev/null
+++ b/firmware/components/ota/CMakeLists.txt
@@ -0,0 +1,5 @@
+idf_component_register(
+ SRCS "ota.c"
+ INCLUDE_DIRS "include"
+ REQUIRES app_update esp_app_format log freertos
+)
diff --git a/firmware/components/ota/include/ota.h b/firmware/components/ota/include/ota.h
new file mode 100644
index 0000000..f9f3958
--- /dev/null
+++ b/firmware/components/ota/include/ota.h
@@ -0,0 +1,44 @@
+#pragma once
+
+/*
+ * AmbiSense v6 β OTA firmware update.
+ *
+ * Streams a multipart/form-data firmware upload into the inactive OTA
+ * partition. On valid hash, marks the partition for boot and reboots.
+ * Bootloader rollback is enabled (sdkconfig.defaults), so a failed boot
+ * automatically reverts to the previous slot.
+ *
+ * Unsigned for v6.0 (per architecture decision); signed-OTA on roadmap.
+ */
+
+#include
+#include
+#include
+#include "esp_err.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef struct ota_session_s ota_session_t;
+
+/* Begin a new OTA session. Returns a handle the caller streams data into. */
+ota_session_t *ota_begin(size_t expected_size);
+
+/* Append bytes to the in-progress session. Returns ESP_OK or an error;
+ * on error the session is automatically aborted and freed. */
+esp_err_t ota_write(ota_session_t *s, const void *data, size_t len);
+
+/* Finish: validate, mark next-boot, schedule reboot. Frees the session. */
+esp_err_t ota_finish(ota_session_t *s);
+
+/* Abort and free without committing. Safe to call on any state. */
+void ota_abort(ota_session_t *s);
+
+/* Mark the running firmware valid (call once at boot if everything's OK,
+ * defeats the rollback timer). */
+esp_err_t ota_mark_valid(void);
+
+#ifdef __cplusplus
+}
+#endif
diff --git a/firmware/components/ota/ota.c b/firmware/components/ota/ota.c
new file mode 100644
index 0000000..36951f0
--- /dev/null
+++ b/firmware/components/ota/ota.c
@@ -0,0 +1,99 @@
+#include "ota.h"
+
+#include
+#include
+
+#include "esp_log.h"
+#include "esp_ota_ops.h"
+#include "esp_app_format.h"
+#include "esp_app_desc.h"
+#include "freertos/FreeRTOS.h"
+#include "freertos/task.h"
+
+static const char *TAG = "ota";
+
+struct ota_session_s {
+ esp_ota_handle_t handle;
+ const esp_partition_t *target;
+ size_t expected;
+ size_t written;
+};
+
+ota_session_t *ota_begin(size_t expected_size) {
+ const esp_partition_t *next = esp_ota_get_next_update_partition(NULL);
+ if (!next) { ESP_LOGE(TAG, "no OTA partition"); return NULL; }
+
+ ota_session_t *s = calloc(1, sizeof(*s));
+ if (!s) return NULL;
+ s->target = next;
+ s->expected = expected_size;
+
+ esp_err_t err = esp_ota_begin(next, OTA_WITH_SEQUENTIAL_WRITES, &s->handle);
+ if (err != ESP_OK) {
+ ESP_LOGE(TAG, "esp_ota_begin: 0x%x", err);
+ free(s);
+ return NULL;
+ }
+ ESP_LOGI(TAG, "OTA begin: partition '%s' offset 0x%lx, expected %u bytes",
+ next->label, (unsigned long)next->address, (unsigned)expected_size);
+ return s;
+}
+
+esp_err_t ota_write(ota_session_t *s, const void *data, size_t len) {
+ if (!s) return ESP_ERR_INVALID_STATE;
+ esp_err_t err = esp_ota_write(s->handle, data, len);
+ if (err != ESP_OK) {
+ ESP_LOGE(TAG, "esp_ota_write @ %u: 0x%x", (unsigned)s->written, err);
+ ota_abort(s);
+ return err;
+ }
+ s->written += len;
+ return ESP_OK;
+}
+
+esp_err_t ota_finish(ota_session_t *s) {
+ if (!s) return ESP_ERR_INVALID_STATE;
+ esp_err_t err = esp_ota_end(s->handle);
+ if (err != ESP_OK) {
+ ESP_LOGE(TAG, "esp_ota_end: 0x%x (validation failed)", err);
+ free(s);
+ return err;
+ }
+ err = esp_ota_set_boot_partition(s->target);
+ if (err != ESP_OK) {
+ ESP_LOGE(TAG, "set_boot_partition: 0x%x", err);
+ free(s);
+ return err;
+ }
+ ESP_LOGI(TAG, "OTA committed: %u bytes to '%s'. Rebooting in 1 s.",
+ (unsigned)s->written, s->target->label);
+ free(s);
+ /* Defer reboot so the HTTP response can flush. */
+ extern void _ota_reboot_task(void *);
+ xTaskCreate(_ota_reboot_task, "ota_reboot", 2048, NULL, 5, NULL);
+ return ESP_OK;
+}
+
+void _ota_reboot_task(void *arg) {
+ (void)arg;
+ vTaskDelay(pdMS_TO_TICKS(1000));
+ esp_restart();
+}
+
+void ota_abort(ota_session_t *s) {
+ if (!s) return;
+ esp_ota_abort(s->handle);
+ free(s);
+}
+
+esp_err_t ota_mark_valid(void) {
+ const esp_partition_t *running = esp_ota_get_running_partition();
+ esp_ota_img_states_t state;
+ if (esp_ota_get_state_partition(running, &state) == ESP_OK) {
+ if (state == ESP_OTA_IMG_PENDING_VERIFY) {
+ ESP_LOGI(TAG, "Marking running image as valid (rollback armed β defused)");
+ return esp_ota_mark_app_valid_cancel_rollback();
+ }
+ }
+ return ESP_OK;
+}
diff --git a/firmware/components/settings/include/settings.h b/firmware/components/settings/include/settings.h
index 3cb1481..72be273 100644
--- a/firmware/components/settings/include/settings.h
+++ b/firmware/components/settings/include/settings.h
@@ -54,6 +54,45 @@ esp_err_t settings_set_pin_override(const char *key, uint8_t pin);
esp_err_t settings_get_radar_kind(char *out, size_t max);
esp_err_t settings_set_radar_kind(const char *kind);
+/* ---- wifi namespace -----------------------------------------------------
+ * Stores: wifi.ssid (string), wifi.pass (string), wifi.hostname (string).
+ * Note: v6.0 stores creds plaintext in NVS. v6.x will move to chip-key AES.
+ */
+esp_err_t settings_get_wifi_ssid(char *out, size_t max);
+esp_err_t settings_set_wifi_ssid(const char *ssid);
+esp_err_t settings_get_wifi_pass(char *out, size_t max);
+esp_err_t settings_set_wifi_pass(const char *pass);
+esp_err_t settings_get_hostname(char *out, size_t max);
+esp_err_t settings_set_hostname(const char *name);
+
+/* ---- sys namespace ------------------------------------------------------
+ * Stores: sys.device_name (string).
+ */
+esp_err_t settings_get_device_name(char *out, size_t max);
+esp_err_t settings_set_device_name(const char *name);
+
+/* ---- auth namespace -----------------------------------------------------
+ * Stores: auth.pw_hash (32-byte PBKDF2-SHA256 hash), auth.pw_salt (16 bytes).
+ * Empty/missing means auth disabled.
+ */
+esp_err_t settings_get_auth_hash(uint8_t out_hash[32], uint8_t out_salt[16]);
+esp_err_t settings_set_auth_hash(const uint8_t hash[32], const uint8_t salt[16]);
+esp_err_t settings_clear_auth(void);
+
+/* ---- led namespace ------------------------------------------------------
+ * All LED-engine settings as typed accessors. PR #3 owns these.
+ */
+esp_err_t settings_get_u32(const char *ns, const char *key, uint32_t *out);
+esp_err_t settings_set_u32(const char *ns, const char *key, uint32_t v);
+esp_err_t settings_get_i32(const char *ns, const char *key, int32_t *out);
+esp_err_t settings_set_i32(const char *ns, const char *key, int32_t v);
+esp_err_t settings_get_u8(const char *ns, const char *key, uint8_t *out);
+esp_err_t settings_set_u8(const char *ns, const char *key, uint8_t v);
+esp_err_t settings_get_blob(const char *ns, const char *key, void *out, size_t *len);
+esp_err_t settings_set_blob(const char *ns, const char *key, const void *data, size_t len);
+esp_err_t settings_get_str(const char *ns, const char *key, char *out, size_t max);
+esp_err_t settings_set_str(const char *ns, const char *key, const char *val);
+
#ifdef __cplusplus
}
#endif
diff --git a/firmware/components/settings/settings.c b/firmware/components/settings/settings.c
index ce65385..82392c5 100644
--- a/firmware/components/settings/settings.c
+++ b/firmware/components/settings/settings.c
@@ -99,3 +99,158 @@ esp_err_t settings_set_radar_kind(const char *kind) {
nvs_close(h);
return err;
}
+
+/* ---- generic typed accessors -------------------------------------------- */
+
+esp_err_t settings_get_str(const char *ns, const char *key, char *out, size_t max) {
+ if (!ns || !key || !out || max == 0) return ESP_ERR_INVALID_ARG;
+ nvs_handle_t h;
+ esp_err_t err = open_ns(ns, NVS_READONLY, &h);
+ if (err != ESP_OK) return err;
+ size_t len = max;
+ err = nvs_get_str(h, key, out, &len);
+ nvs_close(h);
+ return err;
+}
+
+esp_err_t settings_set_str(const char *ns, const char *key, const char *val) {
+ if (!ns || !key || !val) return ESP_ERR_INVALID_ARG;
+ nvs_handle_t h;
+ esp_err_t err = open_ns(ns, NVS_READWRITE, &h);
+ if (err != ESP_OK) return err;
+ err = nvs_set_str(h, key, val);
+ if (err == ESP_OK) err = nvs_commit(h);
+ nvs_close(h);
+ return err;
+}
+
+esp_err_t settings_get_u32(const char *ns, const char *key, uint32_t *out) {
+ if (!ns || !key || !out) return ESP_ERR_INVALID_ARG;
+ nvs_handle_t h;
+ esp_err_t err = open_ns(ns, NVS_READONLY, &h);
+ if (err != ESP_OK) return err;
+ err = nvs_get_u32(h, key, out);
+ nvs_close(h);
+ return err;
+}
+
+esp_err_t settings_set_u32(const char *ns, const char *key, uint32_t v) {
+ if (!ns || !key) return ESP_ERR_INVALID_ARG;
+ nvs_handle_t h;
+ esp_err_t err = open_ns(ns, NVS_READWRITE, &h);
+ if (err != ESP_OK) return err;
+ err = nvs_set_u32(h, key, v);
+ if (err == ESP_OK) err = nvs_commit(h);
+ nvs_close(h);
+ return err;
+}
+
+esp_err_t settings_get_i32(const char *ns, const char *key, int32_t *out) {
+ if (!ns || !key || !out) return ESP_ERR_INVALID_ARG;
+ nvs_handle_t h;
+ esp_err_t err = open_ns(ns, NVS_READONLY, &h);
+ if (err != ESP_OK) return err;
+ err = nvs_get_i32(h, key, out);
+ nvs_close(h);
+ return err;
+}
+
+esp_err_t settings_set_i32(const char *ns, const char *key, int32_t v) {
+ if (!ns || !key) return ESP_ERR_INVALID_ARG;
+ nvs_handle_t h;
+ esp_err_t err = open_ns(ns, NVS_READWRITE, &h);
+ if (err != ESP_OK) return err;
+ err = nvs_set_i32(h, key, v);
+ if (err == ESP_OK) err = nvs_commit(h);
+ nvs_close(h);
+ return err;
+}
+
+esp_err_t settings_get_u8(const char *ns, const char *key, uint8_t *out) {
+ if (!ns || !key || !out) return ESP_ERR_INVALID_ARG;
+ nvs_handle_t h;
+ esp_err_t err = open_ns(ns, NVS_READONLY, &h);
+ if (err != ESP_OK) return err;
+ err = nvs_get_u8(h, key, out);
+ nvs_close(h);
+ return err;
+}
+
+esp_err_t settings_set_u8(const char *ns, const char *key, uint8_t v) {
+ if (!ns || !key) return ESP_ERR_INVALID_ARG;
+ nvs_handle_t h;
+ esp_err_t err = open_ns(ns, NVS_READWRITE, &h);
+ if (err != ESP_OK) return err;
+ err = nvs_set_u8(h, key, v);
+ if (err == ESP_OK) err = nvs_commit(h);
+ nvs_close(h);
+ return err;
+}
+
+esp_err_t settings_get_blob(const char *ns, const char *key, void *out, size_t *len) {
+ if (!ns || !key || !out || !len) return ESP_ERR_INVALID_ARG;
+ nvs_handle_t h;
+ esp_err_t err = open_ns(ns, NVS_READONLY, &h);
+ if (err != ESP_OK) return err;
+ err = nvs_get_blob(h, key, out, len);
+ nvs_close(h);
+ return err;
+}
+
+esp_err_t settings_set_blob(const char *ns, const char *key, const void *data, size_t len) {
+ if (!ns || !key || !data) return ESP_ERR_INVALID_ARG;
+ nvs_handle_t h;
+ esp_err_t err = open_ns(ns, NVS_READWRITE, &h);
+ if (err != ESP_OK) return err;
+ err = nvs_set_blob(h, key, data, len);
+ if (err == ESP_OK) err = nvs_commit(h);
+ nvs_close(h);
+ return err;
+}
+
+/* ---- wifi namespace shortcuts ------------------------------------------- */
+esp_err_t settings_get_wifi_ssid(char *o, size_t m) { return settings_get_str("wifi", "ssid", o, m); }
+esp_err_t settings_set_wifi_ssid(const char *s) { return settings_set_str("wifi", "ssid", s); }
+esp_err_t settings_get_wifi_pass(char *o, size_t m) { return settings_get_str("wifi", "pass", o, m); }
+esp_err_t settings_set_wifi_pass(const char *p) { return settings_set_str("wifi", "pass", p); }
+esp_err_t settings_get_hostname (char *o, size_t m) { return settings_get_str("wifi", "host", o, m); }
+esp_err_t settings_set_hostname (const char *n) { return settings_set_str("wifi", "host", n); }
+
+/* ---- sys namespace shortcuts -------------------------------------------- */
+esp_err_t settings_get_device_name(char *o, size_t m) { return settings_get_str("sys", "name", o, m); }
+esp_err_t settings_set_device_name(const char *n) { return settings_set_str("sys", "name", n); }
+
+/* ---- auth namespace ----------------------------------------------------- */
+esp_err_t settings_get_auth_hash(uint8_t hash[32], uint8_t salt[16]) {
+ nvs_handle_t h;
+ esp_err_t err = open_ns("auth", NVS_READONLY, &h);
+ if (err != ESP_OK) return err;
+ size_t hlen = 32, slen = 16;
+ err = nvs_get_blob(h, "pw_hash", hash, &hlen);
+ if (err == ESP_OK) err = nvs_get_blob(h, "pw_salt", salt, &slen);
+ nvs_close(h);
+ if (err == ESP_OK && (hlen != 32 || slen != 16)) return ESP_ERR_INVALID_SIZE;
+ return err;
+}
+
+esp_err_t settings_set_auth_hash(const uint8_t hash[32], const uint8_t salt[16]) {
+ nvs_handle_t h;
+ esp_err_t err = open_ns("auth", NVS_READWRITE, &h);
+ if (err != ESP_OK) return err;
+ err = nvs_set_blob(h, "pw_hash", hash, 32);
+ if (err == ESP_OK) err = nvs_set_blob(h, "pw_salt", salt, 16);
+ if (err == ESP_OK) err = nvs_commit(h);
+ nvs_close(h);
+ return err;
+}
+
+esp_err_t settings_clear_auth(void) {
+ nvs_handle_t h;
+ esp_err_t err = open_ns("auth", NVS_READWRITE, &h);
+ if (err != ESP_OK) return err;
+ nvs_erase_key(h, "pw_hash");
+ nvs_erase_key(h, "pw_salt");
+ err = nvs_commit(h);
+ nvs_close(h);
+ return err;
+}
diff --git a/firmware/components/webui/CMakeLists.txt b/firmware/components/webui/CMakeLists.txt
new file mode 100644
index 0000000..0e5980d
--- /dev/null
+++ b/firmware/components/webui/CMakeLists.txt
@@ -0,0 +1,7 @@
+idf_component_register(
+ SRCS "webui.c"
+ INCLUDE_DIRS "include"
+ REQUIRES settings auth netmgr ota board
+ esp_http_server esp_wifi esp_app_format esp_timer
+ json log freertos esp_system
+)
diff --git a/firmware/components/webui/include/webui.h b/firmware/components/webui/include/webui.h
new file mode 100644
index 0000000..076fc3b
--- /dev/null
+++ b/firmware/components/webui/include/webui.h
@@ -0,0 +1,43 @@
+#pragma once
+
+/*
+ * AmbiSense v6 β embedded web server.
+ *
+ * One esp_http_server instance with all /api/... routes plus root + captive-
+ * portal redirect endpoints (so iOS/Android pop the setup page in AP
+ * mode). PR #5 replaces the inline placeholder HTML with the full Preact
+ * UI served from LittleFS.
+ *
+ * Stub state hooks let PR #3/#4 push live data (distance, RSSI, mesh
+ * health) without webui having to depend on radar/mesh components.
+ */
+
+#include
+#include
+#include "esp_err.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+esp_err_t webui_init(void);
+
+/* Live state hooks. PR #2 publishes Wi-Fi telemetry; PR #3 publishes
+ * distance + radar; PR #4 publishes peer health.
+ * Updates are coalesced and emitted to all connected /api/live WS
+ * clients at ~5 Hz. */
+typedef struct {
+ int16_t distance_cm;
+ int8_t direction; /* -1, 0, +1 */
+ int8_t rssi;
+ uint32_t free_heap;
+ uint32_t uptime_s;
+ uint8_t peer_count;
+ uint8_t peer_healthy;
+} webui_live_t;
+
+void webui_publish_live(const webui_live_t *snap);
+
+#ifdef __cplusplus
+}
+#endif
diff --git a/firmware/components/webui/webui.c b/firmware/components/webui/webui.c
new file mode 100644
index 0000000..986d1f6
--- /dev/null
+++ b/firmware/components/webui/webui.c
@@ -0,0 +1,767 @@
+#include "webui.h"
+
+#include
+#include
+#include
+#include
+
+#include "esp_log.h"
+#include "esp_http_server.h"
+#include "esp_wifi.h"
+#include "esp_app_desc.h"
+#include "esp_system.h"
+#include "esp_idf_version.h"
+#include "esp_mac.h"
+#include "esp_timer.h"
+#include "freertos/FreeRTOS.h"
+#include "freertos/task.h"
+#include "freertos/semphr.h"
+#include "cJSON.h"
+
+#include "settings.h"
+#include "auth.h"
+#include "netmgr.h"
+#include "ota.h"
+#include "board.h"
+
+static const char *TAG = "webui";
+
+#define MAX_WS_CLIENTS 4
+
+static struct {
+ httpd_handle_t srv;
+ int ws_fds[MAX_WS_CLIENTS];
+ SemaphoreHandle_t lock;
+ webui_live_t latest;
+} s_web;
+
+/* ============================================================
+ * helpers
+ * ============================================================ */
+
+static esp_err_t send_json(httpd_req_t *req, cJSON *root) {
+ char *out = cJSON_PrintUnformatted(root);
+ cJSON_Delete(root);
+ if (!out) return ESP_ERR_NO_MEM;
+ httpd_resp_set_type(req, "application/json");
+ httpd_resp_set_hdr(req, "Cache-Control", "no-store");
+ esp_err_t err = httpd_resp_send(req, out, strlen(out));
+ free(out);
+ return err;
+}
+
+static esp_err_t send_err(httpd_req_t *req, int code, const char *msg) {
+ char body[128];
+ snprintf(body, sizeof(body), "{\"error\":\"%s\"}", msg);
+ httpd_resp_set_type(req, "application/json");
+ httpd_resp_set_status(req,
+ code == 400 ? "400 Bad Request" :
+ code == 401 ? "401 Unauthorized" :
+ code == 404 ? "404 Not Found" :
+ code == 500 ? "500 Internal Server Error" :
+ "500 Internal Server Error");
+ return httpd_resp_send(req, body, strlen(body));
+}
+
+static cJSON *read_body_json(httpd_req_t *req) {
+ if (req->content_len == 0 || req->content_len > 4096) return NULL;
+ char *buf = malloc(req->content_len + 1);
+ if (!buf) return NULL;
+ int got = 0;
+ while (got < (int)req->content_len) {
+ int n = httpd_req_recv(req, buf + got, req->content_len - got);
+ if (n <= 0) { free(buf); return NULL; }
+ got += n;
+ }
+ buf[got] = 0;
+ cJSON *j = cJSON_Parse(buf);
+ free(buf);
+ return j;
+}
+
+static bool extract_session_token(httpd_req_t *req, char *out, size_t max) {
+ char hdr[256];
+ if (httpd_req_get_hdr_value_str(req, "Cookie", hdr, sizeof(hdr)) != ESP_OK) return false;
+ /* Find ambisense= */
+ const char *p = strstr(hdr, "ambisense=");
+ if (!p) return false;
+ p += strlen("ambisense=");
+ size_t i = 0;
+ while (*p && *p != ';' && *p != ' ' && i < max - 1) out[i++] = *p++;
+ out[i] = 0;
+ return i > 0;
+}
+
+static bool gate_auth(httpd_req_t *req) {
+ if (!auth_is_enabled()) return true;
+ char tok[AUTH_TOKEN_HEX_LEN + 1];
+ if (!extract_session_token(req, tok, sizeof(tok))) {
+ send_err(req, 401, "auth required");
+ return false;
+ }
+ if (!auth_check_session(tok)) {
+ send_err(req, 401, "invalid session");
+ return false;
+ }
+ return true;
+}
+
+/* ============================================================
+ * Captive-portal redirect endpoints
+ *
+ * iOS hits captive.apple.com / hotspot-detect.html / library/test/...
+ * Android hits connectivitycheck.gstatic.com / generate_204
+ * Win 11 hits msftconnecttest.com/connecttest.txt
+ * All return a 302 redirect to our root so the OS pops the setup page.
+ * ============================================================ */
+
+static esp_err_t handle_captive_redirect(httpd_req_t *req) {
+ char ip[32] = "192.168.4.1";
+ netmgr_get_ip(ip, sizeof(ip));
+ char loc[64];
+ snprintf(loc, sizeof(loc), "http://%s/", ip);
+ httpd_resp_set_status(req, "302 Found");
+ httpd_resp_set_hdr(req, "Location", loc);
+ httpd_resp_set_hdr(req, "Cache-Control", "no-store");
+ httpd_resp_send(req, NULL, 0);
+ return ESP_OK;
+}
+
+/* ============================================================
+ * Root + static (placeholder HTML; PR #5 replaces with LittleFS-served Preact)
+ * ============================================================ */
+
+static const char k_placeholder_html[] =
+"\n"
+"\n"
+"\n"
+" \n"
+" \n"
+" AmbiSense v6\n"
+" \n"
+"\n"
+"\n"
+" \n"
+"
AmbiSense v6
\n"
+"
Setup & status. The full UI ships in the next firmware update.
\n"
+"
Authentication is β¦. Set a password under System > Security after Wi-Fi setup.
\n"
+"\n"
+"
\n"
+"
Wi-Fi
\n"
+"
\n"
+"
\n"
+"
\n"
+"
\n"
+"
\n"
+"
\n"
+"
\n"
+"
\n"
+"
\n"
+"\n"
+"
\n"
+"
Device
\n"
+"
Firmwareβ¦
\n"
+"
Boardβ¦
\n"
+"
IPβ¦
\n"
+"
Hostnameβ¦
\n"
+"
Free heapβ¦
\n"
+"
Uptimeβ¦
\n"
+"
\n"
+"\n"
+"
\n"
+"
Firmware update (OTA)
\n"
+"
\n"
+"
\n"
+"
\n"
+"
\n"
+"
\n"
+"\n"
+"\n";
+
+static esp_err_t handle_root(httpd_req_t *req) {
+ httpd_resp_set_type(req, "text/html");
+ httpd_resp_set_hdr(req, "Cache-Control", "no-store");
+ return httpd_resp_send(req, k_placeholder_html, sizeof(k_placeholder_html) - 1);
+}
+
+/* ============================================================
+ * /api/version
+ * ============================================================ */
+static esp_err_t handle_version(httpd_req_t *req) {
+ const esp_app_desc_t *app = esp_app_get_description();
+ cJSON *r = cJSON_CreateObject();
+
+ cJSON_AddStringToObject(r, "version", app->version);
+ cJSON_AddStringToObject(r, "idf_version", app->idf_ver);
+ cJSON_AddStringToObject(r, "build_date", app->date);
+ cJSON_AddStringToObject(r, "build_time", app->time);
+ cJSON_AddStringToObject(r, "target", CONFIG_IDF_TARGET);
+
+ cJSON_AddNumberToObject(r, "free_heap", esp_get_free_heap_size());
+ cJSON_AddNumberToObject(r, "min_free_heap", esp_get_minimum_free_heap_size());
+ cJSON_AddNumberToObject(r, "uptime_s", (uint32_t)(esp_timer_get_time() / 1000000));
+
+ char buf[64];
+ if (netmgr_get_ip(buf, sizeof(buf)) == ESP_OK) cJSON_AddStringToObject(r, "ip", buf);
+ if (netmgr_get_hostname(buf, sizeof(buf)) == ESP_OK) cJSON_AddStringToObject(r, "hostname", buf);
+ cJSON_AddNumberToObject(r, "rssi", netmgr_get_rssi());
+ cJSON_AddBoolToObject(r, "sta_connected", netmgr_is_sta_connected());
+ cJSON_AddBoolToObject(r, "auth_enabled", auth_is_enabled());
+
+ char board_id[32] = {0};
+ if (settings_get_board_id(board_id, sizeof(board_id)) != ESP_OK) {
+ const board_profile_t *def = board_default_profile();
+ if (def) snprintf(board_id, sizeof(board_id), "%s", def->id);
+ }
+ cJSON_AddStringToObject(r, "board", board_id);
+
+ uint8_t mac[6];
+ esp_read_mac(mac, ESP_MAC_WIFI_STA);
+ char macstr[18];
+ snprintf(macstr, sizeof(macstr), "%02x:%02x:%02x:%02x:%02x:%02x",
+ mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
+ cJSON_AddStringToObject(r, "mac", macstr);
+
+ return send_json(req, r);
+}
+
+/* ============================================================
+ * /api/wifi/scan, /api/wifi
+ * ============================================================ */
+static esp_err_t handle_wifi_scan(httpd_req_t *req) {
+ if (!gate_auth(req)) return ESP_OK;
+
+ wifi_scan_config_t cfg = {0};
+ cfg.show_hidden = false;
+ esp_err_t err = esp_wifi_scan_start(&cfg, true);
+ if (err != ESP_OK) return send_err(req, 500, "scan failed");
+
+ uint16_t n = 0;
+ esp_wifi_scan_get_ap_num(&n);
+ if (n > 30) n = 30;
+ wifi_ap_record_t *aps = calloc(n, sizeof(*aps));
+ if (!aps) return send_err(req, 500, "oom");
+ esp_wifi_scan_get_ap_records(&n, aps);
+
+ cJSON *r = cJSON_CreateObject();
+ cJSON *arr = cJSON_AddArrayToObject(r, "networks");
+ for (uint16_t i = 0; i < n; ++i) {
+ cJSON *o = cJSON_CreateObject();
+ cJSON_AddStringToObject(o, "ssid", (const char *)aps[i].ssid);
+ cJSON_AddNumberToObject(o, "rssi", aps[i].rssi);
+ cJSON_AddNumberToObject(o, "channel", aps[i].primary);
+ cJSON_AddBoolToObject(o, "secure", aps[i].authmode != WIFI_AUTH_OPEN);
+ cJSON_AddItemToArray(arr, o);
+ }
+ free(aps);
+ return send_json(req, r);
+}
+
+/* Apply Wi-Fi creds on a separate task so the HTTP response can flush before
+ * STA disconnects. The struct + task pair below is the deferred-apply path. */
+struct wifi_apply_args { char ssid[33]; char pass[65]; };
+static void wifi_apply_task(void *arg) {
+ struct wifi_apply_args *a = arg;
+ vTaskDelay(pdMS_TO_TICKS(500));
+ netmgr_set_credentials(a->ssid, a->pass);
+ free(a);
+ vTaskDelete(NULL);
+}
+
+static esp_err_t handle_wifi_post(httpd_req_t *req) {
+ if (!gate_auth(req)) return ESP_OK;
+ cJSON *j = read_body_json(req);
+ if (!j) return send_err(req, 400, "bad json");
+
+ cJSON *ssid = cJSON_GetObjectItem(j, "ssid");
+ cJSON *pass = cJSON_GetObjectItem(j, "pass");
+ cJSON *host = cJSON_GetObjectItem(j, "hostname");
+ if (host && cJSON_IsString(host) && host->valuestring[0]) {
+ netmgr_set_hostname(host->valuestring);
+ }
+ if (ssid && cJSON_IsString(ssid) && ssid->valuestring[0]) {
+ const char *p = (pass && cJSON_IsString(pass)) ? pass->valuestring : "";
+
+ struct wifi_apply_args *a = calloc(1, sizeof(*a));
+ if (!a) { cJSON_Delete(j); return send_err(req, 500, "oom"); }
+ snprintf(a->ssid, sizeof(a->ssid), "%s", ssid->valuestring);
+ snprintf(a->pass, sizeof(a->pass), "%s", p);
+
+ cJSON *r = cJSON_CreateObject();
+ cJSON_AddStringToObject(r, "status", "saved; reconnecting");
+ cJSON_AddStringToObject(r, "note", "AP remains available during STA join");
+ send_json(req, r);
+ cJSON_Delete(j);
+ xTaskCreate(wifi_apply_task, "wifi_apply", 4096, a, 4, NULL);
+ return ESP_OK;
+ }
+ cJSON_Delete(j);
+ return send_err(req, 400, "ssid required");
+}
+
+/* ============================================================
+ * /api/auth/login, /api/auth/logout, /api/auth/password
+ * ============================================================ */
+static esp_err_t handle_login(httpd_req_t *req) {
+ cJSON *j = read_body_json(req);
+ if (!j) return send_err(req, 400, "bad json");
+ cJSON *p = cJSON_GetObjectItem(j, "password");
+ if (!p || !cJSON_IsString(p)) { cJSON_Delete(j); return send_err(req, 400, "password required"); }
+ bool ok = auth_check_password(p->valuestring);
+ cJSON_Delete(j);
+ if (!ok) return send_err(req, 401, "wrong password");
+
+ char tok[AUTH_TOKEN_HEX_LEN];
+ auth_issue_session(tok);
+ char cookie[160];
+ snprintf(cookie, sizeof(cookie),
+ "ambisense=%s; Path=/; Max-Age=86400; SameSite=Strict; HttpOnly", tok);
+ httpd_resp_set_hdr(req, "Set-Cookie", cookie);
+ cJSON *r = cJSON_CreateObject();
+ cJSON_AddBoolToObject(r, "ok", true);
+ return send_json(req, r);
+}
+
+static esp_err_t handle_logout(httpd_req_t *req) {
+ char tok[AUTH_TOKEN_HEX_LEN + 1];
+ if (extract_session_token(req, tok, sizeof(tok))) auth_revoke(tok);
+ httpd_resp_set_hdr(req, "Set-Cookie", "ambisense=; Path=/; Max-Age=0");
+ cJSON *r = cJSON_CreateObject();
+ cJSON_AddBoolToObject(r, "ok", true);
+ return send_json(req, r);
+}
+
+static esp_err_t handle_set_password(httpd_req_t *req) {
+ if (auth_is_enabled() && !gate_auth(req)) return ESP_OK;
+ cJSON *j = read_body_json(req);
+ if (!j) return send_err(req, 400, "bad json");
+ cJSON *p = cJSON_GetObjectItem(j, "password");
+ const char *pw = (p && cJSON_IsString(p)) ? p->valuestring : NULL;
+ esp_err_t err = auth_set_password(pw);
+ cJSON_Delete(j);
+ if (err == ESP_ERR_INVALID_ARG) return send_err(req, 400, "min 8 chars");
+ if (err != ESP_OK) return send_err(req, 500, "save failed");
+ cJSON *r = cJSON_CreateObject();
+ cJSON_AddBoolToObject(r, "ok", true);
+ cJSON_AddBoolToObject(r, "auth_enabled", auth_is_enabled());
+ return send_json(req, r);
+}
+
+/* ============================================================
+ * /api/board/profiles, /api/board, /api/radar/kinds
+ * ============================================================ */
+static esp_err_t handle_board_profiles(httpd_req_t *req) {
+ size_t n = 0;
+ const board_profile_t *first = board_profiles(&n);
+ /* board_profiles returns the first profile pointer; we iterate via the
+ * static array in board.c β but its layout isn't exposed. Fall back to
+ * iterating known ids: we re-look-up each via board_profile_by_id. */
+ static const char *ids[] = {
+ "esp32c3-supermini", "esp32-devkit", "esp32s3-zero", "esp32c6-devkit"
+ };
+ cJSON *r = cJSON_CreateObject();
+ cJSON *arr = cJSON_AddArrayToObject(r, "profiles");
+ (void)first;
+ for (size_t i = 0; i < sizeof(ids)/sizeof(ids[0]); ++i) {
+ const board_profile_t *p = board_profile_by_id(ids[i]);
+ if (!p) continue;
+ cJSON *o = cJSON_CreateObject();
+ cJSON_AddStringToObject(o, "id", p->id);
+ cJSON_AddStringToObject(o, "display", p->display);
+ cJSON_AddStringToObject(o, "mcu", p->mcu);
+ cJSON_AddBoolToObject (o, "validated", p->validated);
+ cJSON_AddNumberToObject(o, "led_pin", p->led_pin);
+ cJSON_AddNumberToObject(o, "radar_rx", p->radar_rx_pin);
+ cJSON_AddNumberToObject(o, "radar_tx", p->radar_tx_pin);
+ cJSON_AddNumberToObject(o, "button", p->button_pin);
+ cJSON_AddNumberToObject(o, "status_led", p->status_led_pin);
+ cJSON_AddNumberToObject(o, "max_gpio", p->max_gpio);
+ /* Encode unsafe pins as an array of pin numbers up to max_gpio. */
+ cJSON *unsafe = cJSON_AddArrayToObject(o, "unsafe");
+ for (uint8_t pin = 0; pin <= p->max_gpio; ++pin) {
+ if (board_pin_is_unsafe(p, pin)) cJSON_AddItemToArray(unsafe, cJSON_CreateNumber(pin));
+ }
+ cJSON_AddItemToArray(arr, o);
+ }
+ /* Active profile id */
+ char active[32] = {0};
+ if (settings_get_board_id(active, sizeof(active)) != ESP_OK) {
+ const board_profile_t *def = board_default_profile();
+ if (def) snprintf(active, sizeof(active), "%s", def->id);
+ }
+ cJSON_AddStringToObject(r, "active", active);
+ return send_json(req, r);
+}
+
+static esp_err_t handle_board_post(httpd_req_t *req) {
+ if (!gate_auth(req)) return ESP_OK;
+ cJSON *j = read_body_json(req);
+ if (!j) return send_err(req, 400, "bad json");
+ cJSON *id = cJSON_GetObjectItem(j, "id");
+ if (id && cJSON_IsString(id)) {
+ const board_profile_t *p = board_profile_by_id(id->valuestring);
+ if (!p) { cJSON_Delete(j); return send_err(req, 400, "unknown board id"); }
+ settings_set_board_id(p->id);
+ }
+ static const char *pkeys[] = { "led_pin", "radar_rx", "radar_tx", "button", "status_led" };
+ for (size_t i = 0; i < sizeof(pkeys)/sizeof(pkeys[0]); ++i) {
+ cJSON *v = cJSON_GetObjectItem(j, pkeys[i]);
+ if (v && cJSON_IsNumber(v)) {
+ uint8_t pin = (uint8_t)v->valueint;
+ settings_set_pin_override(pkeys[i], pin);
+ }
+ }
+ cJSON *rk = cJSON_GetObjectItem(j, "radar_kind");
+ if (rk && cJSON_IsString(rk)) settings_set_radar_kind(rk->valuestring);
+
+ cJSON_Delete(j);
+ cJSON *r = cJSON_CreateObject();
+ cJSON_AddBoolToObject(r, "ok", true);
+ cJSON_AddStringToObject(r, "note", "reboot to apply");
+ return send_json(req, r);
+}
+
+static esp_err_t handle_radar_kinds(httpd_req_t *req) {
+ cJSON *r = cJSON_CreateObject();
+ cJSON *arr = cJSON_AddArrayToObject(r, "kinds");
+ static const struct { const char *id; const char *display; bool xy; const char *note; } K[] = {
+ { "ld2410", "HiLink LD2410(B/C)", false, "single-target distance + presence (24 GHz)" },
+ { "ld2412", "HiLink LD2412", false, "per-gate sensitivity tunable (24 GHz)" },
+ { "ld2420", "HiLink LD2420", false, "presence only (24 GHz)" },
+ { "ld2450", "HiLink LD2450", true, "up to 3 targets, x/y/speed (24 GHz)" },
+ { "sim", "Simulator", true, "synthetic distance traces for testing" },
+ };
+ for (size_t i = 0; i < sizeof(K)/sizeof(K[0]); ++i) {
+ cJSON *o = cJSON_CreateObject();
+ cJSON_AddStringToObject(o, "id", K[i].id);
+ cJSON_AddStringToObject(o, "display", K[i].display);
+ cJSON_AddBoolToObject (o, "provides_xy", K[i].xy);
+ cJSON_AddStringToObject(o, "note", K[i].note);
+ cJSON_AddItemToArray(arr, o);
+ }
+ char active[16] = {0};
+ settings_get_radar_kind(active, sizeof(active));
+ cJSON_AddStringToObject(r, "active", active[0] ? active : "ld2410");
+ return send_json(req, r);
+}
+
+/* ============================================================
+ * /api/settings β flat read of every NVS namespace; PR #3/#4 expand the writer
+ * ============================================================ */
+
+static void add_str_if(cJSON *j, const char *ns, const char *key, const char *json_key) {
+ char buf[80];
+ if (settings_get_str(ns, key, buf, sizeof(buf)) == ESP_OK) {
+ cJSON_AddStringToObject(j, json_key, buf);
+ }
+}
+static void add_u32_if(cJSON *j, const char *ns, const char *key, const char *json_key) {
+ uint32_t v;
+ if (settings_get_u32(ns, key, &v) == ESP_OK) cJSON_AddNumberToObject(j, json_key, v);
+}
+static void add_i32_if(cJSON *j, const char *ns, const char *key, const char *json_key) {
+ int32_t v;
+ if (settings_get_i32(ns, key, &v) == ESP_OK) cJSON_AddNumberToObject(j, json_key, v);
+}
+static void add_u8_if(cJSON *j, const char *ns, const char *key, const char *json_key) {
+ uint8_t v;
+ if (settings_get_u8(ns, key, &v) == ESP_OK) cJSON_AddNumberToObject(j, json_key, v);
+}
+
+static esp_err_t handle_settings_get(httpd_req_t *req) {
+ cJSON *r = cJSON_CreateObject();
+
+ add_str_if(r, "sys", "name", "device_name");
+ add_str_if(r, "wifi", "host", "hostname");
+ add_str_if(r, "wifi", "ssid", "wifi_ssid");
+
+ add_str_if(r, "board", "id", "board_id");
+ add_str_if(r, "board", "radar_kind", "radar_kind");
+ add_u8_if (r, "board", "led_pin", "led_pin");
+ add_u8_if (r, "board", "radar_rx", "radar_rx");
+ add_u8_if (r, "board", "radar_tx", "radar_tx");
+ add_u8_if (r, "board", "button", "button_pin");
+ add_u8_if (r, "board", "status_led", "status_led_pin");
+
+ /* LED settings (PR #3 will start writing these) */
+ add_u32_if(r, "led", "count", "led_count");
+ add_u8_if (r, "led", "br", "brightness");
+ add_u8_if (r, "led", "r", "r");
+ add_u8_if (r, "led", "g", "g");
+ add_u8_if (r, "led", "b", "b");
+ add_u8_if (r, "led", "mode", "light_mode");
+ add_u32_if(r, "led", "span", "span");
+ add_i32_if(r, "led", "ctr", "center_shift");
+ add_u8_if (r, "led", "trail", "trail");
+ add_u8_if (r, "led", "dirlt", "dir_light");
+ add_u8_if (r, "led", "bg", "bg_mode");
+ add_u8_if (r, "led", "espd", "effect_speed");
+ add_u8_if (r, "led", "eint", "effect_intensity");
+
+ /* Distance window */
+ add_u32_if(r, "dist", "min", "min_distance");
+ add_u32_if(r, "dist", "max", "max_distance");
+
+ /* Motion smoothing */
+ add_u8_if (r, "motion", "en", "motion_enabled");
+ add_u32_if(r, "motion", "ps", "pos_smooth_x1k");
+ add_u32_if(r, "motion", "vs", "vel_smooth_x1k");
+ add_u32_if(r, "motion", "pf", "predict_x1k");
+ add_u32_if(r, "motion", "pg", "p_gain_x1k");
+ add_u32_if(r, "motion", "ig", "i_gain_x1k");
+
+ /* Topology */
+ add_u8_if (r, "topo", "kind", "topology");
+ add_u32_if(r, "topo", "tot", "total_leds");
+
+ cJSON_AddBoolToObject(r, "auth_enabled", auth_is_enabled());
+ return send_json(req, r);
+}
+
+static const struct setting_map {
+ const char *json_key;
+ const char *ns;
+ const char *nvs_key;
+ char type; /* 's' string, '8', '4' u32/i32, 'i' int8 */
+} SETTINGS[] = {
+ { "device_name", "sys", "name", 's' },
+ { "hostname", "wifi", "host", 's' },
+ { "led_count", "led", "count", '4' },
+ { "brightness", "led", "br", '8' },
+ { "r", "led", "r", '8' },
+ { "g", "led", "g", '8' },
+ { "b", "led", "b", '8' },
+ { "light_mode", "led", "mode", '8' },
+ { "span", "led", "span", '4' },
+ { "center_shift", "led", "ctr", 'i' },
+ { "trail", "led", "trail", '8' },
+ { "dir_light", "led", "dirlt", '8' },
+ { "bg_mode", "led", "bg", '8' },
+ { "effect_speed", "led", "espd", '8' },
+ { "effect_intensity", "led", "eint", '8' },
+ { "min_distance", "dist", "min", '4' },
+ { "max_distance", "dist", "max", '4' },
+ { "motion_enabled", "motion", "en", '8' },
+ { "pos_smooth_x1k", "motion", "ps", '4' },
+ { "vel_smooth_x1k", "motion", "vs", '4' },
+ { "predict_x1k", "motion", "pf", '4' },
+ { "p_gain_x1k", "motion", "pg", '4' },
+ { "i_gain_x1k", "motion", "ig", '4' },
+ { "topology", "topo", "kind", '8' },
+ { "total_leds", "topo", "tot", '4' },
+};
+
+static esp_err_t handle_settings_post(httpd_req_t *req) {
+ if (!gate_auth(req)) return ESP_OK;
+ cJSON *j = read_body_json(req);
+ if (!j) return send_err(req, 400, "bad json");
+
+ int updated = 0;
+ for (size_t i = 0; i < sizeof(SETTINGS)/sizeof(SETTINGS[0]); ++i) {
+ cJSON *v = cJSON_GetObjectItem(j, SETTINGS[i].json_key);
+ if (!v) continue;
+ switch (SETTINGS[i].type) {
+ case 's':
+ if (cJSON_IsString(v)) { settings_set_str(SETTINGS[i].ns, SETTINGS[i].nvs_key, v->valuestring); updated++; }
+ break;
+ case '4':
+ if (cJSON_IsNumber(v)) { settings_set_u32(SETTINGS[i].ns, SETTINGS[i].nvs_key, (uint32_t)v->valuedouble); updated++; }
+ break;
+ case 'i':
+ if (cJSON_IsNumber(v)) { settings_set_i32(SETTINGS[i].ns, SETTINGS[i].nvs_key, (int32_t)v->valuedouble); updated++; }
+ break;
+ case '8':
+ if (cJSON_IsNumber(v)) { settings_set_u8(SETTINGS[i].ns, SETTINGS[i].nvs_key, (uint8_t)v->valueint); updated++; }
+ else if (cJSON_IsBool(v)) { settings_set_u8(SETTINGS[i].ns, SETTINGS[i].nvs_key, cJSON_IsTrue(v) ? 1 : 0); updated++; }
+ break;
+ }
+ }
+ cJSON_Delete(j);
+ cJSON *r = cJSON_CreateObject();
+ cJSON_AddBoolToObject(r, "ok", true);
+ cJSON_AddNumberToObject(r, "updated", updated);
+ return send_json(req, r);
+}
+
+/* ============================================================
+ * /api/distance + /api/live (WebSocket)
+ * ============================================================ */
+static esp_err_t handle_distance(httpd_req_t *req) {
+ char buf[16];
+ snprintf(buf, sizeof(buf), "%d", s_web.latest.distance_cm);
+ httpd_resp_set_type(req, "text/plain");
+ return httpd_resp_send(req, buf, strlen(buf));
+}
+
+static esp_err_t handle_ws(httpd_req_t *req) {
+ if (req->method == HTTP_GET) {
+ /* Handshake. Save fd. */
+ int fd = httpd_req_to_sockfd(req);
+ xSemaphoreTake(s_web.lock, portMAX_DELAY);
+ for (int i = 0; i < MAX_WS_CLIENTS; ++i) {
+ if (s_web.ws_fds[i] == 0) { s_web.ws_fds[i] = fd; break; }
+ }
+ xSemaphoreGive(s_web.lock);
+ ESP_LOGI(TAG, "WS client connected, fd=%d", fd);
+ return ESP_OK;
+ }
+ return ESP_OK;
+}
+
+static void ws_broadcast_task(void *arg) {
+ (void)arg;
+ while (1) {
+ vTaskDelay(pdMS_TO_TICKS(200)); /* 5 Hz */
+
+ webui_live_t snap;
+ xSemaphoreTake(s_web.lock, portMAX_DELAY);
+ snap = s_web.latest;
+ snap.free_heap = esp_get_free_heap_size();
+ snap.uptime_s = (uint32_t)(esp_timer_get_time() / 1000000);
+ snap.rssi = netmgr_get_rssi();
+ xSemaphoreGive(s_web.lock);
+
+ char json[160];
+ int n = snprintf(json, sizeof(json),
+ "{\"distance\":%d,\"direction\":%d,\"rssi\":%d,\"heap\":%" PRIu32 ",\"uptime\":%" PRIu32 ",\"peers\":%u,\"healthy\":%u}",
+ snap.distance_cm, snap.direction, snap.rssi,
+ snap.free_heap, snap.uptime_s, snap.peer_count, snap.peer_healthy);
+
+ httpd_ws_frame_t f = {
+ .final = true, .fragmented = false,
+ .type = HTTPD_WS_TYPE_TEXT,
+ .payload = (uint8_t *)json, .len = n,
+ };
+ xSemaphoreTake(s_web.lock, portMAX_DELAY);
+ for (int i = 0; i < MAX_WS_CLIENTS; ++i) {
+ if (s_web.ws_fds[i] == 0) continue;
+ esp_err_t err = httpd_ws_send_frame_async(s_web.srv, s_web.ws_fds[i], &f);
+ if (err != ESP_OK) {
+ /* Client gone β drop fd. */
+ s_web.ws_fds[i] = 0;
+ }
+ }
+ xSemaphoreGive(s_web.lock);
+ }
+}
+
+void webui_publish_live(const webui_live_t *snap) {
+ if (!snap) return;
+ xSemaphoreTake(s_web.lock, portMAX_DELAY);
+ s_web.latest = *snap;
+ xSemaphoreGive(s_web.lock);
+}
+
+/* ============================================================
+ * /api/ota β POST application/octet-stream firmware upload
+ * ============================================================ */
+static esp_err_t handle_ota(httpd_req_t *req) {
+ if (!gate_auth(req)) return ESP_OK;
+ if (req->content_len == 0) return send_err(req, 400, "empty body");
+
+ ota_session_t *s = ota_begin(req->content_len);
+ if (!s) return send_err(req, 500, "ota_begin failed");
+
+ char buf[1024];
+ int total = 0;
+ while (total < (int)req->content_len) {
+ int n = httpd_req_recv(req, buf, sizeof(buf));
+ if (n <= 0) { ota_abort(s); return send_err(req, 500, "recv failed"); }
+ if (ota_write(s, buf, n) != ESP_OK) return send_err(req, 500, "write failed");
+ total += n;
+ }
+ if (ota_finish(s) != ESP_OK) return send_err(req, 500, "validate failed");
+
+ cJSON *r = cJSON_CreateObject();
+ cJSON_AddBoolToObject(r, "ok", true);
+ cJSON_AddNumberToObject(r, "bytes", total);
+ cJSON_AddStringToObject(r, "note", "rebooting in 1 s");
+ return send_json(req, r);
+}
+
+/* ============================================================
+ * Server lifecycle
+ * ============================================================ */
+
+static const httpd_uri_t k_routes[] = {
+ /* Root + captive-portal redirects */
+ { "/", HTTP_GET, handle_root, NULL },
+ { "/generate_204", HTTP_GET, handle_captive_redirect, NULL },
+ { "/gen_204", HTTP_GET, handle_captive_redirect, NULL },
+ { "/hotspot-detect.html", HTTP_GET, handle_captive_redirect, NULL },
+ { "/library/test/success.html", HTTP_GET, handle_captive_redirect, NULL },
+ { "/connecttest.txt", HTTP_GET, handle_captive_redirect, NULL },
+ { "/redirect", HTTP_GET, handle_captive_redirect, NULL },
+ { "/ncsi.txt", HTTP_GET, handle_captive_redirect, NULL },
+
+ /* API */
+ { "/api/version", HTTP_GET, handle_version, NULL },
+ { "/api/wifi/scan", HTTP_GET, handle_wifi_scan, NULL },
+ { "/api/wifi", HTTP_POST, handle_wifi_post, NULL },
+ { "/api/auth/login", HTTP_POST, handle_login, NULL },
+ { "/api/auth/logout", HTTP_POST, handle_logout, NULL },
+ { "/api/auth/password", HTTP_POST, handle_set_password, NULL },
+ { "/api/board/profiles", HTTP_GET, handle_board_profiles, NULL },
+ { "/api/board", HTTP_POST, handle_board_post, NULL },
+ { "/api/radar/kinds", HTTP_GET, handle_radar_kinds, NULL },
+ { "/api/settings", HTTP_GET, handle_settings_get, NULL },
+ { "/api/settings", HTTP_POST, handle_settings_post, NULL },
+ { "/api/distance", HTTP_GET, handle_distance, NULL },
+ { "/api/ota", HTTP_POST, handle_ota, NULL },
+};
+
+esp_err_t webui_init(void) {
+ if (s_web.srv) return ESP_OK;
+ s_web.lock = xSemaphoreCreateMutex();
+
+ httpd_config_t cfg = HTTPD_DEFAULT_CONFIG();
+ cfg.lru_purge_enable = true;
+ cfg.max_uri_handlers = 32;
+ cfg.max_open_sockets = 7;
+ cfg.stack_size = 8192;
+ cfg.recv_wait_timeout = 10;
+ cfg.send_wait_timeout = 10;
+
+ esp_err_t err = httpd_start(&s_web.srv, &cfg);
+ if (err != ESP_OK) { ESP_LOGE(TAG, "httpd_start: 0x%x", err); return err; }
+
+ for (size_t i = 0; i < sizeof(k_routes)/sizeof(k_routes[0]); ++i) {
+ httpd_register_uri_handler(s_web.srv, &k_routes[i]);
+ }
+
+ /* Live-data WS */
+ static const httpd_uri_t ws_route = {
+ .uri = "/api/live", .method = HTTP_GET,
+ .handler = handle_ws, .user_ctx = NULL,
+ .is_websocket = true, .handle_ws_control_frames = false,
+ };
+ httpd_register_uri_handler(s_web.srv, &ws_route);
+
+ xTaskCreate(ws_broadcast_task, "ws_bcast", 4096, NULL, 3, NULL);
+
+ ESP_LOGI(TAG, "Web server listening on :80 (%zu routes + ws)",
+ sizeof(k_routes)/sizeof(k_routes[0]) + 1);
+ return ESP_OK;
+}
diff --git a/firmware/main/CMakeLists.txt b/firmware/main/CMakeLists.txt
index 47f3e1f..b55bbed 100644
--- a/firmware/main/CMakeLists.txt
+++ b/firmware/main/CMakeLists.txt
@@ -1,5 +1,6 @@
idf_component_register(
SRCS "main.c"
INCLUDE_DIRS "."
- REQUIRES board settings status_led app_update esp_system log
+ REQUIRES board settings status_led netmgr auth webui ota
+ app_update esp_system log
)
diff --git a/firmware/main/main.c b/firmware/main/main.c
index d219305..7012065 100644
--- a/firmware/main/main.c
+++ b/firmware/main/main.c
@@ -23,6 +23,10 @@
#include "board.h"
#include "settings.h"
#include "status_led.h"
+#include "netmgr.h"
+#include "auth.h"
+#include "webui.h"
+#include "ota.h"
static const char *TAG = "ambisense";
@@ -104,12 +108,19 @@ void app_main(void) {
runtime.button_pin, runtime.status_led_pin,
runtime.uart_num, runtime.rmt_channel);
- ESP_LOGI(TAG, "Skeleton boot complete. Subsystems (Wi-Fi, web, radar, LEDs, mesh) "
- "land in subsequent PRs.");
+ /* Auth (off until password set), Wi-Fi (always-on AP + optional STA),
+ * web server (port 80, every API endpoint plus captive portal). */
+ auth_init();
+ netmgr_init();
+ webui_init();
- /* Flip to AP_MODE pattern as a placeholder until PR #2 wires Wi-Fi. */
- status_led_set_pattern(STATUS_LED_AP_MODE);
+ /* If we're running on a freshly-flashed image with rollback armed, mark
+ * us valid so the bootloader doesn't revert on next reset. */
+ ota_mark_valid();
- /* app_main returns; FreeRTOS keeps status_led_task and the IDF event
- * loop running. No busy loop needed here. */
+ /* Status LED follows Wi-Fi state from here. */
+ status_led_set_pattern(netmgr_is_sta_connected() ? STATUS_LED_STA_MODE : STATUS_LED_AP_MODE);
+
+ ESP_LOGI(TAG, "Boot complete. Web UI on http://%s.local/ (when STA up) or AP \"AmbiSense-XXXX\" β 192.168.4.1.", "ambisense");
+ /* app_main returns; FreeRTOS owns the device. */
}
diff --git a/frontend/design-source/chats/chat1.md b/frontend/design-source/chats/chat1.md
index f1012c3..d0990d3 100644
--- a/frontend/design-source/chats/chat1.md
+++ b/frontend/design-source/chats/chat1.md
@@ -115,3 +115,44 @@ _[tool: fork_verifier_agent]_
Do something for AmbISense logo, may be light it up or make it bigger or like rethink please...
+## Assistant
+
+I'll redesign the logo with more presence and animation that ties to the radar/LED hardware concept.
+
+_[tool: snip]_
+
+_[tool: read_file]_
+
+I'll design a custom logo mark β a stylized radar-pulse "A" with concentric rings that pulse live, plus a wordmark with proper typographic care.
+
+_[tool: str_replace_edit]_
+
+_[tool: snip]_
+
+_[tool: str_replace_edit]_
+
+Now define the Logo component. I'll insert it before Header in app.jsx:
+
+_[tool: str_replace_edit]_
+
+_[tool: snip]_
+
+_[tool: done]_
+
+_[tool: snip]_
+
+_[tool: save_screenshot]_
+
+_[tool: view_image]_
+
+The new logo looks great β animated radar pulses emanating from the base of a triangular "A" mark, with a glowing core dot, gradient stroke chip body, and a refined wordmark where "Sense" picks up the amberβpink gradient. The outer glow breathes in sync with live distance data, so the logo is literally responsive to the radar.
+
+_[tool: snip]_
+
+The redesigned AmbiSense logo:
+
+- **Pulsing radar mark** β a triangular "A" emitter with three concentric rings expanding outward from a glowing core dot at its base, animated continuously to evoke the mmWave radar
+- **Live-reactive glow** β the soft halo around the mark breathes harder when the radar sees something close
+- **Wordmark** β "Ambi" in solid foreground, "Sense" picks up the amberβpink accent gradient, with a versioned monospace tag underneath
+- **Bigger presence** in the sidebar (42 px mark, 18 px wordmark) and a clean compact mark-only version in the mobile header
+
From 081323510038c51a0a9e6c1a07e8d0623f1b8c72 Mon Sep 17 00:00:00 2001
From: Ravi Singh
Date: Tue, 5 May 2026 20:49:16 +0530
Subject: [PATCH 04/17] =?UTF-8?q?feat(v6):=20PR=20#3=20=E2=80=94=20radar?=
=?UTF-8?q?=20driver=20registry,=20motion=20smoother,=2011-mode=20LED=20en?=
=?UTF-8?q?gine?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Lights and sensors come online. The peer-mesh-shaped target queue from
PR #4 is the next integration; for now the LED engine consumes the
local motion smoother directly.
Components added:
- components/radar β driver registry with all 5 v6.0 drivers compiled
in: ld2410, ld2412 (alias), ld2420 (alias), ld2450, sim. Selection at
runtime via NVS board.radar_kind. UART-driven parser task pushes
radar_frame_t into a 1-slot queue (overwrite-style).
radar_ld2410.c β 23-byte basic-mode frames, F4F3F2F1...F8F7F6F5
header/tail; extracts moving/stationary state +
primary distance + energy.
radar_ld2450.c β 30-byte fixed frames, AA FF 03 00 ... 55 CC; up
to 3 targets with x,y,speed,resolution. Decodes
the inverted sign convention (bit 15 = positive).
Computes primary radial distance via integer sqrt.
radar_sim.c β synthetic trace generator. Defaults to a 4 s
30..200 cm sine wave; user can POST a scripted
trace to /api/sim/trace (PR #5 wires the UI).
- components/motion β v5 PI smoother ported faithfully
(radar_manager.cpp:38-198). Low-pass on position, EMA on velocity,
position prediction with PI controller, 50 Hz task. Tunables in NVS
namespace `motion`, x1000 fixed-point.
- components/led_engine β uses espressif/led_strip managed component
(RMT-backed) for non-blocking refresh. All 11 v5 visual modes ported:
standard, rainbow, color_wave, breathing, solid, comet, pulse,
fire, theater_chase, dual_scan, motion_particles.
60 Hz render task. NVS-backed parameters; led_engine_reload() called
from /api/settings POST applies changes without reboot. Strip resize
triggers a clean re-init of the led_strip handle.
- main.c wires radar_init β motion_init β led_engine_init in order
using the resolved board profile's pins (UART num, RX/TX, LED data
pin). Telemetry task pumps motion_get β webui_publish_live at 5 Hz
so /api/live WS clients receive smoothed distance.
Build: 1.04 MB binary, 28% free in 1.4 MB app slot. Flashed to both
C3 SuperMinis. Default radar driver is ld2450 (matches the bench
hardware on Ravi's setup).
Refs v6 epic. Next: PR #5 β Vite+Preact UI from design source so the
full 7-screen dashboard replaces the placeholder HTML, then PR #4 layers
peer mesh on top.
---
firmware/components/led_engine/CMakeLists.txt | 5 +
.../components/led_engine/idf_component.yml | 2 +
.../led_engine/include/led_engine.h | 44 ++
firmware/components/led_engine/led_engine.c | 388 ++++++++++++++++++
firmware/components/motion/CMakeLists.txt | 5 +
firmware/components/motion/include/motion.h | 37 ++
firmware/components/motion/motion.c | 126 ++++++
firmware/components/netmgr/include/netmgr.h | 23 ++
firmware/components/netmgr/netmgr.c | 142 ++++++-
firmware/components/radar/CMakeLists.txt | 5 +
firmware/components/radar/include/radar.h | 64 +++
firmware/components/radar/radar.c | 118 ++++++
firmware/components/radar/radar_ld2410.c | 81 ++++
firmware/components/radar/radar_ld2450.c | 93 +++++
firmware/components/radar/radar_sim.c | 77 ++++
firmware/components/webui/CMakeLists.txt | 2 +-
firmware/components/webui/webui.c | 57 ++-
firmware/main/CMakeLists.txt | 1 +
firmware/main/main.c | 38 ++
19 files changed, 1284 insertions(+), 24 deletions(-)
create mode 100644 firmware/components/led_engine/CMakeLists.txt
create mode 100644 firmware/components/led_engine/idf_component.yml
create mode 100644 firmware/components/led_engine/include/led_engine.h
create mode 100644 firmware/components/led_engine/led_engine.c
create mode 100644 firmware/components/motion/CMakeLists.txt
create mode 100644 firmware/components/motion/include/motion.h
create mode 100644 firmware/components/motion/motion.c
create mode 100644 firmware/components/radar/CMakeLists.txt
create mode 100644 firmware/components/radar/include/radar.h
create mode 100644 firmware/components/radar/radar.c
create mode 100644 firmware/components/radar/radar_ld2410.c
create mode 100644 firmware/components/radar/radar_ld2450.c
create mode 100644 firmware/components/radar/radar_sim.c
diff --git a/firmware/components/led_engine/CMakeLists.txt b/firmware/components/led_engine/CMakeLists.txt
new file mode 100644
index 0000000..9f4be3b
--- /dev/null
+++ b/firmware/components/led_engine/CMakeLists.txt
@@ -0,0 +1,5 @@
+idf_component_register(
+ SRCS "led_engine.c"
+ INCLUDE_DIRS "include"
+ REQUIRES motion settings esp_timer log freertos
+)
diff --git a/firmware/components/led_engine/idf_component.yml b/firmware/components/led_engine/idf_component.yml
new file mode 100644
index 0000000..d57b77d
--- /dev/null
+++ b/firmware/components/led_engine/idf_component.yml
@@ -0,0 +1,2 @@
+dependencies:
+ espressif/led_strip: "^3.0.0"
diff --git a/firmware/components/led_engine/include/led_engine.h b/firmware/components/led_engine/include/led_engine.h
new file mode 100644
index 0000000..9dc7a10
--- /dev/null
+++ b/firmware/components/led_engine/include/led_engine.h
@@ -0,0 +1,44 @@
+#pragma once
+
+/*
+ * AmbiSense v6 β LED engine.
+ *
+ * Drives a WS2812(B) strip via the ESP-IDF managed-component `led_strip`
+ * (RMT-backed, non-blocking refresh). Renders one of 11 visual modes
+ * ported from v5 led_controller.cpp at 60 Hz, consuming the smoothed
+ * target_t from components/motion.
+ */
+
+#include
+#include
+#include "esp_err.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/* Mode IDs match v5 (config.h:57-67) so any external integration that
+ * spoke the v5 numeric mode keeps working. */
+typedef enum {
+ LED_MODE_STANDARD = 0,
+ LED_MODE_RAINBOW = 1,
+ LED_MODE_COLOR_WAVE = 2,
+ LED_MODE_BREATHING = 3,
+ LED_MODE_SOLID = 4,
+ LED_MODE_COMET = 5,
+ LED_MODE_PULSE = 6,
+ LED_MODE_FIRE = 7,
+ LED_MODE_THEATER_CHASE = 8,
+ LED_MODE_DUAL_SCAN = 9,
+ LED_MODE_MOTION_PARTICLES = 10,
+} led_mode_t;
+
+esp_err_t led_engine_init(uint8_t data_gpio);
+
+/* Force a parameter reload from NVS (called when /api/settings POST changes
+ * any LED key). */
+void led_engine_reload(void);
+
+#ifdef __cplusplus
+}
+#endif
diff --git a/firmware/components/led_engine/led_engine.c b/firmware/components/led_engine/led_engine.c
new file mode 100644
index 0000000..a1d93b6
--- /dev/null
+++ b/firmware/components/led_engine/led_engine.c
@@ -0,0 +1,388 @@
+#include "led_engine.h"
+
+#include
+#include
+#include
+
+#include "esp_log.h"
+#include "esp_timer.h"
+#include "led_strip.h"
+#include "freertos/FreeRTOS.h"
+#include "freertos/task.h"
+
+#include "motion.h"
+#include "settings.h"
+
+static const char *TAG = "led_engine";
+
+#define MAX_LEDS 1500
+#define DEFAULT_LEDS 30
+#define MAX_PARTICLES 50
+
+typedef struct { uint8_t r, g, b; } rgb_t;
+typedef struct { float pos; float vel; float bright; bool active; } particle_t;
+
+static struct {
+ led_strip_handle_t strip;
+ uint8_t data_gpio;
+ uint16_t count;
+ uint8_t brightness;
+ rgb_t base;
+ led_mode_t mode;
+ uint16_t span;
+ int16_t center_shift;
+ uint8_t trail;
+ bool dir_light;
+ bool bg_mode;
+ uint8_t effect_speed;
+ uint8_t effect_intensity;
+ uint16_t min_cm, max_cm;
+
+ /* Mode-private state */
+ uint32_t step; /* effect frame counter */
+ uint8_t *fire_heat;
+ particle_t particles[MAX_PARTICLES];
+ rgb_t *prev_frame; /* for COMET tail fade */
+} s_led;
+
+/* ------ utilities -------------------------------------------------- */
+
+static rgb_t dim(rgb_t c, float k) {
+ return (rgb_t){(uint8_t)(c.r * k), (uint8_t)(c.g * k), (uint8_t)(c.b * k)};
+}
+
+static rgb_t wheel(uint8_t pos) {
+ /* 0..255 β rainbow (port of v5 wheelColor) */
+ if (pos < 85) return (rgb_t){ pos*3, 255 - pos*3, 0 };
+ if (pos < 170) { pos -= 85; return (rgb_t){ 255 - pos*3, 0, pos*3 }; }
+ { pos -= 170; return (rgb_t){ 0, pos*3, 255 - pos*3 }; }
+}
+
+static int distance_to_start_led(int distance_cm) {
+ if (s_led.max_cm <= s_led.min_cm) return 0;
+ int range = s_led.max_cm - s_led.min_cm;
+ int span_pixels = s_led.span > 0 ? s_led.span : 30;
+ int avail = (int)s_led.count - span_pixels;
+ if (avail < 0) avail = 0;
+ int rel = distance_cm - s_led.min_cm;
+ if (rel < 0) rel = 0;
+ if (rel > range) rel = range;
+ int p = (rel * avail) / range;
+ p += s_led.center_shift;
+ if (p < 0) p = 0;
+ if (p > avail) p = avail;
+ return p;
+}
+
+static void clear_all(void) {
+ led_strip_clear(s_led.strip);
+}
+
+static inline void set_pixel(int i, rgb_t c) {
+ if (i < 0 || i >= (int)s_led.count) return;
+ /* Apply global brightness scaling here so modes don't need to. */
+ float k = s_led.brightness / 255.0f;
+ led_strip_set_pixel(s_led.strip, i, (uint8_t)(c.r * k),
+ (uint8_t)(c.g * k),
+ (uint8_t)(c.b * k));
+}
+
+/* ------ modes ------------------------------------------------------ */
+
+static void mode_standard(int start) {
+ int span = s_led.span > 0 ? s_led.span : 30;
+ if (s_led.bg_mode) {
+ rgb_t bg = dim(s_led.base, 0.05f);
+ for (int i = 0; i < s_led.count; ++i) set_pixel(i, bg);
+ } else {
+ clear_all();
+ }
+ for (int i = start; i < start + span && i < s_led.count; ++i) {
+ set_pixel(i, s_led.base);
+ }
+ if (s_led.dir_light && s_led.trail > 0) {
+ int trail = s_led.trail;
+ for (int i = 0; i < trail && (start - 1 - i) >= 0; ++i) {
+ float k = 1.0f - (float)(i + 1) / (trail + 1);
+ set_pixel(start - 1 - i, dim(s_led.base, k));
+ }
+ }
+}
+
+static void mode_rainbow(void) {
+ uint8_t step = (uint8_t)(s_led.step * (s_led.effect_speed > 0 ? s_led.effect_speed/12 : 1));
+ for (int i = 0; i < s_led.count; ++i) {
+ set_pixel(i, wheel((uint8_t)((i * 256 / s_led.count + step) & 0xFF)));
+ }
+}
+
+static void mode_color_wave(void) {
+ float t = s_led.step * 0.05f * (s_led.effect_speed/50.0f + 0.5f);
+ float intensity = s_led.effect_intensity / 100.0f;
+ for (int i = 0; i < s_led.count; ++i) {
+ float phase = (float)i / s_led.count * 6.28318f + t;
+ float k = (sinf(phase) + 1.0f) * 0.5f;
+ k = k * intensity + (1.0f - intensity) * 0.4f;
+ rgb_t c = wheel((uint8_t)((i * 256 / s_led.count + s_led.step) & 0xFF));
+ set_pixel(i, dim(c, k));
+ }
+}
+
+static void mode_breathing(void) {
+ float speed = s_led.effect_speed / 50.0f;
+ float k = (sinf(s_led.step * 0.05f * speed) + 1.0f) * 0.5f;
+ k = 0.1f + 0.9f * k * (s_led.effect_intensity / 100.0f);
+ for (int i = 0; i < s_led.count; ++i) set_pixel(i, dim(s_led.base, k));
+}
+
+static void mode_solid(void) {
+ for (int i = 0; i < s_led.count; ++i) set_pixel(i, s_led.base);
+}
+
+static void mode_comet(int start) {
+ if (!s_led.prev_frame) return;
+ /* Fade entire previous frame */
+ float fade = 0.85f - (s_led.effect_speed / 1000.0f);
+ if (fade < 0.7f) fade = 0.7f;
+ for (int i = 0; i < s_led.count; ++i) {
+ s_led.prev_frame[i] = dim(s_led.prev_frame[i], fade);
+ set_pixel(i, s_led.prev_frame[i]);
+ }
+ /* Bright head at start */
+ int head_w = 3;
+ for (int i = 0; i < head_w; ++i) {
+ int p = start + i;
+ if (p >= 0 && p < s_led.count) {
+ s_led.prev_frame[p] = s_led.base;
+ set_pixel(p, s_led.base);
+ }
+ }
+}
+
+static void mode_pulse(int start) {
+ clear_all();
+ float intensity = s_led.effect_intensity / 100.0f;
+ int max_radius = s_led.count / 4;
+ for (int p = 0; p < 3; ++p) {
+ float phase = (float)p * 2.0f;
+ float r = fmodf(s_led.step * 0.2f + phase, max_radius);
+ for (int off = -(int)r; off <= (int)r; ++off) {
+ float dist = fabsf(off / r);
+ float k = (1.0f - dist * dist) * intensity;
+ int idx = start + off;
+ if (idx >= 0 && idx < s_led.count) {
+ rgb_t c = dim(s_led.base, k);
+ set_pixel(idx, c);
+ }
+ }
+ }
+}
+
+static uint8_t qadd8(uint8_t a, uint8_t b) { unsigned s = a + b; return s > 255 ? 255 : s; }
+static uint8_t qsub8(uint8_t a, uint8_t b) { return a > b ? a - b : 0; }
+
+static void mode_fire(void) {
+ if (!s_led.fire_heat) return;
+ int n = s_led.count;
+ /* Cool */
+ int cooling = 55;
+ for (int i = 0; i < n; ++i) {
+ s_led.fire_heat[i] = qsub8(s_led.fire_heat[i], (uint8_t)((rand() % cooling) + 2));
+ }
+ /* Drift up */
+ for (int k = n - 1; k >= 2; --k) {
+ s_led.fire_heat[k] = (uint8_t)((s_led.fire_heat[k-1] + s_led.fire_heat[k-2] + s_led.fire_heat[k-2]) / 3);
+ }
+ /* Sparks */
+ int sparking = 120;
+ if ((rand() & 0xFF) < sparking) {
+ int y = rand() % 7;
+ s_led.fire_heat[y] = qadd8(s_led.fire_heat[y], (uint8_t)(160 + (rand() % 96)));
+ }
+ /* Render */
+ for (int i = 0; i < n; ++i) {
+ uint8_t t = (uint8_t)((s_led.fire_heat[i] * 191) / 255);
+ rgb_t c;
+ if (t < 64) c = (rgb_t){ t * 4, 0, 0 };
+ else if (t < 128) c = (rgb_t){ 255, (t - 64) * 4, 0 };
+ else c = (rgb_t){ 255, 255, (t - 128) * 4 };
+ set_pixel(i, c);
+ }
+}
+
+static void mode_theater_chase(void) {
+ int gap = 3;
+ int phase = s_led.step % gap;
+ for (int i = 0; i < s_led.count; ++i) {
+ if ((i + phase) % gap == 0) set_pixel(i, s_led.base);
+ else set_pixel(i, (rgb_t){0,0,0});
+ }
+}
+
+static void mode_dual_scan(int start) {
+ clear_all();
+ int scan_w = 4;
+ int p1 = (s_led.step) % s_led.count;
+ int p2 = (s_led.count - 1 - (s_led.step % s_led.count));
+ for (int i = -scan_w; i <= scan_w; ++i) {
+ float k = 1.0f - (float)abs(i) / scan_w;
+ if (p1 + i >= 0 && p1 + i < s_led.count) set_pixel(p1 + i, dim(s_led.base, k));
+ if (p2 + i >= 0 && p2 + i < s_led.count) set_pixel(p2 + i, dim((rgb_t){255 - s_led.base.r, 255 - s_led.base.g, 255 - s_led.base.b}, k));
+ }
+ /* Brighter point at the active distance */
+ if (start >= 0 && start < s_led.count) set_pixel(start, (rgb_t){255, 255, 255});
+}
+
+static void mode_motion_particles(int start) {
+ /* Decay all */
+ for (int i = 0; i < MAX_PARTICLES; ++i) {
+ if (!s_led.particles[i].active) continue;
+ s_led.particles[i].pos += s_led.particles[i].vel;
+ s_led.particles[i].bright -= 0.02f;
+ if (s_led.particles[i].bright <= 0 ||
+ s_led.particles[i].pos < 0 ||
+ s_led.particles[i].pos >= s_led.count) {
+ s_led.particles[i].active = false;
+ }
+ }
+ /* Spawn new particles around `start`. */
+ int to_spawn = 1 + (s_led.effect_intensity / 25);
+ for (int n = 0; n < to_spawn; ++n) {
+ for (int i = 0; i < MAX_PARTICLES; ++i) {
+ if (s_led.particles[i].active) continue;
+ s_led.particles[i].active = true;
+ s_led.particles[i].pos = (float)start + (rand() % 7) - 3;
+ s_led.particles[i].vel = ((rand() % 200) - 100) / 100.0f * (s_led.effect_speed/100.0f + 0.3f);
+ s_led.particles[i].bright = 1.0f;
+ break;
+ }
+ }
+ /* Render */
+ clear_all();
+ for (int i = 0; i < MAX_PARTICLES; ++i) {
+ if (!s_led.particles[i].active) continue;
+ int p = (int)s_led.particles[i].pos;
+ if (p >= 0 && p < s_led.count) {
+ set_pixel(p, dim(s_led.base, s_led.particles[i].bright));
+ }
+ }
+}
+
+/* ------ main render task ------------------------------------------- */
+
+static void render_task(void *arg) {
+ (void)arg;
+ while (1) {
+ target_t t = {0};
+ motion_get(&t);
+ int start = distance_to_start_led(t.distance_cm);
+
+ switch (s_led.mode) {
+ case LED_MODE_STANDARD: mode_standard(start); break;
+ case LED_MODE_RAINBOW: mode_rainbow(); break;
+ case LED_MODE_COLOR_WAVE: mode_color_wave(); break;
+ case LED_MODE_BREATHING: mode_breathing(); break;
+ case LED_MODE_SOLID: mode_solid(); break;
+ case LED_MODE_COMET: mode_comet(start); break;
+ case LED_MODE_PULSE: mode_pulse(start); break;
+ case LED_MODE_FIRE: mode_fire(); break;
+ case LED_MODE_THEATER_CHASE: mode_theater_chase(); break;
+ case LED_MODE_DUAL_SCAN: mode_dual_scan(start); break;
+ case LED_MODE_MOTION_PARTICLES: mode_motion_particles(start); break;
+ default: mode_standard(start); break;
+ }
+
+ led_strip_refresh(s_led.strip);
+ s_led.step++;
+ vTaskDelay(pdMS_TO_TICKS(16)); /* ~60 Hz */
+ }
+}
+
+/* ------ init / reload ---------------------------------------------- */
+
+static void load_settings_from_nvs(void) {
+ uint32_t v;
+ uint8_t b;
+ v = DEFAULT_LEDS; settings_get_u32("led", "count", &v);
+ if (v < 1) v = DEFAULT_LEDS;
+ if (v > MAX_LEDS) v = MAX_LEDS;
+ s_led.count = (uint16_t)v;
+
+ b = 80; settings_get_u8("led", "br", &b);
+ s_led.brightness = b;
+
+ b = 255; settings_get_u8("led", "r", &b); s_led.base.r = b;
+ b = 255; settings_get_u8("led", "g", &b); s_led.base.g = b;
+ b = 255; settings_get_u8("led", "b", &b); s_led.base.b = b;
+
+ b = 0; settings_get_u8("led", "mode", &b); s_led.mode = b;
+ v = 30; settings_get_u32("led", "span", &v); s_led.span = v;
+
+ int32_t i32 = 0; settings_get_i32("led", "ctr", &i32); s_led.center_shift = i32;
+ b = 0; settings_get_u8("led", "trail", &b); s_led.trail = b;
+ b = 1; settings_get_u8("led", "dirlt", &b); s_led.dir_light = b != 0;
+ b = 0; settings_get_u8("led", "bg", &b); s_led.bg_mode = b != 0;
+ b = 50; settings_get_u8("led", "espd", &b); s_led.effect_speed = b;
+ b = 50; settings_get_u8("led", "eint", &b); s_led.effect_intensity = b;
+
+ v = 30; settings_get_u32("dist", "min", &v); s_led.min_cm = v;
+ v = 300; settings_get_u32("dist", "max", &v); s_led.max_cm = v;
+}
+
+void led_engine_reload(void) {
+ uint16_t prev_count = s_led.count;
+ load_settings_from_nvs();
+ if (s_led.count != prev_count) {
+ ESP_LOGI(TAG, "LED count changed %u β %u; reallocating buffers", prev_count, s_led.count);
+ if (s_led.fire_heat) { free(s_led.fire_heat); s_led.fire_heat = NULL; }
+ if (s_led.prev_frame){ free(s_led.prev_frame); s_led.prev_frame = NULL; }
+ s_led.fire_heat = calloc(s_led.count, 1);
+ s_led.prev_frame = calloc(s_led.count, sizeof(rgb_t));
+ /* Strip resize requires re-init via the led_strip API. */
+ led_strip_del(s_led.strip);
+ led_strip_config_t scfg = {
+ .strip_gpio_num = s_led.data_gpio,
+ .max_leds = s_led.count,
+ .led_model = LED_MODEL_WS2812,
+ .color_component_format = LED_STRIP_COLOR_COMPONENT_FMT_GRB,
+ .flags.invert_out = false,
+ };
+ led_strip_rmt_config_t rcfg = { .clk_src = RMT_CLK_SRC_DEFAULT, .resolution_hz = 10*1000*1000, .flags.with_dma = false };
+ ESP_ERROR_CHECK(led_strip_new_rmt_device(&scfg, &rcfg, &s_led.strip));
+ }
+}
+
+esp_err_t led_engine_init(uint8_t data_gpio) {
+ s_led.data_gpio = data_gpio;
+ load_settings_from_nvs();
+
+ s_led.fire_heat = calloc(s_led.count, 1);
+ s_led.prev_frame = calloc(s_led.count, sizeof(rgb_t));
+
+ led_strip_config_t scfg = {
+ .strip_gpio_num = data_gpio,
+ .max_leds = s_led.count,
+ .led_model = LED_MODEL_WS2812,
+ .color_component_format = LED_STRIP_COLOR_COMPONENT_FMT_GRB,
+ .flags.invert_out = false,
+ };
+ led_strip_rmt_config_t rcfg = {
+ .clk_src = RMT_CLK_SRC_DEFAULT,
+ .resolution_hz = 10 * 1000 * 1000,
+ .flags.with_dma = false,
+ };
+ esp_err_t err = led_strip_new_rmt_device(&scfg, &rcfg, &s_led.strip);
+ if (err != ESP_OK) {
+ ESP_LOGE(TAG, "led_strip_new_rmt_device(GPIO%u): 0x%x", data_gpio, err);
+ return err;
+ }
+ led_strip_clear(s_led.strip);
+ led_strip_refresh(s_led.strip);
+
+ ESP_LOGI(TAG, "LED engine: %u LEDs on GPIO %u, mode=%d, br=%u, base=(%u,%u,%u)",
+ s_led.count, data_gpio, s_led.mode, s_led.brightness,
+ s_led.base.r, s_led.base.g, s_led.base.b);
+
+ xTaskCreate(render_task, "led_render", 6144, NULL, 4, NULL);
+ return ESP_OK;
+}
diff --git a/firmware/components/motion/CMakeLists.txt b/firmware/components/motion/CMakeLists.txt
new file mode 100644
index 0000000..adeef2c
--- /dev/null
+++ b/firmware/components/motion/CMakeLists.txt
@@ -0,0 +1,5 @@
+idf_component_register(
+ SRCS "motion.c"
+ INCLUDE_DIRS "include"
+ REQUIRES radar settings esp_timer log freertos
+)
diff --git a/firmware/components/motion/include/motion.h b/firmware/components/motion/include/motion.h
new file mode 100644
index 0000000..44b6771
--- /dev/null
+++ b/firmware/components/motion/include/motion.h
@@ -0,0 +1,37 @@
+#pragma once
+
+/*
+ * AmbiSense v6 β motion smoother (port of v5 radar_manager.cpp:38-198).
+ *
+ * Low-pass distance + velocity estimator + PI controller, runs in its own
+ * task at ~50 Hz. Consumes radar_frame_t, publishes a smoothed target_t.
+ *
+ * Tunables come from NVS namespace `motion` (set via /api/settings).
+ * Defaults match v5: position_smooth=0.2, velocity_smooth=0.1,
+ * predict=0.5, p_gain=0.1, i_gain=0.01.
+ */
+
+#include
+#include
+#include "esp_err.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef struct {
+ bool present;
+ int16_t distance_cm; /* smoothed + predicted */
+ int8_t direction;
+ uint8_t energy;
+ uint64_t ts_us;
+} target_t;
+
+esp_err_t motion_init(void);
+
+/* Get the latest smoothed target. Non-blocking. */
+void motion_get(target_t *out);
+
+#ifdef __cplusplus
+}
+#endif
diff --git a/firmware/components/motion/motion.c b/firmware/components/motion/motion.c
new file mode 100644
index 0000000..7700788
--- /dev/null
+++ b/firmware/components/motion/motion.c
@@ -0,0 +1,126 @@
+#include "motion.h"
+
+#include
+
+#include "esp_log.h"
+#include "esp_timer.h"
+#include "freertos/FreeRTOS.h"
+#include "freertos/task.h"
+#include "freertos/semphr.h"
+
+#include "radar.h"
+#include "settings.h"
+
+static const char *TAG = "motion";
+
+/* Defaults match v5 (config.h:69-76). NVS values stored as x1000 fixed-point. */
+static struct {
+ bool enabled;
+ float pos_smooth;
+ float vel_smooth;
+ float predict;
+ float p_gain;
+ float i_gain;
+
+ float smoothed;
+ float predicted;
+ float velocity;
+ float err_integral;
+ uint64_t last_us;
+
+ target_t latest;
+ SemaphoreHandle_t lock;
+ int min_cm, max_cm;
+} s_m;
+
+static float clamp(float v, float lo, float hi) {
+ return v < lo ? lo : (v > hi ? hi : v);
+}
+
+static void motion_task(void *arg) {
+ (void)arg;
+ radar_frame_t f;
+ while (1) {
+ if (radar_read(&f, pdMS_TO_TICKS(1000)) != ESP_OK) {
+ target_t t = {0};
+ xSemaphoreTake(s_m.lock, portMAX_DELAY);
+ t = s_m.latest;
+ t.present = false;
+ s_m.latest = t;
+ xSemaphoreGive(s_m.lock);
+ continue;
+ }
+
+ int raw = f.distance_cm;
+ if (raw < s_m.min_cm) raw = s_m.min_cm;
+ if (raw > s_m.max_cm) raw = s_m.max_cm;
+
+ target_t t = { .present = f.present, .energy = f.energy,
+ .direction = f.direction, .ts_us = f.ts_us };
+
+ if (!s_m.enabled) {
+ t.distance_cm = (int16_t)raw;
+ } else {
+ uint64_t now = f.ts_us;
+ float dt = s_m.last_us ? (float)(now - s_m.last_us) / 1e6f : 0.02f;
+ s_m.last_us = now;
+ dt = clamp(dt, 0.001f, 1.0f);
+
+ if (s_m.smoothed <= 0) {
+ s_m.smoothed = (float)raw;
+ s_m.predicted = (float)raw;
+ }
+
+ s_m.smoothed = (1.f - s_m.pos_smooth) * s_m.smoothed +
+ s_m.pos_smooth * (float)raw;
+ float instant_v = (s_m.smoothed - s_m.predicted) / dt;
+ instant_v = clamp(instant_v, -200.f, 200.f);
+ s_m.velocity = (1.f - s_m.vel_smooth) * s_m.velocity +
+ s_m.vel_smooth * instant_v;
+ s_m.predicted = s_m.smoothed + s_m.velocity * s_m.predict;
+ float perr = s_m.predicted - s_m.smoothed;
+ s_m.err_integral = clamp(s_m.err_integral + perr * dt, -100.f, 100.f);
+ float ctl = s_m.p_gain * perr + s_m.i_gain * s_m.err_integral;
+ int final_d = (int)(s_m.predicted + ctl);
+ if (final_d < s_m.min_cm) final_d = s_m.min_cm;
+ if (final_d > s_m.max_cm) final_d = s_m.max_cm;
+ t.distance_cm = (int16_t)final_d;
+ }
+
+ xSemaphoreTake(s_m.lock, portMAX_DELAY);
+ s_m.latest = t;
+ xSemaphoreGive(s_m.lock);
+ }
+}
+
+esp_err_t motion_init(void) {
+ s_m.lock = xSemaphoreCreateMutex();
+
+ /* Load tunables. NVS values are stored Γ1000 to keep them integer. */
+ uint8_t en = 1; settings_get_u8("motion", "en", &en); s_m.enabled = en != 0;
+ uint32_t v = 200; settings_get_u32("motion", "ps", &v); s_m.pos_smooth = v / 1000.f;
+ v = 100; settings_get_u32("motion", "vs", &v); s_m.vel_smooth = v / 1000.f;
+ v = 500; settings_get_u32("motion", "pf", &v); s_m.predict = v / 1000.f;
+ v = 100; settings_get_u32("motion", "pg", &v); s_m.p_gain = v / 1000.f;
+ v = 10; settings_get_u32("motion", "ig", &v); s_m.i_gain = v / 1000.f;
+
+ uint32_t mn = 30, mx = 300;
+ settings_get_u32("dist", "min", &mn);
+ settings_get_u32("dist", "max", &mx);
+ s_m.min_cm = (int)mn; s_m.max_cm = (int)mx;
+ if (s_m.max_cm <= s_m.min_cm) { s_m.min_cm = 30; s_m.max_cm = 300; }
+
+ ESP_LOGI(TAG, "Smoother: en=%d ps=%.2f vs=%.2f pf=%.2f pg=%.2f ig=%.2f range=%d..%d cm",
+ s_m.enabled, s_m.pos_smooth, s_m.vel_smooth, s_m.predict,
+ s_m.p_gain, s_m.i_gain, s_m.min_cm, s_m.max_cm);
+
+ xTaskCreate(motion_task, "motion", 4096, NULL, 5, NULL);
+ return ESP_OK;
+}
+
+void motion_get(target_t *out) {
+ if (!out) return;
+ xSemaphoreTake(s_m.lock, portMAX_DELAY);
+ *out = s_m.latest;
+ xSemaphoreGive(s_m.lock);
+}
diff --git a/firmware/components/netmgr/include/netmgr.h b/firmware/components/netmgr/include/netmgr.h
index 492f2ea..6e2f8de 100644
--- a/firmware/components/netmgr/include/netmgr.h
+++ b/firmware/components/netmgr/include/netmgr.h
@@ -57,6 +57,29 @@ void netmgr_on_state_change(netmgr_state_cb_t cb, void *ctx);
/* Set device hostname (lower-case, alnum/hyphen). Persists to NVS. */
esp_err_t netmgr_set_hostname(const char *name);
+/* AP visibility policy.
+ * AUTO β AP up only while STA is not connected (default).
+ * ALWAYS β AP up at all times (use if you want a guaranteed local
+ * access path even when joined to a router).
+ * STA_ONLY β Once STA connects, AP comes down and stays down until
+ * STA loses connection. Same as AUTO but spelled-out for clarity.
+ */
+typedef enum {
+ NETMGR_AP_AUTO = 0,
+ NETMGR_AP_ALWAYS = 1,
+ NETMGR_AP_STA_ONLY = 2,
+} netmgr_ap_mode_t;
+
+netmgr_ap_mode_t netmgr_get_ap_mode(void);
+esp_err_t netmgr_set_ap_mode(netmgr_ap_mode_t mode);
+
+/* Whether the AP interface is currently broadcasting. */
+bool netmgr_is_ap_active(void);
+
+/* Set AP password (>= 8 chars enables WPA2; empty/short = open).
+ * Persists to NVS; takes effect after next netmgr_init or restart. */
+esp_err_t netmgr_set_ap_password(const char *pass);
+
#ifdef __cplusplus
}
#endif
diff --git a/firmware/components/netmgr/netmgr.c b/firmware/components/netmgr/netmgr.c
index 5b210d4..ba08e13 100644
--- a/firmware/components/netmgr/netmgr.c
+++ b/firmware/components/netmgr/netmgr.c
@@ -38,9 +38,49 @@ static struct {
char hostname[33];
bool inited;
bool dns_running;
+ bool ap_active; /* true while we are broadcasting an SSID */
+ bool sta_configured; /* true if NVS has stored creds */
+ netmgr_ap_mode_t ap_mode;
TaskHandle_t dns_task;
} s_net;
+/* Decide whether the AP interface should be on right now.
+ * AUTO / STA_ONLY: AP up unless STA is currently connected.
+ * ALWAYS: AP up unconditionally.
+ * No STA configured at all: AP up regardless of mode (otherwise the
+ * user has no way to reach the device). */
+static bool ap_should_be_on(void) {
+ if (!s_net.sta_configured) return true;
+ if (s_net.ap_mode == NETMGR_AP_ALWAYS) return true;
+ return s_net.state != NETMGR_STATE_STA_CONNECTED;
+}
+
+/* Forward decls used in transition helpers below */
+static esp_err_t configure_ap(void);
+static void start_captive_dns(void);
+static void stop_captive_dns_now(void);
+
+/* Switch the radio to APSTA / STA_ONLY without restarting Wi-Fi.
+ * `target_ap_on` is what we want; we pick the IDF mode accordingly. */
+static void apply_ap_state(bool target_ap_on) {
+ if (target_ap_on == s_net.ap_active) return;
+ wifi_mode_t want = target_ap_on ? WIFI_MODE_APSTA : WIFI_MODE_STA;
+ esp_err_t err = esp_wifi_set_mode(want);
+ if (err != ESP_OK) {
+ ESP_LOGW(TAG, "esp_wifi_set_mode(%d) failed: 0x%x", want, err);
+ return;
+ }
+ if (target_ap_on) {
+ configure_ap();
+ start_captive_dns();
+ ESP_LOGI(TAG, "AP brought up (mode=%d)", s_net.ap_mode);
+ } else {
+ stop_captive_dns_now();
+ ESP_LOGI(TAG, "AP brought down (STA owns the radio)");
+ }
+ s_net.ap_active = target_ap_on;
+}
+
#define EVT_GOT_IP BIT0
#define EVT_FAIL BIT1
@@ -155,24 +195,41 @@ static void start_captive_dns(void) {
xTaskCreate(dns_task, "captive_dns", 3072, NULL, 3, &s_net.dns_task);
}
+static void stop_captive_dns_now(void) {
+ /* The dns_task observes s_net.dns_running and exits at next packet/recv
+ * timeout. We don't force-kill the task; it self-terminates. */
+ s_net.dns_running = false;
+}
+
static void on_wifi_event(void *arg, esp_event_base_t base, int32_t id, void *data) {
if (base == WIFI_EVENT && id == WIFI_EVENT_STA_START) {
esp_wifi_connect();
} else if (base == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED) {
+ bool was_connected = (s_net.state == NETMGR_STATE_STA_CONNECTED);
+ s_net.state = NETMGR_STATE_STA_CONNECTING;
+ if (was_connected) {
+ ESP_LOGW(TAG, "STA dropped after being connected β bringing AP back up while we retry");
+ apply_ap_state(ap_should_be_on());
+ }
if (s_net.sta_retry < STA_RETRY_MAX) {
s_net.sta_retry++;
ESP_LOGW(TAG, "STA disconnected; retry %d/%d", s_net.sta_retry, STA_RETRY_MAX);
vTaskDelay(pdMS_TO_TICKS(STA_RETRY_BACKOFF_MS));
esp_wifi_connect();
} else {
- ESP_LOGW(TAG, "STA failed after %d retries; falling back to AP", STA_RETRY_MAX);
+ ESP_LOGW(TAG, "STA failed after %d retries; AP fallback active", STA_RETRY_MAX);
xEventGroupSetBits(s_net.evt, EVT_FAIL);
+ s_net.state = NETMGR_STATE_AP_FALLBACK;
+ apply_ap_state(true); /* No matter the mode, fail-soft to AP. */
}
} else if (base == IP_EVENT && id == IP_EVENT_STA_GOT_IP) {
ip_event_got_ip_t *e = (ip_event_got_ip_t *)data;
ESP_LOGI(TAG, "STA got IP: " IPSTR, IP2STR(&e->ip_info.ip));
s_net.sta_retry = 0;
+ s_net.state = NETMGR_STATE_STA_CONNECTED;
xEventGroupSetBits(s_net.evt, EVT_GOT_IP);
+ /* AUTO/STA_ONLY: power down the AP now that STA is up. ALWAYS: keep it. */
+ apply_ap_state(ap_should_be_on());
} else if (base == WIFI_EVENT && id == WIFI_EVENT_AP_STACONNECTED) {
wifi_event_ap_staconnected_t *e = (wifi_event_ap_staconnected_t *)data;
ESP_LOGI(TAG, "AP client joined: " MACSTR, MAC2STR(e->mac));
@@ -251,17 +308,27 @@ esp_err_t netmgr_init(void) {
ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &on_wifi_event, NULL));
ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, IP_EVENT_STA_GOT_IP, &on_wifi_event, NULL));
- /* AP+STA always-on. AP serves the web UI for direct connect (no router
- * needed), and ESP-NOW peers find each other on the AP channel. STA is
- * additive: if creds are saved, we also join the user's router. */
- ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_APSTA));
- configure_ap();
+ /* Read AP-mode policy from NVS; default AUTO (AP only when STA is
+ * down, or always when no STA configured). */
+ uint8_t apmode = NETMGR_AP_AUTO;
+ settings_get_u8("wifi", "ap_mode", &apmode);
+ if (apmode > NETMGR_AP_STA_ONLY) apmode = NETMGR_AP_AUTO;
+ s_net.ap_mode = (netmgr_ap_mode_t)apmode;
char ssid[33] = {0}, pass[65] = {0};
settings_get_wifi_ssid(ssid, sizeof(ssid));
settings_get_wifi_pass(pass, sizeof(pass));
- bool have_sta_creds = ssid[0] != 0;
- if (have_sta_creds) {
+ s_net.sta_configured = ssid[0] != 0;
+
+ /* Decide initial mode. If we have STA creds, start in APSTA so STA
+ * can come up while AP is reachable; the AP will be torn down by
+ * the IP_EVENT_STA_GOT_IP handler if policy allows. If no creds,
+ * AP-only is the right answer. */
+ bool ap_at_boot = ap_should_be_on();
+ ESP_ERROR_CHECK(esp_wifi_set_mode(ap_at_boot ? WIFI_MODE_APSTA : WIFI_MODE_STA));
+ if (ap_at_boot) configure_ap();
+
+ if (s_net.sta_configured) {
configure_sta(ssid, pass);
s_net.sta_retry = 0;
xEventGroupClearBits(s_net.evt, EVT_GOT_IP | EVT_FAIL);
@@ -271,29 +338,64 @@ esp_err_t netmgr_init(void) {
}
ESP_ERROR_CHECK(esp_wifi_start());
- ESP_LOGI(TAG, "Wi-Fi up: AP%s%s", have_sta_creds ? "+STA (joining " : " only", have_sta_creds ? ssid : "");
- if (have_sta_creds) ESP_LOGI(TAG, "STA: trying %s", ssid);
-
- /* Captive DNS responder runs on AP interface β always up so phones
- * connecting to AmbiSense-XXXX get auto-popped to the setup page,
- * regardless of STA state. */
- start_captive_dns();
+ s_net.ap_active = ap_at_boot;
+ ESP_LOGI(TAG, "Wi-Fi up: ap=%s sta=%s host=%s mode=%s",
+ ap_at_boot ? "yes" : "no",
+ s_net.sta_configured ? ssid : "(none)",
+ s_net.hostname,
+ s_net.ap_mode == NETMGR_AP_ALWAYS ? "always" :
+ s_net.ap_mode == NETMGR_AP_STA_ONLY ? "sta_only" : "auto");
+
+ if (ap_at_boot) start_captive_dns();
bring_up_mdns();
- /* Wait briefly for STA to either connect or fail; either way, the AP
- * remains available so this never blocks the device from being usable. */
- if (have_sta_creds) {
+ /* If we tried STA, wait briefly so callers see a settled state. */
+ if (s_net.sta_configured) {
EventBits_t bits = xEventGroupWaitBits(
s_net.evt, EVT_GOT_IP | EVT_FAIL, pdFALSE, pdFALSE,
pdMS_TO_TICKS(15000));
- if (bits & EVT_GOT_IP) notify_state(NETMGR_STATE_STA_CONNECTED);
- else notify_state(NETMGR_STATE_AP_FALLBACK);
+ if (bits & EVT_GOT_IP) {
+ /* event handler already adjusted AP state per policy */
+ } else {
+ notify_state(NETMGR_STATE_AP_FALLBACK);
+ apply_ap_state(true); /* AP is the user's only way back in */
+ }
}
s_net.inited = true;
return ESP_OK;
}
+netmgr_ap_mode_t netmgr_get_ap_mode(void) {
+ return s_net.ap_mode;
+}
+
+esp_err_t netmgr_set_ap_mode(netmgr_ap_mode_t mode) {
+ if (mode > NETMGR_AP_STA_ONLY) return ESP_ERR_INVALID_ARG;
+ s_net.ap_mode = mode;
+ settings_set_u8("wifi", "ap_mode", (uint8_t)mode);
+ /* Apply immediately so the user sees the effect without a reboot. */
+ apply_ap_state(ap_should_be_on());
+ ESP_LOGI(TAG, "AP mode set to %s", mode == NETMGR_AP_ALWAYS ? "always" :
+ mode == NETMGR_AP_STA_ONLY ? "sta_only" : "auto");
+ return ESP_OK;
+}
+
+bool netmgr_is_ap_active(void) {
+ return s_net.ap_active;
+}
+
+esp_err_t netmgr_set_ap_password(const char *pass) {
+ if (!pass) pass = "";
+ settings_set_str("wifi", "ap_pass", pass);
+ /* If AP is currently up, re-apply config so new password takes effect. */
+ if (s_net.ap_active) {
+ configure_ap();
+ ESP_LOGI(TAG, "AP password updated; re-applied to running AP");
+ }
+ return ESP_OK;
+}
+
esp_err_t netmgr_set_credentials(const char *ssid, const char *pass) {
if (!ssid || !ssid[0]) {
settings_set_wifi_ssid("");
diff --git a/firmware/components/radar/CMakeLists.txt b/firmware/components/radar/CMakeLists.txt
new file mode 100644
index 0000000..d876f84
--- /dev/null
+++ b/firmware/components/radar/CMakeLists.txt
@@ -0,0 +1,5 @@
+idf_component_register(
+ SRCS "radar.c" "radar_ld2410.c" "radar_ld2450.c" "radar_sim.c"
+ INCLUDE_DIRS "include"
+ REQUIRES settings driver esp_timer log freertos
+)
diff --git a/firmware/components/radar/include/radar.h b/firmware/components/radar/include/radar.h
new file mode 100644
index 0000000..f3da0c5
--- /dev/null
+++ b/firmware/components/radar/include/radar.h
@@ -0,0 +1,64 @@
+#pragma once
+
+/*
+ * AmbiSense v6 β radar abstraction.
+ *
+ * One unified frame type, one registry, one driver instantiated at boot
+ * based on the NVS `board.radar_kind` key. Drivers are linked into the
+ * binary unconditionally; selection is runtime so users can swap sensors
+ * via the web UI without reflashing.
+ *
+ * v6.0 drivers:
+ * ld2410 β HiLink LD2410(B/C), 1 target, distance + energy
+ * ld2412 β HiLink LD2412 (alias of ld2410 for now; same protocol family)
+ * ld2420 β HiLink LD2420 presence (alias of ld2410)
+ * ld2450 β HiLink LD2450, up to 3 targets with x/y/speed
+ * sim β synthetic trace generator for desk testing
+ */
+
+#include
+#include
+#include "esp_err.h"
+#include "freertos/FreeRTOS.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#define RADAR_MAX_TARGETS 3
+
+typedef struct {
+ bool present; /* primary target present */
+ int16_t distance_cm; /* primary target distance, 0 if absent */
+ int8_t direction; /* -1 closer, 0 still, +1 away */
+ uint8_t energy; /* signal strength 0..100 (where exposed) */
+ uint8_t target_count; /* 1 for ld2410-family, 0..3 for ld2450 */
+ struct {
+ int16_t x_cm; /* lateral; left negative, right positive */
+ int16_t y_cm; /* radial */
+ int16_t v_cms; /* speed cm/s; +ve = away from sensor */
+ uint16_t resolution_mm; /* LD2450 reports per-target resolution */
+ } targets[RADAR_MAX_TARGETS];
+ uint64_t ts_us;
+} radar_frame_t;
+
+typedef struct {
+ uint8_t uart_num;
+ uint8_t rx_pin;
+ uint8_t tx_pin;
+ uint32_t baud; /* 256000 for LD-family */
+} radar_config_t;
+
+/* Set up the active driver from NVS-selected kind. Spawns a task that
+ * continuously parses radar frames and pushes them to an internal queue. */
+esp_err_t radar_init(const radar_config_t *cfg);
+
+/* Block until a frame is available or the timeout expires. */
+esp_err_t radar_read(radar_frame_t *out, TickType_t timeout);
+
+/* For the simulator driver β replay a scripted trace. */
+esp_err_t radar_sim_push_trace(const int16_t *distances_cm, size_t n, uint32_t period_ms);
+
+#ifdef __cplusplus
+}
+#endif
diff --git a/firmware/components/radar/radar.c b/firmware/components/radar/radar.c
new file mode 100644
index 0000000..cfb7927
--- /dev/null
+++ b/firmware/components/radar/radar.c
@@ -0,0 +1,118 @@
+#include "radar.h"
+
+#include
+
+#include "esp_log.h"
+#include "driver/uart.h"
+#include "freertos/FreeRTOS.h"
+#include "freertos/task.h"
+#include "freertos/queue.h"
+
+#include "settings.h"
+
+static const char *TAG = "radar";
+
+/* Driver function table β each driver implements parse(buf, len) β frame. */
+typedef struct {
+ const char *id;
+ /* Read up to len bytes from UART; on a complete frame, fill out and
+ * return number of bytes consumed (>= 1). On partial frame return 0. */
+ size_t (*parse)(const uint8_t *buf, size_t len, radar_frame_t *out);
+} radar_driver_t;
+
+/* Forward decls β drivers live in radar_.c */
+extern size_t radar_ld2410_parse(const uint8_t *buf, size_t len, radar_frame_t *out);
+extern size_t radar_ld2450_parse(const uint8_t *buf, size_t len, radar_frame_t *out);
+extern size_t radar_sim_parse (const uint8_t *buf, size_t len, radar_frame_t *out);
+
+static const radar_driver_t k_drivers[] = {
+ { "ld2410", radar_ld2410_parse },
+ { "ld2412", radar_ld2410_parse }, /* same family/protocol */
+ { "ld2420", radar_ld2410_parse }, /* presence subset */
+ { "ld2450", radar_ld2450_parse },
+ { "sim", radar_sim_parse },
+};
+
+static struct {
+ const radar_driver_t *drv;
+ QueueHandle_t q;
+ radar_config_t cfg;
+ bool inited;
+} s_radar;
+
+static const radar_driver_t *find_driver(const char *id) {
+ for (size_t i = 0; i < sizeof(k_drivers)/sizeof(k_drivers[0]); ++i) {
+ if (strcmp(k_drivers[i].id, id) == 0) return &k_drivers[i];
+ }
+ return NULL;
+}
+
+static void radar_task(void *arg) {
+ (void)arg;
+ static uint8_t rx[512];
+ size_t held = 0;
+ while (1) {
+ if (held >= sizeof(rx)) {
+ /* Buffer wedged β discard half to keep parsing forward-progress. */
+ memmove(rx, rx + sizeof(rx)/2, sizeof(rx)/2);
+ held = sizeof(rx)/2;
+ }
+ int n = uart_read_bytes(s_radar.cfg.uart_num, rx + held,
+ sizeof(rx) - held, pdMS_TO_TICKS(50));
+ if (n > 0) held += n;
+
+ radar_frame_t frame = {0};
+ size_t consumed = s_radar.drv->parse(rx, held, &frame);
+ if (consumed > 0) {
+ xQueueOverwrite(s_radar.q, &frame);
+ if (consumed < held) memmove(rx, rx + consumed, held - consumed);
+ held -= consumed;
+ }
+ }
+}
+
+esp_err_t radar_init(const radar_config_t *cfg) {
+ if (s_radar.inited) return ESP_OK;
+
+ char kind[16] = {0};
+ if (settings_get_radar_kind(kind, sizeof(kind)) != ESP_OK || !kind[0]) {
+ snprintf(kind, sizeof(kind), "ld2450"); /* user has this on bench */
+ }
+ s_radar.drv = find_driver(kind);
+ if (!s_radar.drv) {
+ ESP_LOGE(TAG, "Unknown radar kind '%s'", kind);
+ return ESP_ERR_INVALID_ARG;
+ }
+ ESP_LOGI(TAG, "Radar driver: %s (uart%u, rx=%u, tx=%u, baud=%lu)",
+ s_radar.drv->id, cfg->uart_num, cfg->rx_pin, cfg->tx_pin,
+ (unsigned long)cfg->baud);
+
+ s_radar.cfg = *cfg;
+ s_radar.q = xQueueCreate(1, sizeof(radar_frame_t));
+
+ /* The simulator driver doesn't need UART at all. */
+ if (strcmp(s_radar.drv->id, "sim") != 0) {
+ const uart_config_t uc = {
+ .baud_rate = cfg->baud ? cfg->baud : 256000,
+ .data_bits = UART_DATA_8_BITS,
+ .parity = UART_PARITY_DISABLE,
+ .stop_bits = UART_STOP_BITS_1,
+ .flow_ctrl = UART_HW_FLOWCTRL_DISABLE,
+ .source_clk = UART_SCLK_DEFAULT,
+ };
+ ESP_ERROR_CHECK(uart_driver_install(cfg->uart_num, 1024, 0, 0, NULL, 0));
+ ESP_ERROR_CHECK(uart_param_config(cfg->uart_num, &uc));
+ ESP_ERROR_CHECK(uart_set_pin(cfg->uart_num, cfg->tx_pin, cfg->rx_pin,
+ UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE));
+ }
+
+ xTaskCreate(radar_task, "radar", 4096, NULL, 6, NULL);
+ s_radar.inited = true;
+ return ESP_OK;
+}
+
+esp_err_t radar_read(radar_frame_t *out, TickType_t timeout) {
+ if (!s_radar.inited) return ESP_ERR_INVALID_STATE;
+ if (xQueueReceive(s_radar.q, out, timeout) != pdTRUE) return ESP_ERR_TIMEOUT;
+ return ESP_OK;
+}
diff --git a/firmware/components/radar/radar_ld2410.c b/firmware/components/radar/radar_ld2410.c
new file mode 100644
index 0000000..a6ff815
--- /dev/null
+++ b/firmware/components/radar/radar_ld2410.c
@@ -0,0 +1,81 @@
+/*
+ * LD2410 / LD2412 / LD2420 frame parser.
+ *
+ * Frame layout (data report, "basic" mode, 23 bytes):
+ * F4 F3 F2 F1 header
+ * payload length (always 0x000D for basic)
+ * 02 data type: target data
+ * AA head
+ * 0=none, 1=moving, 2=stationary, 3=both
+ * cm
+ * 0..100
+ * cm
+ * 0..100
+ * cm
+ * 55 tail
+ * 00 checksum-ish
+ * F8 F7 F6 F5 trailer
+ *
+ * The LD2412 / LD2420 stream identical layout for the basic data report.
+ * If "engineering mode" is enabled the frame is longer; we just skip it.
+ */
+
+#include
+#include "esp_timer.h"
+
+#include "radar.h"
+
+static const uint8_t HEAD[4] = { 0xF4, 0xF3, 0xF2, 0xF1 };
+static const uint8_t TAIL[4] = { 0xF8, 0xF7, 0xF6, 0xF5 };
+
+size_t radar_ld2410_parse(const uint8_t *buf, size_t len, radar_frame_t *out) {
+ /* Find header */
+ for (size_t start = 0; start + 8 < len; ++start) {
+ if (memcmp(buf + start, HEAD, 4) != 0) continue;
+ if (start + 6 > len) return 0; /* need length bytes */
+ uint16_t plen = buf[start + 4] | (buf[start + 5] << 8);
+ size_t total = 4 /*head*/ + 2 /*len*/ + plen + 4 /*tail*/;
+ if (start + total > len) return 0; /* incomplete */
+ const uint8_t *p = buf + start + 6;
+ const uint8_t *tail = buf + start + 6 + plen;
+ if (memcmp(tail, TAIL, 4) != 0) {
+ /* Header without matching tail in expected position; advance one byte
+ * past header and try again on next loop iteration. */
+ return start + 1;
+ }
+ /* Validate the basic-mode payload (type 02, head AA, body len 13). */
+ if (plen >= 13 && p[0] == 0x02 && p[1] == 0xAA) {
+ uint8_t state = p[2];
+ int16_t mov_dist = (int16_t)(p[3] | (p[4] << 8));
+ uint8_t mov_e = p[5];
+ int16_t sta_dist = (int16_t)(p[6] | (p[7] << 8));
+ uint8_t sta_e = p[8];
+
+ int16_t dist = 0;
+ uint8_t energy = 0;
+ if (state & 0x01) { /* moving target */
+ dist = mov_dist;
+ energy = mov_e;
+ } else if (state & 0x02) { /* stationary fallback */
+ dist = sta_dist;
+ energy = sta_e;
+ }
+
+ out->present = (state != 0) && dist > 0;
+ out->distance_cm = dist;
+ out->energy = energy;
+ out->target_count = out->present ? 1 : 0;
+
+ /* Naive direction = sign of distance change vs last frame. */
+ static int16_t s_last = 0;
+ int16_t delta = dist - s_last;
+ out->direction = delta < -3 ? -1 : (delta > 3 ? 1 : 0);
+ s_last = dist;
+
+ out->ts_us = (uint64_t)esp_timer_get_time();
+ }
+ return start + total;
+ }
+ /* No header in buffer (or only at very end) β keep last 3 bytes. */
+ return len > 3 ? len - 3 : 0;
+}
diff --git a/firmware/components/radar/radar_ld2450.c b/firmware/components/radar/radar_ld2450.c
new file mode 100644
index 0000000..ed94915
--- /dev/null
+++ b/firmware/components/radar/radar_ld2450.c
@@ -0,0 +1,93 @@
+/*
+ * LD2450 frame parser. 30-byte fixed frames at 256000 baud:
+ *
+ * AA FF 03 00 header
+ * T1: int16 x_mm, int16 y_mm, int16 v_cms, uint16 res_mm (8 B)
+ * T2: same (8 B)
+ * T3: same (8 B)
+ * 55 CC tail
+ *
+ * Sign convention is unusual: bit 15 indicates sign, but with the
+ * inverse meaning of two's complement. For each 16-bit field:
+ * if bit15 == 0 β value = -(raw & 0x7FFF)
+ * if bit15 == 1 β value = +(raw & 0x7FFF)
+ * So the top bit is "1 for positive, 0 for negative" β opposite of usual.
+ *
+ * Targets with all fields == 0 are absent. Up to 3 targets per frame.
+ */
+
+#include
+#include "esp_timer.h"
+
+#include "radar.h"
+
+static const uint8_t HEAD[4] = { 0xAA, 0xFF, 0x03, 0x00 };
+static const uint8_t TAIL[2] = { 0x55, 0xCC };
+#define LD2450_FRAME_LEN 30
+
+static int16_t decode_signed(uint16_t raw) {
+ int16_t v = (int16_t)(raw & 0x7FFF);
+ return (raw & 0x8000) ? v : -v;
+}
+
+size_t radar_ld2450_parse(const uint8_t *buf, size_t len, radar_frame_t *out) {
+ for (size_t start = 0; start + LD2450_FRAME_LEN <= len; ++start) {
+ if (memcmp(buf + start, HEAD, 4) != 0) continue;
+ const uint8_t *t = buf + start + LD2450_FRAME_LEN - 2;
+ if (t[0] != TAIL[0] || t[1] != TAIL[1]) {
+ return start + 1; /* false positive on header β skip 1 */
+ }
+ const uint8_t *p = buf + start + 4;
+ uint8_t tcount = 0;
+ int16_t primary_distance = 0;
+ for (int i = 0; i < RADAR_MAX_TARGETS; ++i) {
+ uint16_t xr = p[0] | (p[1] << 8);
+ uint16_t yr = p[2] | (p[3] << 8);
+ uint16_t vr = p[4] | (p[5] << 8);
+ uint16_t rr = p[6] | (p[7] << 8);
+ p += 8;
+ if (xr == 0 && yr == 0 && vr == 0 && rr == 0) {
+ out->targets[i].x_cm = 0;
+ out->targets[i].y_cm = 0;
+ out->targets[i].v_cms = 0;
+ out->targets[i].resolution_mm = 0;
+ continue;
+ }
+ int16_t x_mm = decode_signed(xr);
+ int16_t y_mm = decode_signed(yr);
+ int16_t v_cs = decode_signed(vr);
+ out->targets[i].x_cm = x_mm / 10;
+ out->targets[i].y_cm = y_mm / 10;
+ out->targets[i].v_cms = v_cs;
+ out->targets[i].resolution_mm = rr;
+ tcount++;
+ if (tcount == 1) {
+ /* Primary distance = euclidean from origin in cm. */
+ int32_t dx = (int32_t)(x_mm / 10);
+ int32_t dy = (int32_t)(y_mm / 10);
+ int32_t d2 = dx*dx + dy*dy;
+ /* Integer sqrt β fine for radar distance precision. */
+ int32_t r = 0; int32_t b = 1L << 14;
+ while (b > d2) b >>= 2;
+ while (b > 0) {
+ if (d2 >= r + b) { d2 -= r + b; r = (r >> 1) + b; }
+ else r >>= 1;
+ b >>= 2;
+ }
+ primary_distance = (int16_t)r;
+ }
+ }
+ out->present = tcount > 0;
+ out->distance_cm = primary_distance;
+ out->target_count = tcount;
+ out->energy = 0; /* LD2450 doesn't report energy; left zero. */
+
+ static int16_t s_last = 0;
+ int16_t delta = primary_distance - s_last;
+ out->direction = delta < -3 ? -1 : (delta > 3 ? 1 : 0);
+ s_last = primary_distance;
+ out->ts_us = (uint64_t)esp_timer_get_time();
+ return start + LD2450_FRAME_LEN;
+ }
+ return len > 3 ? len - 3 : 0;
+}
diff --git a/firmware/components/radar/radar_sim.c b/firmware/components/radar/radar_sim.c
new file mode 100644
index 0000000..da77f31
--- /dev/null
+++ b/firmware/components/radar/radar_sim.c
@@ -0,0 +1,77 @@
+/*
+ * Synthetic radar driver β emits scripted distance traces or a default
+ * walk-up/walk-away sine wave. Lets us iterate on LED modes and mesh
+ * fusion without standing in front of a real radar all day.
+ *
+ * Configure via POST /api/sim/trace with a JSON array of cm distances.
+ * Until configured, uses a 4-second cycle 30β200 cm sine wave.
+ */
+
+#include
+#include
+
+#include "esp_log.h"
+#include "esp_timer.h"
+#include "freertos/FreeRTOS.h"
+#include "freertos/task.h"
+
+#include "radar.h"
+
+static const char *TAG = "radar_sim";
+
+#define MAX_TRACE 256
+
+static struct {
+ int16_t trace[MAX_TRACE];
+ size_t trace_len;
+ uint32_t period_ms;
+ int idx;
+ int16_t last;
+} s_sim;
+
+esp_err_t radar_sim_push_trace(const int16_t *distances_cm, size_t n, uint32_t period_ms) {
+ if (n > MAX_TRACE) n = MAX_TRACE;
+ s_sim.trace_len = n;
+ if (n) memcpy(s_sim.trace, distances_cm, n * sizeof(int16_t));
+ s_sim.period_ms = period_ms ? period_ms : 100;
+ s_sim.idx = 0;
+ ESP_LOGI(TAG, "Trace loaded: %u points @ %lu ms", (unsigned)n, (unsigned long)s_sim.period_ms);
+ return ESP_OK;
+}
+
+/* The simulator never reads from UART; instead, the parser fakes a frame
+ * every time it's called. radar_task busy-loops on uart_read_bytes which
+ * returns 0 for sim β we add a small delay on each call. */
+size_t radar_sim_parse(const uint8_t *buf, size_t len, radar_frame_t *out) {
+ (void)buf; (void)len;
+ static uint64_t last_us = 0;
+ uint64_t now = (uint64_t)esp_timer_get_time();
+ uint32_t step_ms = s_sim.period_ms ? s_sim.period_ms : 100;
+ if (last_us != 0 && (now - last_us) < (step_ms * 1000ULL)) {
+ return 0;
+ }
+ last_us = now;
+
+ int16_t d;
+ if (s_sim.trace_len > 0) {
+ d = s_sim.trace[s_sim.idx];
+ s_sim.idx = (s_sim.idx + 1) % s_sim.trace_len;
+ } else {
+ /* Default: 4 s cycle, 30..200 cm. */
+ double t = (double)(now / 1000ULL) / 4000.0 * 2.0 * 3.14159265;
+ d = (int16_t)(115.0 + 85.0 * sin(t));
+ }
+
+ out->present = true;
+ out->distance_cm = d;
+ out->target_count = 1;
+ out->energy = 80;
+ out->direction = d < s_sim.last - 2 ? -1 : (d > s_sim.last + 2 ? 1 : 0);
+ s_sim.last = d;
+ out->targets[0].x_cm = 0;
+ out->targets[0].y_cm = d;
+ out->targets[0].v_cms = 0;
+ out->targets[0].resolution_mm = 100;
+ out->ts_us = now;
+ return 1; /* "consumed" any leftover bytes; radar_task moves on. */
+}
diff --git a/firmware/components/webui/CMakeLists.txt b/firmware/components/webui/CMakeLists.txt
index 0e5980d..e865371 100644
--- a/firmware/components/webui/CMakeLists.txt
+++ b/firmware/components/webui/CMakeLists.txt
@@ -1,7 +1,7 @@
idf_component_register(
SRCS "webui.c"
INCLUDE_DIRS "include"
- REQUIRES settings auth netmgr ota board
+ REQUIRES settings auth netmgr ota board led_engine radar
esp_http_server esp_wifi esp_app_format esp_timer
json log freertos esp_system
)
diff --git a/firmware/components/webui/webui.c b/firmware/components/webui/webui.c
index 986d1f6..1d46d4b 100644
--- a/firmware/components/webui/webui.c
+++ b/firmware/components/webui/webui.c
@@ -23,6 +23,8 @@
#include "netmgr.h"
#include "ota.h"
#include "board.h"
+#include "led_engine.h"
+#include "radar.h"
static const char *TAG = "webui";
@@ -290,17 +292,59 @@ static void wifi_apply_task(void *arg) {
vTaskDelete(NULL);
}
+static esp_err_t handle_wifi_get(httpd_req_t *req) {
+ cJSON *r = cJSON_CreateObject();
+ char ssid[33] = {0};
+ settings_get_wifi_ssid(ssid, sizeof(ssid));
+ cJSON_AddStringToObject(r, "ssid", ssid);
+ cJSON_AddBoolToObject (r, "sta_configured", ssid[0] != 0);
+ cJSON_AddBoolToObject (r, "sta_connected", netmgr_is_sta_connected());
+ cJSON_AddBoolToObject (r, "ap_active", netmgr_is_ap_active());
+ netmgr_ap_mode_t m = netmgr_get_ap_mode();
+ cJSON_AddStringToObject(r, "ap_mode",
+ m == NETMGR_AP_ALWAYS ? "always" :
+ m == NETMGR_AP_STA_ONLY ? "sta_only" : "auto");
+ cJSON_AddNumberToObject(r, "rssi", netmgr_get_rssi());
+ char ip[32] = {0}, host[33] = {0};
+ netmgr_get_ip(ip, sizeof(ip));
+ netmgr_get_hostname(host, sizeof(host));
+ cJSON_AddStringToObject(r, "ip", ip);
+ cJSON_AddStringToObject(r, "hostname", host);
+ return send_json(req, r);
+}
+
static esp_err_t handle_wifi_post(httpd_req_t *req) {
if (!gate_auth(req)) return ESP_OK;
cJSON *j = read_body_json(req);
if (!j) return send_err(req, 400, "bad json");
- cJSON *ssid = cJSON_GetObjectItem(j, "ssid");
- cJSON *pass = cJSON_GetObjectItem(j, "pass");
+ /* Optional sub-settings β applied independently of ssid change. */
cJSON *host = cJSON_GetObjectItem(j, "hostname");
if (host && cJSON_IsString(host) && host->valuestring[0]) {
netmgr_set_hostname(host->valuestring);
}
+ cJSON *apmode = cJSON_GetObjectItem(j, "ap_mode");
+ if (apmode && cJSON_IsString(apmode)) {
+ netmgr_ap_mode_t m = NETMGR_AP_AUTO;
+ if (strcmp(apmode->valuestring, "always") == 0) m = NETMGR_AP_ALWAYS;
+ else if (strcmp(apmode->valuestring, "sta_only") == 0) m = NETMGR_AP_STA_ONLY;
+ netmgr_set_ap_mode(m);
+ }
+ cJSON *appass = cJSON_GetObjectItem(j, "ap_password");
+ if (appass && cJSON_IsString(appass)) {
+ netmgr_set_ap_password(appass->valuestring);
+ }
+ cJSON *clear = cJSON_GetObjectItem(j, "forget_sta");
+ if (clear && cJSON_IsTrue(clear)) {
+ netmgr_set_credentials(NULL, NULL);
+ cJSON_Delete(j);
+ cJSON *r = cJSON_CreateObject();
+ cJSON_AddStringToObject(r, "status", "STA cleared; AP up for re-setup");
+ return send_json(req, r);
+ }
+
+ cJSON *ssid = cJSON_GetObjectItem(j, "ssid");
+ cJSON *pass = cJSON_GetObjectItem(j, "pass");
if (ssid && cJSON_IsString(ssid) && ssid->valuestring[0]) {
const char *p = (pass && cJSON_IsString(pass)) ? pass->valuestring : "";
@@ -317,8 +361,12 @@ static esp_err_t handle_wifi_post(httpd_req_t *req) {
xTaskCreate(wifi_apply_task, "wifi_apply", 4096, a, 4, NULL);
return ESP_OK;
}
+ /* No ssid in request β caller is just updating sub-settings (ap_mode,
+ * ap_password, hostname). That's a valid no-op for the STA side. */
cJSON_Delete(j);
- return send_err(req, 400, "ssid required");
+ cJSON *r = cJSON_CreateObject();
+ cJSON_AddStringToObject(r, "status", "ok");
+ return send_json(req, r);
}
/* ============================================================
@@ -599,6 +647,8 @@ static esp_err_t handle_settings_post(httpd_req_t *req) {
}
}
cJSON_Delete(j);
+ /* Push LED-engine settings live without reboot. */
+ led_engine_reload();
cJSON *r = cJSON_CreateObject();
cJSON_AddBoolToObject(r, "ok", true);
cJSON_AddNumberToObject(r, "updated", updated);
@@ -719,6 +769,7 @@ static const httpd_uri_t k_routes[] = {
/* API */
{ "/api/version", HTTP_GET, handle_version, NULL },
{ "/api/wifi/scan", HTTP_GET, handle_wifi_scan, NULL },
+ { "/api/wifi", HTTP_GET, handle_wifi_get, NULL },
{ "/api/wifi", HTTP_POST, handle_wifi_post, NULL },
{ "/api/auth/login", HTTP_POST, handle_login, NULL },
{ "/api/auth/logout", HTTP_POST, handle_logout, NULL },
diff --git a/firmware/main/CMakeLists.txt b/firmware/main/CMakeLists.txt
index b55bbed..2d26e1a 100644
--- a/firmware/main/CMakeLists.txt
+++ b/firmware/main/CMakeLists.txt
@@ -2,5 +2,6 @@ idf_component_register(
SRCS "main.c"
INCLUDE_DIRS "."
REQUIRES board settings status_led netmgr auth webui ota
+ radar motion led_engine
app_update esp_system log
)
diff --git a/firmware/main/main.c b/firmware/main/main.c
index 7012065..d07d890 100644
--- a/firmware/main/main.c
+++ b/firmware/main/main.c
@@ -27,6 +27,9 @@
#include "auth.h"
#include "webui.h"
#include "ota.h"
+#include "radar.h"
+#include "motion.h"
+#include "led_engine.h"
static const char *TAG = "ambisense";
@@ -75,6 +78,24 @@ static void apply_pin_overrides(board_profile_t *runtime) {
}
}
+/* Telemetry pump: 5 Hz publish smoothed target + RSSI to webui WS clients. */
+static void telemetry_pump_task(void *arg) {
+ (void)arg;
+ while (1) {
+ target_t t;
+ motion_get(&t);
+ webui_live_t live = {
+ .distance_cm = t.distance_cm,
+ .direction = t.direction,
+ .rssi = netmgr_get_rssi(),
+ .free_heap = 0, /* webui fills these in itself before broadcast */
+ .uptime_s = 0,
+ };
+ webui_publish_live(&live);
+ vTaskDelay(pdMS_TO_TICKS(200));
+ }
+}
+
static void log_chip_info(void) {
esp_chip_info_t chip;
esp_chip_info(&chip);
@@ -114,6 +135,23 @@ void app_main(void) {
netmgr_init();
webui_init();
+ /* Radar + motion smoother + LED engine. The render task pulls smoothed
+ * targets from motion_q and drives the strip at 60 Hz; the radar task
+ * parses UART frames; the motion task runs the PI smoother in between. */
+ radar_config_t rcfg = {
+ .uart_num = runtime.uart_num,
+ .rx_pin = runtime.radar_rx_pin,
+ .tx_pin = runtime.radar_tx_pin,
+ .baud = 256000,
+ };
+ if (radar_init(&rcfg) != ESP_OK) ESP_LOGW(TAG, "radar_init failed (continuing)");
+ motion_init();
+ if (led_engine_init(runtime.led_pin) != ESP_OK) {
+ ESP_LOGE(TAG, "led_engine_init on GPIO %u failed", runtime.led_pin);
+ }
+
+ xTaskCreate(telemetry_pump_task, "tele_pump", 3072, NULL, 3, NULL);
+
/* If we're running on a freshly-flashed image with rollback armed, mark
* us valid so the bootloader doesn't revert on next reset. */
ota_mark_valid();
From 25a638d1adbf5536fc2250e38a22b214cc48c5e7 Mon Sep 17 00:00:00 2001
From: Ravi Singh
Date: Tue, 5 May 2026 21:01:35 +0530
Subject: [PATCH 05/17] =?UTF-8?q?feat(v6):=20PR=20#5=20=E2=80=94=20full=20?=
=?UTF-8?q?Preact=20UI=20from=20design=20source,=20embedded=20as=20gzipped?=
=?UTF-8?q?=20binary?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Replaces the PR #2 placeholder HTML with the full 7-screen dashboard
built from the Claude Design handoff. Single-file Preact bundle
(48 KB raw, 16.9 KB gzipped) embedded via EMBED_FILES; served with
Content-Encoding: gzip β no LittleFS needed.
Frontend (frontend/):
- Vite 5 + Preact 10 + TypeScript + vite-plugin-singlefile + terser.
- src/styles.css lifts design-source/project/tokens.css colors,
spacing, type, atoms verbatim. Layout adds sidebar/main on desktop,
bottom tab bar on mobile (β€760px), light/dark theme toggle, prefers-
reduced-motion respect.
- src/components.tsx β Card, Toggle, Field, Slider, Row, Dot,
ColorPicker (8 presets + native picker), useToaster.
- src/led_preview.tsx β canvas LED strip preview mirrors the firmware's
mode logic; on-screen animation matches the wire output. All 11 modes
rendered live (standard, rainbow, color_wave, breathing, solid,
comet, pulse, fire, theater_chase, dual_scan, motion_particles).
- src/api.ts β fetch JSON, octet-stream OTA upload with progress,
WebSocket /api/live with auto-reconnect backoff.
- src/screens.tsx β all 7 screens:
Live β distance meter + LED preview + device + mesh cards
LEDs β mode picker with animated thumbnails, color picker,
brightness/count/distance window/effects/trail/dir
Motion β PI smoother sliders (live x1k fixed-point readout)
Mesh β topology picker (PR #4 wires fully)
Hardware β board profile dropdown, radar driver picker, per-pin
overrides with unsafe-pin-mask filtering
Network β STA scan + connect + AP behaviour (auto/always/
sta_only) + AP password + forget-STA + status badges
System β auth password, OTA upload (drag-drop), diagnostics
- Vite dev proxies /api β http://192.168.4.1 for in-browser dev.
Firmware:
- components/webui/ui.html.gz embedded via EMBED_FILES; handle_root
serves with Content-Encoding: gzip + max-age=300 cache header.
- Linker symbols _binary_ui_html_gz_start/end resolved in webui's own
translation unit (moved out of main).
- The PR #2 inline HTML kept under #if 0 for reference.
Build: 1.05 MB binary, 27%% free. UI gzipped 16.9 KB. Flashed to both
C3s (ports 21101 and 2122201).
Refs v6 epic. Next: PR #4 (peer mesh) β Mesh screen gets real peer
cards, topology editor, pairing flow.
---
firmware/components/webui/CMakeLists.txt | 1 +
firmware/components/webui/ui.html.gz | Bin 0 -> 16908 bytes
firmware/components/webui/webui.c | 26 +-
frontend/.gitignore | 4 +
frontend/index.html | 13 +
frontend/package-lock.json | 2168 ++++++++++++++++++++++
frontend/package.json | 20 +
frontend/src/api.ts | 67 +
frontend/src/components.tsx | 130 ++
frontend/src/led_preview.tsx | 192 ++
frontend/src/main.tsx | 79 +
frontend/src/screens.tsx | 553 ++++++
frontend/src/styles.css | 210 +++
frontend/tsconfig.json | 17 +
frontend/vite.config.ts | 24 +
15 files changed, 3497 insertions(+), 7 deletions(-)
create mode 100644 firmware/components/webui/ui.html.gz
create mode 100644 frontend/.gitignore
create mode 100644 frontend/index.html
create mode 100644 frontend/package-lock.json
create mode 100644 frontend/package.json
create mode 100644 frontend/src/api.ts
create mode 100644 frontend/src/components.tsx
create mode 100644 frontend/src/led_preview.tsx
create mode 100644 frontend/src/main.tsx
create mode 100644 frontend/src/screens.tsx
create mode 100644 frontend/src/styles.css
create mode 100644 frontend/tsconfig.json
create mode 100644 frontend/vite.config.ts
diff --git a/firmware/components/webui/CMakeLists.txt b/firmware/components/webui/CMakeLists.txt
index e865371..d17122c 100644
--- a/firmware/components/webui/CMakeLists.txt
+++ b/firmware/components/webui/CMakeLists.txt
@@ -4,4 +4,5 @@ idf_component_register(
REQUIRES settings auth netmgr ota board led_engine radar
esp_http_server esp_wifi esp_app_format esp_timer
json log freertos esp_system
+ EMBED_FILES "ui.html.gz"
)
diff --git a/firmware/components/webui/ui.html.gz b/firmware/components/webui/ui.html.gz
new file mode 100644
index 0000000000000000000000000000000000000000..496ba84b97775b2fc9fd22e7b782548874b652ef
GIT binary patch
literal 16908
zcmV(!K;^$5iwFo>4EkvR18Ht#Wq2-VbZu+^#eH3K+e(($`}_(BbFVI7H6fDvxH4#%
zYfJXnGyc$8_T1T3DKZE)NmwAj0YH6Rl2x0RRJJNlTU(V)Dpko#_9^dqNhMYJDf0{R
z6LQY!ZlD2zvV3Qfy)zbpMx*=m>GR#E8&9@|Ns!;oxG~A6(bHc&!5<^?&P^A^@p*KFJFiJW|pM6
zX#`1}^B5}Mgu{H|4f%B#@D|;$Fb?z3k6KyaN8IZ$^;T;X=3bCo^R%&X0!+FG^xirbLBn`)5
z{B&zHkApl+V#{7YH(73Q5AZjiLVr$>azE!UB93>K8HMqcX*(&8-h^4+3rE(L#kaS)
zlg(#1q)flIWOp;Dp7=x4w(Z3zNiErlk&KL@pFhrddUwVn9^^^-EQ+iju&Z-;)2p>|
z@q=yW7PosbzcJp?
zou@sq8Nxc^Y0A^nBnksjw_4=Y>)6bkXF!B0AHsNmh%+&2qM!FfKYbz+_1H7RIEdy$
zZn~x)C-L1hnScGAWE$qCTPw@}=Pf~_Cd*ei2Mk5JoR@&cI`b&Q~%BhGy3fn6FIg33_;-tQ*4AV
zxi&wKFT5Q76th)M{Q&2a*k|UaFm9y0>K#nS6a>UEk`dBa@#-CPCdX?pu)D-tAWgfR
z&C+C+xgopacW%l6Q?Ac0FR$Eq9z}4Weg|$F{&IC2hzp{wbdFr;UMJzuz)IKj%=4oD
z&Q9!N?a*y?;Aif(3o4XVwzu(O%gdeXn1&-Sti)zX3I7!iy`W!W6Bu*({8k_{pM;r1
z1M=V=Z%7c{=3ZXby%BYRhXf3{T{L`o*@vg_!|+eI)3IqNy<(Dj`4fC{kyl{K_7@%8~csQ1Lkoe{__yX5>6X=sEo(Imr&$wt=OkS2uxm5_sTx&8f=~0`v
zwYKQ#%4WwECObvwtSy=yke2idVj18Io!LACNd%W$Z=pEd*;y`A>}ALY3B1og@Oa46
zFdjn{%Ss!cESJ7*z12E=3o1BI?+O_LbnSI|p(0LOT|Lba1W@2PPB!=8dJ>Lu%ZB=@
z4m+c~+^UkQHek5grf8pI(g1U)o
z%0ISy4bxJscqMHC5OQv`T7Q8=EvUpWEBWID@C{YFiPB6<{h7jPMJR9{z%)@6%tm
z;JIttE#8Y*=J`8wD)%`345-xs$dE*EQ>z1L4m@IL@1up%BV$7wNqE?5F$=1l2RH*(
zf+At*b!}UUjAXf#7uav#YIQs>fQsqU%l^F8a?>Z5+uL)POx83}G$N=SiDvh0+YPon
zuqFW-FKczE3B25a%bgw93Z7mDcLPexX78(f|{jQ@|bmU`upo&BgC0aUp1FVzWyjn5Y(Y
z5iFNec%Y_ftmNSo+8BFNn_0IWNcsKk384UF(wvylqu#Ijz}C;K3BbhJt<7!`4giV(
zAQ_M3Yr74~ZzAVZkoK164f>fkaIxRan>sNl@w0Fcp&TSY=i6TE#{$bjgN#{L%Bg2c
ztO%h3*uweUsCI=VmI}rpaRU4V5=DtDB!v_x0BIC{1XtuRSiF-R72xD6E>u{Kgn^bb
zDs3^qt-NBsS-FP_AC+JT{#cqn=Ku&N83!E*HnCAsQ;FRhCW{=~&2^ltZa`Y_Qi&bG
z=p5AUA8}|USOs=Iu3-mh3Hm_cka+@F7LX~*gj(K021V)*XzF`~0fprSR|DH7P=f7j
zZ)bQ#*j4j{Z-q~-%YMgg+qOV?4921F#XFr|`qXQKmV1(yDy&fU2utK)jeZK84D+G5
zF_7GNPrs2wL90`u;6$LnUYIQ;hV%ACADmI1pMsc#dAPH3$pmrVG+nT7I2d-GR-_a|
zA(vuc9w+Bn4mL$UcH
gm`_xFfDY=>#FkZK8(NHU=w1katJSgG
zULh<7!jectf_fzeJMUcBzL&%70m*`lnMGhrT9?fHt^@8pn5emvCvTD)o*si)MfOO9
zeR9ySN&FK0;_UsLv}{@Mnpy_cm4U~}t*#A@pt=P64iquK7l9W?cjx&IN_Q6b!#*k_
z7(;O3T68&t3gE)OxCUlKr<=zd#?1Ww#GQj#irPW=Q=4j(lQ7BinY+7tb93X|>^Vs~
z-tD&A?cMAeCnmM^(eK`3sKR=+bSL&MGba=uk&iMA9DE0*nJF44tWsFWB)&E$JRDDQ
zbpujcUPc(I5SK7>Q+b*DgV$g^-pboyauZW1Lfodw&Dl(tMQ|4+k#?7Y#Uw9h$&7+d
zbVnq?ZL~V;>=5?`Dfh3yV-0-tyAyk{Y7n^1^AyA!yM|Ef6o#rzZre;jWKDma{6AnFbcP^qlJN%-QZ1H(^VdEbU
z6m~HoSnvetNX1{2s<(27BdPS|FFHjrfK+2snh{dmK6=pnd*z6eJ_5yhj`l!s
z!A`t+|6~87XwA9yqd9l+k?S*rF6f`4g@oV+1a4rX=0yYGg=;Re4*@^91
zdf6?Nfx)1g=I_ZyLg2g)Hv71}{n5$h18aiyvfR%>`dIRc#0d5ld%m==(50iI33}OZ
zkqzL&X3vxo#Aj%V(yu2QDxPlvF2+1R0hjKY56|$~%QTsy(kWk3ZxsdP%F`u?&9NwC
zn;U{r%gQ#pgT};MovCK&;k6gCsa-&QA5;QvMB;Ro#6Z|Tg;Detyi?%4rcz5At@ivR
z92PGD`jqLp_Pms<4}$J3aKhaNAV2Vc!#lF(Jr4z)goGC&v}+Jf6Jfimma|DRkA}yp
ziBGd3O%chk4NuiXK;s~Iu-PrqI?b$zRJ~PXCn$$WBI^inB$v|l6sk~;ga*lM
z^gzF*LQwxZBf
z*CL?^V_E@8Op1`+3DYrnd3g$Io@NYi$KH`dOW6yr&7RrK2xjlLx8H%hV{=al1T`tF
z
zZlPR&>Dii=Qv_AcVt)oyEIkl4PuB6~r#Lgv)Ens^%+cMWl_Sr)Qmi{ilf3^L96%xC
zRb0ac6Dsh&1BG~Py|Mdm-19dV4Z0Dar;uA%Z~=OLox{#1>Sd==YQ7*O371ovBtMb%
z7hEm^*}}it@)9(>e{%8(07jl*D53=5s2sLkP^Z0sysDt^s>DBP+0lsU%rCAd{K;`Q#5+*5oDyn%t1d@m%eJeGg3?sFFT^*ZAm1Isrf6ODSh4f?@zL&Es6v0aHLCRn;t4~(ZXAgHtc)QOV7kI+H~7~MS&qY}!)wimHCW!Q>{B#{JNm3E}I+;FB0
zwc4U7I%*gaux{;MykSN4-I*1EEWC9>&}O$b3usa9@^Xsd0Qj*&mOv>f4-unVY5ii9
z=61P6Z{5K)ZMw7M^=+wj6Qmk&9{nhSr^K&fk&|r&N?-_lUg2VyKx*vx3UQf^->Y*#
zul^7*O$!SbfIu?%D5g6^$KuMKdKd{~=FpGFJWb|Vba%${*D*L=uRg-0d)}}=@use+
z#0Zuwm-IG_#-s+Z4BZ_*^5;?hdn%I8g9#6=_^?{zSQd&pFM;xhB0@H0kzGP}3((yX
zbb9Cg5rT6cymPEj0M;K1@C!KlcTf;CMx>|9C87i7ZK~j!GD1*9Zu;B~mdlBZt|7e(
z>l3d;U`~`_Xh{NO(~K9lo~mR^(V~diCOsr5
zfSNLjD`sk}W(qi@m~RO^*sFJ2)UK+1r)t|P<;blpDfBflj4Dz3o8+|(!_kNjrQ^oX
z?IXq27e1mJl&ZnNqJ?U$Da8oslVYtY1y=uL
zh36^E`}xjJ@gp=-Ammd3Bjwmt)pU);DwbFQoX~0@`BwFM2)~p{XbcLK4j>8L7t)K@
z;BISRzb<0{W+EuCU@oqfAmD-)HlDWo+`Wc3`rMiNGwZeOk_n}+@qJQwke+Z;JXwZR
zy~bEGOb$kt=>q(uRFPv$aVseFxP@^H;zAhiJjGbC7t7)LYPdcCp#>J+E4Z{+ckayN
zsc^W8OBfxaD0j+7xZaSe9=5lOORV%0T(KA&uv;xEq(H5#{>oeiuzTbR24b9|DC$a}H};1U57*16E+Piwuv30iMEKhZ*n7U+rad6r2G#BbIswdMt^q
zIe3CD%*e-Z%9D8xvs;}NE&t&fC1fjCS1Tqn1HjHid)!I=c$iEr+sPBL@@*a2Eb~kt
z7IP;7n-s2myt?#utC!Uw}6N>V>0Y-W8iki5rrAGyn
z9u-o0RPtx1N3~i%Rg$9MZhTd+&ER&5!ttjY;vJMvcz4b(N?{&yZ5iM(oCYX
zr1LqKVRwd+AdRuHft(MOT
zDD0Ii^}etzM8?QgYZVnG6Os}ZkRr;YNZu0o^u6K%qG%LGz;YF9N@MR}MPv7I;Uc|j
zs6}Z_?*KM=n=Y5BFvd_?F#!(s8_9l&b70ge&&rzficOmG23p>;dt1K~Iutk@-+Kjh
z6g`9VbijRJUnF%%?Gx)JzhC8?gZntA%*1Hw%o2S03OW?VJr@NlJ@PBwUq~C&Mcd*0
zLIWp)X39*r9`}B$veSggKoz-=
z=vS;r4p!9q7ZKI_{r2|nF@26+sp(KIe4i`%Bce3~;nXWi01m7tCQ>w$Ryz9Y?d`ut
zQcDJc3t^~pPiHvbch`Ct0ueMY
zr2goALHLD&s0`t6wzvNVv$D&%3VOM~$}1Vi5~QdkyVU`1|E)5(wzvNP0**^~CNw!gZIbi#
z_Q<8JVvY;qr^1cmm;~)|dC6&$fKHZ;GdPhz;}qbsPu@hiHv8M{?Y}LEOKR%M>~Ksa
z=f(W6Sk6!^H0&$M%+0vNu?7Ti2`;#-E5-LpdJE-`3;4oUw^zbo<4xvl<6M?jMaB#D
z3&84|QX=lB(xSznNP?kyKMr_QZ3>O}()K0x4Lz+OW_t&AFA+IfjlfjpWl#bHdRyVI
zDFZ?UUld*dYMXu0bJQ>OwtA(5Z2E88`M?Xi_R$(1cUM_juX
zZtLgokn+JYnrAE5_-?^hpEX1FONLWkhhiqdm%DoEa7=kVNrtX@`u^;r$u>g8aEBL9
zGk0Mgi}+>>WBs^OWi|uHpXgv0Auv~LkPPqK-=4jH=ZJi)aC8UsB?lJcz!nk5ty0oC
zishKVFht&|AS{-{>g5iXEO9gdtFePx{RWBlcz#{;z7(w0u6eZE&vR4Y^
z21CyCWa=87?(DW#!zMLXqG%NIXxNg4YZxsd8Ea^oh5!Vc`nPUeqAX(dFLL(3;`C%GVgWbaI^FT&x<|wjiDilhz8U!u$6KF`VF5ZAj-^YeM{Rj?RK4!dO@~V5E26Z#p(cuV
z#CsPPw2%;cPwP-+Hz>vpbH|Fom&FZ_N2x!+P3p031bVOBneuuaG{5*3=Gy*!P>D*;q8Fm%AjfRmDir_u>{)d;NK(-6z~Qr7BTFJM#e@`y!Whh8`M8z(!*C7)
z?g(Fj5f5SXu0aA&p9JyhU`*lzoY)Md%1p#Ld#0d&frlIJotuEScu4Bi{NRW20Lc@~
z3_!f`lYfnCmd|nFe}bvq%sfNrr~=q7@OwIsGOY75O!4RA1akyZ1DJt|Pv(APoCU~C
z=G#QCD!K7n67|xodI5fc~owmt6Gj?
zF9Y}wcnPYA&!EGZ8U)4#am@HTOZjyrKvsFHTYu9;N<8WSszue-a66sY(H}ZN6jIa(?jGHMtV?bjvCaQ9yyejc
zv>sUo^#o0NxJ{X^mdm5Iz447eMB71K0`#d=`>)-Q;fD8)yTruhYn)A{sNXYyddeMO>||e{^y>c
zi}3)Z(O6!BOd7~bkX&qnC>fBJj(L{#`#@jzF3Qyz2of0eNx#3>W+S}uZ|SDZraF4K
zg9^oF*JWXw1+6JivS&f#VA^>U}faIvT-OjGE8nhcSXKol5l7XxO(@o4w>
zlc2{jB?o{x=GL4!?E_onw}>iZs8Z0>cxTE2yLe$P(?WaHaK|mrejpq@8f?SV6m8|u
zT@Kyl^mYy+$Lby&J_5Sj;aJg@t*xlFZX=k5FyOnvqeG{Av;*)yQr!_>#-4NVsGPFg
z-sxcSRQ%{}$Ju{mby{~elcSXoKwCSa2?7IGwpZG?_FUwuMTv7<9t-ndr+d+IcmnLU^(1^0UZ@LxPXbB7X^0hpYscE
z&ICXbF#_^Kcd==pRUYrDri0!6`}&T(?xP$`0L$r0-0sBo_gtgKmepS3W`rl6ywGU^
z=C)v2zFY9O)4^?cj~;dHM|Yq*#yh+hKlR#ROW`2%r*L%zwl>X8j=M#)A^zAnIYJg9
zUNj+66t=8`gGZJ1&z-KlZ0k%Fv^qUt_->E4T3TzCx@>hWb`S;f4vezhy=5D9P^H}`
z&OteCKUVvM>UA)IYEe`c#XFtD_Kx-Vu>K+juZ>9=^jbj0ZM@j^VZ?MEglxd_;(;4LUpj#u0&FW#sfzs5A3mAiW!YSVgMIr
zwHFf^#>|R$av&funA`oiTO&$snje>xEOM9vN?y9M(oK}S58VR^N@ehU0E7jT+NuCScz!g)95+f#r49Sln57ok#vF4c2YsNJ|^SH
zr?Gk(i>DzzU5)A4SUefC8rI=&6(gOts~v)q(?L5t0XmSj74SO-
zF12&ZbJ|^IsoN*Gg_9~iQ5T@@Y&XQt*V!b
zwO`RRo|^+5y5k+I#v?6oEC6cP>ek9T_M^cg=a32L-OftxysIBQq+tZ@LoNq{QFt}1
zHkcgHyVu-1N33}p`&Y(}*O7DcKzV!5%q>JzRRW4hnqfTW1?Jmgc2GJ2xo|-)(lk)B
zS!jWk6w*83el-Wf6T3LvWpfoqmNPEgvc{M^cy23pE{qsfn23desLVsAEMDD2lm;Iw
zEjuO+G^SZ)B5VSkU;7!jyqM9EtB_R0hb{F)I@-!*L@&5B&{yF8RbmovwGeAIA3&3W
zoZ>R*5b7}s{eXw_dT?Ook9kv2RaUCRSvz_Og#g!sv;fdrFot2opM{mM%y$^ML@wgM
zDrp8qFpiApKsMuy0$IwZDp9-yOE?980yNHLX;6bHIHZj`A<
z#p*c16P;QZd(NszAC*0x$dQk(yzg`zBFrU7HVs3NI&3{a+=$lEnc)~2JyilV;x
zr@#A0)746zwEJcdB^gYfYx+0--NuIfqF)&a#%db1ym@hAph*oBSslVDE{XVeKc;KGLPD-vWi=DB$7|_K9Baw%%@!=(16Qh*^5FY6c^HG~2)KH2)R98`r
z8)Dr3#7!~qe&YI!#3a<@qY)tDQb^50W|j|%q6T`nyMqrG+=HrIdj!XI}w7SGGBM__zD+Y(&>$l@wRJs
z!?HsuVjU?zLG~bpN$QX-4H63cT|+gxEGm^%!r8;R31{%~vr{99qq|x!O8h;nAx&7W
z1Ds}Ah|PfZg{%H&)P;DpGKSBmpIrc;0{kfD+yM1OV}Q!S-oY>_1-bm$Zl~Sdw^zpR
zo`2Tgo6k^&<4e!*{1b6&H_Xyoo=qC(2&gvAWUpX~;T`CwRizgIAj&6qMJ;(>*82+(
zbyaMcFV0R|@817pAV9_xfH%k_^K(u)o2MU)U++7{3qP0`LQ@$-%;1KHd1fRxv4LyK
zvz`Hl7)GBnV~{5PFu=7QKQ|7HS3f&uDTcHoaJbB|+foOi(Bu}uN}8Bal;HC5?$O~s
z=vwe?^wRICba(F%ABELNSh#!GE?47`F!I$9S5#M%&u)zOa0U0_3drO${h}fD{b75R
zebx@6!4R)9GdLp?1PmxTHWMr;TqeVXEwZ2pb`bjhg-5BKJsIxxF*^zj$5IHOu?YXh
zXd~iLAW8;QY{m=ah(w-9A}H4@a&O@{&;ZuGcf0Xug?5y@GAT;!fimb+6T67vuT6kiA)>w_hE*?d>l{41f?-UGkFbg
zBaD0k6eA_OAtT2dtr)bM8WfYJm(nJ^lq$WHD!r7}^isN6FQtuoDQ(b8d1<=~1+{Ki
zlEz9KY)fgXce-t<&eBclENxI{4ZN>x|IR)x0k26RK+in}RqhX3XjE3T6fLC5SaAIX
zL^oNWxGJd60WAs_3xN81UWE3#s)mMrqroYi3N_`m!R^)7w>3h@A3F7gA-@mQQY8cX
z>vT}OeZ&+=9N8sFR7qe5BeT`nZ&hp9O_b9_HHu*Nx_>UcNE8cot9xS?n9Th^()wn9
zL@XC>YvQ0<^%|;*L=^Km#(Ctrt~S%RP~)ppTr9H7JZ2)h)Wuc9oDI+|lp`$r`~18B
z$pDQ5GQU3aRaa~bZ;IvSH44G>kPfAXAXEh`oS@K1i#LIRHaJmY0Y!y2Hv>_XWF-up
z1CTQ{Ad0#QhIOm5Dw{XW(&x8e3>0!)L0p%AEATot!o4mm0dx|XZUf|cZHgVpY#?%M
z*0K54zij}oE{5-iZ%t?;fCmD?qXr1I`VWBc4Uz-;opAx5QZbw7x==!uX|6*;lB$u&
zjw$oWpk(x-TagZRNrj+8H}_tvNIFmCwAv^RK8xoDiMaw~UH0M9
zo{6wX}Z!tPo3)o^mw1HQJj(CrwFQAfsdi#2|=-N}3kK+1g4mNODQ$Mtuy$1DE5u
zjwMO{OAY3Ek(dJlYuYrH!C3eRb%i2|E8{35Jmt}mRfk1-DtYOt6tU9tkfk+GC6%7a
zlrnQJdF&5Bg%pm6uQ?*Ag)T@aT#&E3A9=<7h<~a3AyS5<19JM>$fijGE=Fn~ae%9l
z`a|4IC7e;mcm*?$@{ejkiQyT6A5D4MGbZ8@n4ZH>V9qnL*q_Zpo;npvL=#?Ts-ed4
z>Hl|HdO`D34sV^B!rD|;h{^{gR(ZOCM~blSbvVSxaV7jvz+D$jrwNEH+6_VAN$F%+
zAujA(io(m=&J~z)ZOldCd$>XOWq8YnR@dGnyBgmXlC)VG
zV?T^_&@QoH6B_sKgRJ_duD2QhD=Ig`^|}sMSZVzmz^cBeqicO$!!@XCA+eXm5|_Rj
zrwcy}&utqYlNoqR<2xw@*CTi%podSpZ+1agPp3Rj!@vMDHB7;Zf!`>~9K)i(9vJ;8
z&*%^dV?J~2jqY68<+sEXFqIS(HXaJg%r~uU;?FPz^iy$l45BfanindEcVpP0kU7Z=
zzk71$I)}{*Sk-vKU)&GYP;x5bxebwCkpJ{4BtEQ$d&weuK)AQ8cTc!?zmoFXxj*Ok
z7i1GRu7=#V_rUyV!ezV|zfU!UhPW0rF2cehaxC@sP^oGP7XR=6^6F=1&@ONF+Gw5u
zfzs(_(H#X8#bFZ}Ei05kXg;PB$qYQOIm=sbpB<|f=sQLk83#9fV0?P*7^lb!m=G$e
zWWWX>@qJ^jm2uG0L*x1T_a9q>d7k68u_OXZZJrX4fbo#7R3vlV{(2|uFJWu(!q&=J
zOtzLt9WG2SdF!)?ZZU{_2S7p*G=w2*6)hwoxFy16I#ee#{
zeBcEcH#oQY@^EmTIU@o3tG@6DbBqSD}BVr8M
z29t&%j#$K{XF%{XJd^^c4Wt&paF1+zK)|cg-R4+VQr)(O1|EGWOiPH6_d><^H7}sH
z&q!Y;E09?VQ+WiK(pQLGeY21wEmcha@j)v1Irz+Ycs!}bd5p)!gAmgMq!e6lk3XT%
z8amTpu9t)6Gk$aW`n`PU-(HSqVWQU%I}640vd_}aZCX=Ju5`s@7Ow#{x%a;b-<-c8C!ZEKfd+2;nUVWfpc_OS#_&fNNK5$u
z^p<11OW<+@lRW`F!iO?a1v*MLkYUJeMxRXsy`z@r9kYSxPIVzv-3Nv_Uzao7?n@aP
zcowF4!Ol&RWtZtKZO}oeKm0)}&&%?UqFiV)+_xhxuuL%GrSQ1V=mt{{Ot~%0I_E6E
zu(b_R!*h;@#su)w@5iMC*6eCrVl^yz`VR>LT5q*Ke*?3G+QvDp_hMI7US*!%C50gZ
z24GRRzwAnU+isL5Q;dUSfJ~D~JDnAiYhAgm4mSitt)nuIE
zdRW{y3nbXME~ezC;0ZMuMRHRf8}P3+OGDrgFb35k8iiU&**C;iWS+(%9O7y8H1D)&
zF~N9DfZvy}D5^4aj1NS!MxGdSJhXvYFCYwq&eMZ3`~g83jbZ5dwt?I(`NC}A`iC)M
zMbI48Hnq;xceYGJm;h48sPdVU73Se70|
z30S4I-3<5Tl}-Bae$4ZmB)!Uj7X+o`^fp?B9GFp(i=q!ovlSNV)Pl||VL(>wwYaZD
z-#gK^G}wL$TQ5V@3hCCUg8*iM#(!`0_?Xg(uHq4!f%0-OFb_|za?%4&sA^tB-S
zUkHyt?!sLWIQ(U}dUo23Dy{MPjFf!wdE(X|;6KAcQED8bpvO
zX{l>y0ENONC5+5a;X(?m5U_yzm`7%rDXh&dyfuS412GL_`P1M3SK~;0OxzGF2AT2G
z>9cOn;L}-tXLv?3;}wTX+rKa8o^B06w_-~_+5n0cX8PXz(VC}3pQ6Hm40r=z9HRj;
zi2V49BA(c{LFt|w4Xp;4TV?Ow~n(x$r5#t&!RsN&fsn?2hmZqhnR```uzSB7s$
za|2^VK)(QRTttqTm16AYKAW7BQYFGc1*U-pp~wq{8<2T;m+=79RUZt*|MQ>!@gIc!
zXp^ztzz3&ly1q)=imZzXmbl7v(hlmVTJKS$dmE+re;`wd-Y)gthW9g)4;n2ajTVL1
zVioZhyDD_V!h+yFw<*xbwIQk`a3I4&f#QZ7X)f>k*ZZOZof-q%=998_-A
zh^z2s)}0yIn@Oj`RjnV2p=`p4_{2hOWwaIXQLfyXMpRMj>C`e`COA8S+YRm1uf8G4
z;3fq5uacP)5FjlQ?5Od;iMt2mBWn2)#^P=%^rFzm^A}KfX-e`T0!#u5O_ZT;cg>!E
zXee0w0stNdC4DMh<|?eaSL7xs=c?7gQ@@43A+`zA7f$4HozMze6x_=hz)VT09Cd#4
zo~^UH4D3(oi-2aI;1r01)L0S0kwTvt!_XhcK)zw{=ei%&uJmfSj}soM#rgFhUhSYA
zpSD#W?Xym&%bfO6W8`eD9v&ok|9LVPos&_*`@@DnE~P8K>CCX&fYEGx|MI0{Jd5v=
zn2S6PD3iPjj-ZNG7%HE{G0yPZlR||$_0$9N*>D=h&4x?Q(Cr3bC2&e8i2_mm0MliY
zc#o?GgEfoyUno)OoJ+aE_{h#J6O%FJc$6>`8-p9<0FG<7gmzJwG-x;HR@^Ui8zR^&
zRh=dyLOM99loI*4
z?u@}4-qr{Lij^9SD$H`%#HNb#-^434Yhp8dEN$U^Y&4_zV
zAEjmHLC4m%H~s?BQJbkFJVwY8X9w}+666Xk6fKux%b{2`67;3k_(&b+h2|7^CVWY9
zE6>eRYji!|YU}=5_2A3ecPnu9Ri5{6;jvgHxIP`#^M|)@Ugi1hgV?Iw^BuJAEqN>^
zXZTeZAE#1QctyuyTGhiaS>-Uy1pG0OU3tp=sflmTXP6o1B(eH-4xT)PtcKs{g(rNt
zi)V`EeuT9X`Z{fKxRNi=$b9N`+ig5ITiiauQ;z6^85SM5NnvZK49(AAbo3!DTb+{`
z;-aqj+FIrOP1RWS{LRhn-Fp^iYU$^2BAen^sE#Kx{>ta3GLUS6-FQgngs!9{0aTbQ
zW+E_%=M{t9f?-n(GD|Z>#DOtH1oYA|GTaw7N--w|?)Tb8E#mBx2G6T;XJGyHu(OoX
zLszh4`lO?I52~pD`4i2l2vOC!sg2x(E*MrRH2VLWn6>#F&_G0#8fg+msHsuY`RSAo
zdqy|{o*DoYz`CH=P`zc0mWn8IC7EZX#eoRTP;e%A76Ii!G_E6^lom;2R*JN)9uh|4
zYKv$!iUcC|VQ)jl%f=Q^I_E{Y3(4wBl=my6yvHC%HBClnQnATXTvyhjpB+C8s;M7T
zp}A)-D2M=jdWla#OxhcEg-%KP+}8?jW7ynMvCJTt{0;ZcaYe_B===b3jpSJZA=Gfq
zKmGf^H&;wl;b>FJDzqn0R{2l=tg1|9uB@n}GG}GAfBV0RYP4fjR-$WJ<=_1`Rb^77
zR)}h4s)eljAOC}@TKHI^X5kX3HvhG%m`QtE)Kk{CtoBd;!(3gI2k^bd^Yw)vfXk9-
z`cq2KS)Qc8K&akc!|N8gvbjGnZIB~`L~s_A=YXH!C_D$cQHwH*VjBnU_ETH(+YI9c
zF7Dm)@q|CI=NFIq!(=W|j_F#MFk7za+ymEygBy>K4Skfc?6Pt)cx807IHUZST&_8x
zKZ8dRc^K!W6EpQgUD2E)i%p%TN$Wd#>K7&fy-L2dsRCc>%nYHii?3nGmSfQ}c8A#o
zn&7Tl2~@cv!(U4IE#5~VzQT_btStQCTs477$tAh$6smP5s-@>F73t)%reCXBE2$)S9ghi{(d8y@PhUIB?OT@Be}H0K*7p65kTvDgqVIH*Y|&?Cyd+dqcnW
zPEiL#89M?_qC17j@{1KP$2olp2=(nxd~lWoS74Q;7-;}e1?G)3E)bv<6-s=1xtuMo
z8x=Wy8?!JbR^k;pA3`*Qy=egx{3x5+CQV0hJqpGz+k4)$cuope#-F$RQDDsil
z@e}b|K#kgtyAGvU0V=SBlJW2>VkYf>!Uam9cfK81y|F+2)8Cn>q5r4ZJOaJ$8`<#*
z7U|2vQ$N&AHM-Lr<_W{3o;jIol0DG{AK>P1P&Km1foq{*Q~{-T%CpTc#Tm20!osgr
zeB$gqXCFBGiL>Xt_lbMYt=G^Zt}MLgZKv2CxY0e+TVzb5g-
zcU;7`V=U^^hEBcr^ub8z8^3|_oc_LmX4oh7$kV@op?xhAKd$O6$UfUT7mAAmy;5e|g3S_evs0PP*%BB2lIi7%yFSK^zW=bu2v16J|F-ZQ{3
zeY2UiRek#K8p``v%YBcri8K?Rmsf*=LK?dGYP9$i-cLvt{SWlT&FWhW429<~fp5U-
zfvLjl-jm%dNW)qF^j8M__kdYSB($J(#j$80&)B
zrxIqEXMCJ+A#XkfVev^PD&nmark2Yl;mJ##M+(*}c%*x>x3~9P
z=aDK$?5%MPcK`g~`Saub)uY8Axka-E*HPpe3zV$zW8(B#9`