mirror of
https://github.com/ruvnet/RuView
synced 2026-08-03 19:21:42 +00:00
9744d367a2
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
354 lines
12 KiB
C
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");
|
|
}
|
|
}
|
|
}
|
|
}
|