Add Typed Display Settings Administration

Implements admin-only Display settings with generation-checked
Apply, Save, Load, Defaults, and Reset operations across the web UI,
CLI, SSH dispatcher, and local UI owner. Adds bounded HTTP handling,
session-isolated operation results, browser lifecycle support, and
comprehensive host tests and documentation.
This commit is contained in:
2026-09-09 10:15:23 +02:00
parent 60d9c54bb4
commit d9ec3c08de
26 changed files with 1164 additions and 81 deletions
+1
View File
@@ -34,6 +34,7 @@ idf_component_register(
"web_serial_settings.c"
"web_account_settings.c"
"web_network_settings.c"
"web_display_settings.c"
"web_admin_tickets.c"
"web_admin_transport.c"
"web_assets_data.c"
+16 -2
View File
@@ -19,6 +19,7 @@
#include "web_serial_settings.h"
#include "web_account_settings.h"
#include "web_network_settings.h"
#include "web_display_settings.h"
#define ADMIN_SSH_CONSOLE_MAX_SESSIONS 2U
#define ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY 4096U
@@ -87,6 +88,7 @@ typedef enum {
ADMIN_REQUEST_SERIAL_SETTINGS,
ADMIN_REQUEST_ACCOUNT_SETTINGS,
ADMIN_REQUEST_NETWORK_SETTINGS,
ADMIN_REQUEST_DISPLAY_SETTINGS,
} admin_request_origin_t;
typedef struct {
@@ -100,6 +102,7 @@ typedef struct {
uint32_t serial_settings_id;
uint32_t account_settings_id;
uint32_t network_settings_id;
uint32_t display_settings_id;
};
} admin_request_t;
@@ -685,6 +688,16 @@ esp_err_t admin_ssh_console_submit_network_settings(uint32_t id)
return xQueueSend(s_request_queue, &request, 0U) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT;
}
esp_err_t admin_ssh_console_submit_display_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_DISPLAY_SETTINGS, .display_settings_id = id};
return xQueueSend(s_request_queue, &request, 0U) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT;
}
static void worker_task(void *context)
{
(void)context;
@@ -694,10 +707,11 @@ static void worker_task(void *context)
continue;
}
if (request.origin == ADMIN_REQUEST_SERIAL_SETTINGS || request.origin == ADMIN_REQUEST_ACCOUNT_SETTINGS ||
request.origin == ADMIN_REQUEST_NETWORK_SETTINGS) {
request.origin == ADMIN_REQUEST_NETWORK_SETTINGS || request.origin == ADMIN_REQUEST_DISPLAY_SETTINGS) {
if (request.origin == ADMIN_REQUEST_SERIAL_SETTINGS) web_serial_settings_execute(request.serial_settings_id);
else if (request.origin == ADMIN_REQUEST_ACCOUNT_SETTINGS) web_account_settings_execute(request.account_settings_id);
else web_network_settings_execute(request.network_settings_id);
else if (request.origin == ADMIN_REQUEST_NETWORK_SETTINGS) web_network_settings_execute(request.network_settings_id);
else web_display_settings_execute(request.display_settings_id);
secure_wipe(&request, sizeof(request));
continue;
}
+1
View File
@@ -18,6 +18,7 @@ extern "C" {
esp_err_t admin_ssh_console_submit_serial_settings(uint32_t id);
esp_err_t admin_ssh_console_submit_account_settings(uint32_t id);
esp_err_t admin_ssh_console_submit_network_settings(uint32_t id);
esp_err_t admin_ssh_console_submit_display_settings(uint32_t id);
/* Fits the longest supported ECDSA P-256 OpenSSH key import command. */
#define ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY 256U
+61 -13
View File
@@ -203,6 +203,8 @@ static TickType_t s_diagnostic_hold_started;
static uint32_t s_external_activity_sequence;
static local_ui_config_t s_config;
static bool s_config_available;
static bool s_config_busy;
static uint32_t s_config_generation;
static const gpio_num_t s_button_gpios[LOCAL_STATUS_BUTTON_COUNT] = {
LOCAL_UI_BUTTON_PREVIOUS_GPIO,
@@ -1368,21 +1370,62 @@ esp_err_t local_status_ui_get_config(local_ui_config_t *config)
return available ? ESP_OK : ESP_ERR_INVALID_STATE;
}
esp_err_t local_status_ui_get_settings(local_ui_config_t *config, uint32_t *generation)
{
if (config == NULL || generation == NULL) return ESP_ERR_INVALID_ARG;
portENTER_CRITICAL(&s_timing_mux);
esp_err_t error = !s_config_available ? ESP_ERR_INVALID_STATE :
s_config_busy ? ESP_ERR_TIMEOUT : ESP_OK;
if (error == ESP_OK) {
*config = s_config;
*generation = s_config_generation;
}
portEXIT_CRITICAL(&s_timing_mux);
return error;
}
esp_err_t local_status_ui_update_settings(local_ui_settings_action_t action,
uint32_t expected_generation, const local_ui_config_t *config, bool *loaded_defaults)
{
if (loaded_defaults != NULL) *loaded_defaults = false;
if (action < LOCAL_UI_SETTINGS_APPLY || action > LOCAL_UI_SETTINGS_RESET ||
(action == LOCAL_UI_SETTINGS_APPLY && local_ui_config_validate(config) != ESP_OK))
return ESP_ERR_INVALID_ARG;
local_ui_config_t candidate;
portENTER_CRITICAL(&s_timing_mux);
esp_err_t error = !s_config_available ||
(expected_generation && expected_generation != s_config_generation) ||
s_config_generation == UINT32_MAX ? ESP_ERR_INVALID_STATE :
s_config_busy ? ESP_ERR_TIMEOUT : ESP_OK;
if (error == ESP_OK) {
candidate = action == LOCAL_UI_SETTINGS_APPLY ? *config : s_config;
s_config_busy = true;
}
portEXIT_CRITICAL(&s_timing_mux);
if (error != ESP_OK) return error;
bool stored = true;
if (action == LOCAL_UI_SETTINGS_LOAD) error = local_ui_config_load(&candidate, &stored);
if (action == LOCAL_UI_SETTINGS_DEFAULTS || action == LOCAL_UI_SETTINGS_RESET)
local_ui_config_defaults(&candidate);
if (action == LOCAL_UI_SETTINGS_SAVE || action == LOCAL_UI_SETTINGS_RESET)
error = local_ui_config_save(&candidate);
portENTER_CRITICAL(&s_timing_mux);
if (error == ESP_OK && action != LOCAL_UI_SETTINGS_SAVE) {
s_config = candidate;
++s_config_generation;
++s_external_activity_sequence;
}
s_config_busy = false;
portEXIT_CRITICAL(&s_timing_mux);
if (error == ESP_OK && loaded_defaults != NULL) *loaded_defaults = !stored;
return error;
}
esp_err_t local_status_ui_apply_config(const local_ui_config_t *config)
{
esp_err_t error = local_ui_config_validate(config);
if (error != ESP_OK) {
return error;
}
portENTER_CRITICAL(&s_timing_mux);
if (!s_config_available) {
portEXIT_CRITICAL(&s_timing_mux);
return ESP_ERR_INVALID_STATE;
}
s_config = *config;
++s_external_activity_sequence;
portEXIT_CRITICAL(&s_timing_mux);
return ESP_OK;
return local_status_ui_update_settings(LOCAL_UI_SETTINGS_APPLY, 0, config, NULL);
}
esp_err_t local_status_ui_start(const local_ui_config_t *config)
@@ -1397,6 +1440,11 @@ esp_err_t local_status_ui_start(const local_ui_config_t *config)
portENTER_CRITICAL(&s_timing_mux);
s_config = *config;
if (s_config_generation == UINT32_MAX) {
portEXIT_CRITICAL(&s_timing_mux);
return ESP_ERR_INVALID_STATE;
}
++s_config_generation;
s_config_available = true;
portEXIT_CRITICAL(&s_timing_mux);
+16
View File
@@ -21,6 +21,22 @@ esp_err_t local_status_ui_start(const local_ui_config_t *config);
esp_err_t local_status_ui_get_config(local_ui_config_t *config);
esp_err_t local_status_ui_apply_config(const local_ui_config_t *config);
typedef enum {
LOCAL_UI_SETTINGS_APPLY, LOCAL_UI_SETTINGS_SAVE, LOCAL_UI_SETTINGS_LOAD,
LOCAL_UI_SETTINGS_DEFAULTS, LOCAL_UI_SETTINGS_RESET
} local_ui_settings_action_t;
/* Zero-wait RAM projection. Generation is nonzero and never wraps. */
esp_err_t local_status_ui_get_settings(local_ui_config_t *config, uint32_t *generation);
/* Reserve configuration across storage IO, without holding a critical section.
* Zero expected_generation is for canonical unconditional CLI operations only.
* Nonzero stale generations return ESP_ERR_INVALID_STATE; contention returns
* ESP_ERR_TIMEOUT. Load retains the canonical default fallback. Reset commits
* defaults before publishing RAM, so a storage failure needs no RAM rollback.
* No display IO occurs here; successful RAM changes signal renderer activity. */
esp_err_t local_status_ui_update_settings(local_ui_settings_action_t action,
uint32_t expected_generation, const local_ui_config_t *config, bool *loaded_defaults);
/* Preserve a manually selected display diagnostic for a bounded interval. */
void local_status_ui_hold_for_diagnostics(void);
+9 -24
View File
@@ -87,7 +87,8 @@ static int apply_parameter(const char *parameter, const char *text)
}
local_ui_config_t config;
esp_err_t error = local_status_ui_get_config(&config);
uint32_t generation;
esp_err_t error = local_status_ui_get_settings(&config, &generation);
if (error != ESP_OK) {
printf("Could not read local UI configuration: %s\n", esp_err_to_name(error));
return 1;
@@ -102,7 +103,7 @@ static int apply_parameter(const char *parameter, const char *text)
return 1;
}
error = local_status_ui_apply_config(&config);
error = local_status_ui_update_settings(LOCAL_UI_SETTINGS_APPLY, generation, &config, NULL);
if (error != ESP_OK) {
printf("Invalid display configuration: %s. When both timeouts are enabled, off must be later than dim.\n",
esp_err_to_name(error));
@@ -122,11 +123,7 @@ static int command_display(int argc, char **argv)
return apply_parameter(argv[2], argv[3]);
}
if (argc == 2 && strcmp(argv[1], "save") == 0) {
local_ui_config_t config;
esp_err_t error = local_status_ui_get_config(&config);
if (error == ESP_OK) {
error = local_ui_config_save(&config);
}
esp_err_t error = local_status_ui_update_settings(LOCAL_UI_SETTINGS_SAVE, 0, NULL, NULL);
if (error != ESP_OK) {
printf("Could not save display configuration: %s\n", esp_err_to_name(error));
return 1;
@@ -136,17 +133,15 @@ static int command_display(int argc, char **argv)
}
if (argc == 2 && strcmp(argv[1], "load") == 0) {
local_ui_config_t config;
bool used_stored_config;
esp_err_t error = local_ui_config_load(&config, &used_stored_config);
if (error == ESP_OK) {
error = local_status_ui_apply_config(&config);
}
bool loaded_defaults = false;
esp_err_t error = local_status_ui_update_settings(LOCAL_UI_SETTINGS_LOAD, 0, NULL, &loaded_defaults);
if (error == ESP_OK) error = local_status_ui_get_config(&config);
if (error != ESP_OK) {
printf("Could not load display configuration: %s\n", esp_err_to_name(error));
return 1;
}
printf("Loaded %s display configuration.\n",
used_stored_config ? "stored" : "default");
loaded_defaults ? "default" : "stored");
print_config(&config);
return 0;
}
@@ -163,19 +158,9 @@ static int command_display(int argc, char **argv)
return 0;
}
if (argc == 2 && strcmp(argv[1], "reset") == 0) {
local_ui_config_t previous;
local_ui_config_t defaults;
local_ui_config_defaults(&defaults);
esp_err_t error = local_status_ui_get_config(&previous);
if (error == ESP_OK) {
error = local_status_ui_apply_config(&defaults);
}
if (error == ESP_OK) {
error = local_ui_config_reset_storage();
if (error != ESP_OK) {
(void)local_status_ui_apply_config(&previous);
}
}
esp_err_t error = local_status_ui_update_settings(LOCAL_UI_SETTINGS_RESET, 0, NULL, NULL);
if (error != ESP_OK) {
printf("Could not reset display configuration: %s\n", esp_err_to_name(error));
return 1;
+236
View File
@@ -0,0 +1,236 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "web_display_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 "local_status_ui.h"
#include "web_cookie_auth.h"
#include "web_httpd_adapter.h"
enum { APPLY, SAVE, LOAD, DEFAULTS, RESET, ACTION_COUNT };
static const char *const s_actions[] = {"apply", "save", "load", "defaults", "reset"};
enum { IDLE, PENDING, OK, FAILED, CANCELLED, LOADED_DEFAULTS, CONFLICT };
static const char *const s_states[] = {"idle", "pending", "ok", "failed", "cancelled", "loaded_defaults", "conflict"};
typedef struct {
uint32_t id;
web_session_id_t session;
user_principal_t principal;
int64_t deadline;
local_ui_config_t config;
uint32_t generation;
unsigned action, state;
} display_operation_t;
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
static display_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, display_operation_t *operation)
{
const char *keys[] = {"action", "generation", "dim_seconds", "off_seconds"};
unsigned seen = 0;
size_t pos = 0;
local_ui_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 < 4; ++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 < 4; ++key)
if (strlen(keys[key]) == pos - start && !memcmp(body + start, keys[key], pos - start)) break;
if (key == 4 || (seen & (1U << key))) return false;
++pos; TAKE(':'); SPACE();
if (key == 0) {
TAKE('"'); start = pos;
while (pos < length && body[pos] != '"') ++pos;
if (pos == length) return false;
for (unsigned i = 0; i < ACTION_COUNT; ++i)
if (strlen(s_actions[i]) == pos - start && !memcmp(body + start, s_actions[i], pos - start)) operation->action = i;
if (operation->action == ACTION_COUNT) return false;
++pos;
} else {
uint32_t number = 0;
start = pos;
while (pos < length && body[pos] >= '0' && body[pos] <= '9') {
unsigned digit = (unsigned)(body[pos++] - '0');
if (number > (UINT32_MAX - digit) / 10U) return false;
number = number * 10U + digit;
}
if (pos == start || (pos - start > 1 && body[start] == '0')) return false;
if (key == 1) operation->generation = number;
if (key == 2) operation->config.dim_timeout_seconds = number;
if (key == 3) operation->config.off_timeout_seconds = number;
}
seen |= 1U << key;
SPACE();
if (pos < length && body[pos] == '}') break;
}
TAKE('}'); SPACE();
#undef TAKE
#undef SPACE
return pos == length && seen == (operation->action == APPLY ? 15U : 3U) &&
operation->generation != 0 && local_ui_config_validate(&operation->config) == ESP_OK;
}
void web_display_settings_execute(uint32_t id)
{
display_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) {
/* The owner checks the selected generation and reserves all config
* mutations, including CLI callers, across storage IO. Buttons only
* signal activity: they never replace configuration or own this gate. */
bool defaults = false;
static const local_ui_settings_action_t actions[] = {
LOCAL_UI_SETTINGS_APPLY, LOCAL_UI_SETTINGS_SAVE, LOCAL_UI_SETTINGS_LOAD,
LOCAL_UI_SETTINGS_DEFAULTS, LOCAL_UI_SETTINGS_RESET
};
error = local_status_ui_update_settings(actions[operation.action], operation.generation,
&operation.config, &defaults);
state = error == ESP_OK ? (defaults ? LOADED_DEFAULTS : OK) :
error == ESP_ERR_INVALID_STATE ? CONFLICT : 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_display_operation_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;
}
display_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_display_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_display_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;
}
esp_err_t web_display_settings_handler(httpd_req_t *request)
{
web_session_view_t view = {0};
bool allowed = false;
esp_err_t error = 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;
}
local_ui_config_t config;
uint32_t generation;
error = local_status_ui_get_settings(&config, &generation);
if (error != ESP_OK) {
error = respond(request, "503 Service Unavailable", "{\"error\":\"display_unavailable\"}");
goto done;
}
char response[128];
int written = snprintf(response, sizeof(response),
"{\"generation\":%" PRIu32 ",\"dim_seconds\":%" PRIu32 ",\"off_seconds\":%" PRIu32 "}",
generation, config.dim_timeout_seconds, config.off_timeout_seconds);
error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL :
respond(request, "200 OK", response);
done:
secure_wipe(&view, sizeof(view));
web_httpd_wipe_request(request, web_httpd_unread_body(request));
return error;
}
+9
View File
@@ -0,0 +1,9 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#pragma once
#include <stdint.h>
#include "esp_http_server.h"
/* Optional admin-only RAM snapshot and typed dispatcher admission/results. */
esp_err_t web_display_settings_handler(httpd_req_t *request);
esp_err_t web_display_operation_handler(httpd_req_t *request);
void web_display_settings_execute(uint32_t id);
+15 -1
View File
@@ -26,6 +26,7 @@
#include "web_serial_settings.h"
#include "web_account_settings.h"
#include "web_network_settings.h"
#include "web_display_settings.h"
#include "web_admin_transport.h"
#include "web_session_store.h"
#include "web_cookie_auth.h"
@@ -413,6 +414,15 @@ static const httpd_uri_t s_account_generate_password_uri = {
static const httpd_uri_t s_network_uri = {
.uri = "/api/settings/network", .method = HTTP_GET, .handler = web_network_snapshot_handler,
};
static const httpd_uri_t s_display_uri = {
.uri = "/api/settings/display", .method = HTTP_GET, .handler = web_display_settings_handler,
};
static const httpd_uri_t s_display_operation_get_uri = {
.uri = "/api/settings/display-operation", .method = HTTP_GET, .handler = web_display_operation_handler,
};
static const httpd_uri_t s_display_operation_post_uri = {
.uri = "/api/settings/display-operation", .method = HTTP_POST, .handler = web_display_operation_handler,
};
static const httpd_uri_t s_network_operation_get_uri = {
.uri = "/api/settings/network-operation", .method = HTTP_GET, .handler = web_network_operation_handler,
};
@@ -635,7 +645,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]) + 13U;
sizeof(s_auth_uris) / sizeof(s_auth_uris[0]) + 16U;
/* Exhaustion rejects new sockets, never evicts an existing serial writer. */
config.httpd.lru_purge_enable = false;
config.httpd.recv_wait_timeout = 1;
@@ -699,6 +709,10 @@ esp_err_t web_server_start(void)
web_httpd_register_optional_get(server, &s_network_operation_get_uri) == ESP_OK &&
web_httpd_register_optional(server, &s_network_operation_post_uri) != ESP_OK)
(void)httpd_unregister_uri_handler(server, s_network_operation_get_uri.uri, HTTP_GET);
if (web_httpd_register_optional_get(server, &s_display_uri) == ESP_OK &&
web_httpd_register_optional_get(server, &s_display_operation_get_uri) == ESP_OK &&
web_httpd_register_optional(server, &s_display_operation_post_uri) != ESP_OK)
(void)httpd_unregister_uri_handler(server, s_display_operation_get_uri.uri, HTTP_GET);
}
if (error != ESP_OK) {
web_cookie_auth_stop();
+158 -2
View File
@@ -185,7 +185,23 @@ static const char s_index_html[] =
"<section id=\"serial-settings\" class=\"settings-page\" aria-label=\"Serial settings\" hidden>"
"<div class=\"serial-actions\"><button id=\"settings-serial\" class=\"button\" type=\"button\" aria-pressed=\"true\">Serial settings</button>"
"<button id=\"settings-accounts\" class=\"button\" type=\"button\" aria-pressed=\"false\">Accounts</button>"
"<button id=\"settings-network\" class=\"button\" type=\"button\" aria-pressed=\"false\">Network</button></div>"
"<button id=\"settings-network\" class=\"button\" type=\"button\" aria-pressed=\"false\">Network</button>"
"<button id=\"settings-display\" class=\"button\" type=\"button\" aria-pressed=\"false\">Display</button></div>"
"<div id=\"display-settings\" hidden><h2>Display</h2>\n"
"<p class=\"connection-detail\">Working OLED inactivity settings, not saved NVS values. Zero disables a transition. Each timeout is 086400 seconds; when both are enabled, Off must be later than Dim.</p>\n"
"<p class=\"connection-detail\">Apply and Defaults change RAM only. Save persists the working snapshot, not browser drafts. Load discards drafts and uses stored settings, or defaults if storage is absent/incompatible; it does not change NVS. Reset saves defaults and applies them. Refresh discards drafts. Intervening configuration edits reject stale operations: Refresh and review before retrying.</p>\n"
"<p class=\"connection-detail\">Configuration works with an absent panel if the local UI task is available; success does not prove the panel changed. Buttons keep their normal wake/reprobe behavior. Navigation and these settings leave both terminals and the writer lease unchanged.</p>\n"
"<button id=\"display-refresh\" class=\"button\" type=\"button\">Refresh</button>\n"
"<p id=\"display-detail\" class=\"connection-detail\" role=\"status\"></p><dl id=\"display-values\" class=\"settings-values\" hidden><dt>Dim after (seconds)</dt><dd id=\"display-dim_seconds\"></dd><dt>Off after (seconds)</dt><dd id=\"display-off_seconds\"></dd></dl>\n"
"<div id=\"display-edit\" class=\"settings-edit\" hidden><label>Dim after (seconds)<input id=\"display-edit-dim_seconds\" type=\"number\" min=\"0\" max=\"86400\" step=\"1\"></label><label>Off after (seconds)<input id=\"display-edit-off_seconds\" type=\"number\" min=\"0\" max=\"86400\" step=\"1\"></label></div>\n"
"<div class=\"serial-actions\">\n"
"<button id=\"display-apply\" class=\"button\" type=\"button\">Apply to RAM</button>\n"
"<button id=\"display-save\" class=\"button\" type=\"button\">Save working config</button>\n"
"<button id=\"display-load\" class=\"button\" type=\"button\">Load saved config</button>\n"
"<button id=\"display-defaults\" class=\"button\" type=\"button\">Defaults in RAM</button>\n"
"<button id=\"display-reset\" class=\"button\" type=\"button\">Reset and save defaults</button>\n"
"<button id=\"display-result\" class=\"button\" type=\"button\">Check Operation Result</button>\n"
"</div><p id=\"display-operation-detail\" class=\"connection-detail\" role=\"status\">Check Result after uncertainty; navigation, expiry or timeout does not cancel admitted work. No automatic mutation retry.</p></div>\n"
"<div id=\"network-settings\" hidden><h2>Network</h2>"
"<p class=\"connection-detail\">Edits apply to RAM only. Save persists the device working configuration, NOT browser drafts. Refresh discards drafts. "
"Wi-Fi Load uses stored configuration only; missing or invalid storage leaves RAM unchanged. No Wi-Fi defaults/reset. "
@@ -377,7 +393,7 @@ static const char s_app_js[] =
" element('serial-result').disabled = busy;\n"
"}\n"
"function clearSettings() {\n"
" clearAccounts(); clearNetwork();\n"
" clearAccounts(); clearNetwork(); clearDisplay();\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"
@@ -391,6 +407,7 @@ static const char s_app_js[] =
"async function refreshSettings() {\n"
" if (settingsDomain === 'accounts') return refreshAccounts();\n"
" if (settingsDomain === 'network') return refreshNetwork();\n"
" if (settingsDomain === 'display') return refreshDisplay();\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"
@@ -483,6 +500,144 @@ static const char s_app_js[] =
" }\n"
" }\n"
"}\n"
"const displayDetail = element('display-detail'), displayFields = ['dim_seconds', 'off_seconds'];\n"
"let displayAbort = null, displayGeneration = 0, displayOperationAction = '';\n"
"function displayValid(v) { return displayFields.every(k => Number.isInteger(v[k]) && v[k] >= 0 && v[k] <= 86400) && (!v.dim_seconds || !v.off_seconds || v.off_seconds > v.dim_seconds); }\n"
"const displayActions = ['apply', 'save', 'load', 'defaults', 'reset'];\n"
"let displayOperationId = 0, displayOperationPending = false, displayAwaitingAck = false;\n"
"let displayOutcomeWarning = '', displayAuto = null;\n"
"function stopDisplayAuto(recovery = false) {\n"
" if (!displayAuto) return;\n"
" window.clearTimeout(displayAuto.timer); window.clearTimeout(displayAuto.deadline); displayAuto = null;\n"
" if (recovery) element('display-operation-detail').textContent += ' Automatic checking stopped; outcome still uncertain. Select Check Result; do not resubmit.';\n"
"}\n"
"function expireDisplayAuto(auto) {\n"
" if (displayAuto !== auto) return;\n"
" stopDisplayAuto(true);\n"
" if (displayAbort) displayAbort.abort();\n"
" displayAbort = null; displayButtons();\n"
"}\n"
"function scheduleDisplayCheck() {\n"
" const auto = displayAuto;\n"
" if (!auto) return;\n"
" if (auto.attempts >= 10) { stopDisplayAuto(true); displayButtons(); return; }\n"
" auto.timer = window.setTimeout(() => {\n"
" if (displayAuto !== auto) return;\n"
" if (performance.now() >= auto.until) { expireDisplayAuto(auto); return; }\n"
" ++auto.attempts; displayOperation(null, true);\n"
" }, 1000);\n"
"}\n"
"function startDisplayAuto() {\n"
" const auto = {attempts: 0, timer: 0, deadline: 0, until: performance.now() + 15000}; displayAuto = auto;\n"
" auto.deadline = window.setTimeout(() => expireDisplayAuto(auto), 15000);\n"
" scheduleDisplayCheck();\n"
"}\n"
"function displayButtons() {\n"
" const busy = !!displayAbort || !!displayAuto;\n"
" for (const action of displayActions) element('display-' + action).disabled = busy || displayOperationPending || !displayGeneration;\n"
" for (const key of displayFields) element('display-edit-' + key).disabled = busy || displayOperationPending || !displayGeneration;\n"
" element('display-refresh').disabled = busy;\n"
" element('display-result').disabled = busy;\n"
"}\n"
"function clearDisplay() {\n"
" if (!displayAuto && displayOperationPending) element('display-operation-detail').textContent = displayOutcomeWarning + 'Outcome pending or unknown. Check Result on return; navigation does not cancel work.';\n"
" stopDisplayAuto(true);\n"
" if (displayAbort) displayAbort.abort(); displayAbort = null; displayGeneration = 0;\n"
" element('display-values').hidden = true; element('display-edit').hidden = true;\n"
" for (const key of displayFields) { element('display-' + key).textContent = ''; element('display-edit-' + key).value = ''; }\n"
" displayDetail.textContent = 'Select Refresh to read current values.'; displayButtons();\n"
"}\n"
"async function refreshDisplay() {\n"
" if (settingsDomain !== 'display' || selected !== 'settings' || accountRole !== 'admin' || !sessionVerified || suspended || unloading || navigating || loggingOut || displayAbort || displayAuto) return;\n"
" const controller = new AbortController(), generation = workGeneration; displayAbort = controller; displayButtons();\n"
" const current = () => displayAbort === controller && selected === 'settings' && settingsDomain === 'display';\n"
" displayDetail.textContent = 'Reading display configuration... Previous snapshot is stale until refreshed.';\n"
" try {\n"
" if (!await loadSession(generation, controller.signal, false) || !current()) return;\n"
" const {status, payload: v} = await api('/api/settings/display', generation, {signal: controller.signal, limit: 128, current});\n"
" if (status !== 200 || !v || Object.keys(v).length !== 3 || !Number.isInteger(v.generation) || v.generation < 1 || v.generation > 4294967295 || !displayValid(v)) throw new Error('Invalid snapshot');\n"
" displayGeneration = v.generation;\n"
" for (const key of displayFields) { element('display-' + key).textContent = String(v[key]); element('display-edit-' + key).value = String(v[key]); }\n"
" element('display-values').hidden = false; element('display-edit').hidden = false;\n"
" displayDetail.textContent = (displayOperationPending ? 'Snapshot may be stale: operation outcome pending or unknown. ' : 'Working snapshot loaded. ') + 'Apply changes RAM; Save explicitly persists this working generation. Refresh replaces drafts.';\n"
" } catch (error) {\n"
" if (live(generation) && current()) { displayGeneration = 0; displayDetail.textContent = (error.status ? error.message : 'Display snapshot unavailable or invalid.') + ' Select Refresh to retry.'; }\n"
" } finally { if (current()) { displayAbort = null; displayButtons(); } }\n"
"}\n"
"async function displayOperation(action, automatic = false) {\n"
" if (settingsDomain !== 'display') return;\n"
" if (selected !== 'settings' || accountRole !== 'admin' || !sessionVerified || suspended || unloading || navigating || loggingOut || displayAbort || (action && displayOperationPending)) return;\n"
" if (!automatic && displayAuto) return;\n"
" const detail = element('display-operation-detail');\n"
" let refresh = false, poll = false;\n"
" let body;\n"
" if (action) {\n"
" if (element('display-edit').hidden || !displayGeneration) return;\n"
" const value = {action, generation: displayGeneration};\n"
" if (action === 'apply') {\n"
" for (const key of displayFields) {\n"
" const text = element('display-edit-' + key).value;\n"
" if (!/^(0|[1-9][0-9]{0,4})$/.test(text)) { detail.textContent = 'Enter whole-number seconds, 086400.'; return; }\n"
" value[key] = Number(text);\n"
" }\n"
" if (!displayValid(value)) { detail.textContent = 'Timeouts must be 086400; Off must be later than Dim when both are enabled.'; 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; displayAbort = controller;\n"
" const auto = automatic ? displayAuto : null;\n"
" const current = () => {\n"
" if (auto && displayAuto === auto && performance.now() >= auto.until) expireDisplayAuto(auto);\n"
" return displayAbort === controller && selected === 'settings' && settingsDomain === 'display';\n"
" };\n"
" element('display-refresh').disabled = true; displayButtons();\n"
" detail.textContent = displayOutcomeWarning + (action ? (action === 'apply' ? 'Applying' : action) + '... Submitting once; completion will be checked automatically.' : 'Reading latest result for this login...');\n"
" displayDetail.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) { displayOperationPending = true; displayAwaitingAck = true; }\n"
" const {status, payload: result} = await api('/api/settings/display-operation', generation, {method: action ? 'POST' : 'GET', body, signal: controller.signal, limit: 96, current});\n"
" if (status !== (action ? 202 : 200) || !result || Object.keys(result).length !== 3 || !Number.isInteger(result.id) || result.id < 0 || result.id > 4294967295 ||\n"
" (result.state === 'loaded_defaults' && result.action !== 'load') ||\n"
" !['none', ...displayActions].includes(result.action) || !['idle','pending','ok','failed','cancelled','loaded_defaults','conflict'].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"
" if (!action && displayOperationId && displayOperationId === result.id && displayOperationAction && displayOperationAction !== result.action) throw new Error('Operation action changed for the same ID');\n"
" const uncertain = !action && displayAwaitingAck;\n"
" const replaced = !action && displayOperationId && displayOperationId !== result.id;\n"
" if (action) displayOutcomeWarning = '';\n"
" else if (uncertain) displayOutcomeWarning = 'Submission acknowledgement was lost; this latest result may belong to an earlier operation or another tab. Inspect before retrying. ';\n"
" else if (replaced) displayOutcomeWarning = 'Previous result was replaced or unavailable; its outcome is unknown. ';\n"
" displayOperationId = result.id; displayOperationAction = result.action; displayOperationPending = result.state === 'pending'; displayAwaitingAck = 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 or configuration was busy. RAM was not changed by this operation; inspect working settings and storage before retrying.',\n"
" conflict: 'Working configuration changed or the UI is unavailable. Refresh and review before retrying; this operation made no change.',\n"
" cancelled: 'Operation rejected before execution because the login or queue deadline was no longer current.'};\n"
" detail.textContent = displayOutcomeWarning + result.action + ': ' + messages[result.state];\n"
" poll = !replaced && result.state === 'pending' && (!!action || automatic);\n"
" refresh = !action && result.state !== 'pending' && result.state !== 'idle';\n"
" } catch (error) {\n"
" if (live(generation) && current()) detail.textContent = displayOutcomeWarning + (error.status ? error.message : 'Operation outcome unknown.') + ' Check Result and Refresh before any explicit retry. No automatic retry.';\n"
" } finally {\n"
" if (current()) {\n"
" displayAbort = null;\n"
" if (poll) { if (action) startDisplayAuto(); else scheduleDisplayCheck(); }\n"
" else stopDisplayAuto();\n"
" displayButtons();\n"
" if (refresh) await refreshDisplay();\n"
" }\n"
" }\n"
"}\n"
"element('settings-display').addEventListener('click', () => selectSettingsDomain('display'));\n"
"element('display-refresh').addEventListener('click', refreshDisplay);\n"
"element('display-result').addEventListener('click', () => displayOperation(null));\n"
"for (const action of displayActions) element('display-' + action).addEventListener('click', () => displayOperation(action));\n"
"let settingsDomain = 'serial', accounts = [], accountsAbort = null, accountId = 0, accountPending = false, accountAwaitingAck = false, accountWarning = '';\n"
"let keysAbort = null, accountKeys = [], keysIdentity = '';\n"
"function keyIdentity() { const t = accounts[Number(element('account-target').value)]; return t ? JSON.stringify([t.username,t.user_id,t.auth_generation]) : ''; }\n"
@@ -561,6 +716,7 @@ static const char s_app_js[] =
" if (!sessionVerified || accountRole !== 'admin' || selected !== 'settings' || domain === settingsDomain) return;\n"
" clearSettings(); settingsDomain = domain; settingsHost.hidden = false;\n"
" element('serial-settings-content').hidden = domain !== 'serial'; element('account-settings').hidden = domain !== 'accounts'; element('network-settings').hidden = domain !== 'network';\n"
" element('display-settings').hidden = domain !== 'display'; element('settings-display').setAttribute('aria-pressed', String(domain === 'display'));\n"
" element('settings-network').setAttribute('aria-pressed', String(domain === 'network'));\n"
" element('settings-serial').setAttribute('aria-pressed', String(domain === 'serial')); element('settings-accounts').setAttribute('aria-pressed', String(domain === 'accounts'));\n"
" refreshSettings();\n"