Skip to content

Commit a4c2935

Browse files
authored
feat(firmware): onboard LED 40 Hz gamma stimulus + CSI-motion colour (ADR-183) (ruvnet#1127)
* chore(deps): bump ruv-neural submodule — ColorMap no_std for ESP32 Points to ruvnet/ruv-neural#3 (c9638fa): ruv-neural-viz::ColorMap now builds no_std, so it can run on the ESP32. Unblocks driving the onboard WS2812 from the viridis/cool-warm colormap. Co-Authored-By: claude-flow <ruv@ruv.net> * feat(firmware): onboard LED as 40 Hz gamma stimulus, colour from live CSI motion (ADR-183) The S3 onboard WS2812 (GPIO 48, ruvnet#962) now runs a GENUS-style 40 Hz gamma square wave (12.5 ms on/off, 50% duty). The ON-phase colour is live CSI motion (edge motion_energy) mapped through a 60-step viridis LUT generated from ruv-neural-viz::ColorMap::viridis() — still=purple, moving=yellow. Uses the now-no_std ColorMap (ruvnet/ruv-neural#3 / ruvnet#1126). Hardware- confirmed on ESP32-S3 N16R8 (COM8): boot log shows the timer armed, CSI keeps flowing (27-38 pps). Honesty + photosensitivity notes + a Kconfig-gate follow-up are in ADR-183. Co-Authored-By: claude-flow <ruv@ruv.net>
1 parent 315d7df commit a4c2935

2 files changed

Lines changed: 157 additions & 7 deletions

File tree

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# ADR-183: Onboard LED as a 40 Hz Gamma Stimulus, Colour-Mapped from Live CSI via `ruv-neural-viz`
2+
3+
| Field | Value |
4+
|-------|-------|
5+
| **Status** | Accepted — implemented & hardware-confirmed on ESP32-S3 N16R8 (COM8) |
6+
| **Date** | 2026-06-17 |
7+
| **Deciders** | ruv |
8+
| **Codename** | **GAMMA-VIZ** |
9+
| **Builds on** | `ruv-neural-viz::ColorMap` (now `no_std`ruvnet/ruv-neural#3 / RuView#1126), the ESP32 edge `motion_energy` metric (`edge_processing.c`), PR #962 (WS2812 on GPIO 48) |
10+
11+
## Context
12+
13+
Two threads converged. (1) `ruv-neural-viz::ColorMap` — the viridis/cool-warm
14+
palette the rUv-Neural stack uses to render brain-topology graphs — was `std`-only,
15+
so it couldn't run on the ESP32. (2) The onboard WS2812 on the S3 CSI node was dead
16+
weight: the firmware only cleared it on boot (and on the wrong pin for N16R8 — GPIO
17+
38 vs the actual 48, see #962).
18+
19+
The ask: make the LED do something real and honest, using the project's own visual
20+
capability — not a decorative blink. The natural fit is a **40 Hz gamma stimulus**
21+
(the GENUS gamma-entrainment frequency from Alzheimer's light-therapy research)
22+
whose **colour is driven by live sensed motion**, so the node's front panel is both
23+
a known bio-stimulus waveform and a truthful readout of what the CSI is detecting.
24+
25+
## Decision
26+
27+
### Part A — make `ColorMap` `no_std`
28+
29+
`colormap.rs` is self-contained (no cross-crate deps), so expose it on `no_std`
30+
targets. The only blockers were two `std`-only `f64` ops:
31+
32+
- `f64::round` / `f64::abs` → replaced with `core`+`alloc`-safe helpers `fround`
33+
(round via `f64 as i64` truncation — a `core` cast, no `libm`) and `fabs`.
34+
- `Vec`/`String`/`format!` → from `alloc`.
35+
36+
The graph-bound modules (`animation`/`ascii`/`export`/`layout`) and their heavy deps
37+
move behind a default `std` feature; `--no-default-features` builds the crate `no_std`
38+
and exposes only `colormap`. Output is **byte-identical** (8/8 colormap tests pass with
39+
the same RGB values), so this is a pure portability change.
40+
41+
### Part B — the LED stimulus (firmware)
42+
43+
`firmware/esp32-csi-node/main/main.c`, on boot:
44+
45+
- WS2812 on **GPIO 48** (N16R8 / DevKitC-1 v1.1; GPIO 8 on C6).
46+
- An `esp_timer` periodic at **12 500 µs toggles a square wave → 40 Hz, 50 % duty**
47+
(full-on / full-off — a *perceptible* gamma flicker, not a colour drift).
48+
- **ON-phase colour = live CSI motion.** Each ON phase reads `edge_get_vitals().motion_energy`,
49+
normalises it (`/ LED_MOTION_FULLSCALE`, clamped `[0,1]`), and indexes a **60-step
50+
viridis LUT generated from `ColorMap::viridis().map()`** — still = dark purple,
51+
strong motion = yellow.
52+
53+
The LUT is baked from the real crate (Part A makes the same `ColorMap` embeddable
54+
for a future direct FFI path once the ESP Rust toolchain is in CI). The colours are
55+
therefore provably `ruv-neural-viz`'s, and the motion is provably real.
56+
57+
## Honesty (what it is and is not)
58+
59+
- **40 Hz is a real square-wave stimulus** (12.5 ms on / 12.5 ms off), not a label on
60+
a colour sweep. It is *not* tied to any measured 40 Hz brain rhythm — it is an
61+
*output* stimulus at the gamma frequency, not a readout of neural gamma.
62+
- **Colour is a real CSI readout**`motion_energy` is the on-device phase-variance
63+
motion metric the node already computes; no fabrication. At rest the LED sits at the
64+
purple (low) end and flickers there.
65+
- No therapeutic claim is made. 40 Hz GENUS entrainment is cited as the *origin of the
66+
frequency choice*, not as a validated medical effect of this device.
67+
68+
## Consequences
69+
70+
**Positive**
71+
- The LED is now an honest front-panel: gamma-frequency flicker + a live motion readout.
72+
- `ColorMap` is embeddable (`no_std`), unblocking on-device use of the rUv-Neural
73+
palette beyond this LED.
74+
- Confirms #962's GPIO-48 fix visually (the LED lights on N16R8).
75+
76+
**Negative / risks**
77+
- Changes *default* firmware behaviour: the onboard LED now animates instead of staying
78+
off (minor power + a visible flicker some may not want). Gate behind a Kconfig
79+
(`CONFIG_LED_GAMMA_VIZ`) if a dark default is preferred — follow-up.
80+
- A 40 Hz flicker can be an issue for photosensitive users; document on the enclosure.
81+
- `LED_MOTION_FULLSCALE` (0.25) is hand-tuned, not calibrated per-environment.
82+
- The colour uses a baked LUT, not the live Rust `ColorMap` (FFI path deferred — needs
83+
the ESP Rust/xtensa toolchain, not yet in CI).
84+
85+
## Validation
86+
87+
- `ruv-neural-viz`: `cargo build` (std) ✓, `cargo test colormap` 8/8 ✓ (identical RGB),
88+
`cargo build --no-default-features` compiles `no_std` ✓.
89+
- Firmware: built (1.13 MB), flashed to ESP32-S3 N16R8 (COM8). Boot log:
90+
`Onboard WS2812: 40 Hz gamma flicker (GENUS), colour=CSI motion via ruv-neural-viz, GPIO 48`;
91+
CSI continues (27–38 pps), `motion=0.00` at rest → purple flicker as designed.
92+
- Full on-device (xtensa) Rust build of `ColorMap` not run — ESP Rust toolchain absent.
93+
94+
## References
95+
- ruvnet/ruv-neural#3 (ColorMap no_std), RuView#1126 (submodule bump), #962 (GPIO 48).
96+
- Singer/Tsai GENUS 40 Hz gamma entrainment (origin of the frequency, not a device claim).

firmware/esp32-csi-node/main/main.c

Lines changed: 61 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,52 @@ static void wifi_init_sta(void)
144144
}
145145
}
146146

147+
/* Viridis colormap (60 steps), generated from ruv-neural-viz::ColorMap::viridis()
148+
* — the rUv-Neural brain-topology colormap, now no_std (ruvnet/ruv-neural#3 /
149+
* RuView#1126). Used as the ON-phase colour of the 40 Hz gamma flicker below:
150+
* dark-purple (still) -> teal -> green -> yellow (strong motion). */
151+
static const uint8_t VIRIDIS_LUT[60][3] = {
152+
{ 68, 1, 84},{ 67, 6, 88},{ 67, 12, 91},{ 66, 17, 95},{ 66, 23, 99},
153+
{ 65, 28,103},{ 64, 34,106},{ 64, 39,110},{ 63, 45,114},{ 63, 50,118},
154+
{ 62, 56,121},{ 61, 61,125},{ 61, 67,129},{ 60, 72,132},{ 59, 78,136},
155+
{ 59, 83,139},{ 57, 87,139},{ 55, 92,139},{ 53, 96,139},{ 52,100,139},
156+
{ 50,104,139},{ 48,109,139},{ 46,113,139},{ 44,117,140},{ 43,122,140},
157+
{ 41,126,140},{ 39,130,140},{ 37,134,140},{ 36,139,140},{ 34,143,140},
158+
{ 35,147,139},{ 39,151,136},{ 43,154,133},{ 47,158,130},{ 52,162,127},
159+
{ 56,166,124},{ 60,170,121},{ 64,173,119},{ 68,177,116},{ 72,181,113},
160+
{ 76,185,110},{ 81,189,107},{ 85,192,104},{ 89,196,102},{ 93,200, 99},
161+
{102,203, 95},{113,205, 91},{124,207, 87},{134,209, 82},{145,211, 78},
162+
{156,213, 74},{167,215, 70},{178,217, 66},{188,219, 62},{199,221, 58},
163+
{210,223, 54},{221,225, 49},{231,227, 45},{242,229, 41},{253,231, 37},
164+
};
165+
static led_strip_handle_t s_viz_led;
166+
167+
/* motion_energy that saturates the colormap to yellow (tunable). */
168+
#define LED_MOTION_FULLSCALE 0.25f
169+
170+
/* GENUS-style 40 Hz gamma flicker: full on/off square wave, 50% duty (toggled
171+
* every 12.5 ms → 40 Hz). The ON colour is live CSI motion (edge motion_energy)
172+
* mapped through the ruv-neural-viz viridis LUT — still=purple, moving=yellow.
173+
* So the LED is a real 40 Hz gamma stimulus whose hue tracks sensed motion. */
174+
static void led_gamma_40hz_cb(void *arg)
175+
{
176+
static bool on = false;
177+
on = !on;
178+
if (on) {
179+
edge_vitals_pkt_t v;
180+
float m = edge_get_vitals(&v) ? v.motion_energy : 0.0f;
181+
float norm = m / LED_MOTION_FULLSCALE;
182+
if (norm < 0.0f) norm = 0.0f;
183+
if (norm > 1.0f) norm = 1.0f;
184+
int idx = (int)(norm * 59.0f + 0.5f);
185+
const uint8_t *c = VIRIDIS_LUT[idx];
186+
led_strip_set_pixel(s_viz_led, 0, c[0], c[1], c[2]); /* R,G,B (driver maps to GRB) */
187+
} else {
188+
led_strip_set_pixel(s_viz_led, 0, 0, 0, 0); /* off phase */
189+
}
190+
led_strip_refresh(s_viz_led);
191+
}
192+
147193
void app_main(void)
148194
{
149195
/* Initialize NVS */
@@ -173,15 +219,15 @@ void app_main(void)
173219
ESP_LOGI(TAG, "%s CSI Node (ADR-018 / ADR-110) — v%s — Node ID: %d",
174220
target_name, app_desc->version, g_nvs_config.node_id);
175221

176-
/* Turn off onboard WS2812 LED.
177-
* S3 dev boards put the LED on GPIO 38; C6 dev boards on GPIO 8.
178-
* On C6, GPIO 38 doesn't exist (only 0-30) — gate the init by target. */
222+
/* Onboard WS2812: sweep the ruv-neural-viz viridis colormap at 40 Hz.
223+
* C6 dev boards wire the LED to GPIO 8; S3 boards to GPIO 38 (DevKitC-1 v1.0)
224+
* or GPIO 48 (DevKitC-1 v1.1 / N16R8 — see #962). On S3 we drive 48 (the
225+
* common module). On C6, GPIO 38/48 don't exist (only 0-30) — gate by target. */
179226
#if defined(CONFIG_IDF_TARGET_ESP32C6)
180227
const int led_gpio = 8;
181228
#else
182-
const int led_gpio = 38;
229+
const int led_gpio = 48;
183230
#endif
184-
led_strip_handle_t led_strip;
185231
led_strip_config_t strip_config = {
186232
.strip_gpio_num = led_gpio,
187233
.max_leds = 1,
@@ -193,8 +239,16 @@ void app_main(void)
193239
.resolution_hz = 10 * 1000 * 1000, // 10MHz
194240
.flags.with_dma = false,
195241
};
196-
if (led_strip_new_rmt_device(&strip_config, &rmt_config, &led_strip) == ESP_OK) {
197-
led_strip_clear(led_strip);
242+
if (led_strip_new_rmt_device(&strip_config, &rmt_config, &s_viz_led) == ESP_OK) {
243+
const esp_timer_create_args_t viz_args = {
244+
.callback = &led_gamma_40hz_cb,
245+
.name = "led_gamma_40hz",
246+
};
247+
esp_timer_handle_t viz_timer;
248+
if (esp_timer_create(&viz_args, &viz_timer) == ESP_OK) {
249+
esp_timer_start_periodic(viz_timer, 12500); // 12.5 ms toggle → 40 Hz square wave
250+
ESP_LOGI(TAG, "Onboard WS2812: 40 Hz gamma flicker (GENUS), colour=CSI motion via ruv-neural-viz, GPIO %d", led_gpio);
251+
}
198252
}
199253

200254
/* ADR-110 P4: 802.15.4 mesh time-sync (C6 only).

0 commit comments

Comments
 (0)