From 235d3b41159143cf4845a3f88ba5621194b1a9f2 Mon Sep 17 00:00:00 2001 From: ruv Date: Thu, 11 Jun 2026 15:26:17 -0400 Subject: [PATCH] fix(firmware): gamma-stim e-stop ISR safety + dead-man + unlock guard (fw 0.1.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- firmware/esp32-gamma-stim/README.md | 60 +++- .../esp32-gamma-stim/main/Kconfig.projbuild | 21 +- firmware/esp32-gamma-stim/main/main.c | 316 ++++++++++++++---- firmware/esp32-gamma-stim/main/stim_core.h | 6 +- 4 files changed, 332 insertions(+), 71 deletions(-) diff --git a/firmware/esp32-gamma-stim/README.md b/firmware/esp32-gamma-stim/README.md index bcfb2c69..8fb1e812 100644 --- a/firmware/esp32-gamma-stim/README.md +++ b/firmware/esp32-gamma-stim/README.md @@ -14,12 +14,49 @@ Rust crate. This is the actuator the `hil::verify_hil` contract grades. | 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, LEDC PWM (LED + audio), sync GPIO, e-stop ISR, USB-CDC console. Only moves registers. | on hardware (HIL) | +| `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 + 36–44 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 @@ -37,7 +74,8 @@ 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). +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) @@ -45,10 +83,16 @@ e-stop button GPIO 7 (to GND, active-low). START STOP STATUS -UNLOCK # clear a latched e-stop +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: @@ -68,10 +112,12 @@ the host pairs it with the RuFlo session builder to reproduce the witness hash | HIL target | How this firmware meets it | |------------|----------------------------| -| LED frequency ±0.1 Hz | 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 | LED and audio duty written in the **same ISR**; skew is a few register writes | -| Stop → actuator off < 100 ms | e-stop GPIO ISR turns outputs off **in the ISR** before latching — microseconds | -| Session-hash reproducibility 100% | canonical integer `SESSION {...}` record, no float formatting | +| 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 diff --git a/firmware/esp32-gamma-stim/main/Kconfig.projbuild b/firmware/esp32-gamma-stim/main/Kconfig.projbuild index bfdd693f..6df75a20 100644 --- a/firmware/esp32-gamma-stim/main/Kconfig.projbuild +++ b/firmware/esp32-gamma-stim/main/Kconfig.projbuild @@ -25,9 +25,24 @@ config GAMMA_STIM_ESTOP_GPIO int "Emergency-stop button GPIO (active low)" default 7 help - Button to GND. Falling edge latches an emergency stop: outputs off in - the ISR, sessions refused until UNLOCK. Stop latency target <100 ms; - the ISR path achieves microseconds. + Button to GND. Falling edge latches an emergency stop: the output + gate GPIO is driven low in the ISR (direct register write), sessions + refused until UNLOCK. UNLOCK is refused while the button is still + held. Stop latency target <100 ms; the gate write achieves + microseconds. + +config GAMMA_STIM_GATE_GPIO + int "Output-enable gate GPIO (hard off path)" + default 10 + help + Plain GPIO that enables BOTH the LED driver and the audio amplifier + (hardware AND with the LEDC carriers). The half-period ISR toggles it + to deliver the 36-44 Hz envelope, and the e-stop ISR / dead-man + watchdog force it low with a direct register write — the <100 ms + stop guarantee rests on this pin, NOT on the LEDC driver. Wire it to + the driver/amplifier enable inputs with an external pulldown so the + outputs stay disabled while the pin floats during boot/reset. Avoid + strapping pins (e.g. GPIO8/GPIO9 on ESP32-C6). config GAMMA_STIM_AUDIO_TONE_HZ int "Audio tone carrier frequency (Hz)" diff --git a/firmware/esp32-gamma-stim/main/main.c b/firmware/esp32-gamma-stim/main/main.c index f7029853..c38362e1 100644 --- a/firmware/esp32-gamma-stim/main/main.c +++ b/firmware/esp32-gamma-stim/main/main.c @@ -5,17 +5,40 @@ * v2/crates/ruview-gamma/src/hil.rs): * * GPTimer (1 MHz, crystal-derived) ─ ISR every half-period - * ├── LED: LEDC channel 0, 19.5 kHz carrier; duty = brightness or 0 - * ├── Audio: LEDC channel 1, tone carrier; duty = volume or 0 - * └── SYNC: bare GPIO mirroring the envelope (logic-analyzer capture) + * ├── GATE: plain GPIO output-enable, toggled with a direct register + * │ write (gpio_ll, IRAM-resident). Carries the 36-44 Hz + * │ envelope AND the hard real-time off path. + * └── SYNC: bare GPIO mirroring the envelope (logic-analyzer capture) * - * E-STOP button ─ GPIO ISR -> outputs off in the ISR itself, state LOCKED. - * Stop path is interrupt -> register write: microseconds, vs the 100 ms - * HIL budget. The latch is enforced by stim_core (host-tested). + * LEDC (LED 19.5 kHz carrier, audio tone carrier) runs at a CONSTANT duty + * for the whole session, programmed from task context only. The emitted + * output is the hardware AND of (LEDC carrier duty) and (GATE asserted): + * the gate pin must drive the enable of the LED driver / audio amplifier + * (see README "Output gate wiring"). ISRs never call LEDC driver APIs — + * they take a non-ISR spinlock and are not IRAM-resident, so calling them + * from an ISR is undefined behavior. The stop guarantee rests on a single + * GPIO register write instead. + * + * E-STOP button ─ GPIO ISR: gate low FIRST (register write, microseconds), + * then latch LOCKED (stim_core) and queue an event; the main task zeroes + * the LEDC duty afterwards (defense in depth, not the stop path). + * + * DEAD-MAN ─ independent 250 ms FreeRTOS software timer: while RUNNING the + * half-period ISR must advance elapsed_half_periods every <= 13.9 ms + * (36 Hz worst case); if a dead-man tick sees no progress the ISR/timer + * has died with the envelope possibly ON, so it forces the gate low and + * latches a FAULT (same latch semantics as the e-stop). + * + * GPTimer ownership: ONLY the console task starts/stops the GPTimer + * (handle_start / STOP). ISRs and the dead-man never call gptimer + * functions; after completion/e-stop/fault the timer may idle-tick + * harmlessly (state != RUNNING -> gate stays low) until the next host + * command reconfigures it. This keeps every driver call in task context. * * Host protocol: line-based over USB-CDC/UART0 console at 115200 * (START/STOP/STATUS/UNLOCK/VERSION — see stim_core.h). Every session * ends with one "SESSION {...}" JSON line for the host to witness-hash. + * UNLOCK is refused while the e-stop button is still held down. * * All safety decisions (envelope, latch, session math) are in stim_core.c, * which is unit-tested on the host. This file only moves registers. @@ -26,29 +49,33 @@ #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "freertos/queue.h" +#include "freertos/timers.h" #include "driver/gptimer.h" #include "driver/ledc.h" #include "driver/gpio.h" +#include "hal/gpio_ll.h" +#include "soc/gpio_struct.h" #include "esp_log.h" #include "stim_core.h" static const char *TAG = "gamma-stim"; -#define FIRMWARE_VERSION "0.1.0" +#define FIRMWARE_VERSION "0.1.1" /* ---- Pins / peripherals (Kconfig-overridable) ----------------------------- */ #define PIN_LED CONFIG_GAMMA_STIM_LED_GPIO #define PIN_AUDIO CONFIG_GAMMA_STIM_AUDIO_GPIO #define PIN_SYNC CONFIG_GAMMA_STIM_SYNC_GPIO #define PIN_ESTOP CONFIG_GAMMA_STIM_ESTOP_GPIO +#define PIN_GATE CONFIG_GAMMA_STIM_GATE_GPIO #define LEDC_LED_CH LEDC_CHANNEL_0 #define LEDC_AUDIO_CH LEDC_CHANNEL_1 #define LEDC_LED_TIMER LEDC_TIMER_0 #define LEDC_AUDIO_TIMER LEDC_TIMER_1 -/* 13-bit duty at ~19.5 kHz LED carrier: flicker-free dimming far above the +/* 12-bit duty at ~19.5 kHz LED carrier: flicker-free dimming far above the * envelope band; the 36-44 Hz stimulus is the *envelope*, not the carrier. */ #define LED_CARRIER_HZ 19500 #define LED_DUTY_RES LEDC_TIMER_12_BIT @@ -58,35 +85,65 @@ static const char *TAG = "gamma-stim"; #define AUDIO_DUTY_RES LEDC_TIMER_12_BIT #define AUDIO_DUTY_MAX ((1 << 12) - 1) +/* Dead-man check period. Worst-case half-period is 13.9 ms (36 Hz), so a + * healthy session advances elapsed_half_periods ~18+ times per check. */ +#define DEADMAN_PERIOD_MS 250 + /* ---- Shared state ---------------------------------------------------------- */ -static stim_ctx_t s_ctx; /* guarded: ISR + main task */ +static stim_ctx_t s_ctx; /* guarded by s_mux: ISRs + tasks */ static portMUX_TYPE s_mux = portMUX_INITIALIZER_UNLOCKED; -static gptimer_handle_t s_timer = NULL; -static QueueHandle_t s_evt_queue = NULL; /* session-finished events to task */ +static gptimer_handle_t s_timer = NULL; /* started/stopped by console task ONLY */ +static QueueHandle_t s_evt_queue = NULL; /* events to the main task */ +static TimerHandle_t s_deadman = NULL; +static uint32_t s_printed_seq = 0; /* guarded by s_mux: record dedupe */ -typedef enum { EVT_SESSION_DONE = 1, EVT_ESTOP = 2 } stim_evt_t; +typedef enum { + EVT_SESSION_DONE = 1, /* duration elapsed (RUNNING -> IDLE edge) */ + EVT_ESTOP = 2, /* e-stop button latched */ + EVT_FAULT = 3, /* dead-man detected a stalled timer ISR */ +} stim_evt_kind_t; -/* Apply outputs for the current envelope phase. ISR-safe (register writes). */ -static void IRAM_ATTR apply_outputs(bool on, uint8_t brightness_pct, uint8_t volume_pct) +typedef struct { + stim_evt_kind_t kind; + stim_ctx_t snap; /* context captured at the moment of the event */ +} stim_evt_t; + +/* ---- ISR-safe GPIO writes (direct register path, inline => IRAM) ---------- */ + +static inline void IRAM_ATTR gate_write(bool on) { - uint32_t led_duty = on ? ((uint32_t)brightness_pct * LED_DUTY_MAX) / 100U : 0U; + gpio_ll_set_level(&GPIO, PIN_GATE, on ? 1 : 0); +} + +static inline void IRAM_ATTR sync_write(bool on) +{ + gpio_ll_set_level(&GPIO, PIN_SYNC, on ? 1 : 0); +} + +/* ---- LEDC carrier control (TASK CONTEXT ONLY — not ISR-safe) --------------- */ + +static void carrier_set(uint8_t brightness_pct, uint8_t volume_pct) +{ + uint32_t led_duty = ((uint32_t)brightness_pct * LED_DUTY_MAX) / 100U; /* Volume cap is 40% -> max audio duty 20% of full scale: keep the square * tone gentle; real loudness control belongs to the analog stage. */ - uint32_t aud_duty = on ? ((uint32_t)volume_pct * (AUDIO_DUTY_MAX / 2U)) / 100U : 0U; - ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_LED_CH, led_duty); - ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_LED_CH); - ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_AUDIO_CH, aud_duty); - ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_AUDIO_CH); - gpio_set_level(PIN_SYNC, on ? 1 : 0); + uint32_t aud_duty = ((uint32_t)volume_pct * (AUDIO_DUTY_MAX / 2U)) / 100U; + ESP_ERROR_CHECK(ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_LED_CH, led_duty)); + ESP_ERROR_CHECK(ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_LED_CH)); + ESP_ERROR_CHECK(ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_AUDIO_CH, aud_duty)); + ESP_ERROR_CHECK(ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_AUDIO_CH)); } -static void IRAM_ATTR outputs_off(void) +static void carrier_off(void) { - apply_outputs(false, 0, 0); + carrier_set(0, 0); } -/* GPTimer alarm ISR: one half-period boundary. */ +/* ---- ISRs (no driver calls: register writes + queue only) ------------------ */ + +/* GPTimer alarm ISR: one half-period boundary. The envelope is delivered by + * toggling the gate GPIO; the LEDC carriers are untouched here. */ static bool IRAM_ATTR on_half_period(gptimer_handle_t timer, const gptimer_alarm_event_data_t *edata, void *user) @@ -94,40 +151,81 @@ static bool IRAM_ATTR on_half_period(gptimer_handle_t timer, (void)timer; (void)edata; (void)user; BaseType_t hpw = pdFALSE; portENTER_CRITICAL_ISR(&s_mux); + bool was_running = (s_ctx.state == STIM_RUNNING); bool running = stim_tick(&s_ctx); - if (running) { - apply_outputs(s_ctx.envelope_on, s_ctx.active.brightness_pct, - s_ctx.active.volume_pct); - } else { - outputs_off(); - gptimer_stop(timer); - stim_evt_t e = EVT_SESSION_DONE; + bool on = running && s_ctx.envelope_on; + gate_write(on); + sync_write(on); + if (was_running && !running) { + /* RUNNING -> done edge: exactly one completion event per session. + * The timer is NOT stopped here (gptimer APIs stay in task context); + * until the next host command it idle-ticks with the gate low. */ + stim_evt_t e = { .kind = EVT_SESSION_DONE, .snap = s_ctx }; xQueueSendFromISR(s_evt_queue, &e, &hpw); } portEXIT_CRITICAL_ISR(&s_mux); return hpw == pdTRUE; } -/* E-stop button ISR: outputs off *here*, then latch + notify. The full stop - * path is ISR latency + two LEDC register writes — microseconds. */ +/* E-stop button ISR. Hard real-time stop path = the first line: one direct + * GPIO register write forcing the output gate low. Everything after that is + * bookkeeping. LEDC shutdown happens later in the main task. */ static void IRAM_ATTR on_estop(void *arg) { (void)arg; + gate_write(false); /* outputs off: microseconds */ BaseType_t hpw = pdFALSE; portENTER_CRITICAL_ISR(&s_mux); - outputs_off(); + bool already_latched = (s_ctx.state == STIM_LOCKED); stim_estop(&s_ctx, STIM_STOP_BUTTON); - if (s_timer) { - gptimer_stop(s_timer); + sync_write(false); + if (!already_latched) { /* debounce: one event per latch */ + stim_evt_t e = { .kind = EVT_ESTOP, .snap = s_ctx }; + xQueueSendFromISR(s_evt_queue, &e, &hpw); } portEXIT_CRITICAL_ISR(&s_mux); - stim_evt_t e = EVT_ESTOP; - xQueueSendFromISR(s_evt_queue, &e, &hpw); if (hpw == pdTRUE) { portYIELD_FROM_ISR(); } } +/* ---- Dead-man watchdog (FreeRTOS timer task context) ----------------------- */ + +/* Independent of the GPTimer ISR: if state == RUNNING but the ISR stopped + * advancing elapsed_half_periods between two checks, the toggling path is + * dead — possibly with the gate left ON. Force outputs off and latch FAULT + * (same latch as the e-stop: START refused until UNLOCK). */ +static void deadman_cb(TimerHandle_t t) +{ + (void)t; + static uint32_t dm_seq = 0; /* session_seq starts at 1; 0 = none */ + static uint32_t dm_elapsed = 0; + bool fault = false; + stim_evt_t e = { .kind = EVT_FAULT }; + portENTER_CRITICAL(&s_mux); + if (s_ctx.state == STIM_RUNNING) { + if (dm_seq == s_ctx.session_seq && + dm_elapsed == s_ctx.elapsed_half_periods) { + gate_write(false); + sync_write(false); + stim_estop(&s_ctx, STIM_STOP_FAULT); + e.snap = s_ctx; + fault = true; + } else { + dm_seq = s_ctx.session_seq; + dm_elapsed = s_ctx.elapsed_half_periods; + } + } else { + dm_seq = 0; + dm_elapsed = 0; + } + portEXIT_CRITICAL(&s_mux); + if (fault) { + carrier_off(); /* task context: LEDC allowed */ + xQueueSend(s_evt_queue, &e, 0); + } +} + /* ---- Peripheral setup -------------------------------------------------------- */ static void setup_ledc(void) @@ -171,6 +269,18 @@ static void setup_ledc(void) static void setup_gpio(void) { + /* Output-enable gate FIRST: drive it low before the LEDC carriers exist. + * The board-level wiring must add an external pulldown so the LED driver + * and audio amp stay disabled while this pin floats during boot/reset + * (see README "Output gate wiring"). */ + gpio_config_t gate = { + .pin_bit_mask = 1ULL << PIN_GATE, + .mode = GPIO_MODE_OUTPUT, + .pull_down_en = GPIO_PULLDOWN_ENABLE, + }; + ESP_ERROR_CHECK(gpio_config(&gate)); + gpio_set_level(PIN_GATE, 0); + gpio_config_t sync = { .pin_bit_mask = 1ULL << PIN_SYNC, .mode = GPIO_MODE_OUTPUT, @@ -204,43 +314,82 @@ static void setup_timer(void) /* ---- Session lifecycle ---------------------------------------------------------- */ -static void print_session_record(void) +/* Print the canonical witness record for a finished session. The snapshot is + * captured at the event source (under s_mux), so a new session racing in + * cannot corrupt the record. Deduped by session_seq: STOP-while-idle or an + * e-stop pressed after completion must NOT re-print the previous record. */ +static void print_session_record(const stim_ctx_t *snap) { + if (snap->session_seq == 0 || snap->state == STIM_RUNNING) { + return; /* no finished session to report */ + } + portENTER_CRITICAL(&s_mux); + bool dup = (snap->session_seq == s_printed_seq); + if (!dup) { + s_printed_seq = snap->session_seq; + } + portEXIT_CRITICAL(&s_mux); + if (dup) { + return; + } /* One canonical JSON line per finished session; the host pairs it with the * RuFlo session builder to compute the witness hash (HIL: 100% hash * reproducibility). Quantized integers only — no float formatting drift. */ - portENTER_CRITICAL(&s_mux); - stim_ctx_t snap = s_ctx; - portEXIT_CRITICAL(&s_mux); printf("SESSION {\"seq\":%u,\"freq_mhz\":%u,\"brightness_pct\":%u," "\"volume_pct\":%u,\"duration_s\":%u,\"half_periods\":%u," "\"stop\":\"%s\",\"fw\":\"%s\"}\n", - (unsigned)snap.session_seq, (unsigned)snap.active.freq_mhz, - (unsigned)snap.active.brightness_pct, (unsigned)snap.active.volume_pct, - (unsigned)snap.active.duration_s, (unsigned)snap.elapsed_half_periods, - stim_stop_str(snap.last_stop), FIRMWARE_VERSION); + (unsigned)snap->session_seq, (unsigned)snap->active.freq_mhz, + (unsigned)snap->active.brightness_pct, (unsigned)snap->active.volume_pct, + (unsigned)snap->active.duration_s, (unsigned)snap->elapsed_half_periods, + stim_stop_str(snap->last_stop), FIRMWARE_VERSION); } static void handle_start(const stim_params_t *p) { portENTER_CRITICAL(&s_mux); stim_rc_t rc = stim_start(&s_ctx, p); + uint32_t seq = s_ctx.session_seq; portEXIT_CRITICAL(&s_mux); if (rc != STIM_OK) { printf("ERR %s\n", stim_rc_str(rc)); return; } + + /* Program the carriers at session intensity (task context). Nothing is + * emitted yet: the gate GPIO stays low until the first ON half-period. */ + carrier_set(p->brightness_pct, p->volume_pct); + uint32_t half_us = stim_half_period_us(p->freq_mhz); gptimer_alarm_config_t alarm = { .alarm_count = half_us, .reload_count = 0, .flags.auto_reload_on_alarm = true, }; + gptimer_stop(s_timer); /* idempotent: may be idle-ticking or stopped */ ESP_ERROR_CHECK(gptimer_set_raw_count(s_timer, 0)); ESP_ERROR_CHECK(gptimer_set_alarm_action(s_timer, &alarm)); - ESP_ERROR_CHECK(gptimer_start(s_timer)); + + /* TOCTOU guard: an e-stop may have latched between the RUNNING commit + * above and here. Start the timer under the same mux the e-stop ISR + * takes, so "state == LOCKED" and "timer started for this session" can + * never coexist. (gptimer_start error, if any, is checked outside the + * critical section because it may log.) */ + esp_err_t start_err = ESP_OK; + portENTER_CRITICAL(&s_mux); + bool still_running = + (s_ctx.state == STIM_RUNNING && s_ctx.session_seq == seq); + if (still_running) { + start_err = gptimer_start(s_timer); + } + portEXIT_CRITICAL(&s_mux); + if (!still_running) { + carrier_off(); /* gate already low (forced by the e-stop ISR) */ + printf("ERR %s\n", stim_rc_str(STIM_ERR_LOCKED)); + return; + } + ESP_ERROR_CHECK(start_err); printf("OK start seq=%u half_period_us=%u\n", - (unsigned)s_ctx.session_seq, (unsigned)half_us); + (unsigned)seq, (unsigned)half_us); } static void handle_line(const char *line) @@ -255,15 +404,19 @@ static void handle_line(const char *line) case STIM_CMD_START: handle_start(&cmd.params); break; - case STIM_CMD_STOP: + case STIM_CMD_STOP: { portENTER_CRITICAL(&s_mux); - outputs_off(); - gptimer_stop(s_timer); + gate_write(false); + sync_write(false); stim_stop_host(&s_ctx); + stim_ctx_t snap = s_ctx; portEXIT_CRITICAL(&s_mux); - print_session_record(); + gptimer_stop(s_timer); /* console task owns the timer; rc ignored */ + carrier_off(); + print_session_record(&snap); /* deduped: no-op if nothing new ran */ printf("OK stop\n"); break; + } case STIM_CMD_STATUS: { portENTER_CRITICAL(&s_mux); stim_ctx_t snap = s_ctx; @@ -276,6 +429,14 @@ static void handle_line(const char *line) break; } case STIM_CMD_UNLOCK: + /* Refuse to clear the latch while the physical e-stop button is still + * held (active low): a host retry loop must never be able to resume + * against a pressed button. If the button is pressed again right + * after this check, the NEGEDGE ISR simply re-latches. */ + if (gpio_get_level(PIN_ESTOP) == 0) { + printf("ERR estop_button_pressed\n"); + break; + } portENTER_CRITICAL(&s_mux); stim_unlock(&s_ctx); portEXIT_CRITICAL(&s_mux); @@ -299,6 +460,7 @@ static void console_task(void *arg) (void)arg; char buf[96]; size_t n = 0; + bool discard = false; /* swallowing the tail of an overlong line */ for (;;) { int ch = fgetc(stdin); if (ch == EOF) { @@ -309,6 +471,11 @@ static void console_task(void *arg) continue; } if (ch == '\n') { + if (discard) { + discard = false; /* overlong line fully consumed */ + n = 0; + continue; + } buf[n] = '\0'; if (n > 0) { handle_line(buf); @@ -316,10 +483,17 @@ static void console_task(void *arg) n = 0; continue; } + if (discard) { + continue; + } if (n + 1 < sizeof(buf)) { buf[n++] = (char)ch; } else { - n = 0; /* overlong line: drop, fail closed */ + /* Overlong line: fail closed — drop it AND everything up to the + * next newline, so the tail is never reinterpreted as a fresh + * command. */ + discard = true; + n = 0; printf("ERR %s\n", stim_rc_str(STIM_ERR_PARSE)); } } @@ -329,11 +503,18 @@ void app_main(void) { ESP_LOGI(TAG, "gamma-stim v%s (ADR-250 M2 device harness)", FIRMWARE_VERSION); s_evt_queue = xQueueCreate(8, sizeof(stim_evt_t)); + configASSERT(s_evt_queue != NULL); stim_init(&s_ctx, stim_envelope_conservative()); + setup_gpio(); /* gate low before the LEDC carriers are configured */ setup_ledc(); - setup_gpio(); setup_timer(); - outputs_off(); + carrier_off(); + + s_deadman = xTimerCreate("deadman", pdMS_TO_TICKS(DEADMAN_PERIOD_MS), + pdTRUE, NULL, deadman_cb); + configASSERT(s_deadman != NULL); + xTimerStart(s_deadman, 0); + xTaskCreate(console_task, "console", 4096, NULL, 5, NULL); ESP_LOGI(TAG, "ready: envelope 36.0-44.0 Hz, brightness<=%u%%, volume<=%u%%", (unsigned)s_ctx.envelope.max_brightness_pct, @@ -342,11 +523,28 @@ void app_main(void) stim_evt_t evt; for (;;) { if (xQueueReceive(s_evt_queue, &evt, portMAX_DELAY) == pdTRUE) { - if (evt == EVT_SESSION_DONE) { - print_session_record(); - } else if (evt == EVT_ESTOP) { - print_session_record(); + /* The hard off (gate low) already happened at the event source. + * Zero the carriers here in task context — but never behind a + * session that has legitimately started since the event fired. */ + bool cleanup; + portENTER_CRITICAL(&s_mux); + cleanup = (s_ctx.state != STIM_RUNNING); + portEXIT_CRITICAL(&s_mux); + if (cleanup) { + carrier_off(); + } + switch (evt.kind) { + case EVT_SESSION_DONE: + print_session_record(&evt.snap); + break; + case EVT_ESTOP: + print_session_record(&evt.snap); printf("EVT estop_latched\n"); + break; + case EVT_FAULT: + print_session_record(&evt.snap); + printf("EVT fault_latched\n"); + break; } } } diff --git a/firmware/esp32-gamma-stim/main/stim_core.h b/firmware/esp32-gamma-stim/main/stim_core.h index ae5616d9..8e27ea33 100644 --- a/firmware/esp32-gamma-stim/main/stim_core.h +++ b/firmware/esp32-gamma-stim/main/stim_core.h @@ -34,8 +34,10 @@ typedef struct { uint32_t max_duration_s; /* 900 = 15 min */ } stim_envelope_t; -/* The compiled-in conservative envelope (ADR-250 §5). Kconfig may narrow it, - * never widen it (enforced by range limits in Kconfig.projbuild). */ +/* The compiled-in conservative envelope (ADR-250 §5). The values are + * hard-coded in stim_core.c — there are deliberately NO Kconfig options for + * them: widening the envelope requires editing the host-tested core and its + * unit tests, never a build-time switch. */ stim_envelope_t stim_envelope_conservative(void); /* ---- Session state machine ---------------------------------------------- */