/* 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 "esp_system.h" #include "freertos/FreeRTOS.h" #include "freertos/portmacro.h" #include "freertos/semphr.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 #define LOCAL_STATUS_UI_DIM_DELAY_MS (5U * 60U * 1000U) #define LOCAL_STATUS_UI_OFF_DELAY_MS (10U * 60U * 1000U) #define LOCAL_STATUS_UI_DIM_CONTRAST 1U #define LOCAL_STATUS_UI_ACTIVE_CONTRAST 127U #define LOCAL_STATUS_UI_ICON_SIZE 8U #define LOCAL_STATUS_UI_STATUS_SLOT_WIDTH 18U #define LOCAL_STATUS_UI_MENU_VISIBLE_ROWS 5U #define LOCAL_STATUS_UI_MENU_TIMEOUT_MS 30000U #define LOCAL_STATUS_UI_RESULT_TIMEOUT_MS 2500U #define LOCAL_STATUS_UI_CONFIRM_HOLD_MS 2000U 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 enum { LOCAL_STATUS_POWER_ACTIVE = 0, LOCAL_STATUS_POWER_DIMMED, LOCAL_STATUS_POWER_OFF, } local_status_power_t; typedef enum { LOCAL_STATUS_MODE_STATUS = 0, LOCAL_STATUS_MODE_MENU, LOCAL_STATUS_MODE_CONFIRM, LOCAL_STATUS_MODE_RESULT, } local_status_mode_t; typedef enum { LOCAL_STATUS_ACTION_SERIAL = 0, LOCAL_STATUS_ACTION_WIFI_POWER, LOCAL_STATUS_ACTION_WIFI_RECONNECT, LOCAL_STATUS_ACTION_WEB, LOCAL_STATUS_ACTION_SSH, LOCAL_STATUS_ACTION_REVOKE_WRITER, LOCAL_STATUS_ACTION_DISPLAY_OFF, LOCAL_STATUS_ACTION_REBOOT, LOCAL_STATUS_ACTION_COUNT, } local_status_action_t; typedef enum { LOCAL_STATUS_BUTTON_EVENT_PRESS = 0, LOCAL_STATUS_BUTTON_EVENT_SHORT_RELEASE, LOCAL_STATUS_BUTTON_EVENT_LONG_REACHED, } local_status_button_event_type_t; typedef struct { local_status_button_t button; local_status_button_event_type_t type; } local_status_button_event_t; typedef enum { LOCAL_STATUS_ICON_SERIAL = 0, LOCAL_STATUS_ICON_WIFI, LOCAL_STATUS_ICON_USB, LOCAL_STATUS_ICON_WEB, LOCAL_STATUS_ICON_SSH, LOCAL_STATUS_ICON_CLIENTS, LOCAL_STATUS_ICON_ALERT, } local_status_icon_t; typedef struct { bool stable_pressed; bool candidate_pressed; TickType_t candidate_since; TickType_t pressed_since; bool long_emitted; bool suppress_gesture; } local_status_button_state_t; typedef struct { local_status_mode_t mode; local_status_page_t page; local_status_action_t menu_action; local_status_action_t confirmed_action; bool confirmed_stop; session_broker_client_id_t expected_writer_id; TickType_t mode_since; esp_err_t result_error; bool result_requested; bool restart_pending; } local_status_ui_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; /* * The USB bitmap is a hand-rasterized 8x8 derivative of Pictogrammers * Material Design Icons "usb". The Wi-Fi strength masks are compact segmented * adaptations of "wifi-strength-4". Upstream SVGs and license are retained in * third_party/material-design-icons/ (version 7.4.47, Apache-2.0 icons). */ static const uint8_t s_icon_serial[LOCAL_STATUS_UI_ICON_SIZE] = { 0x3cU, 0x42U, 0x7eU, 0x42U, 0x7eU, 0x42U, 0x3cU, 0x00U, }; static const uint8_t s_icon_usb[LOCAL_STATUS_UI_ICON_SIZE] = { 0x18U, 0x3cU, 0x18U, 0x5aU, 0x3eU, 0x18U, 0x3cU, 0x18U, }; static const uint8_t s_icon_web[LOCAL_STATUS_UI_ICON_SIZE] = { 0x3cU, 0x42U, 0xa5U, 0x99U, 0x99U, 0xa5U, 0x42U, 0x3cU, }; static const uint8_t s_icon_ssh[LOCAL_STATUS_UI_ICON_SIZE] = { 0x7eU, 0x42U, 0x52U, 0x4aU, 0x52U, 0x42U, 0x7eU, 0x00U, }; static const uint8_t s_icon_clients[LOCAL_STATUS_UI_ICON_SIZE] = { 0x24U, 0x5aU, 0x5aU, 0x24U, 0x00U, 0x7eU, 0xffU, 0x00U, }; static const uint8_t s_icon_alert[LOCAL_STATUS_UI_ICON_SIZE] = { 0x18U, 0x18U, 0x3cU, 0x3cU, 0x7eU, 0x66U, 0xffU, 0x00U, }; static const uint8_t s_icon_wifi_full[LOCAL_STATUS_UI_ICON_SIZE] = { 0x7eU, 0x81U, 0x3cU, 0x42U, 0x18U, 0x24U, 0x00U, 0x18U, }; static const uint8_t s_icon_wifi_three[LOCAL_STATUS_UI_ICON_SIZE] = { 0x00U, 0x00U, 0x3cU, 0x42U, 0x18U, 0x24U, 0x00U, 0x18U, }; static const uint8_t s_icon_wifi_two[LOCAL_STATUS_UI_ICON_SIZE] = { 0x00U, 0x00U, 0x00U, 0x00U, 0x18U, 0x24U, 0x00U, 0x18U, }; static const uint8_t s_icon_wifi_one[LOCAL_STATUS_UI_ICON_SIZE] = { 0x00U, 0x00U, 0x00U, 0x00U, 0x00U, 0x00U, 0x00U, 0x18U, }; static const uint8_t s_icon_access_point[LOCAL_STATUS_UI_ICON_SIZE] = { 0x18U, 0x18U, 0x3cU, 0x5aU, 0x99U, 0x18U, 0x18U, 0x18U, }; static TaskHandle_t s_task; static StaticSemaphore_t s_diagnostic_gate_storage; static SemaphoreHandle_t s_diagnostic_gate; static portMUX_TYPE s_timing_mux = portMUX_INITIALIZER_UNLOCKED; static bool s_diagnostic_hold_active; static TickType_t s_diagnostic_hold_started; static uint32_t s_external_activity_sequence; 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 const char *writer_type_to_string(const local_status_snapshot_t *snapshot) { for (size_t i = 0U; i < snapshot->client_count; ++i) { if (snapshot->clients[i].is_writer) { return broker_type_to_string(snapshot->clients[i].type); } } return "none"; } static const char *on_off(bool value) { return value ? "On" : "Off"; } static char parity_letter(serial_config_parity_t parity) { switch (parity) { case SERIAL_CONFIG_PARITY_NONE: return 'N'; case SERIAL_CONFIG_PARITY_EVEN: return 'E'; case SERIAL_CONFIG_PARITY_ODD: return 'O'; 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 const uint8_t *icon_bitmap(local_status_icon_t icon) { switch (icon) { case LOCAL_STATUS_ICON_SERIAL: return s_icon_serial; case LOCAL_STATUS_ICON_USB: return s_icon_usb; case LOCAL_STATUS_ICON_WEB: return s_icon_web; case LOCAL_STATUS_ICON_SSH: return s_icon_ssh; case LOCAL_STATUS_ICON_CLIENTS: return s_icon_clients; case LOCAL_STATUS_ICON_ALERT: return s_icon_alert; case LOCAL_STATUS_ICON_WIFI: default: return s_icon_wifi_full; } } static const uint8_t *wifi_bitmap(int8_t rssi) { if (rssi >= -55) { return s_icon_wifi_full; } if (rssi >= -67) { return s_icon_wifi_three; } if (rssi >= -75) { return s_icon_wifi_two; } return s_icon_wifi_one; } static void format_compact_u64(uint64_t value, char output[8]) { if (value < 1000U) { (void)snprintf(output, 8U, "%u", (unsigned int)value); } else if (value < 1000000U) { (void)snprintf(output, 8U, "%uk", (unsigned int)(value / 1000U)); } else if (value < 1000000000U) { (void)snprintf(output, 8U, "%uM", (unsigned int)(value / 1000000U)); } else if (value < 1000000000000ULL) { (void)snprintf(output, 8U, "%uG", (unsigned int)(value / 1000000000U)); } else { memcpy(output, "999G+", sizeof("999G+")); } } static void format_ssid(const char *ssid, char output[9]) { size_t length = strlen(ssid); if (length <= 8U) { (void)snprintf(output, 9U, "%s", ssid); return; } memcpy(output, ssid, 7U); output[7] = '~'; output[8] = '\0'; } static bool diagnostic_hold_is_active(TickType_t now) { bool active; portENTER_CRITICAL(&s_timing_mux); if (s_diagnostic_hold_active && (now - s_diagnostic_hold_started) >= pdMS_TO_TICKS(LOCAL_STATUS_UI_DIAGNOSTIC_HOLD_MS)) { s_diagnostic_hold_active = false; } active = s_diagnostic_hold_active; portEXIT_CRITICAL(&s_timing_mux); return active; } static bool consume_external_activity(uint32_t *observed_sequence) { uint32_t sequence; portENTER_CRITICAL(&s_timing_mux); sequence = s_external_activity_sequence; portEXIT_CRITICAL(&s_timing_mux); if (sequence == *observed_sequence) { return false; } *observed_sequence = sequence; return true; } static void draw_bitmap(local_display_panel_t panel, uint8_t x, uint8_t y, const uint8_t bitmap[LOCAL_STATUS_UI_ICON_SIZE]) { for (uint8_t row = 0U; row < LOCAL_STATUS_UI_ICON_SIZE; ++row) { for (uint8_t column = 0U; column < LOCAL_STATUS_UI_ICON_SIZE; ++column) { if ((bitmap[row] & (uint8_t)(0x80U >> column)) != 0U) { local_display_frame_set_pixel(panel, (uint8_t)(x + column), (uint8_t)(y + row), true); } } } } static void draw_content_item(local_status_icon_t icon, uint8_t y, const char *text) { draw_bitmap(LOCAL_DISPLAY_PANEL_CONTENT, 0U, y, icon_bitmap(icon)); local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 10U, y, text); } 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 bool wifi_is_active(const local_status_snapshot_t *snapshot) { if (!snapshot->wifi_available || !snapshot->wifi.started) { return false; } return snapshot->wifi.state == WIFI_MANAGER_STATE_ONLINE || snapshot->wifi.state == WIFI_MANAGER_STATE_AP_ONLY || snapshot->wifi.ap_running; } static const uint8_t *wifi_snapshot_bitmap(const local_status_snapshot_t *snapshot) { if (snapshot->wifi_available && snapshot->wifi.state == WIFI_MANAGER_STATE_ONLINE) { return wifi_bitmap(snapshot->wifi.sta_rssi); } if (snapshot->wifi_available && snapshot->wifi.ap_running) { return s_icon_access_point; } return s_icon_wifi_full; } static void wifi_identity(const local_status_snapshot_t *snapshot, const char **ssid, uint8_t *channel) { if (snapshot->wifi_available && snapshot->wifi.sta_ssid_len > 0U && snapshot->wifi.state == WIFI_MANAGER_STATE_ONLINE) { *ssid = snapshot->wifi.sta_ssid; *channel = snapshot->wifi.sta_channel; return; } if (snapshot->wifi_available && snapshot->wifi.ap_ssid_len > 0U && snapshot->wifi.ap_running) { *ssid = snapshot->wifi.ap_ssid; *channel = snapshot->wifi.ap_channel; return; } *ssid = "none"; *channel = 0U; } static void render_status_slot(uint8_t slot, const uint8_t bitmap[LOCAL_STATUS_UI_ICON_SIZE], bool active, const char *value) { if (!active) { return; } uint8_t x = (uint8_t)(slot * LOCAL_STATUS_UI_STATUS_SLOT_WIDTH + 5U); draw_bitmap(LOCAL_DISPLAY_PANEL_STATUS, x, 0U, bitmap); if (value != NULL) { size_t length = strlen(value); uint8_t text_x = (uint8_t)(slot * LOCAL_STATUS_UI_STATUS_SLOT_WIDTH); if (length < 3U) { text_x = (uint8_t)(text_x + (3U - length) * 3U); } local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_STATUS, text_x, 8U, value); } } static void render_status_panel(const local_status_snapshot_t *snapshot) { char value[4]; bool wifi_active = wifi_is_active(snapshot); bool usb_active = snapshot->usb_available && snapshot->usb.attached && snapshot->usb.dtr; bool web_active = snapshot->web_serial_available && snapshot->web_serial.active_sessions > 0U; bool ssh_active = snapshot->ssh_available && snapshot->ssh.active_sessions > 0U; bool clients_active = snapshot->broker_available && snapshot->broker.connected_clients > 0U; bool alert_active = snapshot_has_alert(snapshot); render_status_slot(0U, s_icon_serial, snapshot->serial_running, snapshot->serial_running ? "On" : NULL); (void)snprintf(value, sizeof(value), "%u", (unsigned int)(snapshot->wifi.sta_channel != 0U ? snapshot->wifi.sta_channel : snapshot->wifi.ap_channel)); bool station_online = snapshot->wifi_available && snapshot->wifi.state == WIFI_MANAGER_STATE_ONLINE; const uint8_t *wifi_icon = station_online ? wifi_bitmap(snapshot->wifi.sta_rssi) : (snapshot->wifi.ap_running ? s_icon_access_point : s_icon_wifi_full); render_status_slot(1U, wifi_icon, wifi_active, wifi_active ? value : NULL); render_status_slot(2U, s_icon_usb, usb_active, usb_active ? (snapshot->usb.writer ? "Wr" : "Ob") : NULL); (void)snprintf(value, sizeof(value), "%u", (unsigned int)snapshot->web_serial.active_sessions); render_status_slot(3U, s_icon_web, web_active, web_active ? value : NULL); (void)snprintf(value, sizeof(value), "%u", (unsigned int)snapshot->ssh.active_sessions); render_status_slot(4U, s_icon_ssh, ssh_active, ssh_active ? value : NULL); (void)snprintf(value, sizeof(value), "%u", (unsigned int)snapshot->broker.connected_clients); render_status_slot(5U, s_icon_clients, clients_active, clients_active ? value : NULL); render_status_slot(6U, s_icon_alert, alert_active, alert_active ? "!" : NULL); } static void render_overview_page(const local_status_snapshot_t *snapshot) { char line[LOCAL_STATUS_UI_TEXT_CAPACITY]; char address[16]; char ssid_display[9]; const char *ssid; uint8_t channel; local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 0U, "Overview"); if (snapshot->serial_config_available) { format_text(line, "Serial:%" PRIu32 " %s%c%s", snapshot->serial_config.baud_rate, serial_config_data_bits_to_string(snapshot->serial_config.data_bits), parity_letter(snapshot->serial_config.parity), serial_config_stop_bits_to_string(snapshot->serial_config.stop_bits)); } else { format_text(line, "Serial:Unavailable"); } draw_content_item(LOCAL_STATUS_ICON_SERIAL, 8U, line); format_text(line, "Clients:%u Wr:%s", snapshot->broker_available ? snapshot->broker.connected_clients : 0U, writer_type_to_string(snapshot)); draw_content_item(LOCAL_STATUS_ICON_CLIENTS, 16U, line); wifi_identity(snapshot, &ssid, &channel); format_ssid(ssid, ssid_display); format_text(line, "WiFi:%s Ch:%u", ssid_display, channel); draw_bitmap(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 24U, wifi_snapshot_bitmap(snapshot)); local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 10U, 24U, line); format_ipv4(snapshot->wifi.ip, address); format_text(line, "IP:%s", address); local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 10U, 32U, line); format_text(line, "Web:%u SSH:%u USB:%u", snapshot->web_serial_available ? snapshot->web_serial.active_sessions : 0U, snapshot->ssh_available ? snapshot->ssh.active_sessions : 0U, snapshot->usb_available && snapshot->usb.dtr ? 1U : 0U); draw_content_item(LOCAL_STATUS_ICON_WEB, 40U, line); } static void render_rs232_page(const local_status_snapshot_t *snapshot) { char line[LOCAL_STATUS_UI_TEXT_CAPACITY]; char rx[8]; char tx[8]; char errors[8]; 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, "RS-232 / modem"); if (snapshot->serial_config_available) { format_text(line, "Mode:%" PRIu32 " %s%c%s", snapshot->serial_config.baud_rate, serial_config_data_bits_to_string(snapshot->serial_config.data_bits), parity_letter(snapshot->serial_config.parity), serial_config_stop_bits_to_string(snapshot->serial_config.stop_bits)); } else { format_text(line, "Mode:Unavailable"); } draw_content_item(LOCAL_STATUS_ICON_SERIAL, 8U, line); format_text(line, "Flow:%s", snapshot->serial_config_available ? serial_config_flow_control_to_string( snapshot->serial_config.flow_control) : "Unavailable"); local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 10U, 16U, line); format_text(line, "DCD:%d DSR:%d CTS:%d", snapshot->modem.dcd, snapshot->modem.dsr, snapshot->modem.cts); local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 10U, 24U, line); format_compact_u64(snapshot->serial_counters.rx_bytes, rx); format_text(line, "Valid:%s RX:%s", snapshot->modem.valid ? "Yes" : "No", rx); local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 10U, 32U, line); format_compact_u64(snapshot->serial_counters.tx_sent_to_uart_bytes, tx); format_compact_u64(faults + snapshot->serial_counters.rx_dropped_bytes + snapshot->serial_counters.tx_dropped_bytes, errors); format_text(line, "TX:%s Err:%s", tx, errors); local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 10U, 40U, line); } static local_status_icon_t client_icon(session_broker_client_type_t type) { switch (type) { case SESSION_BROKER_CLIENT_USB: return LOCAL_STATUS_ICON_USB; case SESSION_BROKER_CLIENT_WEB: return LOCAL_STATUS_ICON_WEB; case SESSION_BROKER_CLIENT_SSH: return LOCAL_STATUS_ICON_SSH; default: return LOCAL_STATUS_ICON_CLIENTS; } } static void render_broker_page(const local_status_snapshot_t *snapshot) { char line[LOCAL_STATUS_UI_TEXT_CAPACITY]; format_text(line, "Broker clients:%u", snapshot->broker_available ? snapshot->broker.connected_clients : 0U); local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 0U, line); for (size_t i = 0U; i < LOCAL_STATUS_UI_CLIENT_ROWS && i < snapshot->client_count; ++i) { const session_broker_client_snapshot_t *client = &snapshot->clients[i]; format_text(line, "%s:%s Q:%u", client->is_writer ? "Wr" : "Ob", broker_type_to_string(client->type), (unsigned int)client->output_bytes_pending); draw_content_item(client_icon(client->type), (uint8_t)((i + 1U) * 8U), line); } if (snapshot->broker_available) { char drops[8]; char events[8]; format_compact_u64(snapshot->broker.counters.output_dropped_bytes, drops); format_compact_u64(snapshot->broker.counters.event_drops, events); format_text(line, "Drop:%s Events:%s", drops, events); local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 10U, 40U, line); } } static void render_network_page(const local_status_snapshot_t *snapshot) { char line[LOCAL_STATUS_UI_TEXT_CAPACITY]; char address[16]; char ssid_display[9]; const char *ssid; uint8_t channel; local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 0U, "Network / services"); wifi_identity(snapshot, &ssid, &channel); format_ssid(ssid, ssid_display); format_text(line, "WiFi:%s Ch:%u", ssid_display, channel); draw_bitmap(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 8U, wifi_snapshot_bitmap(snapshot)); local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 10U, 8U, line); format_ipv4(snapshot->wifi.ip, address); format_text(line, "IP:%s", address); local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 10U, 16U, line); format_text(line, "AP:%s Clients:%u", on_off(snapshot->wifi.ap_running), snapshot->wifi.ap_client_count); local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 10U, 24U, line); format_text(line, "HTTPS:%s Web:%u", on_off(snapshot->web_available && snapshot->web.running), snapshot->web_serial_available ? snapshot->web_serial.active_sessions : 0U); draw_content_item(LOCAL_STATUS_ICON_WEB, 32U, line); format_text(line, "SSH:%s Sessions:%u", on_off(snapshot->ssh_available && snapshot->ssh.running), snapshot->ssh_available ? snapshot->ssh.active_sessions : 0U); draw_content_item(LOCAL_STATUS_ICON_SSH, 40U, line); } static bool network_available(const local_status_snapshot_t *snapshot) { return snapshot->wifi_available && (snapshot->wifi.state == WIFI_MANAGER_STATE_ONLINE || snapshot->wifi.ap_running); } static const char *action_label(local_status_action_t action, const local_status_snapshot_t *snapshot) { switch (action) { case LOCAL_STATUS_ACTION_SERIAL: return snapshot->serial_running ? "Serial:stop" : "Serial:start"; case LOCAL_STATUS_ACTION_WIFI_POWER: return snapshot->wifi_available && snapshot->wifi.started ? "WiFi:stop" : "WiFi:start"; case LOCAL_STATUS_ACTION_WIFI_RECONNECT: return "WiFi:reconnect"; case LOCAL_STATUS_ACTION_WEB: return snapshot->web_available && snapshot->web.running ? "HTTPS:stop" : "HTTPS:start"; case LOCAL_STATUS_ACTION_SSH: return snapshot->ssh_available && snapshot->ssh.running ? "SSH:stop" : "SSH:start"; case LOCAL_STATUS_ACTION_REVOKE_WRITER: return "Writer:revoke"; case LOCAL_STATUS_ACTION_DISPLAY_OFF: return "Display:off"; case LOCAL_STATUS_ACTION_REBOOT: return "Device:reboot"; default: return "Unavailable"; } } static bool action_is_stop(local_status_action_t action, const local_status_snapshot_t *snapshot) { switch (action) { case LOCAL_STATUS_ACTION_SERIAL: return snapshot->serial_running; case LOCAL_STATUS_ACTION_WIFI_POWER: return snapshot->wifi_available && snapshot->wifi.started; case LOCAL_STATUS_ACTION_WEB: return snapshot->web_available && snapshot->web.running; case LOCAL_STATUS_ACTION_SSH: return snapshot->ssh_available && snapshot->ssh.running; default: return false; } } static const char *confirmed_action_label(const local_status_ui_state_t *state) { switch (state->confirmed_action) { case LOCAL_STATUS_ACTION_SERIAL: return state->confirmed_stop ? "Serial:stop" : "Serial:start"; case LOCAL_STATUS_ACTION_WIFI_POWER: return state->confirmed_stop ? "WiFi:stop" : "WiFi:start"; case LOCAL_STATUS_ACTION_WIFI_RECONNECT: return "WiFi:reconnect"; case LOCAL_STATUS_ACTION_WEB: return state->confirmed_stop ? "HTTPS:stop" : "HTTPS:start"; case LOCAL_STATUS_ACTION_SSH: return state->confirmed_stop ? "SSH:stop" : "SSH:start"; case LOCAL_STATUS_ACTION_REVOKE_WRITER: return "Writer:revoke"; case LOCAL_STATUS_ACTION_DISPLAY_OFF: return "Display:off"; case LOCAL_STATUS_ACTION_REBOOT: return "Device:reboot"; default: return "Unavailable"; } } static bool action_requires_confirmation(local_status_action_t action, const local_status_snapshot_t *snapshot) { switch (action) { case LOCAL_STATUS_ACTION_SERIAL: return snapshot->serial_running; case LOCAL_STATUS_ACTION_WIFI_POWER: return snapshot->wifi_available && snapshot->wifi.started; case LOCAL_STATUS_ACTION_WIFI_RECONNECT: return true; case LOCAL_STATUS_ACTION_WEB: return snapshot->web_available && snapshot->web.running; case LOCAL_STATUS_ACTION_SSH: return snapshot->ssh_available && snapshot->ssh.running; case LOCAL_STATUS_ACTION_REVOKE_WRITER: case LOCAL_STATUS_ACTION_REBOOT: return true; case LOCAL_STATUS_ACTION_DISPLAY_OFF: default: return false; } } static void render_menu_page(const local_status_ui_state_t *state, const local_status_snapshot_t *snapshot) { local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 0U, "Controls"); for (uint8_t row = 0U; row < LOCAL_STATUS_UI_MENU_VISIBLE_ROWS; ++row) { uint8_t index = (uint8_t)((state->menu_action + row) % LOCAL_STATUS_ACTION_COUNT); char line[LOCAL_STATUS_UI_TEXT_CAPACITY]; format_text(line, "%c%s", row == 0U ? '>' : ' ', action_label((local_status_action_t)index, snapshot)); local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, (uint8_t)((row + 1U) * 8U), line); } } static void render_confirm_page(const local_status_ui_state_t *state, const local_status_snapshot_t *snapshot) { char line[LOCAL_STATUS_UI_TEXT_CAPACITY]; (void)snapshot; format_text(line, "Confirm:%s", confirmed_action_label(state)); local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 0U, line); switch (state->confirmed_action) { case LOCAL_STATUS_ACTION_REVOKE_WRITER: local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 8U, "Writer loses control"); break; case LOCAL_STATUS_ACTION_REBOOT: local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 8U, "Unsaved RAM is lost"); break; case LOCAL_STATUS_ACTION_WIFI_POWER: local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 8U, "Network access ends"); break; default: local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 8U, "Active service changes"); break; } local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 24U, "Hold select 2s"); local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 40U, "Prev:cancel"); } static void render_result_page(const local_status_ui_state_t *state, const local_status_snapshot_t *snapshot) { char line[LOCAL_STATUS_UI_TEXT_CAPACITY]; (void)snapshot; format_text(line, "Action:%s", confirmed_action_label(state)); local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 0U, line); if (state->restart_pending) { local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 16U, "Restarting..."); } else if (state->result_error == ESP_OK) { local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 16U, state->result_requested ? "Requested" : "Completed"); } else { format_text(line, "Error:%s", esp_err_to_name(state->result_error)); local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 16U, line); } local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 40U, "Select:menu"); } static bool render_ui(const local_status_ui_state_t *state, const local_status_snapshot_t *snapshot) { if (local_display_frame_begin() != ESP_OK) { return false; } if (diagnostic_hold_is_active(xTaskGetTickCount())) { local_display_frame_cancel(); return false; } local_display_frame_clear_all(); render_status_panel(snapshot); switch (state->mode) { case LOCAL_STATUS_MODE_STATUS: switch (state->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 false; } break; case LOCAL_STATUS_MODE_MENU: render_menu_page(state, snapshot); break; case LOCAL_STATUS_MODE_CONFIRM: render_confirm_page(state, snapshot); break; case LOCAL_STATUS_MODE_RESULT: render_result_page(state, snapshot); break; default: local_display_frame_cancel(); return false; } 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)); } return error == ESP_OK; } static bool poll_button_event(local_status_button_state_t states[LOCAL_STATUS_BUTTON_COUNT], bool *chord_latched, local_status_button_event_t *event) { TickType_t now = xTaskGetTickCount(); bool pressed_edge[LOCAL_STATUS_BUTTON_COUNT] = {0}; bool short_release[LOCAL_STATUS_BUTTON_COUNT] = {0}; uint8_t pressed_count = 0U; 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) { states[i].pressed_since = now; states[i].long_emitted = false; pressed_edge[i] = true; } else if (!states[i].long_emitted && !states[i].suppress_gesture) { short_release[i] = true; } if (!states[i].stable_pressed) { states[i].suppress_gesture = false; } } if (states[i].stable_pressed) { ++pressed_count; } } if (pressed_count > 1U) { *chord_latched = true; return false; } if (*chord_latched) { if (pressed_count == 0U) { *chord_latched = false; } return false; } for (size_t i = 0U; i < LOCAL_STATUS_BUTTON_COUNT; ++i) { if (pressed_edge[i]) { *event = (local_status_button_event_t){ .button = (local_status_button_t)i, .type = LOCAL_STATUS_BUTTON_EVENT_PRESS, }; return true; } } for (size_t i = 0U; i < LOCAL_STATUS_BUTTON_COUNT; ++i) { if (states[i].stable_pressed && !states[i].long_emitted && !states[i].suppress_gesture && (now - states[i].pressed_since) >= pdMS_TO_TICKS(LOCAL_STATUS_UI_CONFIRM_HOLD_MS)) { states[i].long_emitted = true; *event = (local_status_button_event_t){ .button = (local_status_button_t)i, .type = LOCAL_STATUS_BUTTON_EVENT_LONG_REACHED, }; return true; } } for (size_t i = 0U; i < LOCAL_STATUS_BUTTON_COUNT; ++i) { if (short_release[i]) { *event = (local_status_button_event_t){ .button = (local_status_button_t)i, .type = LOCAL_STATUS_BUTTON_EVENT_SHORT_RELEASE, }; return true; } } return false; } static void sync_display_power(local_status_power_t *power) { local_display_snapshot_t snapshot; if (local_display_get_snapshot(&snapshot) != ESP_OK) { return; } if (!snapshot.initialized) { *power = LOCAL_STATUS_POWER_OFF; } else if (snapshot.contrast <= LOCAL_STATUS_UI_DIM_CONTRAST) { *power = LOCAL_STATUS_POWER_DIMMED; } else { *power = LOCAL_STATUS_POWER_ACTIVE; } } static bool wake_display(local_status_power_t *power) { local_display_snapshot_t snapshot; bool initialized = local_display_get_snapshot(&snapshot) == ESP_OK && snapshot.initialized; esp_err_t error = ESP_OK; if (*power == LOCAL_STATUS_POWER_OFF || !initialized) { error = local_display_start(); } else if (*power == LOCAL_STATUS_POWER_DIMMED || snapshot.contrast != LOCAL_STATUS_UI_ACTIVE_CONTRAST) { error = local_display_set_contrast(LOCAL_STATUS_UI_ACTIVE_CONTRAST); } if (error != ESP_OK) { ESP_LOGW(TAG, "Display wake failed: %s", esp_err_to_name(error)); return false; } *power = LOCAL_STATUS_POWER_ACTIVE; return true; } static esp_err_t stop_display_from_control(void) { if (s_diagnostic_gate == NULL || xSemaphoreTake(s_diagnostic_gate, pdMS_TO_TICKS(100U)) != pdTRUE) { return ESP_ERR_TIMEOUT; } esp_err_t error = diagnostic_hold_is_active(xTaskGetTickCount()) ? ESP_ERR_INVALID_STATE : local_display_stop(); (void)xSemaphoreGive(s_diagnostic_gate); return error; } static void execute_action(local_status_ui_state_t *state, const local_status_snapshot_t *snapshot, local_status_power_t *power) { local_status_action_t action = state->confirmed_action; esp_err_t error = ESP_OK; bool requested = false; switch (action) { case LOCAL_STATUS_ACTION_SERIAL: if (snapshot->serial_running != state->confirmed_stop) { error = ESP_ERR_INVALID_STATE; } else { error = state->confirmed_stop ? serial_service_stop() : serial_service_start(); } break; case LOCAL_STATUS_ACTION_WIFI_POWER: if (!snapshot->wifi_available || snapshot->wifi.started != state->confirmed_stop) { error = ESP_ERR_INVALID_STATE; } else { error = state->confirmed_stop ? wifi_manager_stop() : wifi_manager_start(); requested = error == ESP_OK; } break; case LOCAL_STATUS_ACTION_WIFI_RECONNECT: error = snapshot->wifi_available && snapshot->wifi.started ? wifi_manager_reconnect() : ESP_ERR_INVALID_STATE; requested = error == ESP_OK; break; case LOCAL_STATUS_ACTION_WEB: if (!snapshot->web_available || snapshot->web.running != state->confirmed_stop) { error = ESP_ERR_INVALID_STATE; } else { error = state->confirmed_stop ? web_server_stop() : (network_available(snapshot) ? web_server_start() : ESP_ERR_INVALID_STATE); } break; case LOCAL_STATUS_ACTION_SSH: if (!snapshot->ssh_available || snapshot->ssh.running != state->confirmed_stop) { error = ESP_ERR_INVALID_STATE; } else { error = state->confirmed_stop ? ssh_transport_stop() : (network_available(snapshot) ? ssh_transport_start() : ESP_ERR_INVALID_STATE); } break; case LOCAL_STATUS_ACTION_REVOKE_WRITER: error = session_broker_force_release_writer(state->expected_writer_id); break; case LOCAL_STATUS_ACTION_DISPLAY_OFF: error = stop_display_from_control(); if (error == ESP_OK) { *power = LOCAL_STATUS_POWER_OFF; } break; case LOCAL_STATUS_ACTION_REBOOT: state->restart_pending = true; break; default: error = ESP_ERR_INVALID_ARG; break; } state->result_error = error; state->result_requested = requested; state->mode = LOCAL_STATUS_MODE_RESULT; state->mode_since = xTaskGetTickCount(); } static void handle_button_event(local_status_ui_state_t *state, const local_status_snapshot_t *snapshot, const local_status_button_event_t *event, local_status_button_state_t buttons[LOCAL_STATUS_BUTTON_COUNT], local_status_power_t *power, bool *render_requested) { if (event->type == LOCAL_STATUS_BUTTON_EVENT_PRESS && *power != LOCAL_STATUS_POWER_ACTIVE) { buttons[event->button].suppress_gesture = true; *render_requested = wake_display(power); return; } if (event->type == LOCAL_STATUS_BUTTON_EVENT_PRESS) { return; } if (event->type == LOCAL_STATUS_BUTTON_EVENT_LONG_REACHED) { if (event->button == LOCAL_STATUS_BUTTON_PREVIOUS) { state->mode = LOCAL_STATUS_MODE_STATUS; state->page = LOCAL_STATUS_PAGE_OVERVIEW; state->mode_since = xTaskGetTickCount(); *render_requested = true; } else if (event->button == LOCAL_STATUS_BUTTON_SELECT && state->mode == LOCAL_STATUS_MODE_CONFIRM) { execute_action(state, snapshot, power); *render_requested = true; } return; } if (event->type != LOCAL_STATUS_BUTTON_EVENT_SHORT_RELEASE) { return; } switch (state->mode) { case LOCAL_STATUS_MODE_STATUS: if (event->button == LOCAL_STATUS_BUTTON_PREVIOUS) { state->page = state->page == LOCAL_STATUS_PAGE_OVERVIEW ? LOCAL_STATUS_PAGE_COUNT - 1U : (local_status_page_t)(state->page - 1U); } else if (event->button == LOCAL_STATUS_BUTTON_NEXT) { state->page = (local_status_page_t)((state->page + 1U) % LOCAL_STATUS_PAGE_COUNT); } else if (event->button == LOCAL_STATUS_BUTTON_SELECT) { state->mode = LOCAL_STATUS_MODE_MENU; state->mode_since = xTaskGetTickCount(); } *render_requested = true; break; case LOCAL_STATUS_MODE_MENU: if (event->button == LOCAL_STATUS_BUTTON_PREVIOUS) { state->menu_action = state->menu_action == LOCAL_STATUS_ACTION_SERIAL ? LOCAL_STATUS_ACTION_COUNT - 1U : (local_status_action_t)(state->menu_action - 1U); } else if (event->button == LOCAL_STATUS_BUTTON_NEXT) { state->menu_action = (local_status_action_t)((state->menu_action + 1U) % LOCAL_STATUS_ACTION_COUNT); } else if (event->button == LOCAL_STATUS_BUTTON_SELECT) { state->confirmed_action = state->menu_action; state->confirmed_stop = action_is_stop(state->menu_action, snapshot); state->expected_writer_id = snapshot->broker_available ? snapshot->broker.writer_id : SESSION_BROKER_NO_CLIENT; if (action_requires_confirmation(state->menu_action, snapshot)) { state->mode = LOCAL_STATUS_MODE_CONFIRM; state->mode_since = xTaskGetTickCount(); } else { execute_action(state, snapshot, power); } } *render_requested = true; break; case LOCAL_STATUS_MODE_CONFIRM: if (event->button == LOCAL_STATUS_BUTTON_PREVIOUS) { state->mode = LOCAL_STATUS_MODE_MENU; state->mode_since = xTaskGetTickCount(); *render_requested = true; } break; case LOCAL_STATUS_MODE_RESULT: if (event->button == LOCAL_STATUS_BUTTON_PREVIOUS) { state->mode = LOCAL_STATUS_MODE_STATUS; state->page = LOCAL_STATUS_PAGE_OVERVIEW; } else if (event->button == LOCAL_STATUS_BUTTON_SELECT) { state->mode = LOCAL_STATUS_MODE_MENU; } state->mode_since = xTaskGetTickCount(); *render_requested = true; break; default: break; } } static void update_display_power(local_status_power_t *power, TickType_t now, TickType_t last_activity) { TickType_t inactive = now - last_activity; bool should_stop = *power != LOCAL_STATUS_POWER_OFF && inactive >= pdMS_TO_TICKS(LOCAL_STATUS_UI_OFF_DELAY_MS); bool should_dim = *power == LOCAL_STATUS_POWER_ACTIVE && inactive >= pdMS_TO_TICKS(LOCAL_STATUS_UI_DIM_DELAY_MS); if ((!should_stop && !should_dim) || s_diagnostic_gate == NULL || xSemaphoreTake(s_diagnostic_gate, pdMS_TO_TICKS(100U)) != pdTRUE) { return; } /* A diagnostic publishes its hold before waiting on this gate. */ if (diagnostic_hold_is_active(xTaskGetTickCount())) { (void)xSemaphoreGive(s_diagnostic_gate); return; } if (should_stop) { esp_err_t error = local_display_stop(); if (error == ESP_OK) { *power = LOCAL_STATUS_POWER_OFF; } } else { esp_err_t error = local_display_set_contrast(LOCAL_STATUS_UI_DIM_CONTRAST); if (error == ESP_OK) { *power = LOCAL_STATUS_POWER_DIMMED; } } (void)xSemaphoreGive(s_diagnostic_gate); } static void local_status_ui_task(void *context) { (void)context; local_status_ui_state_t state = { .mode = LOCAL_STATUS_MODE_STATUS, .page = LOCAL_STATUS_PAGE_OVERVIEW, .menu_action = LOCAL_STATUS_ACTION_SERIAL, .confirmed_action = LOCAL_STATUS_ACTION_SERIAL, }; local_status_power_t power = LOCAL_STATUS_POWER_ACTIVE; local_status_button_state_t buttons[LOCAL_STATUS_BUTTON_COUNT] = {0}; TickType_t last_render = 0U; TickType_t last_activity = xTaskGetTickCount(); uint32_t observed_activity_sequence = 0U; bool chord_latched = false; state.mode_since = last_activity; 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 = last_activity; buttons[i].pressed_since = last_activity; /* A button held during boot cannot become a confirmation gesture. */ buttons[i].long_emitted = pressed; buttons[i].suppress_gesture = pressed; } for (;;) { local_status_button_event_t event; bool have_event = poll_button_event(buttons, &chord_latched, &event); bool render_requested = false; TickType_t now = xTaskGetTickCount(); bool diagnostics_held = diagnostic_hold_is_active(now); if (consume_external_activity(&observed_activity_sequence)) { last_activity = now; } sync_display_power(&power); bool wake_gesture = false; if (power != LOCAL_STATUS_POWER_ACTIVE) { for (size_t i = 0U; i < LOCAL_STATUS_BUTTON_COUNT; ++i) { if (buttons[i].stable_pressed) { buttons[i].suppress_gesture = true; wake_gesture = true; } } if (wake_gesture && !diagnostics_held) { last_activity = now; render_requested = wake_display(&power); } } if (have_event && !wake_gesture) { last_activity = now; if (!diagnostics_held) { local_status_snapshot_t snapshot; collect_snapshot(&snapshot); handle_button_event(&state, &snapshot, &event, buttons, &power, &render_requested); } } if (state.mode == LOCAL_STATUS_MODE_CONFIRM && (now - state.mode_since) >= pdMS_TO_TICKS(LOCAL_STATUS_UI_MENU_TIMEOUT_MS)) { state.mode = LOCAL_STATUS_MODE_MENU; state.mode_since = now; render_requested = true; } else if (state.mode == LOCAL_STATUS_MODE_MENU && (now - last_activity) >= pdMS_TO_TICKS(LOCAL_STATUS_UI_MENU_TIMEOUT_MS)) { state.mode = LOCAL_STATUS_MODE_STATUS; state.page = LOCAL_STATUS_PAGE_OVERVIEW; state.mode_since = now; render_requested = true; } else if (state.mode == LOCAL_STATUS_MODE_RESULT && !state.restart_pending && (now - state.mode_since) >= pdMS_TO_TICKS(LOCAL_STATUS_UI_RESULT_TIMEOUT_MS)) { state.mode = LOCAL_STATUS_MODE_MENU; state.mode_since = now; render_requested = true; } update_display_power(&power, now, last_activity); diagnostics_held = diagnostic_hold_is_active(now); bool rendered = false; if (power != LOCAL_STATUS_POWER_OFF && !diagnostics_held && (render_requested || (now - last_render) >= pdMS_TO_TICKS(LOCAL_STATUS_UI_REFRESH_MS))) { local_status_snapshot_t snapshot; collect_snapshot(&snapshot); rendered = render_ui(&state, &snapshot); last_render = now; } if (state.restart_pending && (rendered || (now - state.mode_since) >= pdMS_TO_TICKS(2000U))) { vTaskDelay(pdMS_TO_TICKS(500U)); esp_restart(); } vTaskDelay(pdMS_TO_TICKS(LOCAL_STATUS_UI_BUTTON_POLL_MS)); } } void local_status_ui_hold_for_diagnostics(void) { portENTER_CRITICAL(&s_timing_mux); s_diagnostic_hold_started = xTaskGetTickCount(); s_diagnostic_hold_active = true; ++s_external_activity_sequence; portEXIT_CRITICAL(&s_timing_mux); /* Wait for any dim/off operation that began before the hold was published. */ if (s_diagnostic_gate != NULL && xSemaphoreTake(s_diagnostic_gate, pdMS_TO_TICKS(500U)) == pdTRUE) { (void)xSemaphoreGive(s_diagnostic_gate); } } esp_err_t local_status_ui_start(void) { if (s_task != NULL || s_diagnostic_gate != NULL) { return ESP_ERR_INVALID_STATE; } s_diagnostic_gate = xSemaphoreCreateMutexStatic(&s_diagnostic_gate_storage); if (s_diagnostic_gate == NULL) { return ESP_ERR_NO_MEM; } 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; vSemaphoreDelete(s_diagnostic_gate); s_diagnostic_gate = NULL; return ESP_ERR_NO_MEM; } ESP_LOGI(TAG, "Read-only status UI started at %u Hz maximum; dim/off after 5/10 minutes", 1000U / LOCAL_STATUS_UI_REFRESH_MS); return ESP_OK; }