forgefixture: the bench actuator's firmware

An ESP32-S3 DevKitC-1 on the bench network drives three relays at the
machine's connectors so the acceptance tool can open the lid loop, pull
the interlock loop and press the button with nobody in the room. Two
contacts are normally closed in loops the safety chain already reads,
the third is normally open across the button input and only ever
pulsed (20 to 500 ms, the end armed before the line rises); every line
is low at boot and after any reset, the task watchdog panics and
reboots, and the button channel needs a jumper. HTTP on port 80 under
a key in X-Fixture-Key; the hostname announced over DHCP and mDNS.

ESP-IDF v5.5 native, the mDNS component pinned in dependencies.lock;
fixture.env (git-ignored) is the one input: the wifi, the key, the
hostname. fixture.sh builds with idf.py or in the espressif/idf
container, flashes with esptool from pip. The decisions that need no
hardware live in policy.c with a gcc host test; CI runs it and builds
the firmware in the pinned container.
This commit is contained in:
ScottW514
2026-08-23 14:15:53 -04:00
parent 335c6dea9d
commit 8ee4ee3946
23 changed files with 1269 additions and 0 deletions
+56
View File
@@ -0,0 +1,56 @@
# forgefixture: the bench actuator's firmware, proven on the host.
#
# - the policy test: the decisions that need no hardware (channel
# names, the loop request words, the button pulse clamp, the key
# comparison), compiled with gcc and run
# - the firmware build: the whole project in the pinned ESP-IDF
# container, with a placeholder fixture.env, so a change that does
# not compile never reaches a bench
name: fixture-ci
on:
push:
paths:
- 'fixture/**'
- '.github/workflows/fixture-ci.yml'
pull_request:
paths:
- 'fixture/**'
workflow_dispatch:
jobs:
policy:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- name: Policy host test
working-directory: fixture
run: CC=gcc sh test/run.sh
firmware:
runs-on: ubuntu-latest
timeout-minutes: 30
container:
image: espressif/idf:v5.5.5
steps:
- uses: actions/checkout@v4
- name: Placeholder fixture.env (the build needs one; the values are not used)
working-directory: fixture
run: |
printf 'WIFI_SSID=ci-network\nWIFI_PSK=ci-passphrase\nAPI_KEY=%s\nHOSTNAME=forgefixture\n' \
0123456789abcdef0123456789abcdef > fixture.env
- name: Build
working-directory: fixture
shell: bash
run: |
. /opt/esp/idf/export.sh >/dev/null
idf.py set-target esp32s3 >/dev/null
idf.py build
- name: Size
working-directory: fixture
shell: bash
run: |
. /opt/esp/idf/export.sh >/dev/null
idf.py size | tail -n 20
+14
View File
@@ -0,0 +1,14 @@
# The bench's own wifi details and API key: never committed.
fixture.env
# ESP-IDF build products and the per-checkout config (regenerated from
# sdkconfig.defaults).
build/
sdkconfig
sdkconfig.old
managed_components/
# Host test binaries.
test/policy_test
test/*.o
+8
View File
@@ -0,0 +1,8 @@
# forgefixture: the ForgeFIRM bench actuator (ESP32-S3, ESP-IDF).
# The wifi details and the API key come from fixture.env next to this
# file (see main/CMakeLists.txt and README.md); nothing else is needed.
cmake_minimum_required(VERSION 3.16)
set(SDKCONFIG_DEFAULTS "${CMAKE_CURRENT_LIST_DIR}/sdkconfig.defaults")
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(forgefixture)
+120
View File
@@ -0,0 +1,120 @@
# forgefixture: the bench actuator
The acceptance campaign asks a person for about eighty small things,
and most of them are the same three: open the lid, pull the interlock,
press the button. This is the box that does those on request, so the
tool can run an operator test with nobody in the room. An ESP32-S3
DevKitC-1 on the bench network drives three relays wired into the
machine's own connectors; the acceptance tool (`forgetest`, on the
machine) asks it over HTTP and then watches the machine for the result,
the way it watches an operator's hand today.
Nothing here touches the laser's safety chain. Two of the contacts are
normally closed and sit in series with loops the chain already reads;
the third is normally open across the button input and is only ever
pulsed. With the fixture unpowered, unplugged, rebooting or crashed, the
machine is a stock machine.
## Hardware
| Part | What |
|---|---|
| ESP32-S3-DevKitC-1 | any flash size; powered from its USB port by a **USB wall adapter** (see grounds) |
| 3 × 1-channel 3.3 V optocoupler relay modules, high-level trigger | VCC, GND, IN; coil ~100 mA each |
| a 2-pin header and jumper | the button channel's enable |
| the interposer harness at the machine | bench-local; described in the project's hardware facts bank, not here |
Pins on the DevKit (all plain GPIOs, no strapping, USB, flash or PSRAM
role):
| Signal | GPIO | Relay contact | Where in the machine |
|---|---|---|---|
| lid | 4 | **NC**, in series with the lid-switch loop | energized = the loop opens = lid open |
| interlock | 5 | **NC**, in series with the interlock loop | energized = the loop opens = interlock pulled |
| button | 6 | **NO**, across the button input | energized = pressed; pulsed only, 20 to 500 ms |
| button enable | 7 | input, internal pull-up | a jumper to GND enables the button channel; no jumper, no presses |
Relay coils from the machine's 3.3 V (the modules' VCC and GND), the
three IN pins from the GPIOs above, the DevKit's GND to the modules'
GND. The modules' opto inputs share GND with their coils, so the ESP32
and the machine share a ground: power the DevKit from a wall adapter,
not from a PC, unless you want that PC's ground on the machine.
## Build and flash
The only input is `fixture.env`: the wifi network, the API key, the
hostname. Everything else is pinned (ESP-IDF v5.5, the mDNS component in
`dependencies.lock`).
./fixture.sh env # fixture.env from the example, with a fresh key; fill in the wifi
./fixture.sh build # idf.py if installed, else the espressif/idf container (docker or podman)
./fixture.sh flash COM5 # or /dev/ttyUSB0; esptool from pip talks to the board directly
./fixture.sh monitor COM5 # the log
Either USB port of the DevKit flashes; the one marked UART shows the log
too. `fixture.env` is git-ignored and never leaves the bench. A build in
the container works anywhere docker or podman runs (Git Bash on Windows
included); `pip install esptool pyserial` is the whole host-side
requirement for flashing and watching.
## What it does on the network
It joins the wifi as `forgefixture` (the `HOSTNAME` in `fixture.env`),
sends that name in its DHCP request, and announces `forgefixture.local`
over mDNS with a `_forgefixture._tcp` service. It reconnects forever and
never sleeps the radio. The API is HTTP on port 80, JSON, LAN only, no
OTA (the flash happens over USB and nowhere else).
Every request carries the key in `X-Fixture-Key`; without it, or with
a wrong one, every path answers 401 and the attempt is logged with the
caller's address.
| Request | Does |
|---|---|
| `GET /` or `/state` | identity, firmware version, uptime, the three channels' states, whether the button is enabled, the wifi link |
| `POST /lid {"state":"open"}` | energizes the lid channel (the loop opens); `"close"` releases it |
| `POST /interlock {"state":"open"}` | the same for the interlock loop |
| `POST /button {"ms":200}` | one press, `ms` clamped into 20 to 500 (200 when absent); 409 while the enable jumper is out or a press is still in progress |
| `POST /release` | every channel released |
An action answers with the state as `GET /` shows it. The fixture does
not read the machine's switches; the tool verifies every action through
the machine's own readings, which is the point.
curl -s -H "X-Fixture-Key: $KEY" http://forgefixture.local/
curl -s -H "X-Fixture-Key: $KEY" -d '{"state":"open"}' http://forgefixture.local/lid
## The tool's side
`forgetest` looks for `/data/forgetest/fixture.json` on the machine:
{"hostname": "forgefixture", "key": "<the API_KEY>", "ip": null,
"channels": ["lid", "interlock", "button"], "arm_press": false}
`ip` overrides the mDNS lookup (the tool resolves `<hostname>.local`
itself; the image has no mDNS resolver). `channels` names what is
wired. `arm_press` stays false unless the fixture may press the button
to arm the laser for a live test; by default that press is a person's.
The file is mode 0600 and bench-local. With the fixture up, an
`operator` test whose actions it covers runs in the unattended queue;
`live` tests keep their kind and their acknowledgment, and every action
records who performed it.
## What keeps it safe
- Every line is driven low first thing at boot, before the radio or the
server exist, and after any reset.
- The button is a pulse whose end is armed before the line rises; a hung
task trips the task watchdog, which panics and reboots to all-low.
- The button channel needs the jumper; a LAN key leak cannot press the
button on a fixture whose jumper is out.
- NC contacts in the loops: a fixture fault can only add an open, never
mask a real lid open or a pulled interlock.
- No OTA, no configuration over the network, nothing persisted beyond
the wifi driver's own calibration.
## Layout
main/ the firmware: main.c, wifi.c, api.c, relays.c, policy.c (the pure decisions)
test/ the host test of policy.c (./fixture.sh test)
fixture.env.example, sdkconfig.defaults, dependencies.lock
+21
View File
@@ -0,0 +1,21 @@
dependencies:
espressif/mdns:
component_hash: e81ca7a7f53ea34e78274df054da692c272e1315572876b237b1748e267c013b
dependencies:
- name: idf
require: private
version: '>=5.0'
source:
registry_url: https://components.espressif.com/
type: service
version: 1.11.3
idf:
source:
type: idf
version: 5.5.5
direct_dependencies:
- espressif/mdns
- idf
manifest_hash: 90c16fd2316c504ccf02e5191bcb9886c6850e5b89a8d08c35e1539ead49703d
target: esp32s3
version: 2.0.0
+16
View File
@@ -0,0 +1,16 @@
# forgefixture build settings. Copy to fixture.env (git-ignored) and fill
# in; `./fixture.sh env` does the copy and generates the API key.
# KEY=value, one per line; quotes around a value are optional.
# The wifi network the fixture joins (2.4 GHz; the ESP32-S3 has no 5 GHz).
WIFI_SSID=
WIFI_PSK=
# The key every API request must carry in X-Fixture-Key. The same value
# goes into the bench's /data/forgetest/fixture.json. Any string; 32 hex
# characters from `openssl rand -hex 16` is what `./fixture.sh env` writes.
API_KEY=
# The fixture's hostname: announced over mDNS (<HOSTNAME>.local) and in
# its DHCP request. Leave empty for forgefixture.
HOSTNAME=
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env bash
# forgefixture: build, flash and watch the bench actuator's firmware.
#
# ./fixture.sh env fixture.env from the example, with a fresh API key
# ./fixture.sh build build (native idf.py if installed, else the IDF container)
# ./fixture.sh flash [PORT] write the build to the board over USB (esptool)
# ./fixture.sh monitor [PORT] the board's log (idf.py monitor, or pyserial's miniterm)
# ./fixture.sh test the host test of the policy (needs a C compiler)
#
# The container build needs docker or podman and pulls espressif/idf once
# (IDF_IMAGE below); flashing never runs in the container: esptool is a
# pip package (pip install esptool) and talks to the port directly.
set -euo pipefail
cd "$(dirname "$0")"
IDF_IMAGE="${IDF_IMAGE:-docker.io/espressif/idf:v5.5.5}"
CHIP=esp32s3
BAUD="${BAUD:-460800}"
have() { command -v "$1" >/dev/null 2>&1; }
runner() {
if have docker; then echo docker; elif have podman; then echo podman; else
echo "neither idf.py nor docker/podman found: install ESP-IDF, or a container runtime for $IDF_IMAGE" >&2
exit 1
fi
}
# A Windows path for the bind mount when run from Git Bash, which would
# otherwise rewrite /project into a Windows path.
host_path() {
if have cygpath; then cygpath -w "$PWD"; else echo "$PWD"; fi
}
cmd_env() {
if [ -e fixture.env ]; then
echo "fixture.env exists; edit it, or remove it first" >&2
exit 1
fi
key=$( (have openssl && openssl rand -hex 16) || python3 -c 'import secrets; print(secrets.token_hex(16))')
sed -e "s/^API_KEY=.*/API_KEY=$key/" fixture.env.example > fixture.env
chmod 600 fixture.env 2>/dev/null || true
echo "fixture.env written with a fresh API key; fill in WIFI_SSID and WIFI_PSK"
}
cmd_build() {
[ -e fixture.env ] || { echo "no fixture.env: run ./fixture.sh env first" >&2; exit 1; }
if have idf.py; then
idf.py set-target "$CHIP" >/dev/null
idf.py build
else
r=$(runner)
MSYS_NO_PATHCONV=1 "$r" run --rm -v "$(host_path):/project" -w /project -e HOME=/tmp "$IDF_IMAGE" \
bash -c "idf.py set-target $CHIP >/dev/null && idf.py build"
fi
}
port_arg() {
if [ -n "${1:-}" ]; then echo "$1"; elif [ -n "${PORT:-}" ]; then echo "$PORT"; else
echo "which port? ./fixture.sh flash PORT (COM5, /dev/ttyUSB0, /dev/cu.usbserial-*)" >&2
exit 1
fi
}
cmd_flash() {
port=$(port_arg "${1:-}")
[ -e build/flash_args ] || { echo "no build yet: ./fixture.sh build" >&2; exit 1; }
if have idf.py; then
idf.py -p "$port" flash
else
have esptool.py || have esptool || { echo "esptool not found: pip install esptool" >&2; exit 1; }
tool=$(have esptool.py && echo esptool.py || echo esptool)
(cd build && "$tool" --chip "$CHIP" -p "$port" -b "$BAUD" --before default_reset --after hard_reset \
write_flash @flash_args)
fi
}
cmd_monitor() {
port=$(port_arg "${1:-}")
if have idf.py; then
idf.py -p "$port" monitor
else
python3 -m serial.tools.miniterm --raw "$port" 115200
fi
}
cmd_test() {
sh test/run.sh
}
case "${1:-}" in
env) cmd_env ;;
build) cmd_build ;;
flash) cmd_flash "${2:-}" ;;
monitor) cmd_monitor "${2:-}" ;;
test) cmd_test ;;
*) sed -n '2,12p' "$0"; exit 1 ;;
esac
+64
View File
@@ -0,0 +1,64 @@
# The fixture's one input is fixture.env at the project root: KEY=value
# lines, read here into a generated header. No file, or a required key
# left empty, stops the build with the reason.
set(FIXTURE_ENV "${CMAKE_CURRENT_LIST_DIR}/../fixture.env")
if(NOT EXISTS "${FIXTURE_ENV}")
message(FATAL_ERROR
"fixture.env not found at ${FIXTURE_ENV}: copy fixture.env.example to "
"fixture.env and fill in WIFI_SSID, WIFI_PSK and API_KEY (./fixture.sh env does it)")
endif()
set(FIXTURE_WIFI_SSID "")
set(FIXTURE_WIFI_PSK "")
set(FIXTURE_API_KEY "")
set(FIXTURE_HOSTNAME "")
# Read the file whole and split on newlines ourselves: file(STRINGS)
# would also split on semicolons, which a passphrase may carry.
file(READ "${FIXTURE_ENV}" _env_text)
string(REPLACE ";" "\\;" _env_text "${_env_text}")
string(REGEX REPLACE "\r?\n" ";" _env_lines "${_env_text}")
foreach(_line IN LISTS _env_lines)
if(_line MATCHES "^[ \t]*#" OR _line MATCHES "^[ \t]*$")
continue()
endif()
if(_line MATCHES "^[ \t]*([A-Za-z_][A-Za-z0-9_]*)[ \t]*=(.*)$")
set(_key "${CMAKE_MATCH_1}")
set(_val "${CMAKE_MATCH_2}")
string(STRIP "${_val}" _val)
string(REGEX REPLACE "^\"(.*)\"$" "\\1" _val "${_val}")
string(REGEX REPLACE "^'(.*)'$" "\\1" _val "${_val}")
set(FIXTURE_${_key} "${_val}")
else()
message(FATAL_ERROR "fixture.env: cannot read line: ${_line}")
endif()
endforeach()
foreach(_key WIFI_SSID API_KEY)
if("${FIXTURE_${_key}}" STREQUAL "")
message(FATAL_ERROR "fixture.env: ${_key} is empty")
endif()
endforeach()
if("${FIXTURE_HOSTNAME}" STREQUAL "")
set(FIXTURE_HOSTNAME "forgefixture")
endif()
string(LENGTH "${FIXTURE_HOSTNAME}" _hostlen)
if(NOT FIXTURE_HOSTNAME MATCHES "^[a-z0-9][a-z0-9-]*$" OR FIXTURE_HOSTNAME MATCHES "-$" OR _hostlen GREATER 32)
message(FATAL_ERROR "fixture.env: HOSTNAME must be a DNS label (lowercase letters, digits, hyphens; 32 at most)")
endif()
# Into C string literals.
foreach(_key WIFI_SSID WIFI_PSK API_KEY HOSTNAME)
string(REPLACE "\\" "\\\\" FIXTURE_${_key} "${FIXTURE_${_key}}")
string(REPLACE "\"" "\\\"" FIXTURE_${_key} "${FIXTURE_${_key}}")
endforeach()
set(_gen_dir "${CMAKE_CURRENT_BINARY_DIR}/generated")
file(MAKE_DIRECTORY "${_gen_dir}")
configure_file("${CMAKE_CURRENT_LIST_DIR}/fixture_config.h.in" "${_gen_dir}/fixture_config.h" @ONLY)
idf_component_register(
SRCS "main.c" "wifi.c" "relays.c" "api.c" "policy.c"
INCLUDE_DIRS "." "${_gen_dir}"
REQUIRES nvs_flash esp_wifi esp_netif esp_event esp_http_server json esp_timer driver
)
+229
View File
@@ -0,0 +1,229 @@
/*
* forgefixture API (see api.h).
*
* (C) Copyright 2026
* Scott Wiederhold, s.e.wiederhold@gmail.com
* SPDX-License-Identifier: MIT
*/
#include "api.h"
#include <string.h>
#include <sys/socket.h>
#include "cJSON.h"
#include "esp_http_server.h"
#include "esp_idf_version.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "lwip/sockets.h"
#include "policy.h"
#include "relays.h"
#include "version.h"
#include "wifi.h"
static const char *TAG = "api";
static const char *s_key;
static const char *s_hostname;
#define BODY_MAX 256
/* The peer's address for the log: every action is attributed. */
static void peer(httpd_req_t *req, char *buf, size_t n)
{
struct sockaddr_in addr; /* IPv4 only: the fixture speaks nothing else */
socklen_t len = sizeof(addr);
int fd = httpd_req_to_sockfd(req);
buf[0] = '\0';
if (fd >= 0 && getpeername(fd, (struct sockaddr *)&addr, &len) == 0 && addr.sin_family == AF_INET)
inet_ntoa_r(addr.sin_addr, buf, n);
}
static esp_err_t send_json(httpd_req_t *req, const char *status, cJSON *obj)
{
char *text = cJSON_PrintUnformatted(obj);
cJSON_Delete(obj);
if (text == NULL)
return httpd_resp_send_500(req);
httpd_resp_set_status(req, status);
httpd_resp_set_type(req, "application/json");
esp_err_t r = httpd_resp_send(req, text, HTTPD_RESP_USE_STRLEN);
cJSON_free(text);
return r;
}
static esp_err_t send_error(httpd_req_t *req, const char *status, const char *msg)
{
cJSON *o = cJSON_CreateObject();
cJSON_AddStringToObject(o, "error", msg);
return send_json(req, status, o);
}
static cJSON *state_json(void)
{
relays_state_t st = relays_state();
char ip[40];
wifi_ip(ip, sizeof(ip));
cJSON *o = cJSON_CreateObject();
cJSON_AddStringToObject(o, "device", "forgefixture");
cJSON_AddStringToObject(o, "hostname", s_hostname);
cJSON_AddStringToObject(o, "version", FIXTURE_VERSION);
cJSON_AddStringToObject(o, "idf", esp_get_idf_version());
cJSON_AddNumberToObject(o, "uptime_s", (double)(esp_timer_get_time() / 1000000LL));
cJSON *ch = cJSON_AddObjectToObject(o, "channels");
for (int i = 0; i < CH_COUNT; i++)
cJSON_AddStringToObject(ch, policy_channel_name((channel_t)i),
policy_state_name((channel_t)i, st.energized[i]));
cJSON_AddBoolToObject(o, "button_enabled", st.button_enabled);
cJSON_AddBoolToObject(o, "button_pulsing", st.button_pulsing);
cJSON *w = cJSON_AddObjectToObject(o, "wifi");
cJSON_AddBoolToObject(w, "connected", wifi_connected());
cJSON_AddStringToObject(w, "ip", ip);
cJSON_AddNumberToObject(w, "rssi", wifi_rssi());
return o;
}
/* The key check, before anything else on every path. */
static bool authorized(httpd_req_t *req)
{
char presented[96] = {0};
esp_err_t r = httpd_req_get_hdr_value_str(req, "X-Fixture-Key", presented, sizeof(presented));
bool ok = (r == ESP_OK) && policy_key_matches(presented, s_key);
if (!ok) {
char who[48];
peer(req, who, sizeof(who));
ESP_LOGW(TAG, "%s %s from %s: refused (%s)", http_method_str(req->method), req->uri, who,
r == ESP_OK ? "wrong key" : "no key");
send_error(req, "401 Unauthorized", "X-Fixture-Key missing or wrong");
}
return ok;
}
/* The JSON body, or NULL (an error already sent). An empty body parses
* as an empty object so "POST /button" alone means the default pulse. */
static cJSON *body_json(httpd_req_t *req)
{
char buf[BODY_MAX + 1];
if (req->content_len > BODY_MAX) {
send_error(req, "413 Payload Too Large", "body over 256 bytes");
return NULL;
}
size_t got = 0;
while (got < req->content_len) {
int n = httpd_req_recv(req, buf + got, req->content_len - got);
if (n <= 0) {
send_error(req, "400 Bad Request", "body not received");
return NULL;
}
got += (size_t)n;
}
buf[got] = '\0';
cJSON *o = got ? cJSON_Parse(buf) : cJSON_CreateObject();
if (o == NULL || !cJSON_IsObject(o)) {
cJSON_Delete(o);
send_error(req, "400 Bad Request", "body is not a JSON object");
return NULL;
}
return o;
}
static esp_err_t h_state(httpd_req_t *req)
{
if (!authorized(req))
return ESP_OK;
return send_json(req, "200 OK", state_json());
}
static esp_err_t h_loop(httpd_req_t *req)
{
if (!authorized(req))
return ESP_OK;
int ch = policy_channel_from_path(req->uri);
cJSON *body = body_json(req);
if (body == NULL)
return ESP_OK;
const cJSON *state = cJSON_GetObjectItemCaseSensitive(body, "state");
bool energize;
if (!cJSON_IsString(state) || !policy_parse_loop_state(state->valuestring, &energize)) {
cJSON_Delete(body);
return send_error(req, "400 Bad Request", "state must be \"open\" or \"close\"");
}
cJSON_Delete(body);
char who[48];
peer(req, who, sizeof(who));
ESP_LOGI(TAG, "%s %s by %s", policy_channel_name((channel_t)ch), energize ? "open" : "close", who);
relays_set_loop((channel_t)ch, energize);
return send_json(req, "200 OK", state_json());
}
static esp_err_t h_button(httpd_req_t *req)
{
if (!authorized(req))
return ESP_OK;
cJSON *body = body_json(req);
if (body == NULL)
return ESP_OK;
const cJSON *ms = cJSON_GetObjectItemCaseSensitive(body, "ms");
int want = cJSON_IsNumber(ms) ? (int)ms->valuedouble : 0;
cJSON_Delete(body);
int pulse = policy_button_ms(want);
char who[48];
peer(req, who, sizeof(who));
esp_err_t r = relays_pulse_button(pulse);
if (r == ESP_ERR_NOT_ALLOWED) {
ESP_LOGW(TAG, "button press by %s refused: enable jumper out", who);
return send_error(req, "409 Conflict", "button disabled: the enable jumper is out");
}
if (r == ESP_ERR_INVALID_STATE) {
ESP_LOGW(TAG, "button press by %s refused: a pulse is in progress", who);
return send_error(req, "409 Conflict", "a button pulse is in progress");
}
ESP_LOGI(TAG, "button %d ms by %s", pulse, who);
cJSON *o = state_json();
cJSON_AddNumberToObject(o, "pulse_ms", pulse);
return send_json(req, "200 OK", o);
}
static esp_err_t h_release(httpd_req_t *req)
{
if (!authorized(req))
return ESP_OK;
char who[48];
peer(req, who, sizeof(who));
ESP_LOGI(TAG, "release by %s", who);
relays_release();
return send_json(req, "200 OK", state_json());
}
static esp_err_t h_404(httpd_req_t *req, httpd_err_code_t err)
{
(void)err;
return send_error(req, "404 Not Found", "no such path");
}
void api_start(const char *api_key, const char *hostname)
{
s_key = api_key;
s_hostname = hostname;
httpd_config_t cfg = HTTPD_DEFAULT_CONFIG();
cfg.server_port = 80;
cfg.lru_purge_enable = true;
cfg.max_open_sockets = 4;
httpd_handle_t srv = NULL;
ESP_ERROR_CHECK(httpd_start(&srv, &cfg));
static const httpd_uri_t routes[] = {
{.uri = "/", .method = HTTP_GET, .handler = h_state},
{.uri = "/state", .method = HTTP_GET, .handler = h_state},
{.uri = "/lid", .method = HTTP_POST, .handler = h_loop},
{.uri = "/interlock", .method = HTTP_POST, .handler = h_loop},
{.uri = "/button", .method = HTTP_POST, .handler = h_button},
{.uri = "/release", .method = HTTP_POST, .handler = h_release},
};
for (size_t i = 0; i < sizeof(routes) / sizeof(routes[0]); i++)
ESP_ERROR_CHECK(httpd_register_uri_handler(srv, &routes[i]));
httpd_register_err_handler(srv, HTTPD_404_NOT_FOUND, h_404);
ESP_LOGI(TAG, "listening on :%d", cfg.server_port);
}
+25
View File
@@ -0,0 +1,25 @@
/*
* forgefixture API: HTTP on port 80, JSON, every request under the key.
*
* GET / identity, uptime, the channels' states, the
* enable jumper, the wifi link
* GET /state the same
* POST /lid {"state": "open" | "close"}
* POST /interlock {"state": "open" | "close"}
* POST /button {"ms": 200} one pulse, clamped to 20..500 ms
* POST /release every channel released
*
* The key travels in the X-Fixture-Key header; without it, or with a
* wrong one, every path answers 401. A lid or interlock "open" energizes
* the channel (the loop opens); "close" releases it. The button is
* refused with 409 while the enable jumper is out or a pulse is still in
* progress. Errors are {"error": "..."}; actions answer with the state
* as GET / shows it.
*
* (C) Copyright 2026
* Scott Wiederhold, s.e.wiederhold@gmail.com
* SPDX-License-Identifier: MIT
*/
#pragma once
void api_start(const char *api_key, const char *hostname);
+7
View File
@@ -0,0 +1,7 @@
/* Generated from fixture.env by main/CMakeLists.txt. Do not edit. */
#pragma once
#define FIXTURE_WIFI_SSID "@FIXTURE_WIFI_SSID@"
#define FIXTURE_WIFI_PSK "@FIXTURE_WIFI_PSK@"
#define FIXTURE_API_KEY "@FIXTURE_API_KEY@"
#define FIXTURE_HOSTNAME "@FIXTURE_HOSTNAME@"
+5
View File
@@ -0,0 +1,5 @@
## The managed components this firmware uses (fetched by the IDF
## component manager on the first build; dependencies.lock pins them).
dependencies:
idf: ">=5.3"
espressif/mdns: "^1.11.3"
+41
View File
@@ -0,0 +1,41 @@
/*
* forgefixture: the ForgeFIRM bench actuator. An ESP32-S3 on the bench
* network drives three relays at the machine's connectors (the lid
* loop, the interlock loop, the button) so the acceptance tool can open
* a lid, pull an interlock or press the button without a hand in the
* room. The firmware's whole job is to do exactly that on request, and
* nothing at all otherwise: every line low at boot and after any reset,
* the button only ever pulsed, every request under a key.
*
* (C) Copyright 2026
* Scott Wiederhold, s.e.wiederhold@gmail.com
* SPDX-License-Identifier: MIT
*/
#include "esp_log.h"
#include "nvs_flash.h"
#include "api.h"
#include "fixture_config.h"
#include "relays.h"
#include "version.h"
#include "wifi.h"
static const char *TAG = "forgefixture";
void app_main(void)
{
/* The relays first: whatever else happens at boot, the lines are low
* before the radio or the server exist. */
relays_init();
esp_err_t r = nvs_flash_init();
if (r == ESP_ERR_NVS_NO_FREE_PAGES || r == ESP_ERR_NVS_NEW_VERSION_FOUND) {
ESP_ERROR_CHECK(nvs_flash_erase());
r = nvs_flash_init();
}
ESP_ERROR_CHECK(r);
ESP_LOGI(TAG, "forgefixture %s, hostname %s", FIXTURE_VERSION, FIXTURE_HOSTNAME);
wifi_start(FIXTURE_HOSTNAME, FIXTURE_WIFI_SSID, FIXTURE_WIFI_PSK);
api_start(FIXTURE_API_KEY, FIXTURE_HOSTNAME);
}
+76
View File
@@ -0,0 +1,76 @@
/*
* forgefixture policy (see policy.h). No ESP-IDF here: test/policy_test.c
* compiles this file with a host compiler.
*
* (C) Copyright 2026
* Scott Wiederhold, s.e.wiederhold@gmail.com
* SPDX-License-Identifier: MIT
*/
#include "policy.h"
#include <string.h>
static const char *const NAMES[CH_COUNT] = {"lid", "interlock", "button"};
const char *policy_channel_name(channel_t ch)
{
return (ch >= 0 && ch < CH_COUNT) ? NAMES[ch] : "?";
}
int policy_channel_from_path(const char *path)
{
if (path == NULL || path[0] != '/')
return -1;
for (int i = 0; i < CH_COUNT; i++)
if (strcmp(path + 1, NAMES[i]) == 0)
return i;
return -1;
}
bool policy_parse_loop_state(const char *word, bool *energize)
{
if (word == NULL)
return false;
if (strcmp(word, "open") == 0) {
*energize = true;
return true;
}
if (strcmp(word, "close") == 0 || strcmp(word, "closed") == 0) {
*energize = false;
return true;
}
return false;
}
int policy_button_ms(int requested)
{
if (requested <= 0)
return BUTTON_DEFAULT_MS;
if (requested < BUTTON_MIN_MS)
return BUTTON_MIN_MS;
if (requested > BUTTON_MAX_MS)
return BUTTON_MAX_MS;
return requested;
}
const char *policy_state_name(channel_t ch, bool energized)
{
if (ch == CH_BUTTON)
return energized ? "pressed" : "idle";
return energized ? "open" : "closed";
}
bool policy_key_matches(const char *presented, const char *expected)
{
if (presented == NULL || expected == NULL)
return false;
size_t lp = strlen(presented), le = strlen(expected);
size_t n = lp > le ? lp : le;
unsigned diff = (unsigned)(lp != le);
for (size_t i = 0; i < n; i++) {
unsigned char a = i < lp ? (unsigned char)presented[i] : 0;
unsigned char b = i < le ? (unsigned char)expected[i] : 0;
diff |= (unsigned)(a ^ b);
}
return diff == 0;
}
+53
View File
@@ -0,0 +1,53 @@
/*
* forgefixture policy: the decisions that need no hardware, kept apart
* so the host test can hold them to account. What a channel is called,
* what a request may ask of it, and how long a button press may last.
*
* (C) Copyright 2026
* Scott Wiederhold, s.e.wiederhold@gmail.com
* SPDX-License-Identifier: MIT
*/
#pragma once
#include <stdbool.h>
#include <stddef.h>
/* The three channels, in the order of the GPIO table in relays.c. */
typedef enum {
CH_LID = 0, /* NC contact in the lid-switch loop: energized = loop open */
CH_INTERLOCK = 1, /* NC contact in the interlock loop: energized = loop open */
CH_BUTTON = 2, /* NO contact across the button input: energized = pressed */
CH_COUNT = 3
} channel_t;
/* A button press is a pulse and nothing else: never held, never longer
* than BUTTON_MAX_MS whatever the request says, never shorter than the
* debounce the machine's input needs to see it. */
#define BUTTON_DEFAULT_MS 200
#define BUTTON_MIN_MS 20
#define BUTTON_MAX_MS 500
/* The channel's name on the API and in the log. */
const char *policy_channel_name(channel_t ch);
/* The channel named by an API path ("/lid", "/interlock", "/button"), or
* -1. */
int policy_channel_from_path(const char *path);
/* The level a lid or interlock request asks for: "open" energizes the
* channel (the loop opens), "close" or "closed" releases it. Returns
* false for any other word. */
bool policy_parse_loop_state(const char *word, bool *energize);
/* The pulse a button request gets: the default for 0 or an absent
* value, otherwise the request clamped into [BUTTON_MIN_MS,
* BUTTON_MAX_MS]. A negative request is the default too. */
int policy_button_ms(int requested);
/* What the state of a channel is called on the API: a loop channel is
* "open" or "closed", the button "pressed" or "idle". */
const char *policy_state_name(channel_t ch, bool energized);
/* Constant-time equality of two keys, so a wrong key costs the same
* whichever byte is wrong. */
bool policy_key_matches(const char *presented, const char *expected);
+128
View File
@@ -0,0 +1,128 @@
/*
* forgefixture relays (see relays.h).
*
* The safety argument lives here: the lid and interlock contacts are
* normally closed and the button contact normally open, so a line that
* is low leaves the machine exactly as it is without the fixture. Every
* line is driven low first thing at boot, after any reset, and by
* relays_release(); the button is only ever pulsed, its end set by a
* one-shot timer that is armed before the line goes high.
*
* (C) Copyright 2026
* Scott Wiederhold, s.e.wiederhold@gmail.com
* SPDX-License-Identifier: MIT
*/
#include "relays.h"
#include "driver/gpio.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
static const char *TAG = "relays";
static const gpio_num_t GPIOS[CH_COUNT] = {RELAY_GPIO_LID, RELAY_GPIO_INTERLOCK, RELAY_GPIO_BUTTON};
static SemaphoreHandle_t s_lock;
static esp_timer_handle_t s_pulse_end;
static bool s_energized[CH_COUNT];
static bool s_pulsing;
static void drive(channel_t ch, bool level)
{
gpio_set_level(GPIOS[ch], level ? 1 : 0);
s_energized[ch] = level;
}
static void pulse_end(void *arg)
{
(void)arg;
xSemaphoreTake(s_lock, portMAX_DELAY);
drive(CH_BUTTON, false);
s_pulsing = false;
xSemaphoreGive(s_lock);
ESP_LOGI(TAG, "button released");
}
void relays_init(void)
{
/* Low before the pins become outputs: the DevKit's pull state at
* reset is not a relay's idea of off. */
for (int i = 0; i < CH_COUNT; i++) {
gpio_reset_pin(GPIOS[i]);
gpio_set_level(GPIOS[i], 0);
gpio_set_direction(GPIOS[i], GPIO_MODE_OUTPUT);
gpio_set_level(GPIOS[i], 0);
s_energized[i] = false;
}
gpio_reset_pin(ENABLE_GPIO_BUTTON);
gpio_set_direction(ENABLE_GPIO_BUTTON, GPIO_MODE_INPUT);
gpio_set_pull_mode(ENABLE_GPIO_BUTTON, GPIO_PULLUP_ONLY);
s_lock = xSemaphoreCreateMutex();
const esp_timer_create_args_t args = {
.callback = pulse_end,
.name = "button-pulse",
};
ESP_ERROR_CHECK(esp_timer_create(&args, &s_pulse_end));
ESP_LOGI(TAG, "lid GPIO%d, interlock GPIO%d, button GPIO%d (enable jumper GPIO%d): all released",
RELAY_GPIO_LID, RELAY_GPIO_INTERLOCK, RELAY_GPIO_BUTTON, ENABLE_GPIO_BUTTON);
}
static bool button_enabled(void)
{
return gpio_get_level(ENABLE_GPIO_BUTTON) == 0;
}
esp_err_t relays_set_loop(channel_t ch, bool energize)
{
if (ch != CH_LID && ch != CH_INTERLOCK)
return ESP_ERR_INVALID_ARG;
xSemaphoreTake(s_lock, portMAX_DELAY);
drive(ch, energize);
xSemaphoreGive(s_lock);
ESP_LOGI(TAG, "%s %s", policy_channel_name(ch), policy_state_name(ch, energize));
return ESP_OK;
}
esp_err_t relays_pulse_button(int ms)
{
if (!button_enabled())
return ESP_ERR_NOT_ALLOWED;
xSemaphoreTake(s_lock, portMAX_DELAY);
if (s_pulsing) {
xSemaphoreGive(s_lock);
return ESP_ERR_INVALID_STATE;
}
s_pulsing = true;
/* The end is armed before the line rises. */
ESP_ERROR_CHECK(esp_timer_start_once(s_pulse_end, (uint64_t)ms * 1000ULL));
drive(CH_BUTTON, true);
xSemaphoreGive(s_lock);
ESP_LOGI(TAG, "button pressed for %d ms", ms);
return ESP_OK;
}
void relays_release(void)
{
xSemaphoreTake(s_lock, portMAX_DELAY);
esp_timer_stop(s_pulse_end); /* harmless when not running */
for (int i = 0; i < CH_COUNT; i++)
drive((channel_t)i, false);
s_pulsing = false;
xSemaphoreGive(s_lock);
ESP_LOGI(TAG, "all released");
}
relays_state_t relays_state(void)
{
relays_state_t st;
xSemaphoreTake(s_lock, portMAX_DELAY);
for (int i = 0; i < CH_COUNT; i++)
st.energized[i] = s_energized[i];
st.button_pulsing = s_pulsing;
xSemaphoreGive(s_lock);
st.button_enabled = button_enabled();
return st;
}
+47
View File
@@ -0,0 +1,47 @@
/*
* forgefixture relays: the three channels on their GPIOs, the button's
* pulse timer, and the enable jumper that gates the button.
*
* (C) Copyright 2026
* Scott Wiederhold, s.e.wiederhold@gmail.com
* SPDX-License-Identifier: MIT
*/
#pragma once
#include <stdbool.h>
#include "esp_err.h"
#include "policy.h"
/* DevKitC-1 pins with no strapping, USB, flash or PSRAM role. Active
* high into the relay modules' opto inputs. */
#define RELAY_GPIO_LID 4
#define RELAY_GPIO_INTERLOCK 5
#define RELAY_GPIO_BUTTON 6
/* The enable jumper for the button channel: input with the pull-up on,
* the jumper shorts it to GND. No jumper = high = button disabled. */
#define ENABLE_GPIO_BUTTON 7
typedef struct {
bool energized[CH_COUNT];
bool button_enabled; /* the jumper is in */
bool button_pulsing; /* a press is in progress */
} relays_state_t;
/* Every line low before anything else runs. */
void relays_init(void);
/* Lid or interlock: hold the channel energized (loop open) or released
* (loop closed). ESP_ERR_INVALID_ARG for the button, which is never
* held. */
esp_err_t relays_set_loop(channel_t ch, bool energize);
/* The button: one pulse of `ms` (already clamped by policy_button_ms).
* ESP_ERR_NOT_ALLOWED without the enable jumper, ESP_ERR_INVALID_STATE
* while a pulse is still in progress. */
esp_err_t relays_pulse_button(int ms);
/* Everything low, a pulse in progress cut short. */
void relays_release(void);
relays_state_t relays_state(void);
+4
View File
@@ -0,0 +1,4 @@
/* forgefixture firmware version, reported by GET /. */
#pragma once
#define FIXTURE_VERSION "1.0.0"
+119
View File
@@ -0,0 +1,119 @@
/*
* forgefixture wifi (see wifi.h).
*
* (C) Copyright 2026
* Scott Wiederhold, s.e.wiederhold@gmail.com
* SPDX-License-Identifier: MIT
*/
#include "wifi.h"
#include <string.h>
#include "esp_event.h"
#include "esp_log.h"
#include "esp_netif.h"
#include "esp_wifi.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "mdns.h"
static const char *TAG = "wifi";
static esp_netif_t *s_netif;
static volatile bool s_connected;
static esp_ip4_addr_t s_ip;
static char s_hostname[32];
static void on_wifi(void *arg, esp_event_base_t base, int32_t id, void *data)
{
(void)arg;
(void)data;
if (id == WIFI_EVENT_STA_START) {
esp_wifi_connect();
} else if (id == WIFI_EVENT_STA_DISCONNECTED) {
const wifi_event_sta_disconnected_t *d = data;
s_connected = false;
ESP_LOGW(TAG, "disconnected (reason %d), reconnecting", d ? d->reason : -1);
/* A short pause keeps a wrong passphrase from becoming a storm;
* the event loop task tolerates it. */
vTaskDelay(pdMS_TO_TICKS(1000));
esp_wifi_connect();
}
}
static void on_ip(void *arg, esp_event_base_t base, int32_t id, void *data)
{
(void)arg;
(void)base;
if (id == IP_EVENT_STA_GOT_IP) {
const ip_event_got_ip_t *ev = data;
s_ip = ev->ip_info.ip;
s_connected = true;
ESP_LOGI(TAG, "joined: " IPSTR " as %s.local", IP2STR(&ev->ip_info.ip), s_hostname);
} else if (id == IP_EVENT_STA_LOST_IP) {
s_connected = false;
ESP_LOGW(TAG, "address lost");
}
}
static void mdns_start(const char *hostname)
{
ESP_ERROR_CHECK(mdns_init());
ESP_ERROR_CHECK(mdns_hostname_set(hostname));
ESP_ERROR_CHECK(mdns_instance_name_set("ForgeFIRM bench fixture"));
mdns_txt_item_t txt[] = {
{"device", "forgefixture"},
{"channels", "lid,interlock,button"},
};
ESP_ERROR_CHECK(mdns_service_add(NULL, "_forgefixture", "_tcp", 80, txt, 2));
ESP_ERROR_CHECK(mdns_service_add(NULL, "_http", "_tcp", 80, txt, 2));
}
void wifi_start(const char *hostname, const char *ssid, const char *psk)
{
strncpy(s_hostname, hostname, sizeof(s_hostname) - 1);
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
s_netif = esp_netif_create_default_wifi_sta();
ESP_ERROR_CHECK(esp_netif_set_hostname(s_netif, hostname)); /* DHCP option 12 */
wifi_init_config_t init = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&init));
ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &on_wifi, NULL));
ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, ESP_EVENT_ANY_ID, &on_ip, NULL));
wifi_config_t cfg = {0};
strncpy((char *)cfg.sta.ssid, ssid, sizeof(cfg.sta.ssid) - 1);
strncpy((char *)cfg.sta.password, psk, sizeof(cfg.sta.password) - 1);
cfg.sta.threshold.authmode = psk[0] ? WIFI_AUTH_WPA2_PSK : WIFI_AUTH_OPEN;
cfg.sta.pmf_cfg.capable = true;
cfg.sta.pmf_cfg.required = false;
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &cfg));
ESP_ERROR_CHECK(esp_wifi_start());
ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_NONE));
mdns_start(hostname);
ESP_LOGI(TAG, "joining %s", ssid);
}
void wifi_ip(char *buf, size_t n)
{
if (s_connected)
snprintf(buf, n, IPSTR, IP2STR(&s_ip));
else
snprintf(buf, n, "0.0.0.0");
}
int wifi_rssi(void)
{
wifi_ap_record_t ap;
if (!s_connected || esp_wifi_sta_get_ap_info(&ap) != ESP_OK)
return 0;
return ap.rssi;
}
bool wifi_connected(void)
{
return s_connected;
}
+23
View File
@@ -0,0 +1,23 @@
/*
* forgefixture wifi: a station that joins the bench network and stays
* joined, announces its hostname over DHCP and mDNS, and never sleeps
* the radio (the API answers in milliseconds, not on the next beacon).
*
* (C) Copyright 2026
* Scott Wiederhold, s.e.wiederhold@gmail.com
* SPDX-License-Identifier: MIT
*/
#pragma once
#include <stdbool.h>
#include <stddef.h>
void wifi_start(const char *hostname, const char *ssid, const char *psk);
/* The station's address as text ("0.0.0.0" while not joined). */
void wifi_ip(char *buf, size_t n);
/* Signal strength in dBm, 0 while not joined. */
int wifi_rssi(void);
bool wifi_connected(void);
+36
View File
@@ -0,0 +1,36 @@
# forgefixture build defaults (ESP32-S3 DevKitC-1). idf.py merges these
# into sdkconfig on the first configure; sdkconfig itself is not tracked.
CONFIG_IDF_TARGET="esp32s3"
# Any DevKitC-1 has at least 8 MB; a 4 MB layout with one large app
# partition fits every one of them and leaves nothing to choose.
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y
# A hung task reboots the board to every relay line low rather than
# holding a button press: the task watchdog panics, and a panic resets.
CONFIG_ESP_TASK_WDT_INIT=y
CONFIG_ESP_TASK_WDT_PANIC=y
CONFIG_ESP_TASK_WDT_TIMEOUT_S=5
CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0=y
CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1=y
CONFIG_ESP_INT_WDT=y
CONFIG_ESP_SYSTEM_PANIC_PRINT_REBOOT=y
# Brownout (the relays share the board's 3.3 V with nothing else, but the
# USB supply is whatever it is): reset rather than limp.
CONFIG_ESP_BROWNOUT_DET=y
# The log on both USB ports: the UART bridge and the native USB-Serial-JTAG.
CONFIG_ESP_CONSOLE_UART_DEFAULT=y
CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG=y
# HTTP server: a few clients, small requests.
CONFIG_HTTPD_MAX_REQ_HDR_LEN=1024
CONFIG_HTTPD_MAX_URI_LEN=128
CONFIG_HTTPD_PURGE_BUF_LEN=32
# Wifi station only; no Bluetooth.
CONFIG_BT_ENABLED=n
CONFIG_ESP_WIFI_NVS_ENABLED=y
CONFIG_LWIP_IPV6=n
+73
View File
@@ -0,0 +1,73 @@
/*
* forgefixture policy, held to account on the host: gcc -o policy_test
* policy_test.c ../main/policy.c (see run.sh).
*
* (C) Copyright 2026
* Scott Wiederhold, s.e.wiederhold@gmail.com
* SPDX-License-Identifier: MIT
*/
#include <stdio.h>
#include <string.h>
#include "../main/policy.h"
static int failures;
#define CHECK(cond) do { if (!(cond)) { failures++; printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); } } while (0)
int main(void)
{
/* names and paths */
CHECK(strcmp(policy_channel_name(CH_LID), "lid") == 0);
CHECK(strcmp(policy_channel_name(CH_INTERLOCK), "interlock") == 0);
CHECK(strcmp(policy_channel_name(CH_BUTTON), "button") == 0);
CHECK(policy_channel_from_path("/lid") == CH_LID);
CHECK(policy_channel_from_path("/interlock") == CH_INTERLOCK);
CHECK(policy_channel_from_path("/button") == CH_BUTTON);
CHECK(policy_channel_from_path("/lids") == -1);
CHECK(policy_channel_from_path("lid") == -1);
CHECK(policy_channel_from_path(NULL) == -1);
/* a loop request: open energizes, close/closed releases, nothing else */
bool e = false;
CHECK(policy_parse_loop_state("open", &e) && e);
CHECK(policy_parse_loop_state("close", &e) && !e);
CHECK(policy_parse_loop_state("closed", &e) && !e);
CHECK(!policy_parse_loop_state("Open", &e));
CHECK(!policy_parse_loop_state("on", &e));
CHECK(!policy_parse_loop_state("", &e));
CHECK(!policy_parse_loop_state(NULL, &e));
/* the button pulse: a default, a floor, a ceiling, never a hold */
CHECK(policy_button_ms(0) == BUTTON_DEFAULT_MS);
CHECK(policy_button_ms(-5) == BUTTON_DEFAULT_MS);
CHECK(policy_button_ms(1) == BUTTON_MIN_MS);
CHECK(policy_button_ms(250) == 250);
CHECK(policy_button_ms(500) == BUTTON_MAX_MS);
CHECK(policy_button_ms(501) == BUTTON_MAX_MS);
CHECK(policy_button_ms(60000) == BUTTON_MAX_MS);
CHECK(BUTTON_MAX_MS <= 500);
/* state names */
CHECK(strcmp(policy_state_name(CH_LID, true), "open") == 0);
CHECK(strcmp(policy_state_name(CH_LID, false), "closed") == 0);
CHECK(strcmp(policy_state_name(CH_INTERLOCK, true), "open") == 0);
CHECK(strcmp(policy_state_name(CH_BUTTON, true), "pressed") == 0);
CHECK(strcmp(policy_state_name(CH_BUTTON, false), "idle") == 0);
/* the key: exact, whole, and never by prefix */
CHECK(policy_key_matches("abc123", "abc123"));
CHECK(!policy_key_matches("abc12", "abc123"));
CHECK(!policy_key_matches("abc1234", "abc123"));
CHECK(!policy_key_matches("", "abc123"));
CHECK(!policy_key_matches("abc123", ""));
CHECK(!policy_key_matches(NULL, "abc123"));
CHECK(!policy_key_matches("ABC123", "abc123"));
if (failures) {
printf("%d failure(s)\n", failures);
return 1;
}
printf("policy: all checks passed\n");
return 0;
}
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
# The host test of the fixture's policy: needs only a C compiler.
set -e
cd "$(dirname "$0")"
${CC:-cc} -std=c11 -Wall -Wextra -Werror -o policy_test policy_test.c ../main/policy.c
./policy_test