From a4c2935a2f85974b3f164c408bfe8f2609fcc964 Mon Sep 17 00:00:00 2001 From: rUv Date: Wed, 17 Jun 2026 21:04:02 -0400 Subject: [PATCH] feat(firmware): onboard LED 40 Hz gamma stimulus + CSI-motion colour (ADR-183) (#1127) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * feat(firmware): onboard LED as 40 Hz gamma stimulus, colour from live CSI motion (ADR-183) The S3 onboard WS2812 (GPIO 48, #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 / #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 --- ...onboard-led-gamma-stimulus-csi-colormap.md | 96 +++++++++++++++++++ firmware/esp32-csi-node/main/main.c | 68 +++++++++++-- 2 files changed, 157 insertions(+), 7 deletions(-) create mode 100644 docs/adr/ADR-183-onboard-led-gamma-stimulus-csi-colormap.md diff --git a/docs/adr/ADR-183-onboard-led-gamma-stimulus-csi-colormap.md b/docs/adr/ADR-183-onboard-led-gamma-stimulus-csi-colormap.md new file mode 100644 index 00000000..4e0b2574 --- /dev/null +++ b/docs/adr/ADR-183-onboard-led-gamma-stimulus-csi-colormap.md @@ -0,0 +1,96 @@ +# ADR-183: Onboard LED as a 40 Hz Gamma Stimulus, Colour-Mapped from Live CSI via `ruv-neural-viz` + +| Field | Value | +|-------|-------| +| **Status** | Accepted — implemented & hardware-confirmed on ESP32-S3 N16R8 (COM8) | +| **Date** | 2026-06-17 | +| **Deciders** | ruv | +| **Codename** | **GAMMA-VIZ** | +| **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) | + +## Context + +Two threads converged. (1) `ruv-neural-viz::ColorMap` — the viridis/cool-warm +palette the rUv-Neural stack uses to render brain-topology graphs — was `std`-only, +so it couldn't run on the ESP32. (2) The onboard WS2812 on the S3 CSI node was dead +weight: the firmware only cleared it on boot (and on the wrong pin for N16R8 — GPIO +38 vs the actual 48, see #962). + +The ask: make the LED do something real and honest, using the project's own visual +capability — not a decorative blink. The natural fit is a **40 Hz gamma stimulus** +(the GENUS gamma-entrainment frequency from Alzheimer's light-therapy research) +whose **colour is driven by live sensed motion**, so the node's front panel is both +a known bio-stimulus waveform and a truthful readout of what the CSI is detecting. + +## Decision + +### Part A — make `ColorMap` `no_std` + +`colormap.rs` is self-contained (no cross-crate deps), so expose it on `no_std` +targets. The only blockers were two `std`-only `f64` ops: + +- `f64::round` / `f64::abs` → replaced with `core`+`alloc`-safe helpers `fround` + (round via `f64 as i64` truncation — a `core` cast, no `libm`) and `fabs`. +- `Vec`/`String`/`format!` → from `alloc`. + +The graph-bound modules (`animation`/`ascii`/`export`/`layout`) and their heavy deps +move behind a default `std` feature; `--no-default-features` builds the crate `no_std` +and exposes only `colormap`. Output is **byte-identical** (8/8 colormap tests pass with +the same RGB values), so this is a pure portability change. + +### Part B — the LED stimulus (firmware) + +`firmware/esp32-csi-node/main/main.c`, on boot: + +- WS2812 on **GPIO 48** (N16R8 / DevKitC-1 v1.1; GPIO 8 on C6). +- An `esp_timer` periodic at **12 500 µs toggles a square wave → 40 Hz, 50 % duty** + (full-on / full-off — a *perceptible* gamma flicker, not a colour drift). +- **ON-phase colour = live CSI motion.** Each ON phase reads `edge_get_vitals().motion_energy`, + normalises it (`/ LED_MOTION_FULLSCALE`, clamped `[0,1]`), and indexes a **60-step + viridis LUT generated from `ColorMap::viridis().map()`** — still = dark purple, + strong motion = yellow. + +The LUT is baked from the real crate (Part A makes the same `ColorMap` embeddable +for a future direct FFI path once the ESP Rust toolchain is in CI). The colours are +therefore provably `ruv-neural-viz`'s, and the motion is provably real. + +## Honesty (what it is and is not) + +- **40 Hz is a real square-wave stimulus** (12.5 ms on / 12.5 ms off), not a label on + a colour sweep. It is *not* tied to any measured 40 Hz brain rhythm — it is an + *output* stimulus at the gamma frequency, not a readout of neural gamma. +- **Colour is a real CSI readout** — `motion_energy` is the on-device phase-variance + motion metric the node already computes; no fabrication. At rest the LED sits at the + purple (low) end and flickers there. +- No therapeutic claim is made. 40 Hz GENUS entrainment is cited as the *origin of the + frequency choice*, not as a validated medical effect of this device. + +## Consequences + +**Positive** +- The LED is now an honest front-panel: gamma-frequency flicker + a live motion readout. +- `ColorMap` is embeddable (`no_std`), unblocking on-device use of the rUv-Neural + palette beyond this LED. +- Confirms #962's GPIO-48 fix visually (the LED lights on N16R8). + +**Negative / risks** +- Changes *default* firmware behaviour: the onboard LED now animates instead of staying + off (minor power + a visible flicker some may not want). Gate behind a Kconfig + (`CONFIG_LED_GAMMA_VIZ`) if a dark default is preferred — follow-up. +- A 40 Hz flicker can be an issue for photosensitive users; document on the enclosure. +- `LED_MOTION_FULLSCALE` (0.25) is hand-tuned, not calibrated per-environment. +- The colour uses a baked LUT, not the live Rust `ColorMap` (FFI path deferred — needs + the ESP Rust/xtensa toolchain, not yet in CI). + +## Validation + +- `ruv-neural-viz`: `cargo build` (std) ✓, `cargo test colormap` 8/8 ✓ (identical RGB), + `cargo build --no-default-features` compiles `no_std` ✓. +- Firmware: built (1.13 MB), flashed to ESP32-S3 N16R8 (COM8). Boot log: + `Onboard WS2812: 40 Hz gamma flicker (GENUS), colour=CSI motion via ruv-neural-viz, GPIO 48`; + CSI continues (27–38 pps), `motion=0.00` at rest → purple flicker as designed. +- Full on-device (xtensa) Rust build of `ColorMap` not run — ESP Rust toolchain absent. + +## References +- ruvnet/ruv-neural#3 (ColorMap no_std), RuView#1126 (submodule bump), #962 (GPIO 48). +- Singer/Tsai GENUS 40 Hz gamma entrainment (origin of the frequency, not a device claim). diff --git a/firmware/esp32-csi-node/main/main.c b/firmware/esp32-csi-node/main/main.c index 536bb95f..7cf32ffb 100644 --- a/firmware/esp32-csi-node/main/main.c +++ b/firmware/esp32-csi-node/main/main.c @@ -144,6 +144,52 @@ static void wifi_init_sta(void) } } +/* Viridis colormap (60 steps), generated from ruv-neural-viz::ColorMap::viridis() + * — the rUv-Neural brain-topology colormap, now no_std (ruvnet/ruv-neural#3 / + * RuView#1126). Used as the ON-phase colour of the 40 Hz gamma flicker below: + * dark-purple (still) -> teal -> green -> yellow (strong motion). */ +static const uint8_t VIRIDIS_LUT[60][3] = { + { 68, 1, 84},{ 67, 6, 88},{ 67, 12, 91},{ 66, 17, 95},{ 66, 23, 99}, + { 65, 28,103},{ 64, 34,106},{ 64, 39,110},{ 63, 45,114},{ 63, 50,118}, + { 62, 56,121},{ 61, 61,125},{ 61, 67,129},{ 60, 72,132},{ 59, 78,136}, + { 59, 83,139},{ 57, 87,139},{ 55, 92,139},{ 53, 96,139},{ 52,100,139}, + { 50,104,139},{ 48,109,139},{ 46,113,139},{ 44,117,140},{ 43,122,140}, + { 41,126,140},{ 39,130,140},{ 37,134,140},{ 36,139,140},{ 34,143,140}, + { 35,147,139},{ 39,151,136},{ 43,154,133},{ 47,158,130},{ 52,162,127}, + { 56,166,124},{ 60,170,121},{ 64,173,119},{ 68,177,116},{ 72,181,113}, + { 76,185,110},{ 81,189,107},{ 85,192,104},{ 89,196,102},{ 93,200, 99}, + {102,203, 95},{113,205, 91},{124,207, 87},{134,209, 82},{145,211, 78}, + {156,213, 74},{167,215, 70},{178,217, 66},{188,219, 62},{199,221, 58}, + {210,223, 54},{221,225, 49},{231,227, 45},{242,229, 41},{253,231, 37}, +}; +static led_strip_handle_t s_viz_led; + +/* motion_energy that saturates the colormap to yellow (tunable). */ +#define LED_MOTION_FULLSCALE 0.25f + +/* GENUS-style 40 Hz gamma flicker: full on/off square wave, 50% duty (toggled + * every 12.5 ms → 40 Hz). The ON colour is live CSI motion (edge motion_energy) + * mapped through the ruv-neural-viz viridis LUT — still=purple, moving=yellow. + * So the LED is a real 40 Hz gamma stimulus whose hue tracks sensed motion. */ +static void led_gamma_40hz_cb(void *arg) +{ + static bool on = false; + on = !on; + if (on) { + edge_vitals_pkt_t v; + float m = edge_get_vitals(&v) ? v.motion_energy : 0.0f; + float norm = m / LED_MOTION_FULLSCALE; + if (norm < 0.0f) norm = 0.0f; + if (norm > 1.0f) norm = 1.0f; + int idx = (int)(norm * 59.0f + 0.5f); + const uint8_t *c = VIRIDIS_LUT[idx]; + led_strip_set_pixel(s_viz_led, 0, c[0], c[1], c[2]); /* R,G,B (driver maps to GRB) */ + } else { + led_strip_set_pixel(s_viz_led, 0, 0, 0, 0); /* off phase */ + } + led_strip_refresh(s_viz_led); +} + void app_main(void) { /* Initialize NVS */ @@ -173,15 +219,15 @@ void app_main(void) ESP_LOGI(TAG, "%s CSI Node (ADR-018 / ADR-110) — v%s — Node ID: %d", target_name, app_desc->version, g_nvs_config.node_id); - /* Turn off onboard WS2812 LED. - * S3 dev boards put the LED on GPIO 38; C6 dev boards on GPIO 8. - * On C6, GPIO 38 doesn't exist (only 0-30) — gate the init by target. */ + /* Onboard WS2812: sweep the ruv-neural-viz viridis colormap at 40 Hz. + * C6 dev boards wire the LED to GPIO 8; S3 boards to GPIO 38 (DevKitC-1 v1.0) + * or GPIO 48 (DevKitC-1 v1.1 / N16R8 — see #962). On S3 we drive 48 (the + * common module). On C6, GPIO 38/48 don't exist (only 0-30) — gate by target. */ #if defined(CONFIG_IDF_TARGET_ESP32C6) const int led_gpio = 8; #else - const int led_gpio = 38; + const int led_gpio = 48; #endif - led_strip_handle_t led_strip; led_strip_config_t strip_config = { .strip_gpio_num = led_gpio, .max_leds = 1, @@ -193,8 +239,16 @@ void app_main(void) .resolution_hz = 10 * 1000 * 1000, // 10MHz .flags.with_dma = false, }; - if (led_strip_new_rmt_device(&strip_config, &rmt_config, &led_strip) == ESP_OK) { - led_strip_clear(led_strip); + if (led_strip_new_rmt_device(&strip_config, &rmt_config, &s_viz_led) == ESP_OK) { + const esp_timer_create_args_t viz_args = { + .callback = &led_gamma_40hz_cb, + .name = "led_gamma_40hz", + }; + esp_timer_handle_t viz_timer; + if (esp_timer_create(&viz_args, &viz_timer) == ESP_OK) { + esp_timer_start_periodic(viz_timer, 12500); // 12.5 ms toggle → 40 Hz square wave + ESP_LOGI(TAG, "Onboard WS2812: 40 Hz gamma flicker (GENUS), colour=CSI motion via ruv-neural-viz, GPIO %d", led_gpio); + } } /* ADR-110 P4: 802.15.4 mesh time-sync (C6 only).