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

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

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

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

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

249 lines
8.4 KiB
C

/*
* 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;
}