Files
ruvnet--RuView/firmware/esp32-gamma-stim/README.md
T
ruv 235d3b4115 fix(firmware): gamma-stim e-stop ISR safety + dead-man + unlock guard (fw 0.1.1)
- gate-GPIO architecture: envelope delivered by direct-register GPIO writes
  in the ISR (gpio_ll); LEDC programmed only from task context — e-stop's
  first instruction is gate-low (microseconds, ISR-legal); gate wiring +
  pulldown documented as REQUIRED
- independent 250ms dead-man: latches FAULT and forces gate low if the
  half-period ISR stalls while RUNNING
- UNLOCK refuses while the e-stop button is physically held
- START TOCTOU closed (gptimer_start under the mux); witness records
  deduped by session_seq; serial overflow discards until newline
- HIL contract: explicit is_finite, hash-count sanity, worst-case-over-window
  frequency error + half-period jitter criteria
Host C tests 15/15 (ruvultra gcc); ESP-IDF v5.5.2 esp32s3 build clean.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-11 15:26:17 -04:00

131 lines
6.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# esp32-gamma-stim — ESP32 gamma stimulation actuator (ADR-250 §21 M2)
The **device harness** for `ruview-gamma`: an ESP32 that drives a light + sound
flicker at a commanded frequency, gated by a hardware emergency stop, with a
compiled-in safety envelope that mirrors `SafetyEnvelope::conservative()` in the
Rust crate. This is the actuator the `hil::verify_hil` contract grades.
> **Not a medical device.** Research/engineering harness. The host
> (`ruview-gamma`) decides *what* to play and never claims a therapeutic effect;
> this firmware only plays it safely and reports exactly what it did.
## Design: safety core vs hardware binding
| File | Role | Tested |
|------|------|--------|
| `main/stim_core.{h,c}` | Pure C safety core: envelope validation, START/STOP/e-stop **latched** state machine, exact integer timing math, line protocol parser. No ESP-IDF deps. | `tests/test_stim_core.c` on the host (gcc), 15 tests |
| `main/main.c` | ESP-IDF binding: GPTimer ISR toggling the **gate GPIO** (envelope + hard off path), LEDC PWM carriers (LED + audio, task-context only), sync GPIO, e-stop ISR, dead-man watchdog, USB-CDC console. Only moves registers. | on hardware (HIL) |
Every safety decision lives in the host-tested core — **defense in depth**: the
Rust host gates the stimulus *and* the device gates it again independently, so a
buggy or compromised host still cannot command an out-of-envelope output.
## Output gate wiring (hard off path) — REQUIRED
The LEDC driver APIs are **not ISR-safe** (non-IRAM, non-ISR internal locking),
so the firmware never touches LEDC from an interrupt. Instead the emitted
output is the **hardware AND** of two signals:
1. **LEDC carrier** (LED 19.5 kHz PWM / audio tone) at a *constant* duty for
the whole session, programmed from task context at START.
2. **Gate GPIO** (`GAMMA_STIM_GATE_GPIO`, default 10) — a plain GPIO toggled
by the half-period ISR with a direct register write. It delivers the
3644 Hz envelope *and* is the hard real-time off path: the e-stop ISR and
the dead-man watchdog force it low in microseconds.
Wiring requirements:
- Route the gate pin to the **enable input of the LED driver and the audio
amplifier** (or a series MOSFET that interrupts both). Output must be
physically impossible unless `gate == high` *and* the carrier is running.
- Add an **external pulldown (~10 kΩ) on the gate pin** so the drivers stay
disabled during the boot/reset window while the pin floats, and on firmware
crash/brownout.
- Avoid strapping pins for the gate (e.g. GPIO8/GPIO9 on ESP32-C6).
Without this wiring the LED pin would carry the bare carrier for the whole
session (no envelope) — the gate is not optional.
## Independent dead-man watchdog
The session-duration cap is enforced by the same GPTimer ISR that toggles the
gate, so a stalled/killed ISR could otherwise leave the envelope ON. A separate
250 ms FreeRTOS software timer checks that `elapsed_half_periods` advanced
since its last tick whenever the state is RUNNING (worst-case half-period is
13.9 ms @ 36 Hz, so a healthy session advances ~18× per check). On a stall it
forces the gate low and latches a **FAULT** — same latch semantics as the
e-stop: the device prints the session record plus `EVT fault_latched`, and
refuses START until `UNLOCK`.
## Run the safety-core tests (no hardware, no ESP-IDF)
```bash
cd firmware/esp32-gamma-stim
gcc -Wall -Wextra -Werror -O2 -I main tests/test_stim_core.c main/stim_core.c -o /tmp/test_stim && /tmp/test_stim
# -> all 15 stim_core tests passed
```
## Build & flash (ESP-IDF v5.2+)
```bash
idf.py set-target esp32s3 # or esp32c6
idf.py menuconfig # Gamma Stimulation -> pins, tone freq
idf.py build flash monitor
```
Default pins (Kconfig-overridable): LED GPIO 4, audio GPIO 5, sync-out GPIO 6,
e-stop button GPIO 7 (to GND, active-low), output gate GPIO 10 (to the
driver/amp enables, with external pulldown — see "Output gate wiring").
## Host protocol (line-based, 115200, USB-CDC/UART0)
```
START <freq_mhz> <brightness_pct> <volume_pct> <duration_s>
STOP
STATUS
UNLOCK # clear a latched e-stop/fault
VERSION
```
`UNLOCK` is refused with `ERR estop_button_pressed` while the physical e-stop
button is still held down — release the button first. (A host retry loop can
therefore never resume a session against a pressed button.) Lines longer than
95 characters are discarded in full (`ERR parse_error`, then everything up to
the next newline is swallowed).
Frequency is **millihertz** (40.0 Hz = `40000`) so the ±0.1 Hz HIL target is
exact integer math (±100 mHz). Example — 40.0 Hz, 30% brightness, 28% volume,
10 min:
```
> START 40000 30 28 600
OK start seq=1 half_period_us=12500
... (session runs) ...
SESSION {"seq":1,"freq_mhz":40000,"brightness_pct":30,"volume_pct":28,"duration_s":600,"half_periods":48000,"stop":"completed","fw":"0.1.0"}
```
The `SESSION {...}` line is canonical (quantized integers, fixed field order) so
the host pairs it with the RuFlo session builder to reproduce the witness hash
(HIL: 100% hash reproducibility).
## How it maps to the HIL targets (`v2/crates/ruview-gamma/src/hil.rs`)
| HIL target | How this firmware meets it |
|------------|----------------------------|
| LED frequency ±0.1 Hz (incl. worst case over the session) | GPTimer at 1 MHz crystal-derived ticks; half-period from exact integer division; worst-case truncation at 44 Hz is ~3 mHz (35× inside budget) |
| A/V sync < 5 ms | one **gate GPIO** enables both the LED driver and the audio amp; on/off skew is zero by construction |
| Stop → actuator off < 100 ms | e-stop GPIO ISR drives the gate GPIO low with a **direct register write** (no driver calls in ISR context) — microseconds; LEDC shutdown follows in task context as defense in depth |
| Stalled-ISR containment | independent 250 ms dead-man timer latches FAULT and forces the gate low if the half-period ISR stops advancing |
| Half-period jitter | gate toggled in a GPTimer alarm ISR with auto-reload; jitter is ISR latency (µs-scale), measured at the sync pin |
| Session-hash reproducibility 100% | canonical integer `SESSION {...}` record, no float formatting, deduped (one record per session seq — STOP-while-idle or e-stop-after-completion never re-print) |
| EEG lift ≥ 20% vs fixed 40 Hz | provided by the host's adaptive optimizer choosing the frequency this firmware plays |
## Hardware notes
- **Drive the LED through a MOSFET/constant-current driver**, not the GPIO
directly. Keep brightness within eye-safe flicker limits — the firmware caps
duty at the envelope's 40%, but the optical design owns absolute luminance.
- **Photosensitivity/epilepsy is a hard exclusion** at the host
(`ExclusionScreen`); the device is the last line, not the only line.
- The e-stop button is mandatory for any human-facing bench run.