diff --git a/AI_RULES.md b/AI_RULES.md index 0fb128a..658fdd7 100644 --- a/AI_RULES.md +++ b/AI_RULES.md @@ -16,10 +16,13 @@ This file is the single source of rules for every AI assistant and contributor. `CLAUDE.md` is a symbolic link to it (so Claude Code loads it) and `AGENTS.md` points here: edit this file only, and never replace the link with a copy (test/guards/test_rules_file.py). -UI model: a home menu (numbered, `menu` anywhere, `exit` back to it), each app a numbered +UI model: a home menu (numbered, `menu` anywhere, `exit` back to it; Ctrl+Del on the Tab5 and +the side panel's Menu button return to it from any screen, ending SSH or ChatGPT), each app a numbered menu of short prompts; the screen is edge-to-edge text (no borders or decoration). SSH sessions: on the T-Deck, scrolling text and a line sent with Enter; on the Tab5, a full-screen xterm (TUI programs) with every key sent to the server. Keep the T-Deck's terminal as it is. +Local commands that are also shell commands (`sleep`, `shutdown`, `poweroff`, `power`) are taken +only while no SSH session is open; in a session they go to the server. ## Scope: keep it lean @@ -114,6 +117,10 @@ on the T-Deck, scrolling text and a line sent with Enter; on the Tab5, a full-sc (40/41/38) with the display: its own SPI3 bus at boot, a second device on the display's SPI2 bus afterwards (MISO routed in by `sdcard::share_display_bus()`). - Board power must be enabled via GPIO 10 before peripherals are used. +- Power: the power switch alone disconnects the battery. `sleep` is CPU light sleep (display lock + held), woken by the trackball lines, the touch interrupt (GPIO 16) or a 200 ms timer that polls + the keyboard (its interrupt, GPIO 46, can't wake it). `shutdown` holds GPIO 10 low and deep + sleeps until the trackball press (GPIO 0, ext1); `init_power()` releases the hold. ## Hardware notes (Tab5) @@ -136,12 +143,16 @@ on the T-Deck, scrolling text and a line sent with Enter; on the Tab5, a full-sc unmounting releases only slot 0. - Audio: ES7210 mics and ES8388 speaker codec on shared I2S pins (MCLK 30, BCLK 27, WS 29, DOUT 26, DIN 28), never used at the same time. +- Power: the power button is wired to the power circuit only (single press on, double press off, + in hardware); firmware can't read it. `shutdown` sends 10 pulses on 0x44 P4 (as M5Unified); + on USB power the Tab5 may stay on. `sleep` turns the backlight and WiFi off; no CPU light sleep + (MIPI-DSI and the C6's SDIO link aren't proven across it). ## Code layout | File | Responsibility | |---|---| -| `main/deck_base.cpp` | Boot, SD key loading, keypad_task (all input and terminal work) | +| `main/deck_base.cpp` | Boot, SD key loading, keypad_task (all input and terminal work, the sleep loop) | | `main/board_tdeck.cpp`, `main/board_tab5.cpp` | Board layer (`board.hpp`): power, display, touch, keyboard, UI metrics, hints | | `main/tab5_keyboard.cpp`, `main/tab5_keymap.cpp` | Tab5 Keyboard: I2C driver; key map (host-tested) | | `main/ssh_terminal.cpp`, `main/command_redact.cpp` | Terminal UI, command parsing, WiFi driver, SSH connection; password redaction | @@ -154,7 +165,7 @@ on the T-Deck, scrolling text and a line sent with Enter; on the Tab5, a full-sc | `main/settings_nvs.cpp` | Picks and opens the settings partition (`nvs` or a launcher's `simplrssh`) | | `main/storage_menu.cpp`, `main/settings_backup.cpp` | `storage` menu; SD backup/restore/erase | | `main/sd_card.cpp` | SD mounting (boot and runtime), SSH key loading | -| `main/home_menu.cpp` | Home and Security menus, `go_home()` | +| `main/home_menu.cpp` | Home, Security and Power menus, `go_home()`, sleep / power-off requests | | `main/chat_app.cpp`, `main/openai_client.cpp`, `main/audio.cpp` | ChatGPT app, OpenAI HTTPS client, recording processing | | `main/audio_tdeck.cpp`, `main/audio_tab5.cpp` | Microphone and speaker drivers per board | diff --git a/README.md b/README.md index 4fad41c..6e6f990 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,9 @@ a Tab5, hold its reset button for about 2 seconds, until the green LED flashes q The device starts at the home menu. Type a number and press Enter, or roll the trackball left/right (Tab5: Up/Down) to pick and press it. `0` goes back, and `menu` returns home from -anywhere. +anywhere. To leave an app from any screen - even an SSH session or ChatGPT, where what you +type goes to the server or the chat - press **Ctrl+Del** on the Tab5 keyboard, or swipe left +and tap **Menu** (the T-Deck's keyboard has no Ctrl key). ``` == SimplrSSH == @@ -57,6 +59,7 @@ anywhere. 3) ChatGPT 4) Storage (device / SD card) 5) Security (PIN, server keys) + 6) Power (sleep, turn off) ``` 1. **WiFi**: choose **1) WiFi** → **Scan and connect**, pick your network and type the @@ -102,7 +105,10 @@ OpenAI, and only while you use ChatGPT. the Tab5 keyboard just press them: Ctrl+letter, Tab and Esc go straight to the server. - **History**: roll the trackball left (older) or right (newer). Tab5: Up / Down. - **Disconnect**: type `exit` to return to the menu (on the Tab5 this ends the shell on the - server). The side panel's **Exit SSH** also disconnects. + server). +- **Back to the menu from anywhere**: **Ctrl+Del** (Tab5), or swipe left and tap **Menu** + (both boards). It ends an SSH session, closes ChatGPT (stopping a recording, reply or + speech) or cancels a menu, clears the input line, and shows the home menu. - **ChatGPT**: type to chat, or hold the trackball (Tab5: hold Ctrl) to speak and let go to send. Start talking once `* Recording` shows. Your speech is transcribed by OpenAI's speech-to-text model, then @@ -110,6 +116,17 @@ OpenAI, and only while you use ChatGPT. device says so and doesn't upload the recording. Press **Esc** (T-Deck: swipe left, then Esc) to stop a recording, a reply being written, or a reply being read aloud. - **Back up**: choose **4) Storage** → **Back up** to copy your settings to the SD card. +- **Sleep**: choose **6) Power** → **Sleep**, or type `sleep`. The screen and WiFi turn off; + press any key or touch the screen to wake it (T-Deck: the trackball works too). The key or + touch that wakes it does nothing else. WiFi reconnects by itself, and with a PIN set the + saved passwords are locked again. Close an SSH session first (`exit`): while connected, + `sleep` and `shutdown` are sent to the server. +- **Turn off**: choose **6) Power** → **Turn off**, or type `shutdown` (or `poweroff`). + Command history is saved first. *Tab5*: the Tab5 switches itself off; press the power button + to turn it on. On USB power it may stay on - then it sleeps instead. *T-Deck*: the screen, + keyboard, SD card and radio lose power and the processor goes into deep sleep (a very small + drain); press the trackball to start it again. Only the power switch disconnects the battery + completely. - **Help**: type `help` to list all commands. **T-Deck**: full-screen live tools such as `htop` or `watch` are hard to use, since input is @@ -132,6 +149,7 @@ Sym+Up / Sym+Down are Page Up / Page Down. | Left / Right | Move the cursor. Sym+Left / Sym+Right: start / end of the line | | Sym+Up / Sym+Down | Scroll the screen | | Del | Delete the character at the cursor | +| Ctrl+Del | Back to the home menu from any screen: ends an SSH session, closes ChatGPT | | Aa | Hold for capitals; tap for one capital; tap twice for caps lock | | Sym + key | The key's second symbol (`?` `/` `<` `>` `{` `}` `\|` `~` `:` `"` `=` `,`) | | Ctrl + letter | Control code to the server (Ctrl+C, Ctrl+D, Ctrl+Z, Ctrl+L ...) | @@ -146,6 +164,7 @@ In an SSH session the keys go to the server: | Sym+1 ... Sym+0, Sym+-, Sym++ | F1 ... F10, F11, F12 (for example F10 quits `htop`) | | Alt + key | The key with Alt (Meta), for programs that use it | | Enter, Backspace, Tab, Esc, Del, Ctrl + letter | As on a computer | +| Ctrl+Del | Not sent: ends the session and returns to the home menu | Held keys repeat. The keyboard can be attached or removed while SimplrSSH runs, and the Tab5 charges its battery over USB-C while SimplrSSH runs. @@ -163,6 +182,11 @@ charges its battery over USB-C while SimplrSSH runs. - **Keys not found**: the card must be FAT32 and the folder must be named `ssh_keys`. - **"Installed without its settings partition"**: the launcher installed the wrong file. Install `-launcher.bin` instead. +- **Tab5 power button**: it is wired to the Tab5's power circuit, not to the processor, so + SimplrSSH can't see it or make it put the device to sleep. A single press turns the Tab5 on, + a quick double press turns it off. To sleep with wake on any key or touch, use `sleep` (or + **6) Power** → **Sleep**). If a press leaves the screen dark and the next press brings back + the boot messages, the Tab5 was off (no drain), not asleep. ## More diff --git a/SECURITY.md b/SECURITY.md index dd35d30..31416b3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -140,6 +140,18 @@ Hardware behaviour on the Tab5 has not been confirmed on a device yet. | Every key goes to the server while connected on the Tab5 (Alt sends Esc + key) | Same data as typing a line on the T-Deck; nothing typed into the session is logged or kept in command history | | Key passphrase offered for every key; if a key turns out to be locked when connecting, the passphrase is asked on the open connection (3 tries) | Masked like every secret, held in the pending-connection record and wiped after the attempt; never logged. The host key is verified before it is asked. Keys in OpenSSH format (unsupported by libssh2 with mbed TLS) are reported with a conversion hint instead of a passphrase prompt | +### Sleep and power off (2026-10-06) + +| Change | Security review | +|---|---| +| `sleep` / `shutdown` (home menu **6) Power**) turn the screen and WiFi off, or the device off (Tab5: power-off pulses on IO expander 0x44 P4; T-Deck: peripheral power off and deep sleep, woken by the trackball) | No new network use: WiFi is stopped while asleep and rejoins only a saved network on waking, as at boot. Sleep and power off are refused while an SSH session is open (there `sleep` and `shutdown` are sent to the server, so a server command is never taken as a local one); an open ChatGPT view is closed first, which cancels its request. With a PIN, sleeping locks the vault, so saved passwords need the PIN again after waking; without a PIN nothing changes. Unsaved command history (which never holds secrets) is saved before power off. The touch that wakes the device is absorbed by a full-screen layer, so it can't press a button on the dark screen | + +### Back to the menu from anywhere (2026-10-06) + +| Change | Security review | +|---|---| +| Ctrl+Del (Tab5) and the side panel's **Menu** button (replaces **Exit SSH**) end an SSH session, close ChatGPT or cancel a menu from any screen | Ctrl+Del is handled on the device and never sent to the server. The half-typed input line is wiped (`vault::wipe`), as it may hold a password; a connection waiting for host-key trust or a passphrase is torn down like Cancel, wiping its pending credentials | + ### Known residual risks - Flash is not encrypted. Non-secret data (profiles, known hosts, command history, SD keys while diff --git a/main/board_tab5.cpp b/main/board_tab5.cpp index 10f348c..33122b1 100644 --- a/main/board_tab5.cpp +++ b/main/board_tab5.cpp @@ -26,6 +26,8 @@ const char* const board::HINT_SCROLL = "Sym+Up/Down or drag: scroll."; const char* const board::HINT_HISTORY = "Up/Down: history."; const char* const board::HINT_STOP = "Esc"; const char* const board::SCREEN_DESC = "a 5-inch tablet with a 1280x720 text screen"; +const char* const board::HINT_WAKE = "any key or a touch"; +const char* const board::HINT_POWER_ON = "Press the power button to turn it on again."; // IO expander pins (M5Stack Tab5 pin map) static const uint32_t IO_RF_EXTERNAL = IO_EXPANDER_PIN_NUM_0; // 0x43 P0: antenna, low = internal @@ -33,7 +35,7 @@ static const uint32_t IO_SPEAKER_EN = IO_EXPANDER_PIN_NUM_1; // 0x43 P1: NS415 static const uint32_t IO_EXT5V_EN = IO_EXPANDER_PIN_NUM_2; // 0x43 P2: 5 V to the side port (keyboard) static const uint32_t IO1_WLAN_PWR_EN = IO_EXPANDER_PIN_NUM_0; // 0x44 P0: ESP32-C6 power static const uint32_t IO1_USB5V_EN = IO_EXPANDER_PIN_NUM_3; // 0x44 P3: 5 V out of the USB-A port -static const uint32_t IO1_PWROFF = IO_EXPANDER_PIN_NUM_4; // 0x44 P4: power-off pulse +static const uint32_t IO1_PWROFF = IO_EXPANDER_PIN_NUM_4; // 0x44 P4: power-off pulses (power_off()) static const uint32_t IO1_NQC_EN = IO_EXPANDER_PIN_NUM_5; // 0x44 P5: low = quick charge static const uint32_t IO1_CHARGE_EN = IO_EXPANDER_PIN_NUM_7; // 0x44 P7: IP2326 charger on @@ -151,3 +153,34 @@ esp_err_t board::wifi_prepare() s_wifi_ready = true; return ESP_OK; } + +void board::display_power(bool on) +{ + bsp_display_brightness_set(on ? 100 : 0); +} + +// The display, WiFi co-processor link and PSRAM stay as they are (no CPU light sleep): the +// backlight is off and WiFi stopped, and the CPU idles between the caller's key and touch checks +bool board::sleep_wait(uint32_t ms) +{ + vTaskDelay(pdMS_TO_TICKS(ms)); + return false; +} + +// The power circuit switches off on a train of pulses on 0x44 P4, as M5Stack's M5Unified does. +// The power button is not wired to the ESP32-P4: a single press turns the Tab5 on, a double press +// off, in hardware. On USB power the Tab5 may stay on; then this returns. +void board::power_off() +{ + esp_io_expander_handle_t io1 = bsp_io_expander1_init(); + if (!io1) { + return; + } + bsp_display_brightness_set(0); + for (int i = 0; i < 10; i++) { + esp_io_expander_set_level(io1, IO1_PWROFF, i & 1); + vTaskDelay(pdMS_TO_TICKS(50)); + } + vTaskDelay(pdMS_TO_TICKS(1000)); + esp_io_expander_set_level(io1, IO1_PWROFF, 0); +} diff --git a/main/board_tdeck.cpp b/main/board_tdeck.cpp index db69a64..04fe0fc 100644 --- a/main/board_tdeck.cpp +++ b/main/board_tdeck.cpp @@ -17,6 +17,8 @@ #include "bsp/touch.h" #include "esp_lcd_touch_gt911.h" #include "driver/gpio.h" +#include "driver/rtc_io.h" +#include "esp_sleep.h" #include "utilities.h" #include "c3_keyboard.hpp" @@ -32,6 +34,8 @@ const char* const board::HINT_SCROLL = "Trackball up/down or drag: scroll."; const char* const board::HINT_HISTORY = "Left/right: history."; const char* const board::HINT_STOP = "Esc (swipe left)"; const char* const board::SCREEN_DESC = "a tiny handheld with a 320x240 text screen"; +const char* const board::HINT_WAKE = "any key, the trackball or a touch"; +const char* const board::HINT_POWER_ON = "Press the trackball to turn it on again (the power switch disconnects the battery)."; static C3Keyboard* s_keyboard = NULL; @@ -53,6 +57,9 @@ TermScreen* board::term_screen() void board::init_power() { + // After 'shutdown' the peripheral power pin is held low through deep sleep: release it + gpio_hold_dis(BOARD_POWERON); + // Level is set before the pin becomes an output, so the peripheral power and the // chip selects never glitch low (a low pulse on the SD card's CS or supply can corrupt it) gpio_config_t out = {}; @@ -232,3 +239,63 @@ esp_err_t board::wifi_prepare() { return ESP_OK; // WiFi is built into the ESP32-S3 } + +void board::display_power(bool on) +{ + if (on) { + bsp_display_backlight_on(); + } else { + bsp_display_backlight_off(); + } +} + +// Light sleep (WiFi is already off). Wakes on the trackball (press or roll: each line wakes on +// the level opposite to its current one), the touch interrupt, or after ms. The keyboard has no +// usable wake line, so the caller polls it between sleeps. The display lock is held while +// asleep, so the CPU never stops in the middle of a display transfer or touch read. +bool board::sleep_wait(uint32_t ms) +{ + if (!bsp_display_lock(ms)) { + return false; + } + const gpio_num_t pins[] = {BOARD_BOOT_PIN, BOARD_TBOX_G01, BOARD_TBOX_G02, BOARD_TBOX_G03, + BOARD_TBOX_G04, BOARD_TOUCH_INT}; + for (gpio_num_t pin : pins) { + int level = gpio_get_level(pin); + if (pin == BOARD_TOUCH_INT && level == 0) { + continue; // Interrupt already active: the touch is read by LVGL between sleeps + } + gpio_wakeup_enable(pin, level ? GPIO_INTR_LOW_LEVEL : GPIO_INTR_HIGH_LEVEL); + } + esp_sleep_enable_gpio_wakeup(); + esp_sleep_enable_timer_wakeup((uint64_t)ms * 1000); + esp_light_sleep_start(); + bool woken = esp_sleep_get_wakeup_cause() == ESP_SLEEP_WAKEUP_GPIO; + esp_sleep_disable_wakeup_source(ESP_SLEEP_WAKEUP_ALL); + for (gpio_num_t pin : pins) { + gpio_wakeup_disable(pin); + } + bsp_display_unlock(); + return woken; +} + +// The battery is switched only by the power switch: the lowest state software can reach is deep +// sleep with the peripherals (display, keyboard, touch, SD card, radio) unpowered. Pressing the +// trackball wakes the ESP32-S3, which then boots as after power-on. +void board::power_off() +{ + bsp_display_backlight_off(); + // A trackball still held down would wake it at once + for (int i = 0; i < 100 && gpio_get_level(BOARD_BOOT_PIN) == 0; i++) { + vTaskDelay(pdMS_TO_TICKS(20)); + } + gpio_set_level(BOARD_POWERON, 0); + gpio_hold_en(BOARD_POWERON); + gpio_deep_sleep_hold_en(); + + esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); // Keeps the pull-up below + rtc_gpio_pullup_en(BOARD_BOOT_PIN); + rtc_gpio_pulldown_dis(BOARD_BOOT_PIN); + esp_sleep_enable_ext1_wakeup_io(1ULL << BOARD_BOOT_PIN, ESP_EXT1_WAKEUP_ANY_LOW); + esp_deep_sleep_start(); +} diff --git a/main/deck_base.cpp b/main/deck_base.cpp index e9ebc98..a87833a 100644 --- a/main/deck_base.cpp +++ b/main/deck_base.cpp @@ -13,6 +13,7 @@ #include "freertos/queue.h" #include "esp_log.h" #include "esp_err.h" +#include "esp_timer.h" #include "bsp/esp-bsp.h" #include "board.hpp" @@ -53,6 +54,7 @@ static void handle_input_event(uint8_t event) case board::INPUT_CURSOR_HOME: ssh_terminal->move_cursor_home(); break; case board::INPUT_CURSOR_END: ssh_terminal->move_cursor_end(); break; case board::INPUT_DELETE: ssh_terminal->delete_at_cursor(); break; + case board::INPUT_GO_HOME: ssh_terminal->leave_to_home(); break; // Ctrl+Del (Tab5) default: if (event >= board::INPUT_F1 && event <= board::INPUT_F12) { ssh_terminal->function_key(event - board::INPUT_F1); @@ -61,6 +63,119 @@ static void handle_input_event(uint8_t event) } } +// Sleep (Power menu, 'sleep'): screen and WiFi off until a key, the trackball or a touch. +// Runs in keypad_task without the display lock, so LVGL keeps reading the touch panel. +static const uint32_t SLEEP_POLL_MS = 200; // Keyboard checks while asleep +static volatile bool s_touch_woke = false; + +static void wake_layer_cb(lv_event_t*) +{ + s_touch_woke = true; +} + +static bool touch_pressed() +{ + for (lv_indev_t* indev = lv_indev_get_next(NULL); indev; indev = lv_indev_get_next(indev)) { + if (lv_indev_get_type(indev) == LV_INDEV_TYPE_POINTER && + lv_indev_get_state(indev) == LV_INDEV_STATE_PRESSED) { + return true; + } + } + return false; +} + +// Reads and drops waiting keys and input events; true if there were any +static bool drop_input() +{ + bool any = false; + for (int i = 0; i < 64 && board::read_key(); i++) { + any = true; + } + if (uxQueueMessagesWaiting(input_events)) { + xQueueReset(input_events); + any = true; + } + return any; +} + +static void sleep_until_woken() +{ + // A full-screen layer takes every touch while asleep, so the touch that wakes the device + // doesn't also press whatever is under it on the dark screen + bsp_display_lock(0); + lv_obj_t* layer = lv_obj_create(lv_layer_top()); + lv_obj_remove_style_all(layer); + lv_obj_set_size(layer, LV_PCT(100), LV_PCT(100)); + lv_obj_add_flag(layer, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_event_cb(layer, wake_layer_cb, LV_EVENT_PRESSED, NULL); + bsp_display_unlock(); + + vTaskDelay(pdMS_TO_TICKS(1000)); // Time to read the message + s_touch_woke = false; + drop_input(); // The key that chose Sleep doesn't wake it + board::display_power(false); + while (!s_touch_woke) { + if (board::sleep_wait(SLEEP_POLL_MS) || drop_input()) { + break; + } + } + board::display_power(true); + + // What woke it does nothing else: keys, trackball and touch are dropped until released + // (at least 0.5 s, at most 3 s) + int64_t start = esp_timer_get_time() / 1000; + int64_t last_input = start; + while (true) { + vTaskDelay(pdMS_TO_TICKS(50)); + int64_t now = esp_timer_get_time() / 1000; + bool busy = drop_input(); + if (bsp_display_lock(0)) { + busy = touch_pressed() || busy; + bsp_display_unlock(); + } + if (busy) { + last_input = now; + } + if ((now - start >= 500 && now - last_input >= 300) || now - start >= 3000) { + break; + } + } + bsp_display_lock(0); + lv_obj_delete(layer); + bsp_display_unlock(); +} + +// Sleep or power off chosen in the Power menu +static void handle_power_request() +{ + SSHTerminal::PowerRequest request = SSHTerminal::PowerRequest::None; + if (bsp_display_lock(0)) { + request = ssh_terminal->take_power_request(); + if (request != SSHTerminal::PowerRequest::None) { + ssh_terminal->power_prepare(request); + } + bsp_display_unlock(); + } + if (request == SSHTerminal::PowerRequest::None) { + return; + } + if (request == SSHTerminal::PowerRequest::Off) { + vTaskDelay(pdMS_TO_TICKS(1000)); // Time to read the message + board::power_off(); // Returns only if the device stayed on + board::display_power(true); + if (bsp_display_lock(0)) { + ssh_terminal->power_off_failed(); + bsp_display_unlock(); + } + vTaskDelay(pdMS_TO_TICKS(2000)); + } + sleep_until_woken(); + if (bsp_display_lock(0)) { + ssh_terminal->power_resume(); + bsp_display_unlock(); + } +} + static void keypad_task(void *param) { // Keep running without the keyboard: this task also runs WiFi auto-connect and trackball input @@ -109,6 +224,8 @@ static void keypad_task(void *param) bsp_display_unlock(); } + handle_power_request(); + // WiFi reconnect, return to the menu after an SSH session ends if (bsp_display_lock(0)) { ssh_terminal->background_tick(); diff --git a/main/home_menu.cpp b/main/home_menu.cpp index 203e731..a742db4 100644 --- a/main/home_menu.cpp +++ b/main/home_menu.cpp @@ -1,11 +1,14 @@ /* * Home Menu * The start screen, and where 'menu' / 'exit' / every "0) Back" lead. Numbers - * open an app; anything else typed is run as a command. + * open an app; anything else typed is run as a command. Also the Security menu and the + * Power menu (sleep, turn off; carried out by keypad_task, deck_base.cpp). */ #include "ssh_terminal.hpp" #include "secret_vault.hpp" +#include "board.hpp" +#include "esp_timer.h" void SSHTerminal::go_home() { @@ -21,6 +24,22 @@ void SSHTerminal::go_home() wizard_goto(WizardStep::HomeMenu); } +void SSHTerminal::leave_to_home() +{ + vault::wipe(current_input); // A half-typed line may be a password + cursor_pos = 0; + history_index = -1; + if (chat_active) { + close_chat(); + } + if (session || ssh_socket >= 0) { + disconnect(); // Also a connection waiting for trust or a passphrase + } + append_text("\nBack to the menu.\n"); + go_home(); + update_input_display(); +} + // After a command started from a menu: return to that menu when it finishes void SSHTerminal::show_menu_after(WizardStep menu) { @@ -55,8 +74,9 @@ bool SSHTerminal::home_step_prompt() " 3) ChatGPT\n" " 4) Storage (device / SD card)\n" " 5) Security (PIN, server keys)\n" - "Select 1-5, or type a command: "); - wizard.choices = {"1", "2", "3", "4", "5"}; + " 6) Power (sleep, turn off)\n" + "Select 1-6, or type a command: "); + wizard.choices = {"1", "2", "3", "4", "5", "6"}; return true; } case WizardStep::SecurityMenu: @@ -70,6 +90,14 @@ bool SSHTerminal::home_step_prompt() "Select: "); wizard.choices = {"1", "2", "3", "4", "0"}; return true; + case WizardStep::PowerMenu: + append_text("\n== Power ==\n"); + append_text((std::string(" 1) Sleep (wake: ") + board::HINT_WAKE + ")\n").c_str()); + append_text(" 2) Turn off\n" + " 0) Back\n" + "Select: "); + wizard.choices = {"1", "2", "0"}; + return true; default: return false; } @@ -91,6 +119,10 @@ bool SSHTerminal::home_step_input(const std::string& raw_input, const std::strin wizard_reset(); wizard.menu = WizardStep::SecurityMenu; wizard_goto(WizardStep::SecurityMenu); + } else if (input == "6") { + wizard_reset(); + wizard.menu = WizardStep::PowerMenu; + wizard_goto(WizardStep::PowerMenu); } else if (input.empty()) { wizard_prompt(); } else { @@ -121,7 +153,85 @@ bool SSHTerminal::home_step_input(const std::string& raw_input, const std::strin } return true; + case WizardStep::PowerMenu: + if (input == "1") { + request_power(PowerRequest::Sleep); + } else if (input == "2") { + request_power(PowerRequest::Off); + } else if (input == "0") { + go_home(); + } else { + append_text("Invalid choice.\n"); + wizard_prompt(); + } + return true; + default: return false; } } + +// Sleep / power off: recorded here, carried out by keypad_task once this input is handled +void SSHTerminal::request_power(PowerRequest request) +{ + wizard_reset(); + if (ssh_connected || session) { + append_text("SSH is connected - type 'exit' to close it first.\n"); + return; + } + power_request = request; +} + +SSHTerminal::PowerRequest SSHTerminal::take_power_request() +{ + PowerRequest request = power_request; + power_request = PowerRequest::None; + return request; +} + +// Before sleeping or turning off: nothing left running, unsaved history saved, WiFi off. +// With a PIN, the vault locks (like a computer going to sleep); without one nothing is asked. +void SSHTerminal::power_prepare(PowerRequest request) +{ + if (chat_active) { + close_chat(); + } + wizard_reset(); + if (history_needs_save) { + history_needs_save = false; + save_history_to_nvs(); + } + wifi_sleep(); + if (vault::has_pin()) { + vault::lock(); + } + if (request == PowerRequest::Off) { + append_text((std::string("\nTurning off. ") + board::HINT_POWER_ON + "\n").c_str()); + } else { + append_text((std::string("\nSleeping. Wake it with ") + board::HINT_WAKE + ".\n").c_str()); + } + refresh_display_now(); +} + +// Awake again: WiFi reconnects as at startup (unless the user had turned it off), then the menu +void SSHTerminal::power_resume() +{ + last_input_ms = esp_timer_get_time() / 1000; + append_text("Awake.\n"); + if (wifi_auto_enabled) { + wifi_retry_delay_s = 0; + wifi_next_retry_ms = last_input_ms + 30000; + wifi_auto_connect(false); + } + if (!wizard_active()) { // Not waiting for a PIN + go_home(); + } +} + +// The device stayed on after 'shutdown' (Tab5 on USB power): it sleeps instead +void SSHTerminal::power_off_failed() +{ + append_text((std::string("Still powered (USB keeps it on?) - sleeping instead. Wake it with ") + + board::HINT_WAKE + ".\n").c_str()); + refresh_display_now(); +} diff --git a/main/include/board.hpp b/main/include/board.hpp index 73dd993..36a7b9b 100644 --- a/main/include/board.hpp +++ b/main/include/board.hpp @@ -33,6 +33,16 @@ namespace board void start_input_tasks(); // Board input pollers (the T-Deck's trackball) esp_err_t wifi_prepare(); // Before the WiFi driver starts (Tab5: link to its WiFi co-processor) + // Power (home menu 'Power', 'sleep' and 'shutdown'), from keypad_task without the display lock + void display_power(bool on); // Backlight off/on; touch keeps working + // Waits up to ms while asleep. T-Deck: CPU light sleep, ended early by the trackball or a + // touch (returns true). Tab5: an idle wait (returns false); keys and touch are polled by the caller. + bool sleep_wait(uint32_t ms); + // Turns the device off and does not return (T-Deck: peripheral power off and deep sleep until + // the trackball is pressed, which starts it again). Returns only if the device stayed on + // (Tab5 on USB power). + void power_off(); + // Text sizes and spacing for the screen's size and pixel density struct Ui { const lv_font_t* small; // Status line, side panel buttons @@ -57,6 +67,8 @@ namespace board extern const char* const HINT_HISTORY; extern const char* const HINT_STOP; // How to press Esc (stops a ChatGPT reply) extern const char* const SCREEN_DESC; // For the ChatGPT system prompt + extern const char* const HINT_WAKE; // What wakes the device from sleep + extern const char* const HINT_POWER_ON; // How to turn it on again after 'shutdown' } #endif diff --git a/main/include/input_events.hpp b/main/include/input_events.hpp index 5847c48..f33b515 100644 --- a/main/include/input_events.hpp +++ b/main/include/input_events.hpp @@ -29,6 +29,7 @@ namespace board INPUT_DELETE, // Delete the character at the cursor INPUT_F1, // Function keys F1-F12 (INPUT_F1 + 0..11), full-screen SSH terminal only INPUT_F12 = INPUT_F1 + 11, + INPUT_GO_HOME, // Leave whatever is running (SSH, ChatGPT, a menu) for the home menu }; // Keyboard values (board::read_key()): 0 when no key is waiting, a byte (printable ASCII, diff --git a/main/include/ssh_terminal.hpp b/main/include/ssh_terminal.hpp index b28719a..39b1c86 100644 --- a/main/include/ssh_terminal.hpp +++ b/main/include/ssh_terminal.hpp @@ -64,10 +64,22 @@ class SSHTerminal void on_trackball_hold(bool start, bool long_press); // Shows the home menu (startup, 'menu', 'exit') void go_home(); + // Ctrl+Del (Tab5) and the side panel's Menu button: from any screen, ends an SSH session, + // closes ChatGPT (stopping recording, a reply or speech) or cancels a menu, then go_home() + void leave_to_home(); // Boot message listing what was restored from the device's storage void print_saved_summary(); void update_status_bar(); + + // Sleep and power off (home menu 'Power', 'sleep', 'shutdown'). The menu only records the + // request; keypad_task carries it out without the display lock (deck_base.cpp), calling + // power_prepare() before and power_resume() after a sleep, both with the display lock held. + enum class PowerRequest { None, Sleep, Off }; + PowerRequest take_power_request(); + void power_prepare(PowerRequest request); + void power_resume(); + void power_off_failed(); // SSH key management void load_key_from_memory(const char* keyname, const char* key_data, size_t key_len); @@ -93,8 +105,8 @@ class SSHTerminal SshKeyPassphrase, // Storage / SD card backup (storage_menu.cpp) StorageMenu, StorageConfirmBackup, StorageConfirmRestore, StorageConfirmErase, - // Home and security menus (home_menu.cpp) - HomeMenu, SecurityMenu, + // Home, security and power menus (home_menu.cpp) + HomeMenu, SecurityMenu, PowerMenu, // ChatGPT (chat_app.cpp) ChatMenu, ChatKeySource, ChatKey, ChatKeyDelete, ChatModelWait, ChatModel, ChatModelOther }; @@ -193,6 +205,7 @@ class SSHTerminal int wifi_retry_delay_s = 0; int64_t last_input_ms = 0; // Background reconnects wait until the user is idle volatile bool home_pending = false; // Set by other tasks (SSH closed): show the home menu + PowerRequest power_request = PowerRequest::None; // For keypad_task (request_power()) // ChatGPT (chat_app.cpp). History is touched only while holding the display lock. // Model names exactly as OpenAI gives them, saved. Until the user picks a chat model, and always @@ -264,6 +277,7 @@ class SSHTerminal esp_err_t ssh_authenticate_pubkey(const char* username, const char* privkey_data, size_t privkey_len, const char* passphrase); esp_err_t start_wifi_driver(); + void wifi_sleep(); // Radio off for sleep/power off; the next scan or connect starts it again esp_err_t scan_wifi(std::vector& results); void refresh_display_now(); esp_err_t ssh_open_channel(); @@ -328,8 +342,9 @@ class SSHTerminal void execute_command(const std::string& cmd); void wifi_maintain(); - // Home and security menus (home_menu.cpp) + // Home, security and power menus (home_menu.cpp) void show_menu_after(WizardStep menu); + void request_power(PowerRequest request); bool home_step_prompt(); bool home_step_input(const std::string& raw, const std::string& input); diff --git a/main/include/tab5_keymap.hpp b/main/include/tab5_keymap.hpp index 73081a7..ee29a98 100644 --- a/main/include/tab5_keymap.hpp +++ b/main/include/tab5_keymap.hpp @@ -9,6 +9,7 @@ * Left / Right move the cursor; Sym+Left / Sym+Right: start / end of the line * Sym+Up / Sym+Down scroll the screen * Del delete at the cursor + * Ctrl+Del back to the home menu from anywhere (ends SSH, closes ChatGPT) * Ctrl+letter control code (Ctrl+C = 3 ...), also Ctrl+[ \ ] ^ _ / * Alt+key the key with Meta (KEY_ALT), for programs over SSH * Sym+1 ... Sym+0, -, + F1 ... F10, F11, F12 diff --git a/main/ssh_terminal.cpp b/main/ssh_terminal.cpp index 835b221..3e4c4dc 100644 --- a/main/ssh_terminal.cpp +++ b/main/ssh_terminal.cpp @@ -44,6 +44,7 @@ static int s_retry_num = 0; #define WIFI_MAXIMUM_RETRY 5 static bool s_wifi_started = false; +static bool s_wifi_stopped = false; // Radio off while asleep (wifi_sleep()) static bool s_connect_requested = false; // Reconnect on drop only while a connection is wanted static esp_event_handler_instance_t s_instance_any_id = NULL; @@ -156,6 +157,14 @@ static void wifi_event_handler(void* arg, esp_event_base_t event_base, esp_err_t SSHTerminal::start_wifi_driver() { if (s_wifi_started) { + if (s_wifi_stopped) { + esp_err_t err = esp_wifi_start(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "WiFi restart failed (%s)", esp_err_to_name(err)); + return err; + } + s_wifi_stopped = false; + } return ESP_OK; } @@ -291,7 +300,24 @@ void SSHTerminal::disconnect_wifi() } } -// Called from the WiFi event task when an established connection is gone for good +// Sleep and power off: leaves the network and turns the radio off. wifi_auto_enabled is kept, +// so a saved network is joined again after waking. +void SSHTerminal::wifi_sleep() +{ + if (!s_wifi_started || s_wifi_stopped) { + return; + } + s_connect_requested = false; + if (wifi_connected) { + esp_wifi_disconnect(); + } + esp_wifi_stop(); + s_wifi_stopped = true; + wifi_connected = false; + wifi_ssid.clear(); + update_status_bar(); +} + void SSHTerminal::print_saved_summary() { std::string line = "Saved on this device: " + std::to_string(saved_networks.size()) + " WiFi network" + @@ -304,6 +330,7 @@ void SSHTerminal::print_saved_summary() } } +// Called from the WiFi event task when an established connection is gone for good void SSHTerminal::wifi_link_lost() { if (!wifi_connected) { @@ -613,7 +640,7 @@ void SSHTerminal::scroll_end_cb(lv_event_t* e) void SSHTerminal::execute_command(const std::string& cmd) { // Home menu numbers work any time the menu isn't on screen (and no SSH session is open) - if (!ssh_connected && cmd.size() == 1 && cmd[0] >= '1' && cmd[0] <= '5') { + if (!ssh_connected && cmd.size() == 1 && cmd[0] >= '1' && cmd[0] <= '6') { wizard_reset(); wizard.menu = WizardStep::HomeMenu; wizard.step = WizardStep::HomeMenu; @@ -623,6 +650,18 @@ void SSHTerminal::execute_command(const std::string& cmd) if (cmd == "connect" || cmd == "wifi" || cmd.rfind("wifi ", 0) == 0) { handle_wifi_command(cmd == "connect" ? "wifi scan" : cmd); } + // Not while SSH is connected: there 'shutdown' and 'sleep' are sent to the server + else if (!ssh_connected && cmd == "power") { + wizard_reset(); + wizard.menu = WizardStep::PowerMenu; + wizard_goto(WizardStep::PowerMenu); + } + else if (!ssh_connected && cmd == "sleep") { + request_power(PowerRequest::Sleep); + } + else if (!ssh_connected && (cmd == "shutdown" || cmd == "poweroff")) { + request_power(PowerRequest::Off); + } else if (cmd == "vault" || cmd.rfind("vault ", 0) == 0) { handle_vault_command(cmd); } @@ -800,7 +839,11 @@ void SSHTerminal::execute_command(const std::string& cmd) append_text(" hosts - Trusted server keys | hosts forget [HOST|#]\n"); append_text(" storage - Saved settings: SD backup/restore, load keys\n"); append_text(" disconnect - Disconnect WiFi\n"); + append_text((std::string(" sleep - Screen and WiFi off until ") + board::HINT_WAKE + "\n").c_str()); + append_text(" shutdown - Turn the device off (also 'poweroff'; 'power' menu)\n"); append_text(" exit - Disconnect SSH\n"); + append_text(" Ctrl+Del (Tab5) or the side panel's Menu (swipe left): back to the menu\n" + " from anywhere - ends SSH, closes ChatGPT\n"); append_text(" clear - Clear terminal\n"); append_text(" help - Show this help\n"); append_text((std::string(board::HINT_SCROLL) + " " + board::HINT_HISTORY + "\n").c_str()); @@ -2055,7 +2098,7 @@ void SSHTerminal::create_side_panel() create_key_button("Ctrl+L", "\x0C"); create_key_button("Tab", "\t"); create_key_button("Esc", "\x1B"); - create_key_button("Exit SSH", "EXIT"); + create_key_button("Menu", "MENU"); // Back to the home menu from anywhere (ends SSH) create_key_button("Clear", "CLEAR"); } @@ -2079,10 +2122,9 @@ void SSHTerminal::send_special_key(const char* sequence) return; } - if (strcmp(sequence, "EXIT") == 0) { - disconnect(); - home_pending = true; + if (strcmp(sequence, "MENU") == 0) { toggle_side_panel(); + leave_to_home(); return; } diff --git a/main/tab5_keymap.cpp b/main/tab5_keymap.cpp index 6ad25d4..c4dc2bb 100644 --- a/main/tab5_keymap.cpp +++ b/main/tab5_keymap.cpp @@ -65,6 +65,7 @@ uint32_t Tab5Keymap::translate(int row, int col, bool* repeats) if (at(row, col, 2, 0)) { *repeats = false; return '\t'; } // Tab if (at(row, col, 3, 13)) { *repeats = false; return '\n'; } // Enter if (at(row, col, 2, 13)) { return 8; } // Backspace + if (at(row, col, 0, 13) && ctrl) { *repeats = false; return KEY_EVENT | INPUT_GO_HOME; } // Ctrl+Del if (at(row, col, 0, 13)) { return KEY_EVENT | INPUT_DELETE; } // Del if (at(row, col, 3, 11)) { return KEY_EVENT | (sym ? INPUT_UP : INPUT_LEFT); } // Up if (at(row, col, 4, 11)) { return KEY_EVENT | (sym ? INPUT_DOWN : INPUT_RIGHT); } // Down diff --git a/sdkconfig b/sdkconfig index 75cbafe..482e0a8 100644 --- a/sdkconfig +++ b/sdkconfig @@ -1184,8 +1184,7 @@ CONFIG_ESP_PHY_IRAM_OPT=y CONFIG_PM_SLEEP_FUNC_IN_IRAM=y # CONFIG_PM_ENABLE is not set CONFIG_PM_SLP_IRAM_OPT=y -CONFIG_PM_POWER_DOWN_CPU_IN_LIGHT_SLEEP=y -CONFIG_PM_RESTORE_CACHE_TAGMEM_AFTER_LIGHT_SLEEP=y +# CONFIG_PM_POWER_DOWN_CPU_IN_LIGHT_SLEEP is not set # end of Power Management # @@ -1393,7 +1392,7 @@ CONFIG_ESP_WIFI_SOFTAP_BEACON_MAX_LEN=752 CONFIG_ESP_WIFI_MGMT_SBUF_NUM=32 CONFIG_ESP_WIFI_IRAM_OPT=y # CONFIG_ESP_WIFI_EXTRA_IRAM_OPT is not set -CONFIG_ESP_WIFI_RX_IRAM_OPT=y +# CONFIG_ESP_WIFI_RX_IRAM_OPT is not set CONFIG_ESP_WIFI_ENABLE_WPA3_SAE=y CONFIG_ESP_WIFI_ENABLE_SAE_PK=y CONFIG_ESP_WIFI_ENABLE_SAE_H2E=y @@ -2962,8 +2961,7 @@ CONFIG_ESP32_PHY_MAX_WIFI_TX_POWER=20 CONFIG_ESP32_PHY_MAX_TX_POWER=20 # CONFIG_REDUCE_PHY_TX_POWER is not set # CONFIG_ESP32_REDUCE_PHY_TX_POWER is not set -CONFIG_ESP_SYSTEM_PM_POWER_DOWN_CPU=y -CONFIG_PM_POWER_DOWN_TAGMEM_IN_LIGHT_SLEEP=y +# CONFIG_ESP_SYSTEM_PM_POWER_DOWN_CPU is not set CONFIG_ESP32S3_SPIRAM_SUPPORT=y CONFIG_DEFAULT_PSRAM_CLK_IO=30 CONFIG_DEFAULT_PSRAM_CS_IO=26 @@ -3013,7 +3011,7 @@ CONFIG_ESP32_WIFI_TASK_PINNED_TO_CORE_0=y CONFIG_ESP32_WIFI_SOFTAP_BEACON_MAX_LEN=752 CONFIG_ESP32_WIFI_MGMT_SBUF_NUM=32 CONFIG_ESP32_WIFI_IRAM_OPT=y -CONFIG_ESP32_WIFI_RX_IRAM_OPT=y +# CONFIG_ESP32_WIFI_RX_IRAM_OPT is not set CONFIG_ESP32_WIFI_ENABLE_WPA3_SAE=y CONFIG_ESP32_WIFI_ENABLE_WPA3_OWE_STA=y CONFIG_WPA_MBEDTLS_CRYPTO=y diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 6f355f0..c9d84fd 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -29,6 +29,12 @@ CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP=y CONFIG_MBEDTLS_DEFAULT_MEM_ALLOC=y # LVGL uses the system heap instead of a fixed 64 KB internal pool CONFIG_LV_USE_CLIB_MALLOC=y +# WiFi receive code runs from flash instead of internal RAM: terminal and ChatGPT traffic +# doesn't need the extra throughput, and it makes room for the sleep code ('sleep', T-Deck) +CONFIG_ESP_WIFI_RX_IRAM_OPT=n +# 'sleep' keeps the CPU powered in light sleep: powering it down saves about 0.65 mA but needs +# about 9 KB of internal RAM for its saved state and code +CONFIG_PM_POWER_DOWN_CPU_IN_LIGHT_SLEEP=n # --- Flash / boot time -------------------------------------------------------- # Only Montserrat 10/12 (UI) and 14 (LVGL default) are used diff --git a/sdkconfig.defaults.esp32p4 b/sdkconfig.defaults.esp32p4 index 5ae4565..d9b2d75 100644 --- a/sdkconfig.defaults.esp32p4 +++ b/sdkconfig.defaults.esp32p4 @@ -45,6 +45,10 @@ CONFIG_WIFI_RMT_SOFTAP_SUPPORT=n # The host-side WiFi driver keeps no copy of the WiFi password in NVS (the vault has it); # the co-processor is told the same at runtime (esp_wifi_set_storage(WIFI_STORAGE_RAM)) CONFIG_WIFI_RMT_NVS_ENABLED=n +# sdkconfig.defaults turns WiFi receive IRAM off for the T-Deck. Here it is not a choice: the P4 +# runs no WiFi driver (the C6 does), and esp_wifi_remote derives this option, without a prompt, +# from WIFI_RMT_RX_IRAM_OPT (y), so the shared =n can't apply and costs the P4 no IRAM +CONFIG_ESP_WIFI_RX_IRAM_OPT=y # --- Display (MIPI-DSI 720x1280 panel shown as 1280x720 landscape) ------------ # Rotate the LVGL frame with the ESP32-P4's pixel-processing accelerator instead of the CPU diff --git a/test/guards/test_firmware_rules.py b/test/guards/test_firmware_rules.py index df99dbd..87d6e15 100644 --- a/test/guards/test_firmware_rules.py +++ b/test/guards/test_firmware_rules.py @@ -151,6 +151,31 @@ def test_secrets_wiped(self): self.assertGreater(sum(code.count("vault::wipe(") for code in SRC.values()), 3) +class Power(unittest.TestCase): + """Sleep and power off (AI_RULES.md, UI model): never taken from an SSH session's input.""" + + def test_shell_named_commands_only_without_ssh(self): + terminal = SRC["main/ssh_terminal.cpp"] + for word in ("sleep", "shutdown", "poweroff", "power"): + lines = [line for line in terminal.splitlines() if f'cmd == "{word}"' in line] + self.assertTrue(lines, f"'{word}' command missing") + for line in lines: + with self.subTest(command=word): + self.assertIn("!ssh_connected", line) + + def test_request_refused_while_connected(self): + home = SRC["main/home_menu.cpp"] + body = home.split("void SSHTerminal::request_power", 1)[1].split("\n}", 1)[0] + self.assertIn("ssh_connected", body) + + def test_power_prepare_saves_and_stops(self): + home = SRC["main/home_menu.cpp"] + body = home.split("void SSHTerminal::power_prepare", 1)[1].split("\n}", 1)[0] + for call in ("close_chat()", "save_history_to_nvs()", "wifi_sleep()", "vault::lock()"): + with self.subTest(call=call): + self.assertIn(call, body) + + # Each board's defaults and the sdkconfig they must have reached (later files override earlier) BOARD_CONFIGS = { "sdkconfig": ["sdkconfig.defaults"], diff --git a/test/host/tests/test_tab5_keymap.cpp b/test/host/tests/test_tab5_keymap.cpp index 15aab58..da655e1 100644 --- a/test/host/tests/test_tab5_keymap.cpp +++ b/test/host/tests/test_tab5_keymap.cpp @@ -45,6 +45,20 @@ TEST(keymap_special_keys) CHECK_EQ(tap(k, DEL), KEY_EVENT | INPUT_DELETE); } +TEST(keymap_ctrl_del_goes_home) +{ + Tab5Keymap k; + press(k, CTRL, 0); + press(k, DEL, 10); + CHECK_EQ(k.pop(), KEY_EVENT | INPUT_GO_HOME); + k.tick(2000); // Held: never repeats + CHECK_EQ(k.pop(), 0u); + release(k, DEL, 2010); + release(k, CTRL, 2020); + CHECK_EQ(k.pop(), 0u); // Not push-to-talk either + CHECK_EQ(tap(k, DEL, 3000), KEY_EVENT | INPUT_DELETE); // Del alone still deletes +} + TEST(keymap_sym_layer) { Tab5Keymap k;