diff --git a/CHANGELOG.md b/CHANGELOG.md index 616c76f8..fb70750a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 36–44 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 3–6).** 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. diff --git a/firmware/esp32-gamma-stim/CMakeLists.txt b/firmware/esp32-gamma-stim/CMakeLists.txt new file mode 100644 index 00000000..5aef563a --- /dev/null +++ b/firmware/esp32-gamma-stim/CMakeLists.txt @@ -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}) diff --git a/firmware/esp32-gamma-stim/README.md b/firmware/esp32-gamma-stim/README.md new file mode 100644 index 00000000..bcfb2c69 --- /dev/null +++ b/firmware/esp32-gamma-stim/README.md @@ -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 +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. diff --git a/firmware/esp32-gamma-stim/main/CMakeLists.txt b/firmware/esp32-gamma-stim/main/CMakeLists.txt new file mode 100644 index 00000000..b3b51b94 --- /dev/null +++ b/firmware/esp32-gamma-stim/main/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + SRCS "main.c" "stim_core.c" + INCLUDE_DIRS "." + REQUIRES driver esp_timer +) diff --git a/firmware/esp32-gamma-stim/main/Kconfig.projbuild b/firmware/esp32-gamma-stim/main/Kconfig.projbuild new file mode 100644 index 00000000..bfdd693f --- /dev/null +++ b/firmware/esp32-gamma-stim/main/Kconfig.projbuild @@ -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 diff --git a/firmware/esp32-gamma-stim/main/main.c b/firmware/esp32-gamma-stim/main/main.c new file mode 100644 index 00000000..f7029853 --- /dev/null +++ b/firmware/esp32-gamma-stim/main/main.c @@ -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 +#include + +#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"); + } + } + } +} diff --git a/firmware/esp32-gamma-stim/main/stim_core.c b/firmware/esp32-gamma-stim/main/stim_core.c new file mode 100644 index 00000000..2fbeeaf9 --- /dev/null +++ b/firmware/esp32-gamma-stim/main/stim_core.c @@ -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 +#include +#include + +/* ---- 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"; + } +} diff --git a/firmware/esp32-gamma-stim/main/stim_core.h b/firmware/esp32-gamma-stim/main/stim_core.h new file mode 100644 index 00000000..ae5616d9 --- /dev/null +++ b/firmware/esp32-gamma-stim/main/stim_core.h @@ -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 (0–100), duration in seconds. + */ +#ifndef STIM_CORE_H +#define STIM_CORE_H + +#include +#include +#include + +#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 + * 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 " 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 */ diff --git a/firmware/esp32-gamma-stim/sdkconfig.defaults b/firmware/esp32-gamma-stim/sdkconfig.defaults new file mode 100644 index 00000000..a070e32f --- /dev/null +++ b/firmware/esp32-gamma-stim/sdkconfig.defaults @@ -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 diff --git a/firmware/esp32-gamma-stim/tests/test_stim_core.c b/firmware/esp32-gamma-stim/tests/test_stim_core.c new file mode 100644 index 00000000..bb76e77a --- /dev/null +++ b/firmware/esp32-gamma-stim/tests/test_stim_core.c @@ -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 +#include +#include + +#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; +} diff --git a/firmware/esp32-gamma-stim/version.txt b/firmware/esp32-gamma-stim/version.txt new file mode 100644 index 00000000..6e8bf73a --- /dev/null +++ b/firmware/esp32-gamma-stim/version.txt @@ -0,0 +1 @@ +0.1.0