Skip to content

Commit d88bd66

Browse files
author
Ravi Singh
committed
feat: full app shell (animated logo + sticky header) + 20Hz WS + motion v2 (median + adaptive)
User reported visible jitter and missing UI elements. Three-pronged fix. Frontend — full app shell port from design-source/app.jsx: - LogoMark: animated triangle "A" with 3 concentric pulse rings emanating outward, radial proximity-glow that breathes (scale + opacity), corner-tick chip details. Pulse strength scales with live distance (closer target = brighter glow). Uses lm-grad (amber→orange→pink) and lm-core radial gradients verbatim from design. - Wordmark: "Ambi" in slate-gradient + "Sense" in accent-gradient, with "v6.x · ESP32" caps subtitle. - Sticky header (.app-header) with backdrop-filter blur — page name + hostname.local breadcrumb + live distance chip + RSSI chip + sun/moon theme toggle. - Sidebar with logo block + nav + bottom IP/board chip (10.0.0.x · ESP32-C3 · LD2410C style). - styles.css: keyframes logo-pulse, logo-breath, pulse-acc; .app-header, .brand-block, .sidebar-foot, .btn-icon classes. Firmware — kill the visible 200ms stair-step jitter: - ws_broadcast_task: 200 ms (5 Hz) → 50 ms (20 Hz). Bandwidth: ~2.5 KB/s, trivial. Browser sparkline now updates 4× more often, no flat dwells. - main.c telemetry_pump_task: same bump 5 Hz → 20 Hz, also publishes raw_cm now (median-filtered but un-smoothed) so the Motion screen graph shows what the firmware *actually* feeds the LED engine, removing 200 ms client-side simulation lag. - webui_live_t and the WS JSON gain a 'raw' field. Firmware — motion algorithm v2: - Median-of-5 filter on raw radar samples before the smoother. Single- sample LD2410 spurious readings (every few minutes) get out-voted; rejection is total. Insertion-sort over 5 ints — cheap. - Adaptive smoothing: alpha scales with motion magnitude. Stationary (|delta| < 30 cm/sample) uses configured pos_smooth; fast-moving (>= 30 cm/sample) ramps alpha up to 4× the configured value (capped at 0.9). Result: calm reading when still, snappy when moving — best of both worlds. - target_t exposes raw_cm alongside distance_cm. Frontend — Live sparkline + Motion graph fixes: - ScreenLive: useEffect deps changed from [dist] to [live] so the sparkline advances on every WS frame at 20 Hz, even when the integer cm value hasn't changed (was freezing during stationary periods). - ScreenMotion: raw and smoothed lines come straight from firmware WS payload, no client-side alpha simulation. Graph now reflects the real on-device smoother instead of a 200 ms-lagged copy. Build: 1.17 MB binary, 19%% free in 1.4 MB app slot. UI gzipped 25.4 KB. Both C3s flashed.
1 parent 40661ca commit d88bd66

11 files changed

Lines changed: 265 additions & 69 deletions

File tree

firmware/components/motion/include/motion.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ extern "C" {
2222
typedef struct {
2323
bool present;
2424
int16_t distance_cm; /* smoothed + predicted */
25+
int16_t raw_cm; /* spike-rejected (median) but un-smoothed —
26+
* for the Motion screen's raw vs smoothed
27+
* line chart so users can see the smoother
28+
* actually working. */
2529
int8_t direction;
2630
uint8_t energy;
2731
uint64_t ts_us;

firmware/components/motion/motion.c

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#include "motion.h"
22

33
#include <string.h>
4+
#include <math.h>
45

56
#include "esp_log.h"
67
#include "esp_timer.h"
@@ -14,6 +15,8 @@
1415
static const char *TAG = "motion";
1516

1617
/* Defaults match v5 (config.h:69-76). NVS values stored as x1000 fixed-point. */
18+
#define MEDIAN_W 5 /* window size for the spike-rejection median filter */
19+
1720
static struct {
1821
bool enabled;
1922
float pos_smooth;
@@ -28,6 +31,14 @@ static struct {
2831
float err_integral;
2932
uint64_t last_us;
3033

34+
/* Spike-rejection median filter: keeps the last MEDIAN_W raw samples
35+
* and uses the middle one (after sorting) as the "true" current value.
36+
* Single-sample radar glitches get out-voted; rejection is total,
37+
* unlike a low-pass which would let the spike bleed through. */
38+
int16_t med_buf[MEDIAN_W];
39+
uint8_t med_idx;
40+
uint8_t med_filled;
41+
3142
target_t latest;
3243
SemaphoreHandle_t lock;
3344
int min_cm, max_cm;
@@ -37,6 +48,18 @@ static float clamp(float v, float lo, float hi) {
3748
return v < lo ? lo : (v > hi ? hi : v);
3849
}
3950

51+
/* In-place median of 5 ints. Insertion-sort is fastest at this size. */
52+
static int16_t median5(int16_t *src) {
53+
int16_t a[MEDIAN_W];
54+
for (int i = 0; i < MEDIAN_W; ++i) a[i] = src[i];
55+
for (int i = 1; i < MEDIAN_W; ++i) {
56+
int16_t x = a[i]; int j = i - 1;
57+
while (j >= 0 && a[j] > x) { a[j+1] = a[j]; j--; }
58+
a[j+1] = x;
59+
}
60+
return a[MEDIAN_W / 2];
61+
}
62+
4063
static void motion_task(void *arg) {
4164
(void)arg;
4265
radar_frame_t f;
@@ -55,24 +78,42 @@ static void motion_task(void *arg) {
5578
if (raw < s_m.min_cm) raw = s_m.min_cm;
5679
if (raw > s_m.max_cm) raw = s_m.max_cm;
5780

81+
/* Spike-rejection: median of last 5 raw samples. Until the buffer
82+
* is filled, just pass-through (so first reading isn't blocked). */
83+
s_m.med_buf[s_m.med_idx] = (int16_t)raw;
84+
s_m.med_idx = (s_m.med_idx + 1) % MEDIAN_W;
85+
if (s_m.med_filled < MEDIAN_W) s_m.med_filled++;
86+
int filtered_raw = (s_m.med_filled == MEDIAN_W) ? median5(s_m.med_buf) : raw;
87+
5888
target_t t = { .present = f.present, .energy = f.energy,
5989
.direction = f.direction, .ts_us = f.ts_us };
6090

91+
t.raw_cm = (int16_t)filtered_raw;
6192
if (!s_m.enabled) {
62-
t.distance_cm = (int16_t)raw;
93+
t.distance_cm = (int16_t)filtered_raw;
6394
} else {
6495
uint64_t now = f.ts_us;
6596
float dt = s_m.last_us ? (float)(now - s_m.last_us) / 1e6f : 0.02f;
6697
s_m.last_us = now;
6798
dt = clamp(dt, 0.001f, 1.0f);
6899

69100
if (s_m.smoothed <= 0) {
70-
s_m.smoothed = (float)raw;
71-
s_m.predicted = (float)raw;
101+
s_m.smoothed = (float)filtered_raw;
102+
s_m.predicted = (float)filtered_raw;
72103
}
73104

74-
s_m.smoothed = (1.f - s_m.pos_smooth) * s_m.smoothed +
75-
s_m.pos_smooth * (float)raw;
105+
/* Adaptive alpha: when the target is moving fast, ease the
106+
* filter (snappier response); when nearly stationary, clamp
107+
* harder (calmer reading). Magnitude is the |delta| from the
108+
* last smoothed estimate, scaled by a 30 cm "fast motion"
109+
* threshold. Output is in [pos_smooth .. min(1, pos_smooth*4)]. */
110+
float delta = fabsf((float)filtered_raw - s_m.smoothed);
111+
float scale = clamp(delta / 30.0f, 0.0f, 1.0f); /* 0=still, 1=fast */
112+
float alpha_max = clamp(s_m.pos_smooth * 4.0f, s_m.pos_smooth, 0.9f);
113+
float alpha_eff = s_m.pos_smooth + (alpha_max - s_m.pos_smooth) * scale;
114+
115+
s_m.smoothed = (1.f - alpha_eff) * s_m.smoothed +
116+
alpha_eff * (float)filtered_raw;
76117
float instant_v = (s_m.smoothed - s_m.predicted) / dt;
77118
instant_v = clamp(instant_v, -200.f, 200.f);
78119
s_m.velocity = (1.f - s_m.vel_smooth) * s_m.velocity +

firmware/components/webui/include/webui.h

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,11 @@ esp_err_t webui_init(void);
2727
* Updates are coalesced and emitted to all connected /api/live WS
2828
* clients at ~5 Hz. */
2929
typedef struct {
30-
int16_t distance_cm;
31-
int8_t direction; /* -1, 0, +1 */
30+
int16_t distance_cm; /* smoothed + predicted */
31+
int16_t raw_cm; /* median-filtered but un-smoothed; lets the
32+
* Motion screen draw raw vs smoothed without
33+
* a second NVS round-trip */
34+
int8_t direction; /* -1, 0, +1 */
3235
int8_t rssi;
3336
uint32_t free_heap;
3437
uint32_t uptime_s;

firmware/components/webui/ui.html

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.
1.33 KB
Binary file not shown.

firmware/components/webui/webui.c

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -961,7 +961,7 @@ static esp_err_t handle_ws(httpd_req_t *req) {
961961
static void ws_broadcast_task(void *arg) {
962962
(void)arg;
963963
while (1) {
964-
vTaskDelay(pdMS_TO_TICKS(200)); /* 5 Hz */
964+
vTaskDelay(pdMS_TO_TICKS(50)); /* 20 Hz — smooth UI updates */
965965

966966
webui_live_t snap;
967967
xSemaphoreTake(s_web.lock, portMAX_DELAY);
@@ -971,10 +971,10 @@ static void ws_broadcast_task(void *arg) {
971971
snap.rssi = netmgr_get_rssi();
972972
xSemaphoreGive(s_web.lock);
973973

974-
char json[160];
974+
char json[200];
975975
int n = snprintf(json, sizeof(json),
976-
"{\"distance\":%d,\"direction\":%d,\"rssi\":%d,\"heap\":%" PRIu32 ",\"uptime\":%" PRIu32 ",\"peers\":%u,\"healthy\":%u}",
977-
snap.distance_cm, snap.direction, snap.rssi,
976+
"{\"distance\":%d,\"raw\":%d,\"direction\":%d,\"rssi\":%d,\"heap\":%" PRIu32 ",\"uptime\":%" PRIu32 ",\"peers\":%u,\"healthy\":%u}",
977+
snap.distance_cm, snap.raw_cm, snap.direction, snap.rssi,
978978
snap.free_heap, snap.uptime_s, snap.peer_count, snap.peer_healthy);
979979

980980
httpd_ws_frame_t f = {

firmware/main/main.c

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,29 +80,35 @@ static void apply_pin_overrides(board_profile_t *runtime) {
8080
}
8181
}
8282

83-
/* Telemetry pump: 5 Hz publish mesh-fused target + RSSI + peer health to
84-
* webui WS clients. Falls back to local motion_get() if mesh has no peers. */
83+
/* Telemetry pump: 20 Hz publish mesh-fused target + raw + RSSI + peer
84+
* health to webui WS clients. Was 5 Hz — bumped to kill the visible
85+
* stair-step jitter on the live distance graph. 20 Hz × ~120-byte JSON
86+
* = 2.5 KB/s on the WiFi link, trivial. Pulls raw_cm from local
87+
* motion_get() because mesh fused only carries the smoothed value. */
8588
static void telemetry_pump_task(void *arg) {
8689
(void)arg;
8790
while (1) {
8891
mesh_fused_t f;
8992
mesh_get_fused(&f);
93+
target_t local;
94+
motion_get(&local);
9095
mesh_peer_t peers[MESH_MAX_PEERS];
9196
size_t pn = mesh_peers_snapshot(peers, MESH_MAX_PEERS);
9297
size_t hn = 0;
9398
for (size_t i = 0; i < pn; ++i) if (peers[i].healthy) hn++;
9499

95100
webui_live_t live = {
96101
.distance_cm = f.distance_cm,
102+
.raw_cm = local.raw_cm,
97103
.direction = f.direction,
98104
.rssi = netmgr_get_rssi(),
99-
.free_heap = 0, /* webui fills these in itself before broadcast */
105+
.free_heap = 0,
100106
.uptime_s = 0,
101107
.peer_count = (uint8_t)pn,
102108
.peer_healthy = (uint8_t)hn,
103109
};
104110
webui_publish_live(&live);
105-
vTaskDelay(pdMS_TO_TICKS(200));
111+
vTaskDelay(pdMS_TO_TICKS(50)); /* 20 Hz — see comment above */
106112
}
107113
}
108114

frontend/src/atoms.tsx

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,3 +218,60 @@ export function hex2rgb(hex: string): [number, number, number] {
218218
const h = hex.replace('#', '').padEnd(6, '0').slice(0, 6);
219219
return [parseInt(h.slice(0,2), 16), parseInt(h.slice(2,4), 16), parseInt(h.slice(4,6), 16)];
220220
}
221+
222+
/* Animated logo mark — triangle "A" with concentric pulse rings.
223+
* Pulse intensity scales with proximity (closer target = brighter).
224+
* Faithful port of frontend/design-source/project/app.jsx LogoMark. */
225+
export function LogoMark({ size = 36, distance = 0 }: { size?: number; distance?: number }) {
226+
const pulseStrength = Math.max(0.2, Math.min(1, (250 - distance) / 200));
227+
return (
228+
<div style={`position: relative; width: ${size}px; height: ${size}px; flex-shrink: 0;`}>
229+
<div style={`position: absolute; inset: -${size * 0.25}px; border-radius: 50%; background: radial-gradient(circle, rgba(255,122,61,0.35) 0%, rgba(255,61,130,0.15) 35%, transparent 65%); filter: blur(6px); opacity: ${0.6 * pulseStrength}; pointer-events: none; animation: logo-breath 2.6s ease-in-out infinite;`}/>
230+
<svg viewBox="0 0 48 48" width={size} height={size} style="position: relative; display: block;">
231+
<defs>
232+
<linearGradient id="lm-grad" x1="0" y1="0" x2="1" y2="1">
233+
<stop offset="0%" stop-color="var(--acc-amber)"/>
234+
<stop offset="50%" stop-color="var(--acc-orange)"/>
235+
<stop offset="100%" stop-color="var(--acc-pink)"/>
236+
</linearGradient>
237+
<radialGradient id="lm-core" cx="0.5" cy="0.5" r="0.5">
238+
<stop offset="0%" stop-color="#FFE4B0"/>
239+
<stop offset="60%" stop-color="var(--acc-orange)"/>
240+
<stop offset="100%" stop-color="var(--acc-pink)"/>
241+
</radialGradient>
242+
</defs>
243+
<rect x="1" y="1" width="46" height="46" rx="12" fill="#0a0c0f" stroke="url(#lm-grad)" stroke-width="1" opacity="0.55"/>
244+
<g style="transform-origin: 24px 32px;">
245+
{[0, 1, 2].map(i => (
246+
<circle cx="24" cy="32" r="6" fill="none" stroke="url(#lm-grad)" stroke-width="1.2" opacity="0.7"
247+
style={`animation: logo-pulse 2.2s ease-out infinite; animation-delay: ${i * 0.6}s; transform-origin: 24px 32px;`}/>
248+
))}
249+
</g>
250+
<path d="M 24 11 L 13 32 L 35 32 Z" fill="url(#lm-grad)" opacity="0.95"/>
251+
<path d="M 24 18 L 18 30 L 30 30 Z" fill="#0a0c0f"/>
252+
<circle cx="24" cy="32" r="2.6" fill="url(#lm-core)"/>
253+
<circle cx="24" cy="32" r="1.1" fill="white" opacity="0.9"/>
254+
{[[6,6],[42,6],[6,42],[42,42]].map(([x,y]) => (
255+
<circle cx={x} cy={y} r="0.8" fill="var(--text-3)" opacity="0.5"/>
256+
))}
257+
</svg>
258+
</div>
259+
);
260+
}
261+
262+
/* Wordmark with gradient "Sense" — paired with LogoMark in sidebar/header. */
263+
export function Wordmark({ font = 16, sub = 10, mono = false, version, target }: {
264+
font?: number; sub?: number; mono?: boolean; version?: string; target?: string;
265+
}) {
266+
if (mono) return null;
267+
return (
268+
<div style="display: flex; flex-direction: column; line-height: 1.05;">
269+
<span style={`font-size: ${font}px; font-weight: 600; letter-spacing: -0.025em; background: linear-gradient(180deg, var(--text-0) 0%, var(--text-1) 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;`}>
270+
Ambi<span style="background: var(--acc-grad); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; font-weight: 600;">Sense</span>
271+
</span>
272+
<span class="mono" style={`font-size: ${sub}px; color: var(--text-3); letter-spacing: 0.08em; text-transform: uppercase; margin-top: 2px;`}>
273+
{version || ''} <span style="color: var(--text-4);">·</span> {target || ''}
274+
</span>
275+
</div>
276+
);
277+
}

0 commit comments

Comments
 (0)