mirror of
https://github.com/ruvnet/RuView
synced 2026-07-27 18:11:43 +00:00
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:
@@ -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 <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)"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ---------------------------------------------- */
|
||||
|
||||
Reference in New Issue
Block a user