Add admin serial settings view

This commit is contained in:
2026-09-07 20:12:33 +02:00
parent c73674cda2
commit 5a2aa0d4d8
20 changed files with 612 additions and 25 deletions
+12
View File
@@ -646,6 +646,18 @@ esp_err_t serial_service_get_config(serial_config_t *config)
return ESP_OK;
}
esp_err_t serial_service_get_snapshot(serial_service_snapshot_t *snapshot)
{
if (snapshot == NULL) return ESP_ERR_INVALID_ARG;
memset(snapshot, 0, sizeof(*snapshot));
if (!s_initialized) return ESP_ERR_INVALID_STATE;
if (xSemaphoreTake(s_state_mutex, 0) != pdTRUE) return ESP_ERR_TIMEOUT;
snapshot->config = s_config;
snapshot->running = atomic_load(&s_running);
xSemaphoreGive(s_state_mutex);
return ESP_OK;
}
size_t serial_service_read(uint8_t *data, size_t size)
{
if (!s_initialized || data == NULL || size == 0) {
+8
View File
@@ -48,6 +48,14 @@ bool serial_service_is_running(void);
esp_err_t serial_service_apply_config(const serial_config_t *config);
esp_err_t serial_service_get_config(serial_config_t *config);
typedef struct {
serial_config_t config;
bool running;
} serial_service_snapshot_t;
/* Nonblocking, consistent working configuration/state; no hardware or NVS IO. */
esp_err_t serial_service_get_snapshot(serial_service_snapshot_t *snapshot);
/*
* Access is intentionally nonblocking. The session broker is the sole
* logical RX consumer and TX producer; calls are serialized internally to
+30
View File
@@ -1,6 +1,7 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* Deliberately isolated dependency on the installed IDF HTTPD layout. */
#include "web_httpd_adapter.h"
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include "esp_idf_version.h"
@@ -104,3 +105,32 @@ bool web_httpd_unread_body(httpd_req_t *request)
const struct httpd_req_aux *aux = request->aux;
return aux && aux->remaining_len != 0;
}
esp_err_t web_httpd_register_optional_get(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 ||
uri->is_websocket || uri->supported_subprotocol || hd->config.uri_match_fn)
return ESP_ERR_INVALID_ARG;
size_t length = 0;
while (length < 128 && uri->uri[length]) ++length;
if (!length || length == 128) return ESP_ERR_INVALID_ARG;
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)
return ESP_ERR_INVALID_STATE;
}
if (slot < 0) return ESP_ERR_NO_MEM;
/* IDF 5.5.0 publishes its descriptor before strdup; strdup failure leaves a
* freed hd_calls entry. Optional registration must leave the table intact. */
httpd_uri_t *copy = malloc(sizeof(*copy));
if (!copy) return ESP_ERR_NO_MEM;
char *name = malloc(length + 1);
if (!name) { free(copy); return ESP_ERR_NO_MEM; }
memcpy(name, uri->uri, length + 1);
*copy = *uri;
copy->uri = name;
hd->hd_calls[slot] = copy;
return ESP_OK;
}
+4
View File
@@ -12,3 +12,7 @@ bool web_httpd_unread_body(httpd_req_t *request);
void web_httpd_wipe_request(httpd_req_t *request, bool closing);
esp_err_t web_httpd_upgrade(httpd_req_t *request,
esp_err_t (*handler)(httpd_req_t *));
/* 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);
+49 -1
View File
@@ -329,6 +329,52 @@ static esp_err_t status_handler(httpd_req_t *request)
return error;
}
static esp_err_t serial_settings_handler(httpd_req_t *request)
{
user_principal_t principal = {0};
bool authorized = false;
esp_err_t error = authorize_or_respond(request, &principal, &authorized);
bool admin = authorized && principal.role == USER_ROLE_ADMIN;
secure_wipe(&principal, sizeof(principal));
if (error != ESP_OK || !authorized) return error;
if (!admin)
return send_plain_error(request, "403 Forbidden", "Administrator access required.\n");
serial_service_snapshot_t snapshot = {0};
if (serial_service_get_snapshot(&snapshot) != ESP_OK) {
error = httpd_resp_set_hdr(request, "Retry-After", "1");
if (error != ESP_OK) return error;
return send_plain_error(request, "503 Service Unavailable", "Serial snapshot unavailable.\n");
}
const serial_config_t *config = &snapshot.config;
char response[256];
/* Only firmware-owned enum names and numeric values, never CLI output. */
int written = snprintf(response, sizeof(response),
"{\"running\":%s,\"baud\":%" PRIu32 ",\"data_bits\":\"%s\","
"\"parity\":\"%s\",\"stop_bits\":\"%s\",\"flow\":\"%s\","
"\"dtr\":\"%s\",\"rts_threshold\":%" PRIu32 "}",
snapshot.running ? "true" : "false", config->baud_rate,
safe_string(serial_config_data_bits_to_string(config->data_bits)),
safe_string(serial_config_parity_to_string(config->parity)),
safe_string(serial_config_stop_bits_to_string(config->stop_bits)),
safe_string(serial_config_flow_control_to_string(config->flow_control)),
safe_string(serial_config_dtr_behavior_to_string(config->dtr_behavior)),
config->rts_threshold);
if (written < 0 || (size_t)written >= sizeof(response))
return send_plain_error(request, "500 Internal Server Error", "Serial response overflow.\n");
error = httpd_resp_set_type(request, "application/json; charset=utf-8");
if (error == ESP_OK) error = set_common_headers(request);
if (error == ESP_OK) error = httpd_resp_send(request, response, (ssize_t)written);
if (error != ESP_OK) increment_counter(&s_counters.response_errors);
return error;
}
static const httpd_uri_t s_serial_settings_uri = {
.uri = "/api/settings/serial",
.method = HTTP_GET,
.handler = serial_settings_handler,
};
static const httpd_uri_t s_root_uri = {
.uri = "/",
.method = HTTP_GET,
@@ -517,7 +563,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]) + 2U;
sizeof(s_auth_uris) / sizeof(s_auth_uris[0]) + 3U;
/* Exhaustion rejects new sockets, never evicts an existing serial writer. */
config.httpd.lru_purge_enable = false;
config.httpd.recv_wait_timeout = 1;
@@ -563,6 +609,8 @@ esp_err_t web_server_start(void)
(void)httpd_unregister_uri_handler(server, WEB_ADMIN_TICKET_URI, HTTP_POST);
if (admin_error == ESP_OK && web_admin_transport_init() == ESP_OK)
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 (error != ESP_OK) {
web_cookie_auth_stop();
+64 -8
View File
@@ -80,6 +80,9 @@ static const char s_index_html[] =
"background:#080c12;padding:8px}\n"
".terminal-host .xterm{width:100%;height:100%}\n"
".terminal-host .xterm-viewport{border-radius:7px}\n"
".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"
".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}"
@@ -163,13 +166,25 @@ static const char s_index_html[] =
"<section class=\"panel terminal-panel\" aria-label=\"Terminal workspace\">\n"
"<div class=\"terminal-toolbar\"><span id=\"terminal-title\" class=\"terminal-title\">Live serial stream</span>"
"<button id=\"admin-toggle\" class=\"button\" type=\"button\" hidden>Open admin</button>"
"<div id=\"terminal-selector\" hidden role=\"group\" aria-label=\"Selected terminal\">"
"<div id=\"terminal-selector\" hidden role=\"group\" aria-label=\"Selected view\">"
"<button id=\"select-serial\" class=\"button\" type=\"button\" aria-pressed=\"true\">Serial</button> "
"<button id=\"select-admin\" class=\"button\" type=\"button\" aria-pressed=\"false\">Admin</button></div></div>\n"
"<button id=\"select-admin\" class=\"button\" type=\"button\" aria-pressed=\"false\">Admin</button> "
"<button id=\"select-settings\" class=\"button\" type=\"button\" aria-pressed=\"false\">Settings</button></div></div>\n"
"<p id=\"admin-detail\" class=\"connection-detail\" aria-live=\"polite\" hidden>Admin closed. Serial stays connected.</p>\n"
"<p id=\"output-detail\" class=\"connection-detail\" aria-live=\"polite\">Scrollback: 5000 lines per terminal; oldest lines expire.</p>\n"
"<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. "
"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>"
"<dl id=\"settings-values\" class=\"settings-values\" hidden>"
"<dt>Service</dt><dd id=\"setting-running\"></dd><dt>Baud rate</dt><dd id=\"setting-baud\"></dd>"
"<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"
"</section>\n"
"</main>\n"
"</body>\n"
@@ -197,6 +212,38 @@ static const char s_app_js[] =
"const adminHost = element('admin-terminal');\n"
"const adminToggle = element('admin-toggle');\n"
"const adminDetail = element('admin-detail');\n"
"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"
"function clearSettings() {\n"
" if (settingsAbort) settingsAbort.abort();\n"
" settingsAbort = null; settingsHost.hidden = true; element('settings-values').hidden = true;\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"
" const controller = new AbortController(), generation = workGeneration; settingsAbort = controller;\n"
" const current = () => settingsAbort === controller && selected === 'settings';\n"
" element('refresh-settings').disabled = true; settingsDetail.textContent = 'Reading serial configuration...';\n"
" try {\n"
" if (!await loadSession(generation, controller.signal, false)) throw new Error('Session check cancelled');\n"
" if (!current()) return;\n"
" const {payload: value} = await api('/api/settings/serial', generation, {signal: controller.signal, limit: 256, current});\n"
" if (!value || Object.keys(value).length !== settingsFields.length || !settingsFields.every(key => Object.hasOwn(value, key)) ||\n"
" typeof value.running !== 'boolean' || !Number.isInteger(value.baud) || value.baud < 110 || value.baud > 1000000 ||\n"
" !['7', '8'].includes(value.data_bits) || !['none', 'even', 'odd'].includes(value.parity) ||\n"
" !['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"
" } 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"
" } finally {\n"
" if (current()) { settingsAbort = null; element('refresh-settings').disabled = false; }\n"
" }\n"
"}\n"
"let accountRole = 'user', selected = 'serial';\n"
"let adminTerminal = null, adminFit = null, adminSocket = null, adminAbort = null;\n"
"let adminGeneration = 0, adminTimer = null;\n"
@@ -240,7 +287,8 @@ static const char s_app_js[] =
" updateControls();\n"
"}\n"
"function selectTerminal(mode) {\n"
" if (unloading || navigating || loggingOut || !sessionVerified || (mode === 'admin' && accountRole !== 'admin')) return;\n"
" if (unloading || navigating || loggingOut || !sessionVerified || (mode !== 'serial' && accountRole !== 'admin')) return;\n"
" clearSettings();\n"
" selected = mode;\n"
" if (mode === 'admin' && !adminTerminal) {\n"
" adminTerminal = new Terminal({...terminal.options, disableStdin: true, scrollback: 5000});\n"
@@ -258,9 +306,11 @@ static const char s_app_js[] =
" adminToggle.hidden = adminDetail.hidden = mode !== 'admin';\n"
" element('select-serial').setAttribute('aria-pressed', String(mode === 'serial'));\n"
" element('select-admin').setAttribute('aria-pressed', String(mode === 'admin'));\n"
" element('terminal-title').textContent = mode === 'serial' ? 'Live serial stream' : 'Administration shell';\n"
" element('select-settings').setAttribute('aria-pressed', String(mode === 'settings'));\n"
" settingsHost.hidden = mode !== 'settings';\n"
" element('terminal-title').textContent = mode === 'serial' ? 'Live serial stream' : mode === 'admin' ? 'Administration shell' : 'Settings';\n"
" lastFitWidth = lastFitHeight = 0; updateControls(); scheduleFit();\n"
" (mode === 'serial' ? terminal : adminTerminal).focus();\n"
" if (mode === 'settings') refreshSettings(); else (mode === 'serial' ? terminal : adminTerminal).focus();\n"
"}\n"
"async function openAdmin() {\n"
" if (accountRole !== 'admin' || selected !== 'admin' || unloading || navigating || loggingOut || suspended || !csrf || adminSocket || adminAbort) return;\n"
@@ -328,6 +378,7 @@ static const char s_app_js[] =
"const requests = new Set();\n"
"const live = (generation) => generation === workGeneration && !unloading && !navigating;\n"
"const cancelWork = () => {\n"
" clearSettings();\n"
" ++workGeneration;\n"
" ++connectionGeneration;\n"
" if (fitFrame) window.cancelAnimationFrame(fitFrame);\n"
@@ -407,8 +458,9 @@ static const char s_app_js[] =
" if (signal) signal.removeEventListener('abort', abort);\n"
" }\n"
"}\n"
"async function loadSession(generation, signal) {\n"
" const sequence = ++sessionGeneration;\n"
"async function loadSession(generation, signal, supersede = true) {\n"
" // A Settings read must not supersede an in-flight serial admission check.\n"
" const sequence = supersede ? ++sessionGeneration : sessionGeneration;\n"
" const current = () => sequence === sessionGeneration;\n"
" const {payload} = await api('/api/session', generation, {signal, current});\n"
" if (!live(generation) || !current() || (signal && signal.aborted)) return false;\n"
@@ -431,6 +483,7 @@ static const char s_app_js[] =
" element('terminal-selector').hidden = accountRole !== 'admin';\n"
" if (accountRole !== 'admin') { closeAdmin(); selectTerminal('serial'); }\n"
" terminalHost.hidden = selected !== 'serial'; adminHost.hidden = selected !== 'admin';\n"
" settingsHost.hidden = selected !== 'settings' || loggingOut || suspended;\n"
" scheduleFit();\n"
" const deadline = Date.now() + payload.expires_in * 1000;\n"
" sessionDeadline = sessionDeadline ? Math.min(sessionDeadline, deadline) : deadline;\n"
@@ -494,6 +547,7 @@ static const char s_app_js[] =
" ? 'Writer mode — terminal input is enabled.'\n"
" : 'Observer mode — terminal input is disabled.';\n"
" if (selected === 'admin') inputState.textContent = `Serial ${writer ? 'writer lease retained' : 'observer'}; serial input disabled while hidden. Admin input ${adminOpen ? 'enabled' : 'disabled'}.`;\n"
" if (selected === 'settings') inputState.textContent = `Serial ${writer ? 'writer lease retained' : 'observer'}; both terminals keep receiving. Terminal input disabled in Settings.`;\n"
" setBadge(roleStatus, writer ? 'Writer' : 'Observer', writer ? 'good' : 'warn');\n"
"};\n"
"const setRole = (nextRole) => {\n"
@@ -651,10 +705,12 @@ static const char s_app_js[] =
"});\n"
"element('select-serial').addEventListener('click', () => selectTerminal('serial'));\n"
"element('select-admin').addEventListener('click', () => selectTerminal('admin'));\n"
"element('select-settings').addEventListener('click', () => selectTerminal('settings'));\n"
"element('refresh-settings').addEventListener('click', refreshSettings);\n"
"adminToggle.addEventListener('click', () => { if (adminSocket || adminAbort) closeAdmin(); else openAdmin(); });\n"
"const fitTerminal = () => {\n"
" fitFrame = 0;\n"
" if (unloading || navigating || loggingOut || !sessionVerified) return;\n"
" if (unloading || navigating || loggingOut || !sessionVerified || selected === 'settings') return;\n"
" const target = selected === 'admin' ? adminTerminal : terminal;\n"
" const addon = selected === 'admin' ? adminFit : fitAddon;\n"
" const bounds = (selected === 'admin' ? adminHost : terminalHost).getBoundingClientRect();\n"