1361 lines
50 KiB
C
1361 lines
50 KiB
C
/* SPDX-License-Identifier: GPL-3.0-only */
|
|
/* Bounded read-only OLED status pages. */
|
|
|
|
#include "local_status_ui.h"
|
|
|
|
#include <inttypes.h>
|
|
#include <stdarg.h>
|
|
#include <stdio.h>
|
|
#include <string.h>
|
|
|
|
#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;
|
|
}
|