diff --git a/.github/workflows/fixture-ci.yml b/.github/workflows/fixture-ci.yml new file mode 100644 index 0000000..adf1e73 --- /dev/null +++ b/.github/workflows/fixture-ci.yml @@ -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 diff --git a/fixture/.gitignore b/fixture/.gitignore new file mode 100644 index 0000000..2786cd6 --- /dev/null +++ b/fixture/.gitignore @@ -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 diff --git a/fixture/CMakeLists.txt b/fixture/CMakeLists.txt new file mode 100644 index 0000000..a9d65fa --- /dev/null +++ b/fixture/CMakeLists.txt @@ -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) diff --git a/fixture/README.md b/fixture/README.md new file mode 100644 index 0000000..31f5415 --- /dev/null +++ b/fixture/README.md @@ -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": "", "ip": null, + "channels": ["lid", "interlock", "button"], "arm_press": false} + +`ip` overrides the mDNS lookup (the tool resolves `.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 diff --git a/fixture/dependencies.lock b/fixture/dependencies.lock new file mode 100644 index 0000000..cad15c3 --- /dev/null +++ b/fixture/dependencies.lock @@ -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 diff --git a/fixture/fixture.env.example b/fixture/fixture.env.example new file mode 100644 index 0000000..a96b1a1 --- /dev/null +++ b/fixture/fixture.env.example @@ -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 (.local) and in +# its DHCP request. Leave empty for forgefixture. +HOSTNAME= diff --git a/fixture/fixture.sh b/fixture/fixture.sh new file mode 100644 index 0000000..7f8b273 --- /dev/null +++ b/fixture/fixture.sh @@ -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 diff --git a/fixture/main/CMakeLists.txt b/fixture/main/CMakeLists.txt new file mode 100644 index 0000000..e7d056d --- /dev/null +++ b/fixture/main/CMakeLists.txt @@ -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 +) diff --git a/fixture/main/api.c b/fixture/main/api.c new file mode 100644 index 0000000..7c1f966 --- /dev/null +++ b/fixture/main/api.c @@ -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 +#include + +#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); +} diff --git a/fixture/main/api.h b/fixture/main/api.h new file mode 100644 index 0000000..291ab6f --- /dev/null +++ b/fixture/main/api.h @@ -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); diff --git a/fixture/main/fixture_config.h.in b/fixture/main/fixture_config.h.in new file mode 100644 index 0000000..13bfb0d --- /dev/null +++ b/fixture/main/fixture_config.h.in @@ -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@" diff --git a/fixture/main/idf_component.yml b/fixture/main/idf_component.yml new file mode 100644 index 0000000..c4c16fe --- /dev/null +++ b/fixture/main/idf_component.yml @@ -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" diff --git a/fixture/main/main.c b/fixture/main/main.c new file mode 100644 index 0000000..a5c38a4 --- /dev/null +++ b/fixture/main/main.c @@ -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); +} diff --git a/fixture/main/policy.c b/fixture/main/policy.c new file mode 100644 index 0000000..6fcfdd8 --- /dev/null +++ b/fixture/main/policy.c @@ -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 + +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; +} diff --git a/fixture/main/policy.h b/fixture/main/policy.h new file mode 100644 index 0000000..38b86dd --- /dev/null +++ b/fixture/main/policy.h @@ -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 +#include + +/* 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); diff --git a/fixture/main/relays.c b/fixture/main/relays.c new file mode 100644 index 0000000..8156e7d --- /dev/null +++ b/fixture/main/relays.c @@ -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; +} diff --git a/fixture/main/relays.h b/fixture/main/relays.h new file mode 100644 index 0000000..6b6cc26 --- /dev/null +++ b/fixture/main/relays.h @@ -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 + +#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); diff --git a/fixture/main/version.h b/fixture/main/version.h new file mode 100644 index 0000000..907d00f --- /dev/null +++ b/fixture/main/version.h @@ -0,0 +1,4 @@ +/* forgefixture firmware version, reported by GET /. */ +#pragma once + +#define FIXTURE_VERSION "1.0.0" diff --git a/fixture/main/wifi.c b/fixture/main/wifi.c new file mode 100644 index 0000000..e8875f9 --- /dev/null +++ b/fixture/main/wifi.c @@ -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 + +#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; +} diff --git a/fixture/main/wifi.h b/fixture/main/wifi.h new file mode 100644 index 0000000..a0c16be --- /dev/null +++ b/fixture/main/wifi.h @@ -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 +#include + +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); diff --git a/fixture/sdkconfig.defaults b/fixture/sdkconfig.defaults new file mode 100644 index 0000000..c6fd640 --- /dev/null +++ b/fixture/sdkconfig.defaults @@ -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 diff --git a/fixture/test/policy_test.c b/fixture/test/policy_test.c new file mode 100644 index 0000000..e7fd622 --- /dev/null +++ b/fixture/test/policy_test.c @@ -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 +#include + +#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; +} diff --git a/fixture/test/run.sh b/fixture/test/run.sh new file mode 100644 index 0000000..64e8808 --- /dev/null +++ b/fixture/test/run.sh @@ -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