Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions AI_RULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)

Expand All @@ -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 |
Expand All @@ -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 |

Expand Down
28 changes: 26 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 ==
Expand All @@ -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
Expand Down Expand Up @@ -102,14 +105,28 @@ 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
sent as your message to the chat model you picked. If the microphone picks up nothing, the
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
Expand All @@ -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 ...) |
Expand All @@ -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.
Expand All @@ -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

Expand Down
12 changes: 12 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 34 additions & 1 deletion main/board_tab5.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,16 @@ 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
static const uint32_t IO_SPEAKER_EN = IO_EXPANDER_PIN_NUM_1; // 0x43 P1: NS4150B amplifier
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

Expand Down Expand Up @@ -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);
}
67 changes: 67 additions & 0 deletions main/board_tdeck.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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;

Expand All @@ -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 = {};
Expand Down Expand Up @@ -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();
}
Loading
Loading