Files
ruvnet--RuView/firmware/esp32-gamma-stim/main/main.c
T
Claude 9744d367a2 feat(firmware): ESP32 gamma stimulation actuator (ADR-250 M2 device harness)
The hardware side of ruview-gamma: an ESP32 driving an LED + audio flicker at
a commanded 36-44 Hz envelope with a hardware emergency stop. This is the
actuator the hil::verify_hil contract grades.

Split for testability:
- main/stim_core.{h,c}: pure C safety core, no ESP-IDF deps. Envelope
  validation mirroring SafetyEnvelope::conservative(), a latched
  START/STOP/e-stop state machine (a session can never silently resume after
  an e-stop), exact integer timing in millihertz (the +/-0.1 Hz HIL target is
  exact: 40.0 Hz = 40000 mHz -> 12500 us half-period), and a fail-closed line
  parser. 15 host tests pass under gcc, no hardware needed.
- main/main.c: ESP-IDF binding. GPTimer ISR at 1 MHz crystal ticks, LEDC PWM
  for LED (19.5 kHz carrier) and audio tone, sync-out GPIO for logic-analyzer
  capture, e-stop GPIO ISR that turns outputs off in the ISR (microseconds,
  vs the 100 ms HIL budget) then latches, USB-CDC line console.

Defense in depth: the device re-enforces the safety envelope independently of
the Rust host, so a buggy or compromised host cannot command an
out-of-envelope output. Emits a canonical integer SESSION {...} record per run
for witness-hash reproduction (HIL 100% reproducibility target).

Kconfig pins, 4 MB single-app, radio-off deterministic actuator profile.
Maps 1:1 to the five hil::verify_hil targets.

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH
2026-06-10 04:39:24 +00:00

354 lines
12 KiB
C

/*
* esp32-gamma-stim — ESP-IDF hardware binding for the gamma stimulation core.
*
* Architecture (ADR-250 §21 M2 device harness, HIL targets in
* 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)
*
* 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).
*
* 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.
*
* All safety decisions (envelope, latch, session math) are in stim_core.c,
* which is unit-tested on the host. This file only moves registers.
*/
#include <stdio.h>
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"
#include "driver/gptimer.h"
#include "driver/ledc.h"
#include "driver/gpio.h"
#include "esp_log.h"
#include "stim_core.h"
static const char *TAG = "gamma-stim";
#define FIRMWARE_VERSION "0.1.0"
/* ---- 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 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
* 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
#define LED_DUTY_MAX ((1 << 12) - 1)
/* Audio: square tone carrier gated by the envelope. */
#define AUDIO_TONE_HZ CONFIG_GAMMA_STIM_AUDIO_TONE_HZ
#define AUDIO_DUTY_RES LEDC_TIMER_12_BIT
#define AUDIO_DUTY_MAX ((1 << 12) - 1)
/* ---- Shared state ---------------------------------------------------------- */
static stim_ctx_t s_ctx; /* guarded: ISR + main task */
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 */
typedef enum { EVT_SESSION_DONE = 1, EVT_ESTOP = 2 } stim_evt_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)
{
uint32_t led_duty = on ? ((uint32_t)brightness_pct * LED_DUTY_MAX) / 100U : 0U;
/* 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);
}
static void IRAM_ATTR outputs_off(void)
{
apply_outputs(false, 0, 0);
}
/* GPTimer alarm ISR: one half-period boundary. */
static bool IRAM_ATTR on_half_period(gptimer_handle_t timer,
const gptimer_alarm_event_data_t *edata,
void *user)
{
(void)timer; (void)edata; (void)user;
BaseType_t hpw = pdFALSE;
portENTER_CRITICAL_ISR(&s_mux);
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;
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. */
static void IRAM_ATTR on_estop(void *arg)
{
(void)arg;
BaseType_t hpw = pdFALSE;
portENTER_CRITICAL_ISR(&s_mux);
outputs_off();
stim_estop(&s_ctx, STIM_STOP_BUTTON);
if (s_timer) {
gptimer_stop(s_timer);
}
portEXIT_CRITICAL_ISR(&s_mux);
stim_evt_t e = EVT_ESTOP;
xQueueSendFromISR(s_evt_queue, &e, &hpw);
if (hpw == pdTRUE) {
portYIELD_FROM_ISR();
}
}
/* ---- Peripheral setup -------------------------------------------------------- */
static void setup_ledc(void)
{
ledc_timer_config_t led_t = {
.speed_mode = LEDC_LOW_SPEED_MODE,
.timer_num = LEDC_LED_TIMER,
.duty_resolution = LED_DUTY_RES,
.freq_hz = LED_CARRIER_HZ,
.clk_cfg = LEDC_AUTO_CLK,
};
ESP_ERROR_CHECK(ledc_timer_config(&led_t));
ledc_channel_config_t led_c = {
.gpio_num = PIN_LED,
.speed_mode = LEDC_LOW_SPEED_MODE,
.channel = LEDC_LED_CH,
.timer_sel = LEDC_LED_TIMER,
.duty = 0,
.hpoint = 0,
};
ESP_ERROR_CHECK(ledc_channel_config(&led_c));
ledc_timer_config_t aud_t = {
.speed_mode = LEDC_LOW_SPEED_MODE,
.timer_num = LEDC_AUDIO_TIMER,
.duty_resolution = AUDIO_DUTY_RES,
.freq_hz = AUDIO_TONE_HZ,
.clk_cfg = LEDC_AUTO_CLK,
};
ESP_ERROR_CHECK(ledc_timer_config(&aud_t));
ledc_channel_config_t aud_c = {
.gpio_num = PIN_AUDIO,
.speed_mode = LEDC_LOW_SPEED_MODE,
.channel = LEDC_AUDIO_CH,
.timer_sel = LEDC_AUDIO_TIMER,
.duty = 0,
.hpoint = 0,
};
ESP_ERROR_CHECK(ledc_channel_config(&aud_c));
}
static void setup_gpio(void)
{
gpio_config_t sync = {
.pin_bit_mask = 1ULL << PIN_SYNC,
.mode = GPIO_MODE_OUTPUT,
};
ESP_ERROR_CHECK(gpio_config(&sync));
gpio_set_level(PIN_SYNC, 0);
gpio_config_t estop = {
.pin_bit_mask = 1ULL << PIN_ESTOP,
.mode = GPIO_MODE_INPUT,
.pull_up_en = GPIO_PULLUP_ENABLE, /* button to GND, active low */
.intr_type = GPIO_INTR_NEGEDGE,
};
ESP_ERROR_CHECK(gpio_config(&estop));
ESP_ERROR_CHECK(gpio_install_isr_service(0));
ESP_ERROR_CHECK(gpio_isr_handler_add(PIN_ESTOP, on_estop, NULL));
}
static void setup_timer(void)
{
gptimer_config_t cfg = {
.clk_src = GPTIMER_CLK_SRC_DEFAULT,
.direction = GPTIMER_COUNT_UP,
.resolution_hz = 1000000, /* 1 us ticks, crystal-derived */
};
ESP_ERROR_CHECK(gptimer_new_timer(&cfg, &s_timer));
gptimer_event_callbacks_t cbs = { .on_alarm = on_half_period };
ESP_ERROR_CHECK(gptimer_register_event_callbacks(s_timer, &cbs, NULL));
ESP_ERROR_CHECK(gptimer_enable(s_timer));
}
/* ---- Session lifecycle ---------------------------------------------------------- */
static void print_session_record(void)
{
/* 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);
}
static void handle_start(const stim_params_t *p)
{
portENTER_CRITICAL(&s_mux);
stim_rc_t rc = stim_start(&s_ctx, p);
portEXIT_CRITICAL(&s_mux);
if (rc != STIM_OK) {
printf("ERR %s\n", stim_rc_str(rc));
return;
}
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,
};
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));
printf("OK start seq=%u half_period_us=%u\n",
(unsigned)s_ctx.session_seq, (unsigned)half_us);
}
static void handle_line(const char *line)
{
stim_cmd_t cmd;
stim_rc_t rc = stim_parse_line(line, &cmd);
if (rc != STIM_OK) {
printf("ERR %s\n", stim_rc_str(rc));
return;
}
switch (cmd.kind) {
case STIM_CMD_START:
handle_start(&cmd.params);
break;
case STIM_CMD_STOP:
portENTER_CRITICAL(&s_mux);
outputs_off();
gptimer_stop(s_timer);
stim_stop_host(&s_ctx);
portEXIT_CRITICAL(&s_mux);
print_session_record();
printf("OK stop\n");
break;
case STIM_CMD_STATUS: {
portENTER_CRITICAL(&s_mux);
stim_ctx_t snap = s_ctx;
portEXIT_CRITICAL(&s_mux);
const char *st = snap.state == STIM_RUNNING ? "running"
: snap.state == STIM_LOCKED ? "locked"
: "idle";
printf("OK status state=%s seq=%u last_stop=%s\n", st,
(unsigned)snap.session_seq, stim_stop_str(snap.last_stop));
break;
}
case STIM_CMD_UNLOCK:
portENTER_CRITICAL(&s_mux);
stim_unlock(&s_ctx);
portEXIT_CRITICAL(&s_mux);
printf("OK unlock\n");
break;
case STIM_CMD_VERSION:
printf("OK version fw=%s envelope=36000-44000mHz b<=%u%% v<=%u%% d<=%us\n",
FIRMWARE_VERSION,
(unsigned)s_ctx.envelope.max_brightness_pct,
(unsigned)s_ctx.envelope.max_volume_pct,
(unsigned)s_ctx.envelope.max_duration_s);
break;
default:
printf("ERR %s\n", stim_rc_str(STIM_ERR_UNKNOWN_CMD));
}
}
/* Console reader: line-buffered stdin (USB-CDC / UART0). */
static void console_task(void *arg)
{
(void)arg;
char buf[96];
size_t n = 0;
for (;;) {
int ch = fgetc(stdin);
if (ch == EOF) {
vTaskDelay(pdMS_TO_TICKS(10));
continue;
}
if (ch == '\r') {
continue;
}
if (ch == '\n') {
buf[n] = '\0';
if (n > 0) {
handle_line(buf);
}
n = 0;
continue;
}
if (n + 1 < sizeof(buf)) {
buf[n++] = (char)ch;
} else {
n = 0; /* overlong line: drop, fail closed */
printf("ERR %s\n", stim_rc_str(STIM_ERR_PARSE));
}
}
}
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));
stim_init(&s_ctx, stim_envelope_conservative());
setup_ledc();
setup_gpio();
setup_timer();
outputs_off();
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,
(unsigned)s_ctx.envelope.max_volume_pct);
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();
printf("EVT estop_latched\n");
}
}
}
}