/* 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; }