diff --git a/docs/electrical_tests.md b/docs/electrical_tests.md index 1d15489..d0f14b6 100644 --- a/docs/electrical_tests.md +++ b/docs/electrical_tests.md @@ -4,7 +4,7 @@ These procedures verify the Phase 7A OLED and buttons, MAX3243 breakout, UART1 d > **Safety:** With power removed, install only the wiring required by the selected test. DE-9 pins 3 (`TX`), 4 (`DTR`), and 7 (`RTS`) are driven outputs. Never connect one of these outputs to another driven output. Keep temporary Dupont wiring short and secure. Power the OLED only from 3.3 V because module-mounted I²C pull-ups may connect SDA and SCL to the OLED `VCC` rail. -## Phase 7A OLED and button bring-up +## Phase 7 OLED and button bring-up Use the exact OLED and button connections in [Hardware wiring](wiring.md). Display diagnostics initially operate I²C at 100 kHz and probe the standard 7-bit `0x3c` and `0x3d` addresses. The connected test module acknowledges at `0x3c`, whose 8-bit write/read forms are `0x78` and `0x79`. A missing or unresponsive display is nonfatal: diagnostics should report it without disrupting UART0 or the serial services. @@ -47,7 +47,7 @@ debug display pattern grid debug display pattern corners ``` -Confirm that clear and fill affect the full 128×64 area, checker and grid have regular spacing without shifted or wrapped columns, and all four corner markers are visible in the correct locations. Unexpected mirroring, rotation, clipping, or column offsets must be recorded before Phase 7B fixes the display-driver assumptions. +Confirm that clear and fill affect the full 128×64 area, checker and grid have regular spacing without shifted or wrapped columns, and all four corner markers are visible in the correct locations. Display diagnostics pause the periodic status UI for 30 seconds so the selected pattern remains observable. Record any unexpected mirroring, rotation, clipping, or column offset for correction. ### 4. Rendered status/content layout @@ -118,6 +118,19 @@ Use the longer run to check: `debug buttons test [seconds]` accepts 1 through 30 seconds and defaults to 10 seconds when omitted. Record unexpected event duplication, missed transitions, incorrect GPIO mapping, false long presses, or a test that fails to terminate. +### 8. Read-only status UI (Phase 7C) + +After boot, the OLED starts on the **OVERVIEW** page. A short previous/back press on GPIO10 and a short next press on GPIO14 must wrap through these read-only pages: + +1. **OVERVIEW** — serial, broker, USB, HTTPS/WebSocket, SSH, and Wi-Fi summary. +2. **RS232 MODEM** — framing, modem inputs, byte counters, queue depth, drops, and faults. +3. **BROKER** — connected clients, current writer marker, pending output, and drop/event counters. +4. **NETWORK SERVICES** — Wi-Fi state/RSSI/IP/AP state and HTTPS, WebSocket, and SSH service state. + +Confirm that each page remains entirely within the blue content panel and that the yellow strip continuously shows compact serial/Wi-Fi/client/service state. The UI refreshes at 4 Hz maximum and is read-only: select (GPIO13) must not change a service, acquire/release a writer, inject serial data, or expose passwords, credentials, tickets, or key material. + +With UART0, USB CDC, WebSocket, or SSH active, navigate pages and confirm that the display continues to update without disrupting serial traffic or UART0 recovery. Record a missing, stale, clipped, or implausible status value before Phase 7C is marked complete. + ## Configuration A: data and handshake pairs Connect the following pairs: diff --git a/docs/roadmap.md b/docs/roadmap.md index 89309af..44b445d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -212,10 +212,12 @@ The persistent yellow strip is reserved for serial-service state, Wi-Fi strength - Frame commits refresh only dirty 8-pixel pages. I²C runs at 100 kHz with bounded transactions. - The display bus initializes at boot; a known display is automatically initialized and cleared. A missing or unresponsive display is nonfatal and does not make the serial core dependent on it. - Target-hardware validation passed for initialization, all display diagnostic patterns, the rendered panel layout and physical divider, row 15/16 boundary, contrast, inversion, display-off/reinitialization, and button diagnostics. Observed contrast control has a useful brightness range of 1–255. -3. **Phase 7C — Read-only status UI — Planned** - - Build display state from existing serial, Wi-Fi, broker, USB, WebSocket, HTTPS, and SSH snapshot APIs rather than parsing CLI output or reaching into transport internals. - - Provide overview, RS-232/modem, broker-client/writer, and network/service pages. - - Refresh at a bounded low rate, initially about 4 Hz, from a low-priority owner task. Never hold a service lock across an I²C transaction. +3. **Phase 7C — Read-only status UI — Implemented; validation pending** + - The low-priority `local_status_ui` owner task builds display state from copied public serial, Wi-Fi, broker, USB, WebSocket, HTTPS, and SSH snapshots. It does not parse CLI output, reach into transport internals, or become a broker client. + - Previous/back and next short presses navigate overview, RS-232/modem, broker-client/writer, and network/service pages. Select has no state-changing action in this read-only phase. + - The task polls and debounces the existing active-low button inputs and refreshes at 4 Hz maximum. It collects service snapshots before beginning a display frame, so no service or broker lock is held across an I²C transaction. + - A missing/unresponsive display is nonfatal; the task remains read-only and never acquires serial writer ownership. + - Pending target-hardware validation: page navigation, each page's live snapshot values, concurrent USB/WebSocket/SSH operation while refreshing, and UART0 responsiveness. 4. **Phase 7D — Local controls — Planned** - Add a shallow menu for safe serial, Wi-Fi, HTTPS, SSH, writer-release, display, and reboot actions through direct service APIs. - Require a visible confirmation screen and a timed select hold before stopping active services, revoking a writer, rebooting, or performing another disruptive action. diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 30fe9cd..eaf33d9 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -7,6 +7,7 @@ idf_component_register( "secure_random.c" "status_led.c" "local_display.c" + "local_status_ui.c" "local_ui_hw_test.c" "rs232_hw_test.c" "rs232_port_owner.c" diff --git a/src/local_status_ui.c b/src/local_status_ui.c new file mode 100644 index 0000000..827dfeb --- /dev/null +++ b/src/local_status_ui.c @@ -0,0 +1,433 @@ +/* SPDX-License-Identifier: GPL-3.0-only */ +/* Bounded read-only OLED status pages. */ + +#include "local_status_ui.h" + +#include +#include +#include +#include + +#include "board_pins.h" +#include "driver/gpio.h" +#include "esp_log.h" +#include "esp_netif_ip_addr.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "local_display.h" +#include "serial_config.h" +#include "serial_service.h" +#include "session_broker.h" +#include "ssh_transport.h" +#include "usb_cdc_transport.h" +#include "web_serial_transport.h" +#include "web_server.h" +#include "wifi_manager.h" + +#define LOCAL_STATUS_UI_REFRESH_MS 250U +#define LOCAL_STATUS_UI_BUTTON_POLL_MS 10U +#define LOCAL_STATUS_UI_DEBOUNCE_MS 30U +#define LOCAL_STATUS_UI_TASK_STACK_SIZE 6144U +#define LOCAL_STATUS_UI_TASK_PRIORITY (tskIDLE_PRIORITY + 1U) +#define LOCAL_STATUS_UI_TEXT_CAPACITY 22U +#define LOCAL_STATUS_UI_CLIENT_ROWS 4U +#define LOCAL_STATUS_UI_DIAGNOSTIC_HOLD_MS 30000U + +static const char *TAG = "local_status_ui"; + +typedef enum { + LOCAL_STATUS_PAGE_OVERVIEW = 0, + LOCAL_STATUS_PAGE_RS232, + LOCAL_STATUS_PAGE_BROKER, + LOCAL_STATUS_PAGE_NETWORK, + LOCAL_STATUS_PAGE_COUNT, +} local_status_page_t; + +typedef enum { + LOCAL_STATUS_BUTTON_PREVIOUS = 0, + LOCAL_STATUS_BUTTON_SELECT, + LOCAL_STATUS_BUTTON_NEXT, + LOCAL_STATUS_BUTTON_COUNT, +} local_status_button_t; + +typedef struct { + bool stable_pressed; + bool candidate_pressed; + TickType_t candidate_since; +} local_status_button_state_t; + +typedef struct { + bool serial_config_available; + bool broker_available; + bool usb_available; + bool wifi_available; + bool web_available; + bool web_serial_available; + bool ssh_available; + bool serial_running; + serial_config_t serial_config; + serial_modem_state_t modem; + serial_service_counters_t serial_counters; + session_broker_global_snapshot_t broker; + session_broker_client_snapshot_t clients[SESSION_BROKER_MAX_CLIENTS]; + size_t client_count; + usb_cdc_transport_snapshot_t usb; + wifi_manager_snapshot_t wifi; + web_server_snapshot_t web; + web_serial_transport_snapshot_t web_serial; + ssh_transport_snapshot_t ssh; +} local_status_snapshot_t; + +static TaskHandle_t s_task; +static volatile TickType_t s_diagnostic_hold_until; + +static const gpio_num_t s_button_gpios[LOCAL_STATUS_BUTTON_COUNT] = { + LOCAL_UI_BUTTON_PREVIOUS_GPIO, + LOCAL_UI_BUTTON_SELECT_GPIO, + LOCAL_UI_BUTTON_NEXT_GPIO, +}; + +static void format_text(char output[LOCAL_STATUS_UI_TEXT_CAPACITY], const char *format, ...) +{ + va_list arguments; + va_start(arguments, format); + (void)vsnprintf(output, LOCAL_STATUS_UI_TEXT_CAPACITY, format, arguments); + va_end(arguments); +} + +static const char *broker_type_to_string(session_broker_client_type_t type) +{ + switch (type) { + case SESSION_BROKER_CLIENT_CONSOLE: + return "CON"; + case SESSION_BROKER_CLIENT_USB: + return "USB"; + case SESSION_BROKER_CLIENT_WEB: + return "WEB"; + case SESSION_BROKER_CLIENT_SSH: + return "SSH"; + case SESSION_BROKER_CLIENT_INTERNAL: + return "INT"; + default: + return "?"; + } +} + +static void format_ipv4(uint32_t address, char output[16]) +{ + if (address == 0U) { + memcpy(output, "0.0.0.0", sizeof("0.0.0.0")); + return; + } + + esp_ip4_addr_t ip = {.addr = address}; + int written = snprintf(output, 16U, IPSTR, IP2STR(&ip)); + if (written < 0 || written >= 16) { + memcpy(output, "0.0.0.0", sizeof("0.0.0.0")); + } +} + +static void collect_snapshot(local_status_snapshot_t *snapshot) +{ + memset(snapshot, 0, sizeof(*snapshot)); + + snapshot->serial_running = serial_service_is_running(); + snapshot->serial_config_available = + serial_service_get_config(&snapshot->serial_config) == ESP_OK; + serial_service_get_modem_state(&snapshot->modem); + serial_service_get_counters(&snapshot->serial_counters); + + snapshot->broker_available = + session_broker_get_global_snapshot(&snapshot->broker) == ESP_OK; + if (snapshot->broker_available) { + snapshot->client_count = session_broker_list_clients( + snapshot->clients, SESSION_BROKER_MAX_CLIENTS); + } + + snapshot->usb_available = usb_cdc_transport_get_snapshot(&snapshot->usb) == ESP_OK; + snapshot->wifi_available = wifi_manager_get_snapshot(&snapshot->wifi) == ESP_OK; + snapshot->web_available = web_server_get_snapshot(&snapshot->web) == ESP_OK; + snapshot->web_serial_available = + web_serial_transport_get_snapshot(&snapshot->web_serial) == ESP_OK; + snapshot->ssh_available = ssh_transport_get_snapshot(&snapshot->ssh) == ESP_OK; +} + +static bool snapshot_has_alert(const local_status_snapshot_t *snapshot) +{ + return !snapshot->broker_available || !snapshot->usb_available || + !snapshot->wifi_available || !snapshot->web_available || + !snapshot->web_serial_available || !snapshot->ssh_available || + (snapshot->wifi_available && snapshot->wifi.last_error != ESP_OK) || + (snapshot->web_available && snapshot->web.last_error != ESP_OK) || + (snapshot->ssh_available && snapshot->ssh.last_error != ESP_OK) || + snapshot->serial_counters.rx_dropped_bytes != 0U || + snapshot->serial_counters.tx_dropped_bytes != 0U || + (snapshot->broker_available && + snapshot->broker.counters.output_dropped_bytes != 0U); +} + +static void render_status_panel(const local_status_snapshot_t *snapshot) +{ + char line[LOCAL_STATUS_UI_TEXT_CAPACITY]; + int rssi = snapshot->wifi_available ? snapshot->wifi.sta_rssi : 0; + unsigned int clients = snapshot->broker_available ? snapshot->broker.connected_clients : 0U; + unsigned int web_sessions = + snapshot->web_serial_available ? snapshot->web_serial.active_sessions : 0U; + unsigned int ssh_sessions = snapshot->ssh_available ? snapshot->ssh.active_sessions : 0U; + + format_text(line, "SER %s WF %d%s", snapshot->serial_running ? "ON" : "OFF", rssi, + snapshot_has_alert(snapshot) ? "!" : ""); + local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_STATUS, 0U, 0U, line); + format_text(line, "C%u U%s W%u S%u", clients, + snapshot->usb_available && snapshot->usb.dtr ? "1" : "0", web_sessions, + ssh_sessions); + local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_STATUS, 0U, 8U, line); +} + +static void render_overview_page(const local_status_snapshot_t *snapshot) +{ + char line[LOCAL_STATUS_UI_TEXT_CAPACITY]; + + local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 0U, "OVERVIEW"); + if (snapshot->serial_config_available) { + format_text(line, "SER %s %" PRIu32 " %s%s%s", snapshot->serial_running ? "ON" : "OFF", + snapshot->serial_config.baud_rate, + serial_config_data_bits_to_string(snapshot->serial_config.data_bits), + serial_config_parity_to_string(snapshot->serial_config.parity), + serial_config_stop_bits_to_string(snapshot->serial_config.stop_bits)); + } else { + format_text(line, "SER %s CONFIG N/A", snapshot->serial_running ? "ON" : "OFF"); + } + local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 8U, line); + + format_text(line, "BROKER %u WR %s", snapshot->broker_available ? snapshot->broker.connected_clients : 0U, + snapshot->broker_available && snapshot->broker.writer_id != SESSION_BROKER_NO_CLIENT + ? "SET" + : "NONE"); + local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 16U, line); + format_text(line, "USB %s WEB %u", snapshot->usb_available && snapshot->usb.dtr ? "OPEN" : "OFF", + snapshot->web_serial_available ? snapshot->web_serial.active_sessions : 0U); + local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 24U, line); + format_text(line, "HTTPS %s SSH %u", snapshot->web_available && snapshot->web.running ? "ON" : "OFF", + snapshot->ssh_available ? snapshot->ssh.active_sessions : 0U); + local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 32U, line); + format_text(line, "WIFI %s", snapshot->wifi_available + ? wifi_manager_state_to_string(snapshot->wifi.state) + : "N/A"); + local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 40U, line); +} + +static void render_rs232_page(const local_status_snapshot_t *snapshot) +{ + char line[LOCAL_STATUS_UI_TEXT_CAPACITY]; + uint64_t faults = snapshot->serial_counters.frame_errors + + snapshot->serial_counters.parity_errors + + snapshot->serial_counters.fifo_overflows + + snapshot->serial_counters.buffer_full_events + + snapshot->serial_counters.breaks; + + local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 0U, "RS232 MODEM"); + if (snapshot->serial_config_available) { + format_text(line, "%" PRIu32 " %s%s%s %s", snapshot->serial_config.baud_rate, + serial_config_data_bits_to_string(snapshot->serial_config.data_bits), + serial_config_parity_to_string(snapshot->serial_config.parity), + serial_config_stop_bits_to_string(snapshot->serial_config.stop_bits), + serial_config_flow_control_to_string(snapshot->serial_config.flow_control)); + } else { + format_text(line, "CONFIG UNAVAILABLE"); + } + local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 8U, line); + format_text(line, "DCD%d DSR%d CTS%d RI%d", snapshot->modem.dcd, snapshot->modem.dsr, + snapshot->modem.cts, snapshot->modem.ri); + local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 16U, line); + format_text(line, "VALID %s RX %" PRIu64, snapshot->modem.valid ? "YES" : "NO", + snapshot->serial_counters.rx_bytes); + local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 24U, line); + format_text(line, "TX %" PRIu64 " Q%u", snapshot->serial_counters.tx_sent_to_uart_bytes, + (unsigned int)serial_service_tx_pending()); + local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 32U, line); + format_text(line, "DROP %" PRIu64 " ERR %" PRIu64, + snapshot->serial_counters.rx_dropped_bytes + snapshot->serial_counters.tx_dropped_bytes, + faults); + local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 40U, line); +} + +static void render_broker_page(const local_status_snapshot_t *snapshot) +{ + char line[LOCAL_STATUS_UI_TEXT_CAPACITY]; + + format_text(line, "BROKER C%u WR %s", snapshot->broker_available ? snapshot->broker.connected_clients : 0U, + snapshot->broker_available && snapshot->broker.writer_id != SESSION_BROKER_NO_CLIENT + ? "SET" + : "NONE"); + local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 0U, line); + + for (size_t i = 0U; i < LOCAL_STATUS_UI_CLIENT_ROWS; ++i) { + if (i >= snapshot->client_count) { + break; + } + const session_broker_client_snapshot_t *client = &snapshot->clients[i]; + format_text(line, "%c%s %s Q%u", client->is_writer ? 'W' : 'O', + broker_type_to_string(client->type), client->name, + (unsigned int)client->output_bytes_pending); + local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, + (uint8_t)((i + 1U) * 8U), line); + } + + if (snapshot->broker_available) { + format_text(line, "DROP %" PRIu64 " EVT %" PRIu64, + snapshot->broker.counters.output_dropped_bytes, + snapshot->broker.counters.event_drops); + local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 40U, line); + } +} + +static void render_network_page(const local_status_snapshot_t *snapshot) +{ + char line[LOCAL_STATUS_UI_TEXT_CAPACITY]; + char address[16]; + + local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 0U, "NETWORK SERVICES"); + if (snapshot->wifi_available) { + format_text(line, "WF %s %dDB", wifi_manager_state_to_string(snapshot->wifi.state), + snapshot->wifi.sta_rssi); + local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 8U, line); + format_ipv4(snapshot->wifi.ip, address); + format_text(line, "IP %s", address); + local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 16U, line); + format_text(line, "AP %s C%u", snapshot->wifi.ap_running ? "ON" : "OFF", + snapshot->wifi.ap_client_count); + } else { + format_text(line, "WIFI UNAVAILABLE"); + local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 8U, line); + format_text(line, "AP N/A"); + } + local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 24U, line); + format_text(line, "HTTPS %s WEB %u", snapshot->web_available && snapshot->web.running ? "ON" : "OFF", + snapshot->web_serial_available ? snapshot->web_serial.active_sessions : 0U); + local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 32U, line); + format_text(line, "SSH %s S%u", snapshot->ssh_available && snapshot->ssh.running ? "ON" : "OFF", + snapshot->ssh_available ? snapshot->ssh.active_sessions : 0U); + local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 40U, line); +} + +static void render_page(local_status_page_t page, const local_status_snapshot_t *snapshot) +{ + if (local_display_frame_begin() != ESP_OK) { + return; + } + + local_display_frame_clear_all(); + render_status_panel(snapshot); + switch (page) { + case LOCAL_STATUS_PAGE_OVERVIEW: + render_overview_page(snapshot); + break; + case LOCAL_STATUS_PAGE_RS232: + render_rs232_page(snapshot); + break; + case LOCAL_STATUS_PAGE_BROKER: + render_broker_page(snapshot); + break; + case LOCAL_STATUS_PAGE_NETWORK: + render_network_page(snapshot); + break; + default: + local_display_frame_cancel(); + return; + } + + esp_err_t error = local_display_frame_end(); + if (error != ESP_OK && error != ESP_ERR_INVALID_STATE) { + ESP_LOGW(TAG, "Display refresh failed: %s", esp_err_to_name(error)); + } +} + +static bool poll_buttons(local_status_button_state_t states[LOCAL_STATUS_BUTTON_COUNT], + local_status_button_t *pressed_button) +{ + TickType_t now = xTaskGetTickCount(); + bool event = false; + + for (size_t i = 0U; i < LOCAL_STATUS_BUTTON_COUNT; ++i) { + bool pressed = gpio_get_level(s_button_gpios[i]) == 0; + if (pressed != states[i].candidate_pressed) { + states[i].candidate_pressed = pressed; + states[i].candidate_since = now; + } + if (states[i].stable_pressed != states[i].candidate_pressed && + (now - states[i].candidate_since) >= pdMS_TO_TICKS(LOCAL_STATUS_UI_DEBOUNCE_MS)) { + states[i].stable_pressed = states[i].candidate_pressed; + if (states[i].stable_pressed) { + *pressed_button = (local_status_button_t)i; + event = true; + } + } + } + return event; +} + +static void local_status_ui_task(void *context) +{ + (void)context; + local_status_page_t page = LOCAL_STATUS_PAGE_OVERVIEW; + local_status_button_state_t buttons[LOCAL_STATUS_BUTTON_COUNT] = {0}; + TickType_t last_render = 0U; + + for (size_t i = 0U; i < LOCAL_STATUS_BUTTON_COUNT; ++i) { + bool pressed = gpio_get_level(s_button_gpios[i]) == 0; + buttons[i].stable_pressed = pressed; + buttons[i].candidate_pressed = pressed; + buttons[i].candidate_since = xTaskGetTickCount(); + } + + for (;;) { + local_status_button_t button; + bool button_event = poll_buttons(buttons, &button); + if (button_event) { + if (button == LOCAL_STATUS_BUTTON_PREVIOUS) { + page = page == LOCAL_STATUS_PAGE_OVERVIEW + ? LOCAL_STATUS_PAGE_COUNT - 1U + : (local_status_page_t)(page - 1U); + } else if (button == LOCAL_STATUS_BUTTON_NEXT) { + page = (local_status_page_t)((page + 1U) % LOCAL_STATUS_PAGE_COUNT); + } + } + + TickType_t now = xTaskGetTickCount(); + bool diagnostics_held = (int32_t)(now - s_diagnostic_hold_until) < 0; + if (!diagnostics_held && + (button_event || (now - last_render) >= pdMS_TO_TICKS(LOCAL_STATUS_UI_REFRESH_MS))) { + local_status_snapshot_t snapshot; + collect_snapshot(&snapshot); + render_page(page, &snapshot); + last_render = now; + } + vTaskDelay(pdMS_TO_TICKS(LOCAL_STATUS_UI_BUTTON_POLL_MS)); + } +} + +void local_status_ui_hold_for_diagnostics(void) +{ + s_diagnostic_hold_until = + xTaskGetTickCount() + pdMS_TO_TICKS(LOCAL_STATUS_UI_DIAGNOSTIC_HOLD_MS); +} + +esp_err_t local_status_ui_start(void) +{ + if (s_task != NULL) { + return ESP_ERR_INVALID_STATE; + } + + BaseType_t created = xTaskCreate(local_status_ui_task, "local_status_ui", + LOCAL_STATUS_UI_TASK_STACK_SIZE, NULL, + LOCAL_STATUS_UI_TASK_PRIORITY, &s_task); + if (created != pdPASS) { + s_task = NULL; + return ESP_ERR_NO_MEM; + } + + ESP_LOGI(TAG, "Read-only status UI started at %u Hz maximum", + 1000U / LOCAL_STATUS_UI_REFRESH_MS); + return ESP_OK; +} diff --git a/src/local_status_ui.h b/src/local_status_ui.h new file mode 100644 index 0000000..6b39250 --- /dev/null +++ b/src/local_status_ui.h @@ -0,0 +1,24 @@ +/* SPDX-License-Identifier: GPL-3.0-only */ +/* Read-only local status UI for the optional OLED. */ + +#pragma once + +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * Starts the low-priority status renderer. The task reads public service + * snapshots only; it never becomes a broker client or changes service state. + * The OLED and buttons are optional, so a missing display is not an error. + */ +esp_err_t local_status_ui_start(void); + +/* Preserve a manually selected display diagnostic for a bounded interval. */ +void local_status_ui_hold_for_diagnostics(void); + +#ifdef __cplusplus +} +#endif diff --git a/src/local_ui_hw_test.c b/src/local_ui_hw_test.c index bd2c7cb..f7c92f7 100644 --- a/src/local_ui_hw_test.c +++ b/src/local_ui_hw_test.c @@ -17,6 +17,7 @@ #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "local_display.h" +#include "local_status_ui.h" #define BUTTON_POLL_MS 10U #define BUTTON_DEBOUNCE_MS 30U @@ -412,6 +413,10 @@ static int command_display(int argc, char **argv) print_display_usage(); return argc < 2 || argc == 2 ? 0 : 1; } + + /* Keep diagnostic output visible instead of immediately redrawing status pages. */ + local_status_ui_hold_for_diagnostics(); + if (strcmp(argv[1], "status") == 0) { return command_display_status(argc - 1, argv + 1); } diff --git a/src/main.c b/src/main.c index bcdeb7f..726171f 100644 --- a/src/main.c +++ b/src/main.c @@ -6,6 +6,7 @@ #include "esp_psram.h" #include "network_console.h" #include "local_display.h" +#include "local_status_ui.h" #include "local_ui_hw_test.h" #include "rs232_hw_test.h" #include "rs232_port_owner.h" @@ -201,6 +202,14 @@ void app_main(void) } } + if (local_ui_error == ESP_OK) { + esp_err_t local_status_ui_error = local_status_ui_start(); + if (local_status_ui_error != ESP_OK) { + ESP_LOGW(TAG, "Local status UI unavailable: %s", + esp_err_to_name(local_status_ui_error)); + } + } + ESP_LOGI( TAG, "Using %s serial configuration; UART service starts on 'serial start' or native USB open",