mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-27 16:51:12 -07:00
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:
@@ -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
|
||||
)
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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@"
|
||||
@@ -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"
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,4 @@
|
||||
/* forgefixture firmware version, reported by GET /. */
|
||||
#pragma once
|
||||
|
||||
#define FIXTURE_VERSION "1.0.0"
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user