Add typed serial settings operations

Route bounded admin mutations through the existing administration
dispatcher,
covering apply, lifecycle, persistence, authorization, and result
tracking.
Add the browser controls, automatic result refresh, regression coverage,
and
phase documentation.
This commit is contained in:
2026-09-08 00:25:31 +02:00
parent 5a2aa0d4d8
commit 42548f6334
27 changed files with 1398 additions and 42 deletions
+1
View File
@@ -31,6 +31,7 @@ idf_component_register(
"user_console.c"
"web_security.c"
"web_serial_transport.c"
"web_serial_settings.c"
"web_admin_tickets.c"
"web_admin_transport.c"
"web_assets_data.c"
+18
View File
@@ -16,6 +16,7 @@
#include "linenoise/linenoise.h"
#include "secure_random.h"
#include "user_database.h"
#include "web_serial_settings.h"
#define ADMIN_SSH_CONSOLE_MAX_SESSIONS 2U
#define ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY 4096U
@@ -81,6 +82,7 @@ typedef enum {
ADMIN_REQUEST_SSH = 0,
ADMIN_REQUEST_UART0,
ADMIN_REQUEST_DEFERRED,
ADMIN_REQUEST_SERIAL_SETTINGS,
} admin_request_origin_t;
typedef struct {
@@ -91,6 +93,7 @@ typedef struct {
union {
uint8_t line[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U];
admin_control_request_t deferred;
uint32_t serial_settings_id;
};
} admin_request_t;
@@ -646,6 +649,16 @@ static void dispatch_registered_command(admin_request_t *request)
static void dispatch_deferred_request(admin_request_t *request);
esp_err_t admin_ssh_console_submit_serial_settings(uint32_t id)
{
taskENTER_CRITICAL(&s_lock);
bool ready = s_dispatch_ready;
taskEXIT_CRITICAL(&s_lock);
if (!ready || !id) return ESP_ERR_INVALID_STATE;
admin_request_t request = {.origin = ADMIN_REQUEST_SERIAL_SETTINGS, .serial_settings_id = id};
return xQueueSend(s_request_queue, &request, 0U) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT;
}
static void worker_task(void *context)
{
(void)context;
@@ -654,6 +667,11 @@ static void worker_task(void *context)
if (xQueueReceive(s_request_queue, &request, portMAX_DELAY) != pdTRUE) {
continue;
}
if (request.origin == ADMIN_REQUEST_SERIAL_SETTINGS) {
web_serial_settings_execute(request.serial_settings_id);
secure_wipe(&request, sizeof(request));
continue;
}
if (request.origin == ADMIN_REQUEST_DEFERRED) {
dispatch_deferred_request(&request);
secure_wipe(&request, sizeof(request));
+3
View File
@@ -14,6 +14,9 @@
extern "C" {
#endif
/* Nonblocking typed Serial-settings admission to the canonical dispatcher. */
esp_err_t admin_ssh_console_submit_serial_settings(uint32_t id);
/* Fits the longest supported ECDSA P-256 OpenSSH key import command. */
#define ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY 256U
+15 -3
View File
@@ -173,14 +173,14 @@ void web_cookie_auth_clear_counters(void)
taskEXIT_CRITICAL(&s_lock);
}
esp_err_t web_cookie_auth_require(httpd_req_t *r, bool mutation, bool upgrade,
web_session_view_t *view, bool *allowed)
static esp_err_t require(httpd_req_t *r, bool mutation, bool upgrade, size_t body_limit,
web_session_view_t *view, bool *allowed)
{
char canonical[129] = {0}, token[65] = {0}, csrf[65] = {0};
*allowed = false;
memset(view, 0, sizeof(*view));
if (!web_httpd_headers_valid(r) || !cookies_valid(r) || (!upgrade && strchr(r->uri, '?')) ||
r->content_len || r->method != (mutation ? HTTP_POST : HTTP_GET))
r->content_len > body_limit || r->method != (mutation ? HTTP_POST : HTTP_GET))
return failure(r, "400 Bad Request", "invalid_request");
if (!origin(r, mutation || upgrade, canonical))
return failure(r, "403 Forbidden", "origin");
@@ -212,6 +212,18 @@ esp_err_t web_cookie_auth_require(httpd_req_t *r, bool mutation, bool upgrade,
return ESP_OK;
}
esp_err_t web_cookie_auth_require(httpd_req_t *r, bool mutation, bool upgrade,
web_session_view_t *view, bool *allowed)
{
return require(r, mutation, upgrade, 0, view, allowed);
}
esp_err_t web_cookie_auth_require_json(httpd_req_t *r, size_t body_limit,
web_session_view_t *view, bool *allowed)
{
return require(r, true, false, body_limit, view, allowed);
}
static bool secret(char out[65])
{
uint8_t bytes[32];
+3
View File
@@ -17,3 +17,6 @@ esp_err_t web_cookie_auth_require(httpd_req_t *request, bool mutation,
bool upgrade, web_session_view_t *view,
bool *allowed);
esp_err_t web_cookie_auth_handler(httpd_req_t *request);
/* Same mutation policy, allowing a bounded body; caller validates JSON/content type. */
esp_err_t web_cookie_auth_require_json(httpd_req_t *request, size_t body_limit,
web_session_view_t *view, bool *allowed);
+10 -3
View File
@@ -106,10 +106,11 @@ bool web_httpd_unread_body(httpd_req_t *request)
return aux && aux->remaining_len != 0;
}
esp_err_t web_httpd_register_optional_get(httpd_handle_t server, const httpd_uri_t *uri)
esp_err_t web_httpd_register_optional(httpd_handle_t server, const httpd_uri_t *uri)
{
struct httpd_data *hd = server;
if (!hd || !uri || !uri->uri || !uri->handler || uri->method != HTTP_GET ||
if (!hd || !uri || !uri->uri || !uri->handler ||
(uri->method != HTTP_GET && uri->method != HTTP_POST) ||
uri->is_websocket || uri->supported_subprotocol || hd->config.uri_match_fn)
return ESP_ERR_INVALID_ARG;
size_t length = 0;
@@ -118,7 +119,7 @@ esp_err_t web_httpd_register_optional_get(httpd_handle_t server, const httpd_uri
int slot = -1;
for (unsigned i = 0; i < hd->config.max_uri_handlers; ++i) {
if (!hd->hd_calls[i]) { if (slot < 0) slot = (int)i; }
else if (!strcmp(hd->hd_calls[i]->uri, uri->uri) && hd->hd_calls[i]->method == HTTP_GET)
else if (!strcmp(hd->hd_calls[i]->uri, uri->uri) && hd->hd_calls[i]->method == uri->method)
return ESP_ERR_INVALID_STATE;
}
if (slot < 0) return ESP_ERR_NO_MEM;
@@ -134,3 +135,9 @@ esp_err_t web_httpd_register_optional_get(httpd_handle_t server, const httpd_uri
hd->hd_calls[slot] = copy;
return ESP_OK;
}
esp_err_t web_httpd_register_optional_get(httpd_handle_t server, const httpd_uri_t *uri)
{
if (!uri || uri->method != HTTP_GET) return ESP_ERR_INVALID_ARG;
return web_httpd_register_optional(server, uri);
}
+2
View File
@@ -16,3 +16,5 @@ esp_err_t web_httpd_upgrade(httpd_req_t *request,
/* Serialized server startup only, exact-match ordinary GET, URI <= 127 bytes.
* Stage both allocations before publication; HTTPD owns/frees them on success. */
esp_err_t web_httpd_register_optional_get(httpd_handle_t server, const httpd_uri_t *uri);
/* Same staged startup ownership for ordinary exact GET or POST. */
esp_err_t web_httpd_register_optional(httpd_handle_t server, const httpd_uri_t *uri);
+240
View File
@@ -0,0 +1,240 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "web_serial_settings.h"
#include <inttypes.h>
#include <stdio.h>
#include <string.h>
#include "admin_ssh_console.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "secure_random.h"
#include "serial_service.h"
#include "web_cookie_auth.h"
#include "web_httpd_adapter.h"
enum { APPLY, START, STOP, SAVE, LOAD, DEFAULTS, RESET, ACTION_COUNT };
static const char *const s_actions[] = {"apply", "start", "stop", "save", "load", "defaults", "reset"};
enum { IDLE, PENDING, OK, FAILED, CANCELLED, LOADED_DEFAULTS, ROLLBACK_FAILED };
static const char *const s_states[] = {"idle", "pending", "ok", "failed", "cancelled", "loaded_defaults", "rollback_failed"};
typedef struct {
uint32_t id;
web_session_id_t session;
user_principal_t principal;
int64_t deadline;
serial_config_t config;
unsigned action, state;
} serial_operation_t;
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
static serial_operation_t s_operation;
static uint32_t s_next_id;
/* Deliberately narrow flat JSON: ASCII names/enums, unsigned decimal integers,
* no escapes, nesting, duplicate/unknown fields, exponent or fractional values. */
static bool parse(const char *body, size_t length, serial_operation_t *operation)
{
const char *keys[] = {"action", "baud", "data_bits", "parity", "stop_bits", "flow", "dtr", "rts_threshold"};
unsigned seen = 0;
size_t pos = 0;
serial_config_defaults(&operation->config);
operation->action = ACTION_COUNT;
#define SPACE() while (pos < length && (body[pos] == ' ' || body[pos] == '\t' || body[pos] == '\r' || body[pos] == '\n')) ++pos
#define TAKE(c) do { SPACE(); if (pos == length || body[pos++] != (c)) return false; } while (0)
TAKE('{');
for (unsigned field = 0; field < 8; ++field) {
if (field) { TAKE(','); }
TAKE('"');
size_t start = pos;
while (pos < length && body[pos] != '"') ++pos;
if (pos == length) return false;
unsigned key = 0;
for (; key < 8; ++key)
if (strlen(keys[key]) == pos - start && !memcmp(body + start, keys[key], pos - start)) break;
if (key == 8 || (seen & (1U << key))) return false;
++pos; TAKE(':'); SPACE();
uint32_t number = 0;
char value[16] = {0};
if (key == 1 || key == 7) {
start = pos;
while (pos < length && body[pos] >= '0' && body[pos] <= '9') {
if (number > 1000000U) return false;
number = number * 10 + (unsigned)(body[pos++] - '0');
}
if (pos == start || (pos - start > 1 && body[start] == '0')) return false;
} else {
TAKE('"'); start = pos;
while (pos < length && body[pos] != '"') {
if (body[pos] < ' ' || body[pos] > '~' || body[pos] == '\\' || pos - start >= sizeof(value) - 1) return false;
++pos;
}
if (pos == length) return false;
memcpy(value, body + start, pos - start); ++pos;
}
switch (key) {
case 0:
for (unsigned i = 0; i < ACTION_COUNT; ++i)
if (!strcmp(value, s_actions[i])) operation->action = i;
if (operation->action == ACTION_COUNT) return false;
break;
case 1: operation->config.baud_rate = number; break;
case 2: if (!serial_config_parse_data_bits(value, &operation->config.data_bits)) return false; break;
case 3: if (!serial_config_parse_parity(value, &operation->config.parity)) return false; break;
case 4: if (!serial_config_parse_stop_bits(value, &operation->config.stop_bits)) return false; break;
case 5: if (!serial_config_parse_flow_control(value, &operation->config.flow_control)) return false; break;
case 6: if (!serial_config_parse_dtr_behavior(value, &operation->config.dtr_behavior)) return false; break;
case 7: operation->config.rts_threshold = number; break;
}
seen |= 1U << key;
SPACE();
if (pos < length && body[pos] == '}') break;
}
TAKE('}'); SPACE();
#undef TAKE
#undef SPACE
return pos == length && seen == (operation->action == APPLY ? 255U : 1U) &&
serial_config_validate(&operation->config) == ESP_OK;
}
void web_serial_settings_execute(uint32_t id)
{
serial_operation_t operation;
taskENTER_CRITICAL(&s_lock);
operation = s_operation;
taskEXIT_CRITICAL(&s_lock);
if (!id || operation.id != id || operation.state != PENDING) {
secure_wipe(&operation, sizeof(operation));
return;
}
bool current = false;
esp_err_t error = web_session_store_check_principal(operation.session, &operation.principal, &current);
unsigned state = CANCELLED;
if (error == ESP_OK && current && operation.principal.role == USER_ROLE_ADMIN &&
esp_timer_get_time() < operation.deadline) {
/* Operation-admission currentness, not cancellation of an admitted NVS
* commit. CLI commands cannot interleave on this single dispatcher. */
serial_config_t config, previous;
bool stored = true;
state = OK;
switch (operation.action) {
case APPLY: error = serial_service_apply_config(&operation.config); break;
case START: error = serial_service_start(); break;
case STOP: error = serial_service_stop(); break;
case SAVE:
error = serial_service_get_config(&config);
if (error == ESP_OK) error = serial_config_save(&config);
break;
case LOAD:
error = serial_config_load(&config, &stored);
if (error == ESP_OK) error = serial_service_apply_config(&config);
if (!stored) state = LOADED_DEFAULTS;
break;
case DEFAULTS:
serial_config_defaults(&config);
error = serial_service_apply_config(&config);
break;
case RESET:
serial_config_defaults(&config);
error = serial_service_get_config(&previous);
if (error == ESP_OK) error = serial_service_apply_config(&config);
if (error == ESP_OK) {
error = serial_config_reset_storage();
if (error != ESP_OK && serial_service_apply_config(&previous) != ESP_OK)
state = ROLLBACK_FAILED;
}
break;
default: error = ESP_ERR_INVALID_ARG; break;
}
if (error != ESP_OK && state != ROLLBACK_FAILED) state = FAILED;
}
taskENTER_CRITICAL(&s_lock);
if (s_operation.id == id && s_operation.state == PENDING) {
s_operation.state = state;
secure_wipe(&s_operation.principal, sizeof(s_operation.principal));
secure_wipe(&s_operation.config, sizeof(s_operation.config));
}
taskEXIT_CRITICAL(&s_lock);
secure_wipe(&operation, sizeof(operation));
}
static esp_err_t respond(httpd_req_t *request, const char *status, const char *body)
{
esp_err_t error = httpd_resp_set_status(request, status);
if (error == ESP_OK) error = httpd_resp_set_type(request, "application/json; charset=utf-8");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Cache-Control", "no-store");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer");
if (error == ESP_OK) error = httpd_resp_sendstr(request, body);
return web_httpd_unread_body(request) ? ESP_FAIL : error;
}
esp_err_t web_serial_settings_handler(httpd_req_t *request)
{
web_session_view_t view = {0};
bool allowed = false;
bool mutation = request->method == HTTP_POST;
esp_err_t error = mutation
? web_cookie_auth_require_json(request, 256, &view, &allowed)
: web_cookie_auth_require(request, false, false, &view, &allowed);
if (error != ESP_OK || !allowed) goto done;
if (view.principal.role != USER_ROLE_ADMIN) {
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
goto done;
}
serial_operation_t operation = {0};
if (mutation) {
char type[40] = {0}, body[256];
size_t received = 0;
bool valid = request->content_len &&
httpd_req_get_hdr_value_str(request, "Content-Type", type, sizeof(type)) == ESP_OK &&
(!strcmp(type, "application/json") || !strcmp(type, "application/json; charset=utf-8"));
/* Finite bytes and receive calls; timeout/error closes, never retry/drain. */
for (unsigned reads = 0; valid && received < request->content_len && reads < 4; ++reads) {
int count = httpd_req_recv(request, body + received, request->content_len - received);
if (count <= 0 || (size_t)count > request->content_len - received) valid = false;
else received += (size_t)count;
}
valid = valid && received == request->content_len && parse(body, received, &operation);
secure_wipe(body, sizeof(body));
if (!valid) {
error = respond(request, "400 Bad Request", "{\"error\":\"invalid_serial_request\"}");
goto done;
}
operation.session = view.id;
operation.principal = view.principal;
operation.deadline = esp_timer_get_time() + 30000000LL;
operation.state = PENDING;
taskENTER_CRITICAL(&s_lock);
bool busy = s_operation.state == PENDING || s_next_id == UINT32_MAX;
if (!busy) {
operation.id = ++s_next_id;
s_operation = operation;
}
taskEXIT_CRITICAL(&s_lock);
if (busy || admin_ssh_console_submit_serial_settings(operation.id) != ESP_OK) {
taskENTER_CRITICAL(&s_lock);
if (!busy && s_operation.id == operation.id) secure_wipe(&s_operation, sizeof(s_operation));
taskEXIT_CRITICAL(&s_lock);
error = httpd_resp_set_hdr(request, "Retry-After", "1");
if (error == ESP_OK) error = respond(request, "503 Service Unavailable", "{\"error\":\"busy\"}");
secure_wipe(&operation, sizeof(operation));
goto done;
}
} else {
taskENTER_CRITICAL(&s_lock);
if (s_operation.session == view.id) {
operation.id = s_operation.id;
operation.action = s_operation.action;
operation.state = s_operation.state;
}
taskEXIT_CRITICAL(&s_lock);
}
char response[96];
int written = snprintf(response, sizeof(response), "{\"id\":%" PRIu32 ",\"action\":\"%s\",\"state\":\"%s\"}",
operation.id, operation.id ? s_actions[operation.action] : "none", s_states[operation.state]);
error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL :
respond(request, mutation ? "202 Accepted" : "200 OK", response);
secure_wipe(&operation, sizeof(operation));
done:
secure_wipe(&view, sizeof(view));
web_httpd_wipe_request(request, web_httpd_unread_body(request));
return error;
}
+14
View File
@@ -0,0 +1,14 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#pragma once
#include <stdint.h>
#include "esp_http_server.h"
/* HTTPD owns requests/responses; the existing admin dispatcher alone executes.
* One global slot rejects mutations while pending (including execution). GET
* exposes only the caller's session result; a later admitted operation replaces
* that result, so this is not a durable history or an idempotent retry API.
* The 30-second deadline is checked when dequeued, not a completion deadline or
* a timer that frees the slot. Revocation/expiry cancels before operation
* admission; admitted serial/NVS work may finish after the session is gone. */
esp_err_t web_serial_settings_handler(httpd_req_t *request);
void web_serial_settings_execute(uint32_t id);
+20 -4
View File
@@ -23,6 +23,7 @@
#include "user_database.h"
#include "web_security.h"
#include "web_serial_transport.h"
#include "web_serial_settings.h"
#include "web_admin_transport.h"
#include "web_session_store.h"
#include "web_cookie_auth.h"
@@ -223,7 +224,7 @@ static esp_err_t status_handler(httpd_req_t *request)
increment_counter(&s_counters.status_requests);
wifi_manager_snapshot_t wifi = {0};
serial_config_t serial_config = {0};
serial_service_snapshot_t serial_snapshot = {0};
serial_service_counters_t serial_counters = {0};
session_broker_global_snapshot_t broker = {0};
usb_cdc_transport_snapshot_t usb = {0};
@@ -235,7 +236,8 @@ static esp_err_t status_handler(httpd_req_t *request)
char response[WEB_SERVER_STATUS_JSON_CAPACITY];
bool wifi_available = wifi_manager_get_snapshot(&wifi) == ESP_OK;
bool serial_config_available = serial_service_get_config(&serial_config) == ESP_OK;
bool serial_config_available = serial_service_get_snapshot(&serial_snapshot) == ESP_OK;
serial_config_t serial_config = serial_snapshot.config;
serial_service_get_counters(&serial_counters);
bool broker_available = session_broker_get_global_snapshot(&broker) == ESP_OK;
bool usb_available = usb_cdc_transport_get_snapshot(&usb) == ESP_OK;
@@ -279,7 +281,7 @@ static esp_err_t status_handler(httpd_req_t *request)
wifi_available ? (unsigned int)wifi.sta_channel : 0U,
wifi_available && wifi.ap_running ? "true" : "false",
wifi_available ? (unsigned int)wifi.ap_client_count : 0U,
serial_service_is_running() ? "true" : "false",
serial_config_available ? (serial_snapshot.running ? "true" : "false") : "null",
serial_config_available ? "true" : "false",
serial_config_available ? serial_config.baud_rate : 0U,
serial_config_available ? safe_string(serial_config_data_bits_to_string(serial_config.data_bits)) : "unknown",
@@ -375,6 +377,17 @@ static const httpd_uri_t s_serial_settings_uri = {
.handler = serial_settings_handler,
};
static const httpd_uri_t s_serial_operation_get_uri = {
.uri = "/api/settings/serial-operation",
.method = HTTP_GET,
.handler = web_serial_settings_handler,
};
static const httpd_uri_t s_serial_operation_post_uri = {
.uri = "/api/settings/serial-operation",
.method = HTTP_POST,
.handler = web_serial_settings_handler,
};
static const httpd_uri_t s_root_uri = {
.uri = "/",
.method = HTTP_GET,
@@ -563,7 +576,7 @@ esp_err_t web_server_start(void)
config.httpd.max_open_sockets = 6;
config.httpd.max_uri_handlers =
sizeof(s_uri_handlers) / sizeof(s_uri_handlers[0]) +
sizeof(s_auth_uris) / sizeof(s_auth_uris[0]) + 3U;
sizeof(s_auth_uris) / sizeof(s_auth_uris[0]) + 5U;
/* Exhaustion rejects new sockets, never evicts an existing serial writer. */
config.httpd.lru_purge_enable = false;
config.httpd.recv_wait_timeout = 1;
@@ -611,6 +624,9 @@ esp_err_t web_server_start(void)
admin_transport_owned = web_admin_transport_attach(server) == ESP_OK;
/* Optional settings allocation failure must not disable either terminal. */
(void)web_httpd_register_optional_get(server, &s_serial_settings_uri);
if (web_httpd_register_optional(server, &s_serial_operation_get_uri) == ESP_OK &&
web_httpd_register_optional(server, &s_serial_operation_post_uri) != ESP_OK)
(void)httpd_unregister_uri_handler(server, s_serial_operation_get_uri.uri, HTTP_GET);
}
if (error != ESP_OK) {
web_cookie_auth_stop();
+152 -10
View File
@@ -83,6 +83,9 @@ static const char s_index_html[] =
".settings-page{overflow:auto;padding:8px;min-height:0}.settings-page h2{margin:0 0 8px;font-size:18px}"
".settings-values{display:grid;grid-template-columns:minmax(110px,1fr) minmax(0,2fr);gap:8px 16px;max-width:600px}"
".settings-values dt{color:var(--muted)}.settings-values dd{margin:0;overflow-wrap:anywhere}\n"
".serial-edit{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;max-width:600px}"
".serial-edit label{display:grid;gap:4px;color:var(--muted)}.serial-edit input,.serial-edit select{font:inherit;width:100%;min-width:0;padding:8px;background:var(--panel);color:var(--text);border:1px solid var(--line);border-radius:6px}"
".serial-actions{display:flex;flex-wrap:wrap;gap:8px;margin:12px 0}\n"
".terminal-toolbar{flex-wrap:wrap}.terminal-toolbar .button{min-height:32px;padding:4px 10px}\n"
"@media(max-width:850px){html,body{overflow:auto}.page{height:auto;min-height:100dvh;grid-template-rows:auto auto minmax(280px,1fr)}"
".terminal-panel{min-height:280px}.dashboard{grid-template-columns:1fr}.controls{align-items:flex-start}"
@@ -175,7 +178,7 @@ static const char s_index_html[] =
"<div id=\"terminal\" class=\"terminal-host\"></div>\n"
"<div id=\"admin-terminal\" class=\"terminal-host\" hidden></div>\n"
"<section id=\"serial-settings\" class=\"settings-page\" aria-label=\"Serial settings\" hidden>"
"<h2>Serial</h2><p class=\"connection-detail\">Read-only working UART1 configuration, not saved NVS values. "
"<h2>Serial</h2><p class=\"connection-detail\">Working UART1 configuration below is not a saved NVS snapshot. "
"Navigation leaves both terminals connected and preserves the serial writer lease.</p>"
"<button id=\"refresh-settings\" class=\"button\" type=\"button\">Refresh</button>"
"<p id=\"settings-detail\" class=\"connection-detail\" role=\"status\">Select Refresh to read current values.</p>"
@@ -184,7 +187,33 @@ static const char s_index_html[] =
"<dt>Data bits</dt><dd id=\"setting-data_bits\"></dd><dt>Parity</dt><dd id=\"setting-parity\"></dd>"
"<dt>Stop bits</dt><dd id=\"setting-stop_bits\"></dd><dt>Flow control</dt><dd id=\"setting-flow\"></dd>"
"<dt>DTR behavior</dt><dd id=\"setting-dtr\"></dd><dt>RTS threshold</dt><dd id=\"setting-rts_threshold\"></dd>"
"</dl></section>\n"
"</dl>"
"<div id=\"serial-edit\" hidden><h3>Edit Working Configuration</h3>"
"<div class=\"serial-edit\">"
"<label>Baud rate<input id=\"edit-baud\" type=\"number\" min=\"110\" max=\"1000000\" step=\"1\"></label>"
"<label>Data bits<select id=\"edit-data_bits\"><option>7</option><option>8</option></select></label>"
"<label>Parity<select id=\"edit-parity\"><option>none</option><option>even</option><option>odd</option></select></label>"
"<label>Stop bits<select id=\"edit-stop_bits\"><option>1</option><option>2</option></select></label>"
"<label>Flow control<select id=\"edit-flow\"><option>none</option><option>rts-cts</option></select></label>"
"<label>DTR behavior<select id=\"edit-dtr\"><option>inactive</option><option>active</option><option>on-connect</option></select></label>"
"<label>RTS threshold<input id=\"edit-rts_threshold\" type=\"number\" min=\"1\" max=\"127\" step=\"1\"></label></div>"
"<p class=\"connection-detail\">Edits are a browser draft until Apply. Apply replaces all working fields, including intervening CLI edits. "
"Save persists the device's working configuration at execution, not this draft. Refresh discards the draft. "
"Load uses stored settings, or defaults if storage is absent/incompatible. Defaults changes RAM only; Reset applies and saves defaults.</p>"
"<p class=\"connection-detail\">Stop or reconfiguration discards queued serial-service RX/TX and pending bytes. "
"Broker clients, writer lease and already-fanned output remain. Open USB may restart a stopped service. "
"Service running state is not saved.</p>"
"<div class=\"serial-actions\">"
"<button id=\"serial-apply\" class=\"button\" type=\"button\">Apply to RAM</button>"
"<button id=\"serial-save\" class=\"button\" type=\"button\">Save Working to NVS</button>"
"<button id=\"serial-load\" class=\"button\" type=\"button\">Load</button>"
"<button id=\"serial-defaults\" class=\"button\" type=\"button\">Defaults in RAM</button>"
"<button id=\"serial-reset\" class=\"button\" type=\"button\">Reset and Save Defaults</button>"
"<button id=\"serial-start\" class=\"button\" type=\"button\">Start</button>"
"<button id=\"serial-stop\" class=\"button\" type=\"button\">Stop</button></div></div>"
"<button id=\"serial-result\" class=\"button\" type=\"button\">Check Operation Result</button>"
"<p id=\"serial-operation-detail\" class=\"connection-detail\" role=\"status\">Check Result after any uncertain submission; never assume timeout or navigation cancels an operation.</p>"
"</section>\n"
"</section>\n"
"</main>\n"
"</body>\n"
@@ -215,18 +244,60 @@ static const char s_app_js[] =
"const settingsHost = element('serial-settings'), settingsDetail = element('settings-detail');\n"
"const settingsFields = ['running', 'baud', 'data_bits', 'parity', 'stop_bits', 'flow', 'dtr', 'rts_threshold'];\n"
"let settingsAbort = null;\n"
"const serialActions = ['apply', 'save', 'load', 'defaults', 'reset', 'start', 'stop'];\n"
"let serialOperationId = 0, serialOperationPending = false, serialAwaitingAck = false;\n"
"let serialOutcomeWarning = '', serialAuto = null;\n"
"function stopSerialAuto(recovery = false) {\n"
" if (!serialAuto) return;\n"
" window.clearTimeout(serialAuto.timer); window.clearTimeout(serialAuto.deadline); serialAuto = null;\n"
" if (recovery) element('serial-operation-detail').textContent += ' Automatic checking stopped; outcome still uncertain. Select Check Result; do not resubmit.';\n"
"}\n"
"function expireSerialAuto(auto) {\n"
" if (serialAuto !== auto) return;\n"
" stopSerialAuto(true);\n"
" if (settingsAbort) settingsAbort.abort();\n"
" settingsAbort = null; serialButtons();\n"
"}\n"
"function scheduleSerialCheck() {\n"
" const auto = serialAuto;\n"
" if (!auto) return;\n"
" if (auto.attempts >= 10) { stopSerialAuto(true); serialButtons(); return; }\n"
" auto.timer = window.setTimeout(() => {\n"
" if (serialAuto !== auto) return;\n"
" if (performance.now() >= auto.until) { expireSerialAuto(auto); return; }\n"
" ++auto.attempts; serialOperation(null, true);\n"
" }, 1000);\n"
"}\n"
"function startSerialAuto() {\n"
" const auto = {attempts: 0, timer: 0, deadline: 0, until: performance.now() + 15000}; serialAuto = auto;\n"
" auto.deadline = window.setTimeout(() => expireSerialAuto(auto), 15000);\n"
" scheduleSerialCheck();\n"
"}\n"
"function serialButtons() {\n"
" const busy = !!settingsAbort || !!serialAuto;\n"
" for (const action of serialActions) element('serial-' + action).disabled = busy || serialOperationPending;\n"
" for (const key of settingsFields.slice(1)) element('edit-' + key).disabled = busy || serialOperationPending;\n"
" element('refresh-settings').disabled = busy;\n"
" element('serial-result').disabled = busy;\n"
"}\n"
"function clearSettings() {\n"
" if (!serialAuto && serialOperationPending) element('serial-operation-detail').textContent = serialOutcomeWarning + 'Operation outcome pending or unknown. Select Check Result on return; navigation does not cancel backend work.';\n"
" stopSerialAuto(true);\n"
" if (settingsAbort) settingsAbort.abort();\n"
" settingsAbort = null; settingsHost.hidden = true; element('settings-values').hidden = true;\n"
" element('serial-edit').hidden = true;\n"
" for (const key of settingsFields.slice(1)) element('edit-' + key).value = '';\n"
" serialButtons();\n"
" for (const key of settingsFields) element('setting-' + key).textContent = '';\n"
" element('refresh-settings').disabled = false; settingsDetail.textContent = 'Select Refresh to read current values.';\n"
"}\n"
"async function refreshSettings() {\n"
" if (selected !== 'settings' || accountRole !== 'admin' || !sessionVerified || suspended || unloading || navigating || loggingOut || settingsAbort) return;\n"
" clearSettings(); settingsHost.hidden = false;\n"
" if (selected !== 'settings' || accountRole !== 'admin' || !sessionVerified || suspended || unloading || navigating || loggingOut || settingsAbort || serialAuto) return;\n"
" settingsHost.hidden = false;\n"
" const controller = new AbortController(), generation = workGeneration; settingsAbort = controller;\n"
" serialButtons();\n"
" const current = () => settingsAbort === controller && selected === 'settings';\n"
" element('refresh-settings').disabled = true; settingsDetail.textContent = 'Reading serial configuration...';\n"
" element('refresh-settings').disabled = true; settingsDetail.textContent = 'Reading serial configuration... Previous snapshot is stale until refreshed.';\n"
" try {\n"
" if (!await loadSession(generation, controller.signal, false)) throw new Error('Session check cancelled');\n"
" if (!current()) return;\n"
@@ -237,11 +308,79 @@ static const char s_app_js[] =
" !['1', '2'].includes(value.stop_bits) || !['none', 'rts-cts'].includes(value.flow) ||\n"
" !['inactive', 'active', 'on-connect'].includes(value.dtr) || !Number.isInteger(value.rts_threshold) || value.rts_threshold < 1 || value.rts_threshold > 127) throw new Error('Invalid snapshot');\n"
" for (const key of settingsFields) element('setting-' + key).textContent = key === 'running' ? (value[key] ? 'Running' : 'Stopped') : String(value[key]);\n"
" element('settings-values').hidden = false; settingsDetail.textContent = 'Snapshot loaded. Refresh to see later changes; nothing is applied or saved here.';\n"
" for (const key of settingsFields.slice(1)) element('edit-' + key).value = String(value[key]);\n"
" element('serial-edit').hidden = false; element('settings-values').hidden = false; settingsDetail.textContent = (serialOperationPending ? 'Snapshot may be stale: operation outcome pending or unknown. ' : 'Working snapshot loaded. ') + 'Apply changes RAM; Save explicitly persists working settings. Refresh replaces your draft.';\n"
" } catch (error) {\n"
" if (live(generation) && current()) settingsDetail.textContent = (error.status ? error.message : 'Serial snapshot could not be read or was invalid.') + ' Select Refresh to retry.';\n"
" if (live(generation) && current()) settingsDetail.textContent = (error.status ? error.message : 'Serial snapshot could not be read or was invalid.') + ' Snapshot stale or unavailable. Select Refresh to retry.';\n"
" } finally {\n"
" if (current()) { settingsAbort = null; element('refresh-settings').disabled = false; }\n"
" if (current()) { settingsAbort = null; element('refresh-settings').disabled = false; serialButtons(); }\n"
" }\n"
"}\n"
"async function serialOperation(action, automatic = false) {\n"
" if (selected !== 'settings' || accountRole !== 'admin' || !sessionVerified || suspended || unloading || navigating || loggingOut || settingsAbort || (action && serialOperationPending)) return;\n"
" if (!automatic && serialAuto) return;\n"
" const detail = element('serial-operation-detail');\n"
" let refresh = false, poll = false;\n"
" let body;\n"
" if (action) {\n"
" if (element('serial-edit').hidden) return;\n"
" const value = {action};\n"
" if (action === 'apply') {\n"
" for (const key of settingsFields.slice(1)) value[key] = element('edit-' + key).value;\n"
" if (!/^[0-9]{1,7}$/.test(value.baud) || !/^[0-9]{1,3}$/.test(value.rts_threshold)) { detail.textContent = 'Enter whole-number baud and RTS threshold.'; return; }\n"
" value.baud = Number(value.baud); value.rts_threshold = Number(value.rts_threshold);\n"
" if (value.baud < 110 || value.baud > 1000000 || value.rts_threshold < 1 || value.rts_threshold > 127 ||\n"
" !['7','8'].includes(value.data_bits) || !['none','even','odd'].includes(value.parity) || !['1','2'].includes(value.stop_bits) ||\n"
" !['none','rts-cts'].includes(value.flow) || !['inactive','active','on-connect'].includes(value.dtr)) { detail.textContent = 'Invalid serial framing or range.'; return; }\n"
" }\n"
" if (action === 'reset' && !window.confirm('Reset applies defaults and overwrites saved NVS configuration. Continue?')) return;\n"
" body = JSON.stringify(value);\n"
" if (new TextEncoder().encode(body).length > 256) return;\n"
" }\n"
" const controller = new AbortController(), generation = workGeneration; settingsAbort = controller;\n"
" const auto = automatic ? serialAuto : null;\n"
" const current = () => {\n"
" if (auto && serialAuto === auto && performance.now() >= auto.until) expireSerialAuto(auto);\n"
" return settingsAbort === controller && selected === 'settings';\n"
" };\n"
" element('refresh-settings').disabled = true; serialButtons();\n"
" detail.textContent = serialOutcomeWarning + (action ? (action === 'apply' ? 'Applying' : action) + '... Submitting once; completion will be checked automatically.' : 'Reading latest result for this login...');\n"
" settingsDetail.textContent = 'Snapshot stale: operation pending or outcome not yet checked.';\n"
" try {\n"
" if (!await loadSession(generation, controller.signal, false)) throw new Error('Session check cancelled');\n"
" if (!current()) return;\n"
" if (action) { serialOperationPending = true; serialAwaitingAck = true; }\n"
" const {payload: result} = await api('/api/settings/serial-operation', generation, {method: action ? 'POST' : 'GET', body, signal: controller.signal, limit: 96, current});\n"
" if (!result || Object.keys(result).length !== 3 || !Number.isInteger(result.id) || result.id < 0 || result.id > 4294967295 ||\n"
" !['none', ...serialActions].includes(result.action) || !['idle','pending','ok','failed','cancelled','loaded_defaults','rollback_failed'].includes(result.state) ||\n"
" ((result.id === 0) !== (result.state === 'idle')) || ((result.id === 0) !== (result.action === 'none')) ||\n"
" (action && (!result.id || result.action !== action || result.state !== 'pending'))) throw new Error('Invalid operation response');\n"
" const uncertain = !action && serialAwaitingAck;\n"
" const replaced = !action && serialOperationId && serialOperationId !== result.id;\n"
" if (action) serialOutcomeWarning = '';\n"
" else if (uncertain) serialOutcomeWarning = 'Submission acknowledgement was lost; this latest result may belong to an earlier operation or another tab. Inspect before retrying. ';\n"
" else if (replaced) serialOutcomeWarning = 'Previous result was replaced or unavailable; its outcome is unknown. ';\n"
" serialOperationId = result.id; serialOperationPending = result.state === 'pending'; serialAwaitingAck = false;\n"
" const messages = {idle: 'No retained result. Outcome may be unknown; inspect working settings and CLI storage before retrying.',\n"
" pending: 'Pending: queued or executing; do not resubmit. Automatic checks are bounded; Check Result is available for recovery.',\n"
" ok: 'Operation completed. Apply/Defaults change RAM only; Save/Reset persist NVS.',\n"
" loaded_defaults: 'Load applied defaults because saved storage was absent or incompatible. NVS was not changed.',\n"
" failed: 'Operation failed; rollback is best-effort and state may have changed. Inspect working settings and UART0 before retrying.',\n"
" rollback_failed: 'Reset persistence and runtime rollback failed. Inspect UART0 and working settings; do not assume defaults were saved.',\n"
" cancelled: 'Operation rejected before execution because the login or queue deadline was no longer current.'};\n"
" detail.textContent = serialOutcomeWarning + result.action + ': ' + messages[result.state];\n"
" poll = result.state === 'pending' && (!!action || automatic);\n"
" refresh = !action && result.state !== 'pending' && result.state !== 'idle';\n"
" } catch (error) {\n"
" if (live(generation) && current()) detail.textContent = serialOutcomeWarning + (error.status ? error.message : 'Operation outcome unknown.') + ' Check Result and Refresh before any explicit retry. No automatic retry.';\n"
" } finally {\n"
" if (current()) {\n"
" settingsAbort = null;\n"
" if (poll) { if (action) startSerialAuto(); else scheduleSerialCheck(); }\n"
" else stopSerialAuto();\n"
" serialButtons();\n"
" if (refresh) await refreshSettings();\n"
" }\n"
" }\n"
"}\n"
"let accountRole = 'user', selected = 'serial';\n"
@@ -287,6 +426,7 @@ static const char s_app_js[] =
" updateControls();\n"
"}\n"
"function selectTerminal(mode) {\n"
" if (mode === selected) return;\n"
" if (unloading || navigating || loggingOut || !sessionVerified || (mode !== 'serial' && accountRole !== 'admin')) return;\n"
" clearSettings();\n"
" selected = mode;\n"
@@ -428,7 +568,7 @@ static const char s_app_js[] =
" return JSON.parse(new TextDecoder('utf-8', {fatal: true}).decode(bytes.subarray(0, length)));\n"
" } finally { await reader.cancel().catch(() => {}); }\n"
"}\n"
"async function api(path, generation, {method = 'GET', signal, limit = 512, current = () => true} = {}) {\n"
"async function api(path, generation, {method = 'GET', body, signal, limit = 512, current = () => true} = {}) {\n"
" const controller = new AbortController();\n"
" const abort = () => controller.abort();\n"
" if (signal) { signal.addEventListener('abort', abort, {once: true}); if (signal.aborted) abort(); }\n"
@@ -438,7 +578,7 @@ static const char s_app_js[] =
/* Non-CORS POST with no-referrer serializes Origin as null in browsers. */
" const response = await fetch(path, {method, credentials: 'same-origin', mode: method === 'POST' ? 'cors' : 'same-origin',\n"
" cache: 'no-store', redirect: 'error', signal: controller.signal,\n"
" ...(method === 'POST' ? {headers: {'X-CSRF-Token': csrf}, body: ''} : {})});\n"
" ...(method === 'POST' ? {headers: {'X-CSRF-Token': csrf, ...(body === undefined ? {} : {'Content-Type': 'application/json'})}, body: body === undefined ? '' : body} : {})});\n"
" if (!live(generation) || controller.signal.aborted || !current()) throw new Error('Cancelled');\n"
" if (response.status === 401) { login(); throw new Error('Session ended.'); }\n"
" if (!response.ok) {\n"
@@ -707,6 +847,8 @@ static const char s_app_js[] =
"element('select-admin').addEventListener('click', () => selectTerminal('admin'));\n"
"element('select-settings').addEventListener('click', () => selectTerminal('settings'));\n"
"element('refresh-settings').addEventListener('click', refreshSettings);\n"
"for (const action of serialActions) element('serial-' + action).addEventListener('click', () => serialOperation(action));\n"
"element('serial-result').addEventListener('click', () => serialOperation(null));\n"
"adminToggle.addEventListener('click', () => { if (adminSocket || adminAbort) closeAdmin(); else openAdmin(); });\n"
"const fitTerminal = () => {\n"
" fitFrame = 0;\n"