Add local OLED recovery controls
This commit is contained in:
@@ -140,6 +140,23 @@ For the OLED-aging policy, leave all three buttons untouched and verify:
|
||||
|
||||
`debug display status` may verify an individual transition in a separate timing run. Every display diagnostic counts as activity and holds normal UI rendering for 30 seconds, so do not invoke it between the five- and ten-minute observations of one continuous run. Record a missing, stale, clipped, or implausible status value or incorrect dim/off/wake transition before Phase 7C is marked complete.
|
||||
|
||||
### 9. Local controls (Phase 7D)
|
||||
|
||||
From a status page, use a short Select press to open **Controls**. Previous/next selects an item; Select activates it. The menu includes serial start/stop, Wi-Fi start/stop/reconnect, HTTPS start/stop, SSH start/stop, writer revocation, display off, and reboot. It must never show or change credentials, Wi-Fi profiles, serial framing, TLS/SSH keys, or I²C scan controls.
|
||||
|
||||
Stopping a service, Wi-Fi reconnect, writer revocation, and reboot open a confirmation page. Verify that:
|
||||
|
||||
1. Previous/back cancels the confirmation without changing the selected service or writer.
|
||||
2. A short Select press does not execute the action.
|
||||
3. Only one continuous two-second Select hold executes the action once; releasing it does not repeat the action.
|
||||
4. Any simultaneous button chord is ignored and cannot confirm an action.
|
||||
5. An untouched confirmation expires to the menu after 30 seconds.
|
||||
6. A dimmed/off OLED consumes the first button press for wake only; its later release and hold must not navigate or confirm an action.
|
||||
|
||||
For Wi-Fi lifecycle calls, confirm the immediate result says `Requested`, then use the status pages to observe the asynchronous state change. HTTPS and SSH starts must fail cleanly when neither station nor AP networking is available. Writer revocation must only release the current writer—never assign a replacement—and the UI must never appear as a broker client. For reboot, observe `Restarting...`, then verify all normal boot services and UART0 recovery return.
|
||||
|
||||
Run these checks with UART0 available. Repeat appropriate stop/revoke cases with USB CDC, WebSocket, and SSH clients connected; verify the intended session/service is interrupted, unrelated recovery paths remain responsive, and no action injects serial data.
|
||||
|
||||
## Configuration A: data and handshake pairs
|
||||
|
||||
Connect the following pairs:
|
||||
|
||||
+7
-5
@@ -184,7 +184,7 @@ The order below is the current plan. Phase 7 is in progress; later phases remain
|
||||
|
||||
Add a standalone local status/control interface without making it a dependency of the serial core. The planning baseline uses a 128×64 dual-color monochrome I²C OLED sold with an SSD1315 controller. Phase 7A confirmed SSD1306-compatible operation, 7-bit I²C address `0x3c`, orientation, column mapping, contrast/inversion behavior, button inputs, and the physical color geometry on the selected hardware.
|
||||
|
||||
Phase 7A diagnostics and target-hardware electrical validation are complete. Phases 7B and 7C are complete; Phase 7 overall remains in progress (7D). Phases 7D and 7E are not complete.
|
||||
Phase 7A diagnostics and target-hardware electrical validation are complete. Phases 7B and 7C are complete; Phase 7 overall remains in progress (7D). Phase 7D is implemented and awaiting validation; Phase 7E is not complete.
|
||||
|
||||
#### Hardware baseline
|
||||
|
||||
@@ -220,10 +220,12 @@ The persistent yellow strip uses fixed-position serial, Wi-Fi-strength, USB, Web
|
||||
- To reduce OLED aging, inactivity dims contrast to `1` after five minutes and switches the panel off after ten minutes. The first debounced button press restores contrast `127` and wakes without navigating.
|
||||
- A missing/unresponsive display is nonfatal; the task remains read-only and never acquires serial writer ownership.
|
||||
- Target-hardware validation passed for the four pages, fixed active-only status icons, lowercase layout, diagnostics hold, dim/off/wake behavior, all available concurrent client interfaces, UART0 recovery, and serial traffic from 9600 through 230400 baud.
|
||||
4. **Phase 7D — Local controls — Planned**
|
||||
- Add a shallow menu for safe serial, Wi-Fi, HTTPS, SSH, writer-release, display, and reboot actions through direct service APIs.
|
||||
- Require a visible confirmation screen and a timed select hold before stopping active services, revoking a writer, rebooting, or performing another disruptive action.
|
||||
- A local display is not a serial broker client and cannot silently acquire the writer lease.
|
||||
4. **Phase 7D — Local controls — Implemented; validation pending**
|
||||
- A shallow local `Controls` menu directly invokes public serial-service, Wi-Fi, HTTPS, SSH, broker, display, and restart APIs; it never parses or injects CLI text.
|
||||
- The menu provides serial start/stop, Wi-Fi start/stop/reconnect, HTTPS start/stop, SSH start/stop, emergency current-writer revocation, display off, and reboot. It deliberately excludes serial configuration editing, credentials, Wi-Fi profile/AP changes, host-key/certificate changes, I²C scans, and assignment of the writer to another client.
|
||||
- A visible confirmation screen and one continuous two-second Select hold are required for service stops, Wi-Fi reconnect, writer revocation, and reboot. The confirmation times out after 30 seconds; previous/back cancels. A wake press is consumed and cannot navigate or confirm an action.
|
||||
- Wi-Fi lifecycle results report a queued request rather than falsely claiming an asynchronous transition has completed. HTTPS/SSH start requests require a connected station or active AP. The current writer is only ever force-released to no writer; the UI is not a broker client and cannot acquire or assign a writer lease.
|
||||
- Pending target-hardware validation: menu navigation/timeouts, each enabled action and error result, confirmation cancellation/chord rejection, wake-press suppression, and preservation of UART0 plus transport recovery.
|
||||
5. **Phase 7E — Reliability, persistence, and documentation — Planned**
|
||||
- Make the implemented contrast dim/blank timeouts configurable and persistent without making the display necessary for recovery.
|
||||
- Validate display removal, I²C NACK/timeouts, stuck buttons, queue saturation, and repeated actions.
|
||||
|
||||
@@ -37,6 +37,7 @@ static const glyph_t s_glyphs[] = {
|
||||
{'.', {0x00, 0x60, 0x60, 0x00, 0x00}},
|
||||
{'/', {0x20, 0x10, 0x08, 0x04, 0x02}},
|
||||
{':', {0x00, 0x36, 0x36, 0x00, 0x00}},
|
||||
{'>', {0x00, 0x41, 0x22, 0x14, 0x08}},
|
||||
{'?', {0x02, 0x01, 0x51, 0x09, 0x06}},
|
||||
{'_', {0x40, 0x40, 0x40, 0x40, 0x40}},
|
||||
{'0', {0x3e, 0x51, 0x49, 0x45, 0x3e}},
|
||||
|
||||
+533
-31
@@ -12,6 +12,7 @@
|
||||
#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"
|
||||
@@ -40,6 +41,10 @@
|
||||
#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";
|
||||
|
||||
@@ -64,6 +69,36 @@ typedef enum {
|
||||
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,
|
||||
@@ -78,8 +113,24 @@ 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;
|
||||
@@ -628,19 +679,183 @@ static void render_network_page(const local_status_snapshot_t *snapshot)
|
||||
draw_content_item(LOCAL_STATUS_ICON_SSH, 40U, line);
|
||||
}
|
||||
|
||||
static void render_page(local_status_page_t page, const local_status_snapshot_t *snapshot)
|
||||
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;
|
||||
return false;
|
||||
}
|
||||
if (diagnostic_hold_is_active(xTaskGetTickCount())) {
|
||||
local_display_frame_cancel();
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
local_display_frame_clear_all();
|
||||
render_status_panel(snapshot);
|
||||
switch (page) {
|
||||
switch (state->mode) {
|
||||
case LOCAL_STATUS_MODE_STATUS:
|
||||
switch (state->page) {
|
||||
case LOCAL_STATUS_PAGE_OVERVIEW:
|
||||
render_overview_page(snapshot);
|
||||
break;
|
||||
@@ -655,20 +870,38 @@ static void render_page(local_status_page_t page, const local_status_snapshot_t
|
||||
break;
|
||||
default:
|
||||
local_display_frame_cancel();
|
||||
return;
|
||||
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_buttons(local_status_button_state_t states[LOCAL_STATUS_BUTTON_COUNT],
|
||||
local_status_button_t *pressed_button)
|
||||
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 event = false;
|
||||
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;
|
||||
@@ -680,12 +913,62 @@ static bool poll_buttons(local_status_button_state_t states[LOCAL_STATUS_BUTTON_
|
||||
(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;
|
||||
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;
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -721,6 +1004,184 @@ static bool wake_display(local_status_power_t *power)
|
||||
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)
|
||||
@@ -758,59 +1219,100 @@ static void update_display_power(local_status_power_t *power,
|
||||
static void local_status_ui_task(void *context)
|
||||
{
|
||||
(void)context;
|
||||
local_status_page_t page = LOCAL_STATUS_PAGE_OVERVIEW;
|
||||
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_t button;
|
||||
bool button_event = poll_buttons(buttons, &button);
|
||||
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);
|
||||
|
||||
if (button_event) {
|
||||
last_activity = now;
|
||||
bool wake_gesture = false;
|
||||
if (power != LOCAL_STATUS_POWER_ACTIVE) {
|
||||
/* The first press wakes only, preventing accidental navigation. */
|
||||
render_requested = wake_display(&power);
|
||||
} else if (button == LOCAL_STATUS_BUTTON_PREVIOUS) {
|
||||
page = page == LOCAL_STATUS_PAGE_OVERVIEW
|
||||
? LOCAL_STATUS_PAGE_COUNT - 1U
|
||||
: (local_status_page_t)(page - 1U);
|
||||
render_requested = true;
|
||||
} else if (button == LOCAL_STATUS_BUTTON_NEXT) {
|
||||
page = (local_status_page_t)((page + 1U) % LOCAL_STATUS_PAGE_COUNT);
|
||||
render_requested = true;
|
||||
} else {
|
||||
render_requested = true;
|
||||
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);
|
||||
bool diagnostics_held = diagnostic_hold_is_active(now);
|
||||
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);
|
||||
render_page(page, &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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Read-only local status UI for the optional OLED. */
|
||||
/* Local OLED status UI and bounded, direct-API recovery controls. */
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -10,9 +10,9 @@ extern "C" {
|
||||
#endif
|
||||
|
||||
/*
|
||||
* Starts the low-priority status renderer. The task reads public service
|
||||
* snapshots only; it never becomes a broker client or changes service state.
|
||||
* The OLED and buttons are optional, so a missing display is not an error.
|
||||
* Starts the low-priority status renderer and local recovery controls. The
|
||||
* task never becomes a broker client or serial writer. The OLED and buttons
|
||||
* are optional, so a missing display is not an error.
|
||||
*/
|
||||
esp_err_t local_status_ui_start(void);
|
||||
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@ static const char *TAG = "firmware";
|
||||
|
||||
void app_main(void)
|
||||
{
|
||||
ESP_LOGI(TAG, "ESP32-S3 Serial Swiss Army Knife Phase 7C local status UI started");
|
||||
ESP_LOGI(TAG, "ESP32-S3 Serial Swiss Army Knife Phase 7D local controls started");
|
||||
|
||||
if (esp_psram_is_initialized()) {
|
||||
ESP_LOGI(TAG, "PSRAM initialized: %u bytes", (unsigned int)esp_psram_get_size());
|
||||
|
||||
@@ -518,6 +518,38 @@ esp_err_t session_broker_force_writer(session_broker_client_id_t client_id)
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t session_broker_force_release_writer(
|
||||
session_broker_client_id_t expected_writer_id)
|
||||
{
|
||||
if (!s_initialized) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
if (expected_writer_id == SESSION_BROKER_NO_CLIENT) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
xSemaphoreTake(s_mutex, portMAX_DELAY);
|
||||
if (s_writer_id != expected_writer_id) {
|
||||
xSemaphoreGive(s_mutex);
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
session_broker_slot_t *writer = find_slot_locked(expected_writer_id);
|
||||
if (writer == NULL) {
|
||||
xSemaphoreGive(s_mutex);
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
|
||||
s_writer_id = SESSION_BROKER_NO_CLIENT;
|
||||
++writer->counters.writer_revocations;
|
||||
++writer->counters.writer_changes;
|
||||
++s_counters.writer_revocations;
|
||||
++s_counters.writer_changes;
|
||||
broadcast_event_locked(SESSION_BROKER_EVENT_WRITER_REVOKED,
|
||||
expected_writer_id, s_writer_id);
|
||||
xSemaphoreGive(s_mutex);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
session_broker_client_id_t session_broker_get_writer_id(void)
|
||||
{
|
||||
if (!s_initialized) {
|
||||
|
||||
@@ -130,6 +130,8 @@ esp_err_t session_broker_disconnect(session_broker_client_id_t client_id);
|
||||
esp_err_t session_broker_request_writer(session_broker_client_id_t client_id);
|
||||
esp_err_t session_broker_release_writer(session_broker_client_id_t client_id);
|
||||
esp_err_t session_broker_force_writer(session_broker_client_id_t client_id);
|
||||
/* Revoke only if the expected client still owns the writer lease. */
|
||||
esp_err_t session_broker_force_release_writer(session_broker_client_id_t expected_writer_id);
|
||||
session_broker_client_id_t session_broker_get_writer_id(void);
|
||||
|
||||
/*
|
||||
|
||||
Reference in New Issue
Block a user