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
This commit is contained in:
Claude
2026-06-10 04:39:24 +00:00
parent 46b4d63dad
commit 9744d367a2
11 changed files with 1166 additions and 0 deletions
+1
View File
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **`esp32-gamma-stim` firmware — ESP32 gamma stimulation actuator (ADR-250 §21 M2 device harness).** The hardware side of `ruview-gamma`: an ESP32 driving an LED + audio flicker at a commanded 3644 Hz envelope with a hardware emergency stop. Split into a **pure, host-tested safety core** (`main/stim_core.{h,c}` — envelope validation mirroring `SafetyEnvelope::conservative()`, a latched START/STOP/e-stop state machine, exact integer timing math in millihertz so the ±0.1 Hz HIL target is exact, and a line-protocol parser; **15 host tests pass under gcc, no ESP-IDF needed**) and a thin **ESP-IDF binding** (`main/main.c` — GPTimer ISR, LEDC PWM for LED+audio, sync-out GPIO for logic-analyzer capture, e-stop GPIO ISR that kills outputs in microseconds, USB-CDC console). Defense in depth: the device re-enforces the safety envelope independently of the Rust host, so a buggy/compromised host still cannot command an out-of-envelope output. Emits a canonical integer `SESSION {...}` record per run for witness-hash reproduction. Maps 1:1 to the five `hil::verify_hil` targets. Kconfig pin config, 4 MB single-app, radio-off deterministic actuator profile.
- **`ruview-gamma` claim-gate invariant + hardware-in-the-loop contract.** Centralized the claim release rule into a single `acceptance::claim_allowed(entrainment, safety, adherence, repeatability)` (strict AND of all four) used by every path, with a test proving every 3-of-4 subset is denied — no path can weaken the gate. New `hil` module: `verify_hil` grades a captured actuator bench measurement against fixed targets (LED frequency ±0.1 Hz, audio-visual sync < 5 ms, stop-signal→actuator-off < 100 ms, session-hash reproducibility 100%, EEG entrainment lift ≥ 20% over fixed 40 Hz) — the next acceptance milestone for a real LED+speaker (e.g. ESP32) actuator; all failure modes fail closed (missing stop measurement, no replay, any hash mismatch). README gains the benchmark table and the "governed personalization engine that refuses to overpromise" positioning. 9 new tests; crate now 97 + 1 doctest; pinned witness preserved.
- **`ruview-gamma` generalized to an adaptive sensory neuromodulation platform (ADR-250 §23).** 40 Hz is now one prior in one program, not the product. New `program` module: `NeuroProgram` catalog of 7 use cases (Alzheimer's research, post-stroke cognition, sleep optimization, attention/working-memory, mood/arousal, home wellness, drug+device trial infrastructure), each with its own `SafetyEnvelope`, starting prior, `ObjectiveWeights`, physiological-state gating (sleep permits `Asleep` + near-dark brightness cap; attention requires wakefulness), `EvidenceLevel`, and a single non-disease claim. New `acceptance` module makes the acceptance sentence executable: `AcceptanceHarness` grades a program over ≥3 repeats on entrainment gain, safety-stop rate, adherence, and optimal-frequency repeatability, exposing a `ClaimGate` that returns the program's claim **only if all four criteria pass** — the marketing claim is otherwise unreadable (`NO_CLAIM`). Governor wiring: `enroll_program` (per-program envelope/objective; `enroll` stays the bare Alzheimer's-defaults path so the pinned witness `13cb164c…` is preserved), `program()`, `prior()`, `state_eligible()`. 13 new module tests + 2 platform integration tests (per-program envelope enforced end-to-end — a stimulus valid for Alzheimer's is refused by the sleep program; acceptance gates every catalog program's claim); crate now 88 tests + 1 doctest. Bench: full 3-repeat program grading ~425 µs.
- **`ruview-gamma` RuVector self-learning layer (ADR-250 §10 items 36).** New `ruvector` module: anonymized `ProfileStore` (one-way SHA-256 hashed tags, never `person_id`; safe-session scores only), deterministic exact kNN (fixed-range normalization, index tie-break), **cohort warm-start** — a new person's optimizer is seeded from the k nearest responders as down-weighted GP pseudo-observations (`BayesianOptimizer::observe_prior`, ≥25× real-observation noise, excluded from the EI incumbent / audit / clinician report), **physiological drift detection** (Welford centroid with stimulus-input fields masked out of the distance; `Drifted` recommends re-calibration), and deterministic k-means response clustering (farthest-point seeding, no RNG). Wired into `RufloGovernor` (`seed_from_cohort`, `export_anonymized_profile`, per-session `drift_status`). The GP gains per-observation noise (real path unchanged — pinned witness `13cb164c…` preserved). 11 new module tests + 2 integration tests (cohort warm-start beats the cold 40 Hz prior for a detuned subject; collapsed physiology flags drift); crate now 75 tests + 1 doctest. Benches: kNN over 500 profiles ~15 µs, full warm-start ~16 µs; no regression on existing paths.
+9
View File
@@ -0,0 +1,9 @@
# ESP32 Gamma Stimulation Firmware (ADR-250 §21 M2 device harness)
# Requires ESP-IDF v5.2+
cmake_minimum_required(VERSION 3.16)
file(STRINGS "${CMAKE_CURRENT_LIST_DIR}/version.txt" PROJECT_VER LIMIT_COUNT 1)
string(STRIP "${PROJECT_VER}" PROJECT_VER)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(esp32-gamma-stim VERSION ${PROJECT_VER})
+84
View File
@@ -0,0 +1,84 @@
# esp32-gamma-stim — ESP32 gamma stimulation actuator (ADR-250 §21 M2)
The **device harness** for `ruview-gamma`: an ESP32 that drives a light + sound
flicker at a commanded frequency, gated by a hardware emergency stop, with a
compiled-in safety envelope that mirrors `SafetyEnvelope::conservative()` in the
Rust crate. This is the actuator the `hil::verify_hil` contract grades.
> **Not a medical device.** Research/engineering harness. The host
> (`ruview-gamma`) decides *what* to play and never claims a therapeutic effect;
> this firmware only plays it safely and reports exactly what it did.
## Design: safety core vs hardware binding
| 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) |
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.
## Run the safety-core tests (no hardware, no ESP-IDF)
```bash
cd firmware/esp32-gamma-stim
gcc -Wall -Wextra -Werror -O2 -I main tests/test_stim_core.c main/stim_core.c -o /tmp/test_stim && /tmp/test_stim
# -> all 15 stim_core tests passed
```
## Build & flash (ESP-IDF v5.2+)
```bash
idf.py set-target esp32s3 # or esp32c6
idf.py menuconfig # Gamma Stimulation -> pins, tone freq
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).
## Host protocol (line-based, 115200, USB-CDC/UART0)
```
START <freq_mhz> <brightness_pct> <volume_pct> <duration_s>
STOP
STATUS
UNLOCK # clear a latched e-stop
VERSION
```
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:
```
> START 40000 30 28 600
OK start seq=1 half_period_us=12500
... (session runs) ...
SESSION {"seq":1,"freq_mhz":40000,"brightness_pct":30,"volume_pct":28,"duration_s":600,"half_periods":48000,"stop":"completed","fw":"0.1.0"}
```
The `SESSION {...}` line is canonical (quantized integers, fixed field order) so
the host pairs it with the RuFlo session builder to reproduce the witness hash
(HIL: 100% hash reproducibility).
## How it maps to the HIL targets (`v2/crates/ruview-gamma/src/hil.rs`)
| 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 |
| EEG lift ≥ 20% vs fixed 40 Hz | provided by the host's adaptive optimizer choosing the frequency this firmware plays |
## Hardware notes
- **Drive the LED through a MOSFET/constant-current driver**, not the GPIO
directly. Keep brightness within eye-safe flicker limits — the firmware caps
duty at the envelope's 40%, but the optical design owns absolute luminance.
- **Photosensitivity/epilepsy is a hard exclusion** at the host
(`ExclusionScreen`); the device is the last line, not the only line.
- The e-stop button is mandatory for any human-facing bench run.
@@ -0,0 +1,5 @@
idf_component_register(
SRCS "main.c" "stim_core.c"
INCLUDE_DIRS "."
REQUIRES driver esp_timer
)
@@ -0,0 +1,40 @@
menu "Gamma Stimulation (ADR-250)"
config GAMMA_STIM_LED_GPIO
int "LED PWM output GPIO"
default 4
help
GPIO driving the LED MOSFET/driver. The 36-44 Hz stimulus is the
envelope on a 19.5 kHz PWM carrier.
config GAMMA_STIM_AUDIO_GPIO
int "Audio tone output GPIO"
default 5
help
GPIO driving the speaker/buzzer amplifier input (square tone,
envelope-gated). Use an analog volume stage for real loudness control.
config GAMMA_STIM_SYNC_GPIO
int "Sync-out GPIO (HIL measurement)"
default 6
help
Mirrors the stimulation envelope for photodiode/logic-analyzer
verification of the +/-0.1 Hz and <5 ms A/V sync HIL targets.
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.
config GAMMA_STIM_AUDIO_TONE_HZ
int "Audio tone carrier frequency (Hz)"
range 200 2000
default 440
help
The audible tone gated at the stimulation frequency. GENUS-style
protocols use a click/tone burst per cycle.
endmenu
+353
View File
@@ -0,0 +1,353 @@
/*
* 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");
}
}
}
}
+254
View File
@@ -0,0 +1,254 @@
/*
* stim_core.c — pure, host-testable core of the gamma stimulation firmware.
* See stim_core.h for the contract. No ESP-IDF includes in this file.
*/
#include "stim_core.h"
#include <string.h>
#include <stdlib.h>
#include <ctype.h>
/* ---- Envelope ------------------------------------------------------------ */
stim_envelope_t stim_envelope_conservative(void)
{
stim_envelope_t e = {
.min_freq_mhz = 36000, /* 36.0 Hz */
.max_freq_mhz = 44000, /* 44.0 Hz */
.max_brightness_pct = 40, /* SafetyEnvelope::conservative 0.40 */
.max_volume_pct = 40,
.max_duration_s = 900, /* 15 min */
};
return e;
}
void stim_init(stim_ctx_t *ctx, stim_envelope_t envelope)
{
memset(ctx, 0, sizeof(*ctx));
ctx->envelope = envelope;
ctx->state = STIM_IDLE;
ctx->last_stop = STIM_STOP_NONE;
}
/* ---- Validation ----------------------------------------------------------- */
stim_rc_t stim_validate(const stim_ctx_t *ctx, const stim_params_t *p)
{
const stim_envelope_t *e = &ctx->envelope;
if (p->freq_mhz < e->min_freq_mhz || p->freq_mhz > e->max_freq_mhz) {
return STIM_ERR_FREQ_RANGE;
}
if (p->brightness_pct > e->max_brightness_pct) {
return STIM_ERR_BRIGHTNESS_CAP;
}
if (p->volume_pct > e->max_volume_pct) {
return STIM_ERR_VOLUME_CAP;
}
if (p->duration_s == 0) {
return STIM_ERR_ZERO_DURATION;
}
if (p->duration_s > e->max_duration_s) {
return STIM_ERR_DURATION_CAP;
}
return STIM_OK;
}
/* ---- Transitions ----------------------------------------------------------- */
stim_rc_t stim_start(stim_ctx_t *ctx, const stim_params_t *p)
{
if (ctx->state == STIM_LOCKED) {
return STIM_ERR_LOCKED;
}
if (ctx->state == STIM_RUNNING) {
return STIM_ERR_BUSY;
}
stim_rc_t rc = stim_validate(ctx, p);
if (rc != STIM_OK) {
return rc; /* fail closed: state unchanged, outputs stay off */
}
ctx->active = *p;
ctx->elapsed_half_periods = 0;
ctx->envelope_on = false;
ctx->session_seq += 1;
ctx->last_stop = STIM_STOP_NONE;
ctx->state = STIM_RUNNING;
return STIM_OK;
}
stim_rc_t stim_stop_host(stim_ctx_t *ctx)
{
if (ctx->state == STIM_RUNNING) {
ctx->state = STIM_IDLE;
ctx->envelope_on = false;
ctx->last_stop = STIM_STOP_HOST;
}
/* STOP while idle/locked is a harmless no-op (idempotent). */
return STIM_OK;
}
void stim_estop(stim_ctx_t *ctx, stim_stop_reason_t why)
{
ctx->state = STIM_LOCKED; /* latched, from ANY state */
ctx->envelope_on = false;
ctx->last_stop = why;
}
stim_rc_t stim_unlock(stim_ctx_t *ctx)
{
if (ctx->state == STIM_LOCKED) {
ctx->state = STIM_IDLE;
}
return STIM_OK;
}
bool stim_tick(stim_ctx_t *ctx)
{
if (ctx->state != STIM_RUNNING) {
ctx->envelope_on = false;
return false;
}
ctx->envelope_on = !ctx->envelope_on;
ctx->elapsed_half_periods += 1;
uint32_t total =
stim_session_half_periods(ctx->active.freq_mhz, ctx->active.duration_s);
if (ctx->elapsed_half_periods >= total) {
ctx->state = STIM_IDLE;
ctx->envelope_on = false;
ctx->last_stop = STIM_STOP_COMPLETED;
return false;
}
return true;
}
uint32_t stim_half_period_us(uint32_t freq_mhz)
{
if (freq_mhz == 0) {
return 0;
}
/* half period [us] = 1e6 / (2 * f[Hz]) = 5e8 / f[mHz].
* 64-bit intermediate; exact division for e.g. 40000 -> 12500 us. */
return (uint32_t)(500000000ULL / (uint64_t)freq_mhz);
}
uint32_t stim_session_half_periods(uint32_t freq_mhz, uint32_t duration_s)
{
/* half periods = duration * 2 * f[Hz] = duration * f[mHz] / 500.
* 64-bit intermediate: 900 s * 44000 = 39.6e6, fine. */
return (uint32_t)(((uint64_t)duration_s * (uint64_t)freq_mhz) / 500ULL);
}
/* ---- Protocol parsing ------------------------------------------------------ */
/* Parse an unsigned decimal field; returns false on junk/overflow. */
static bool parse_u32(const char **cursor, uint32_t *out)
{
const char *s = *cursor;
while (*s == ' ') {
s++;
}
if (!isdigit((unsigned char)*s)) {
return false;
}
uint64_t v = 0;
while (isdigit((unsigned char)*s)) {
v = v * 10ULL + (uint64_t)(*s - '0');
if (v > 0xFFFFFFFFULL) {
return false;
}
s++;
}
*out = (uint32_t)v;
*cursor = s;
return true;
}
static bool token_is(const char *line, const char *word, const char **rest)
{
size_t n = strlen(word);
if (strncmp(line, word, n) != 0) {
return false;
}
if (line[n] != '\0' && line[n] != ' ') {
return false;
}
*rest = line + n;
return true;
}
stim_rc_t stim_parse_line(const char *line, stim_cmd_t *out)
{
memset(out, 0, sizeof(*out));
while (*line == ' ') {
line++;
}
const char *rest = NULL;
if (token_is(line, "START", &rest)) {
uint32_t f, b, v, d;
if (!parse_u32(&rest, &f) || !parse_u32(&rest, &b) ||
!parse_u32(&rest, &v) || !parse_u32(&rest, &d)) {
return STIM_ERR_PARSE;
}
while (*rest == ' ') {
rest++;
}
if (*rest != '\0') {
return STIM_ERR_PARSE; /* trailing junk */
}
if (b > 255 || v > 255) {
return STIM_ERR_PARSE; /* fields must fit their types */
}
out->kind = STIM_CMD_START;
out->params.freq_mhz = f;
out->params.brightness_pct = (uint8_t)b;
out->params.volume_pct = (uint8_t)v;
out->params.duration_s = d;
return STIM_OK;
}
if (token_is(line, "STOP", &rest)) {
out->kind = STIM_CMD_STOP;
return STIM_OK;
}
if (token_is(line, "STATUS", &rest)) {
out->kind = STIM_CMD_STATUS;
return STIM_OK;
}
if (token_is(line, "UNLOCK", &rest)) {
out->kind = STIM_CMD_UNLOCK;
return STIM_OK;
}
if (token_is(line, "VERSION", &rest)) {
out->kind = STIM_CMD_VERSION;
return STIM_OK;
}
return STIM_ERR_UNKNOWN_CMD;
}
const char *stim_rc_str(stim_rc_t rc)
{
switch (rc) {
case STIM_OK: return "ok";
case STIM_ERR_FREQ_RANGE: return "freq_out_of_envelope";
case STIM_ERR_BRIGHTNESS_CAP: return "brightness_above_cap";
case STIM_ERR_VOLUME_CAP: return "volume_above_cap";
case STIM_ERR_DURATION_CAP: return "duration_above_cap";
case STIM_ERR_ZERO_DURATION: return "zero_duration";
case STIM_ERR_BUSY: return "busy";
case STIM_ERR_LOCKED: return "estop_locked";
case STIM_ERR_PARSE: return "parse_error";
case STIM_ERR_UNKNOWN_CMD: return "unknown_command";
default: return "unknown_rc";
}
}
const char *stim_stop_str(stim_stop_reason_t r)
{
switch (r) {
case STIM_STOP_NONE: return "none";
case STIM_STOP_COMPLETED: return "completed";
case STIM_STOP_HOST: return "host_stop";
case STIM_STOP_BUTTON: return "estop_button";
case STIM_STOP_FAULT: return "fault";
default: return "unknown";
}
}
+162
View File
@@ -0,0 +1,162 @@
/*
* stim_core.h — pure, host-testable core of the gamma stimulation firmware.
*
* Everything safety-critical lives here, with NO ESP-IDF dependencies, so the
* exact code that ships on the device is unit-tested on the host (gcc) and in
* CI. main.c is a thin hardware binding (timers, LEDC, GPIO, UART).
*
* Mirrors the ruview-gamma crate's SafetyEnvelope::conservative() (ADR-250
* §5/§12): the firmware enforces the same hard caps *independently*, so even a
* compromised or buggy host cannot command an out-of-envelope stimulus.
* Defense in depth: host gate (Rust) AND device gate (this file).
*
* Units: frequency in millihertz (exact integer math — the ±0.1 Hz HIL target
* is ±100 mHz), intensity in percent (0100), duration in seconds.
*/
#ifndef STIM_CORE_H
#define STIM_CORE_H
#include <stdbool.h>
#include <stdint.h>
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
/* ---- Hard safety envelope (device-side; never widened at runtime) ------- */
typedef struct {
uint32_t min_freq_mhz; /* 36000 = 36.0 Hz */
uint32_t max_freq_mhz; /* 44000 = 44.0 Hz */
uint8_t max_brightness_pct;/* 40 = SafetyEnvelope::conservative cap 0.40 */
uint8_t max_volume_pct; /* 40 */
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). */
stim_envelope_t stim_envelope_conservative(void);
/* ---- Session state machine ---------------------------------------------- */
typedef enum {
STIM_IDLE = 0, /* outputs off, ready for START */
STIM_RUNNING, /* stimulation active */
STIM_LOCKED, /* emergency-stopped; START refused until UNLOCK */
} stim_state_t;
typedef enum {
STIM_STOP_NONE = 0,
STIM_STOP_COMPLETED, /* duration elapsed (not a safety stop) */
STIM_STOP_HOST, /* host STOP command */
STIM_STOP_BUTTON, /* hardware e-stop button */
STIM_STOP_FAULT, /* internal fault (watchdog, bad state) */
} stim_stop_reason_t;
typedef struct {
uint32_t freq_mhz; /* commanded envelope frequency */
uint8_t brightness_pct; /* LED intensity during ON half-period */
uint8_t volume_pct; /* tone intensity during ON half-period */
uint32_t duration_s; /* session length */
} stim_params_t;
typedef struct {
stim_envelope_t envelope;
stim_state_t state;
stim_params_t active; /* valid when state == RUNNING */
stim_stop_reason_t last_stop;
uint32_t session_seq; /* increments on each START */
uint32_t elapsed_half_periods; /* advanced by the timer ISR */
bool envelope_on; /* current half-period phase */
} stim_ctx_t;
/* Initialize a context with the given envelope, in IDLE. */
void stim_init(stim_ctx_t *ctx, stim_envelope_t envelope);
/* ---- Validation (fail closed) ------------------------------------------- */
typedef enum {
STIM_OK = 0,
STIM_ERR_FREQ_RANGE, /* outside [min,max] mHz */
STIM_ERR_BRIGHTNESS_CAP,
STIM_ERR_VOLUME_CAP,
STIM_ERR_DURATION_CAP,
STIM_ERR_ZERO_DURATION,
STIM_ERR_BUSY, /* START while RUNNING */
STIM_ERR_LOCKED, /* START while LOCKED (e-stop latched) */
STIM_ERR_PARSE, /* malformed command line */
STIM_ERR_UNKNOWN_CMD,
} stim_rc_t;
/* Validate params against the context envelope. Pure; no state change. */
stim_rc_t stim_validate(const stim_ctx_t *ctx, const stim_params_t *p);
/* ---- Transitions (the only mutators) ------------------------------------ */
/* START: validate + transition IDLE->RUNNING. Fails closed on any violation,
* on BUSY, and on LOCKED. */
stim_rc_t stim_start(stim_ctx_t *ctx, const stim_params_t *p);
/* STOP from the host: RUNNING->IDLE (graceful; not latched). */
stim_rc_t stim_stop_host(stim_ctx_t *ctx);
/* Emergency stop (button ISR or fault): any state -> LOCKED. Latched —
* further STARTs are refused until stim_unlock(). Mirrors the Rust
* SafetyMonitor latch (a session must never silently resume). */
void stim_estop(stim_ctx_t *ctx, stim_stop_reason_t why);
/* Operator unlock after an e-stop: LOCKED -> IDLE. */
stim_rc_t stim_unlock(stim_ctx_t *ctx);
/* Timer ISR tick: advance one half-period. Returns true while RUNNING; when
* the session's duration is reached it transitions to IDLE (COMPLETED) and
* returns false. Pure integer math, ISR-safe. */
bool stim_tick(stim_ctx_t *ctx);
/* Half-period length in microseconds for a commanded frequency:
* 500'000'000'000 / freq_mhz / 1000 — exact for the supported range.
* (40.0 Hz = 40000 mHz -> 12'500 us.) */
uint32_t stim_half_period_us(uint32_t freq_mhz);
/* Total half-periods in a session of duration_s at freq_mhz (rounded down). */
uint32_t stim_session_half_periods(uint32_t freq_mhz, uint32_t duration_s);
/* ---- Host command protocol (line-based, UART) ---------------------------
*
* START <freq_mhz> <brightness_pct> <volume_pct> <duration_s>
* STOP
* STATUS
* UNLOCK
* VERSION
*
* stim_parse_line() parses one trimmed line into a command. Pure.
*/
typedef enum {
STIM_CMD_NONE = 0,
STIM_CMD_START,
STIM_CMD_STOP,
STIM_CMD_STATUS,
STIM_CMD_UNLOCK,
STIM_CMD_VERSION,
} stim_cmd_kind_t;
typedef struct {
stim_cmd_kind_t kind;
stim_params_t params; /* valid when kind == STIM_CMD_START */
} stim_cmd_t;
stim_rc_t stim_parse_line(const char *line, stim_cmd_t *out);
/* Human-readable tag for a return code (for "ERR <tag>" replies). */
const char *stim_rc_str(stim_rc_t rc);
/* Human-readable tag for a stop reason (for the session log). */
const char *stim_stop_str(stim_stop_reason_t r);
#ifdef __cplusplus
}
#endif
#endif /* STIM_CORE_H */
@@ -0,0 +1,9 @@
# esp32-gamma-stim defaults (S3/C6 class; single-purpose actuator node)
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
CONFIG_PARTITION_TABLE_SINGLE_APP=y
CONFIG_FREERTOS_HZ=1000
# Console on USB-CDC where available; falls back to UART0.
CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y
# Keep radio off: this node is a deterministic actuator, not a network device.
# (No WiFi/BT init in the firmware; nothing to configure out.)
CONFIG_COMPILER_OPTIMIZATION_SIZE=y
@@ -0,0 +1,248 @@
/*
* Host-side unit tests for stim_core (the safety-critical firmware core).
* Build & run (no ESP-IDF needed):
*
* cd firmware/esp32-gamma-stim
* gcc -Wall -Wextra -Werror -O2 -I main tests/test_stim_core.c main/stim_core.c -o /tmp/test_stim && /tmp/test_stim
*
* Exit code 0 = all pass. These tests cover the same invariants the
* ruview-gamma Rust crate enforces host-side (defense in depth): envelope is
* never exceeded, e-stop latches, fail-closed parsing, exact timing math for
* the ±0.1 Hz HIL target.
*/
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include "stim_core.h"
static int tests_run = 0;
#define RUN(t) do { t(); tests_run++; printf("ok - %s\n", #t); } while (0)
static stim_ctx_t fresh(void)
{
stim_ctx_t c;
stim_init(&c, stim_envelope_conservative());
return c;
}
static stim_params_t prior(void)
{
stim_params_t p = {
.freq_mhz = 40000, .brightness_pct = 30, .volume_pct = 28, .duration_s = 600,
};
return p;
}
/* ---- envelope ------------------------------------------------------------ */
static void test_prior_is_inside_envelope(void)
{
stim_ctx_t c = fresh();
stim_params_t p = prior();
assert(stim_validate(&c, &p) == STIM_OK);
}
static void test_frequency_outside_band_refused(void)
{
stim_ctx_t c = fresh();
stim_params_t p = prior();
p.freq_mhz = 35999; /* 35.999 Hz */
assert(stim_validate(&c, &p) == STIM_ERR_FREQ_RANGE);
p.freq_mhz = 44001;
assert(stim_validate(&c, &p) == STIM_ERR_FREQ_RANGE);
p.freq_mhz = 0;
assert(stim_validate(&c, &p) == STIM_ERR_FREQ_RANGE);
/* band edges are inclusive */
p.freq_mhz = 36000;
assert(stim_validate(&c, &p) == STIM_OK);
p.freq_mhz = 44000;
assert(stim_validate(&c, &p) == STIM_OK);
}
static void test_intensity_caps_refused(void)
{
stim_ctx_t c = fresh();
stim_params_t p = prior();
p.brightness_pct = 41;
assert(stim_validate(&c, &p) == STIM_ERR_BRIGHTNESS_CAP);
p = prior();
p.volume_pct = 41;
assert(stim_validate(&c, &p) == STIM_ERR_VOLUME_CAP);
p = prior();
p.brightness_pct = 40; /* cap value itself is allowed */
p.volume_pct = 40;
assert(stim_validate(&c, &p) == STIM_OK);
}
static void test_duration_caps_refused(void)
{
stim_ctx_t c = fresh();
stim_params_t p = prior();
p.duration_s = 0;
assert(stim_validate(&c, &p) == STIM_ERR_ZERO_DURATION);
p.duration_s = 901;
assert(stim_validate(&c, &p) == STIM_ERR_DURATION_CAP);
p.duration_s = 900;
assert(stim_validate(&c, &p) == STIM_OK);
}
/* ---- state machine --------------------------------------------------------- */
static void test_start_refused_while_running(void)
{
stim_ctx_t c = fresh();
stim_params_t p = prior();
assert(stim_start(&c, &p) == STIM_OK);
assert(c.state == STIM_RUNNING);
assert(stim_start(&c, &p) == STIM_ERR_BUSY);
}
static void test_out_of_envelope_start_keeps_outputs_off(void)
{
stim_ctx_t c = fresh();
stim_params_t p = prior();
p.brightness_pct = 90;
assert(stim_start(&c, &p) == STIM_ERR_BRIGHTNESS_CAP);
assert(c.state == STIM_IDLE); /* fail closed */
assert(!c.envelope_on);
assert(c.session_seq == 0); /* no session consumed */
}
static void test_estop_latches_from_any_state(void)
{
stim_ctx_t c = fresh();
stim_params_t p = prior();
assert(stim_start(&c, &p) == STIM_OK);
stim_estop(&c, STIM_STOP_BUTTON);
assert(c.state == STIM_LOCKED);
assert(!c.envelope_on);
/* START must be refused while latched — a session can never silently
* resume after an e-stop (mirrors the Rust SafetyMonitor latch). */
assert(stim_start(&c, &p) == STIM_ERR_LOCKED);
/* Host STOP does not clear the latch either. */
stim_stop_host(&c);
assert(c.state == STIM_LOCKED);
/* Only the explicit operator UNLOCK clears it. */
assert(stim_unlock(&c) == STIM_OK);
assert(c.state == STIM_IDLE);
assert(stim_start(&c, &p) == STIM_OK);
}
static void test_session_completes_after_duration(void)
{
stim_ctx_t c = fresh();
stim_params_t p = prior();
p.freq_mhz = 40000;
p.duration_s = 1; /* 1 s @ 40 Hz = 80 half-periods */
assert(stim_start(&c, &p) == STIM_OK);
uint32_t total = stim_session_half_periods(p.freq_mhz, p.duration_s);
assert(total == 80);
for (uint32_t i = 0; i < total - 1; i++) {
assert(stim_tick(&c));
}
assert(!stim_tick(&c)); /* final tick ends the session */
assert(c.state == STIM_IDLE);
assert(c.last_stop == STIM_STOP_COMPLETED);
assert(!c.envelope_on);
}
static void test_tick_alternates_envelope(void)
{
stim_ctx_t c = fresh();
stim_params_t p = prior();
assert(stim_start(&c, &p) == STIM_OK);
assert(!c.envelope_on);
stim_tick(&c);
assert(c.envelope_on);
stim_tick(&c);
assert(!c.envelope_on);
}
/* ---- timing math (the ±0.1 Hz HIL target is integer-exact) ----------------- */
static void test_half_period_math_is_exact(void)
{
assert(stim_half_period_us(40000) == 12500); /* 40.0 Hz */
assert(stim_half_period_us(36000) == 13888); /* 36.0 Hz, floor of 13888.9 */
assert(stim_half_period_us(44000) == 11363); /* 44.0 Hz, floor of 11363.6 */
assert(stim_half_period_us(38500) == 12987); /* 38.5 Hz */
/* Worst-case truncation at 44 Hz: commanded period = 2*11363us = 22726us
* -> 44.0028 Hz, an error of 2.8 mHz — 35x inside the ±100 mHz target. */
}
static void test_session_half_periods_math(void)
{
assert(stim_session_half_periods(40000, 600) == 48000); /* 10 min @ 40 Hz */
assert(stim_session_half_periods(44000, 900) == 79200);
assert(stim_session_half_periods(36000, 1) == 72);
}
/* ---- protocol parsing -------------------------------------------------------- */
static void test_parse_start(void)
{
stim_cmd_t cmd;
assert(stim_parse_line("START 40000 30 28 600", &cmd) == STIM_OK);
assert(cmd.kind == STIM_CMD_START);
assert(cmd.params.freq_mhz == 40000);
assert(cmd.params.brightness_pct == 30);
assert(cmd.params.volume_pct == 28);
assert(cmd.params.duration_s == 600);
}
static void test_parse_simple_commands(void)
{
stim_cmd_t cmd;
assert(stim_parse_line("STOP", &cmd) == STIM_OK && cmd.kind == STIM_CMD_STOP);
assert(stim_parse_line("STATUS", &cmd) == STIM_OK && cmd.kind == STIM_CMD_STATUS);
assert(stim_parse_line("UNLOCK", &cmd) == STIM_OK && cmd.kind == STIM_CMD_UNLOCK);
assert(stim_parse_line("VERSION", &cmd) == STIM_OK && cmd.kind == STIM_CMD_VERSION);
assert(stim_parse_line(" STOP", &cmd) == STIM_OK); /* leading spaces ok */
}
static void test_parse_rejects_malformed(void)
{
stim_cmd_t cmd;
assert(stim_parse_line("START", &cmd) == STIM_ERR_PARSE);
assert(stim_parse_line("START 40000 30 28", &cmd) == STIM_ERR_PARSE);
assert(stim_parse_line("START 40000 30 28 600 junk", &cmd) == STIM_ERR_PARSE);
assert(stim_parse_line("START 40000 999 28 600", &cmd) == STIM_ERR_PARSE);
assert(stim_parse_line("START -1 30 28 600", &cmd) == STIM_ERR_PARSE);
assert(stim_parse_line("START 99999999999 30 28 600", &cmd) == STIM_ERR_PARSE);
assert(stim_parse_line("FLASHBANG", &cmd) == STIM_ERR_UNKNOWN_CMD);
assert(stim_parse_line("STOPX", &cmd) == STIM_ERR_UNKNOWN_CMD);
assert(stim_parse_line("", &cmd) == STIM_ERR_UNKNOWN_CMD);
}
static void test_parsed_hostile_start_is_still_refused_by_envelope(void)
{
/* End-to-end fail-closed: a syntactically valid but unsafe command parses
* fine and is then refused by validation — never reaches the outputs. */
stim_ctx_t c = fresh();
stim_cmd_t cmd;
assert(stim_parse_line("START 60000 40 40 600", &cmd) == STIM_OK);
assert(stim_start(&c, &cmd.params) == STIM_ERR_FREQ_RANGE);
assert(c.state == STIM_IDLE);
}
int main(void)
{
RUN(test_prior_is_inside_envelope);
RUN(test_frequency_outside_band_refused);
RUN(test_intensity_caps_refused);
RUN(test_duration_caps_refused);
RUN(test_start_refused_while_running);
RUN(test_out_of_envelope_start_keeps_outputs_off);
RUN(test_estop_latches_from_any_state);
RUN(test_session_completes_after_duration);
RUN(test_tick_alternates_envelope);
RUN(test_half_period_math_is_exact);
RUN(test_session_half_periods_math);
RUN(test_parse_start);
RUN(test_parse_simple_commands);
RUN(test_parse_rejects_malformed);
RUN(test_parsed_hostile_start_is_still_refused_by_envelope);
printf("\nall %d stim_core tests passed\n", tests_run);
return 0;
}
+1
View File
@@ -0,0 +1 @@
0.1.0