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>
This commit is contained in:
ruv
2026-06-11 15:26:17 -04:00
parent 192b4c6b4a
commit 235d3b4115
4 changed files with 332 additions and 71 deletions
+53 -7
View File
@@ -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
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
@@ -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 <freq_mhz> <brightness_pct> <volume_pct> <duration_s>
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
@@ -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)"
+257 -59
View File
@@ -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;
}
}
}
+4 -2
View File
@@ -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 ---------------------------------------------- */