Compare commits

...
Author SHA1 Message Date
twislaandClaude Opus 5.5 34e6714785 Keys: one table file for the device's help panel and the website's key tables (#72)
CI / build (pull_request) Successful in 7m14s
Site / build (pull_request) Successful in 8s
Every screen's keys are constant tables in lib/core/src/app_keys.h (52 of
them, each under an `// id: Title` comment). An App's help() picks the table
of the state it is in. site/tools/gen_dev_docs.py reads the same file and
writes site/data/keys.toml; the `keys` shortcode shows a screen's tables on
its guide page, and /guide/keys/ shows all of them.

The Site job fails when the data file is out of date or a page asks for a
table that doesn't exist, and now also runs when app_keys.h changes. A key
added to an App shows up on the website without anyone editing a page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-07 01:42:48 +02:00
twisla 82023d36b3 Merge pull request 'Help: Fn+h lists the keys of the screen you're on; no screen names its keys any more (#69)' (#73) from help-key into main
CI / build (push) Successful in 1m19s
Site / build (push) Successful in 16s
2026-10-06 23:38:49 +00:00
twislaandClaude Opus 5.5 7fe8b3d22a Help: Fn+h lists the keys of the screen you're on, and no screen names its keys any more (#69)
CI / build (pull_request) Successful in 7m22s
Site / build (pull_request) Successful in 14s
Fn+h on any screen, text fields included, and ? outside Text Entry, open a
panel over the content area: the screen's own keys, then the ones that work
everywhere. Every App declares its keys for the state it is in (pages,
viewers, dialogs and text fields answer for themselves); the App manager
opens the panel and takes every key while it is open.

About 30 hint lines are gone, from every App. What stays on a screen is
state. The first-start Setup keeps its hints and teaches the key; a device
set up before gets one Toast, once. The guide and the FAQ open with it.
`key help` over the consoles.

476 host tests (8 new). Checked on the device with key help and screenshots:
the Launcher, all nine Apps and several of their states. 8.5 KB of flash and
40 bytes of static RAM. Decisions Q196 to Q203 in docs/milestones/U1.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-07 01:29:51 +02:00
twisla 1c6ae0e04f Merge pull request 'Devlog: "It was off" (the website, and one firmware with the Debug Console in it)' (#71) from devlog-console into main
Site / build (push) Successful in 9s
Reviewed-on: #71
2026-10-06 22:20:56 +00:00
twislaandClaude Opus 5.5 74b7713553 Devlog: "It was off", the website and the one firmware with the Debug Console in it (v0.12.0)
Site / build (pull_request) Successful in 9s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-07 00:06:47 +02:00
twisla 6be05b782d Merge pull request 'One firmware: the Debug Console in every build, off until switched on (#68)' (#70) from console-setting into main
Site / build (push) Successful in 12s
CI / build (push) Successful in 13m11s
Reviewed-on: #70
2026-10-06 21:58:23 +00:00
80 changed files with 2114 additions and 89 deletions
+4 -4
View File
@@ -3,15 +3,15 @@
#
# It runs when the site, or a document the site is built from, changes (a pull request, or a push to
# main); the firmware workflow (ci.yml) skips a change that touches only these files. A change that
# touches both runs both. src/main.cpp is here too: the site's command reference is generated from the
# firmware's own `help` text, and this job checks that it is still current.
# touches both runs both. src/main.cpp and lib/core/src/app_keys.h are here too: the site's command
# reference and its key tables are generated from them, and this job checks that they are still current.
name: Site
on:
push:
branches: [main]
paths: ['site/**', 'docs/**', 'README.md', 'CONTEXT.md', 'src/main.cpp', '.gitea/workflows/site.yml']
paths: ['site/**', 'docs/**', 'README.md', 'CONTEXT.md', 'src/main.cpp', 'lib/core/src/app_keys.h', '.gitea/workflows/site.yml']
pull_request:
paths: ['site/**', 'docs/**', 'README.md', 'CONTEXT.md', 'src/main.cpp', '.gitea/workflows/site.yml']
paths: ['site/**', 'docs/**', 'README.md', 'CONTEXT.md', 'src/main.cpp', 'lib/core/src/app_keys.h', '.gitea/workflows/site.yml']
jobs:
build:
+4
View File
@@ -106,6 +106,10 @@ The regulatory band plan the device transmits under (here EU868). It sets the al
**Duty Cycle Budget**:
The share of airtime the Region allows this device to transmit. When it's used up, outgoing traffic waits.
**Help panel**:
The list of the keys that work on the screen you are on, opened with Fn+h anywhere (or `?` outside Text Entry). Each App answers for its current state; no screen names keys any other way, except the first-start Setup.
_Avoid_: hints, cheat sheet, shortcuts bar
**Text Entry**:
When an App is editing text. During Text Entry, `;` `.` `,` `/` type their characters and Fn makes them arrows. Otherwise they are arrows on their own.
_Avoid_: edit mode, insert mode
+5 -1
View File
@@ -8,6 +8,10 @@ A multi-app firmware for the **M5Stack Cardputer ADV** with the **Cap LoRa-1262*
- Decisions: [docs/adr/](docs/adr/)
- Milestones: [docs/milestones/](docs/milestones/)
## On the device: one key
**Fn+h, on any screen, lists the keys that work there** (`?` does the same outside a text field). No screen names its keys itself (docs/milestones/U1.md). Every screen's keys are one table in `lib/core/src/app_keys.h`: the help panel shows the table of the state an App is in, and the website's key tables are generated from the same file.
## Requirements
Only **Docker** is needed. PlatformIO and the ESP32 toolchain run inside a container, and are cached in the `roro9stack-pio` Docker volume. The first build downloads about 1 GB and takes a few minutes.
@@ -159,7 +163,7 @@ A note holds up to 16 KB while it's edited. A bigger text file opens read-only i
| Command | Effect |
|---|---|
| `burst` | Publishes 5 Notifications at once |
| `key up\|down\|left\|right\|select\|back\|home`, or `key <char>` | Injects a key press |
| `key up\|down\|left\|right\|select\|back\|home\|del\|tab\|space\|help`, or `key <char>` | Injects a key press (`help` is Fn+h: the keys of the screen that is showing) |
| `sound on` / `sound off` | Toggles the Sound setting (beep + LED) |
| `short` / `normal` | Screen timeouts 5 s / 10 s, or 30 s / 60 s |
| `wifi add <ssid><TAB><password>` | Adds a Saved Network (so credentials stay out of the repo) |
+46
View File
@@ -0,0 +1,46 @@
# U1 — Look and feel
**Status:** in progress. The help key (issue #69) is merged; the website's key tables generated from the same lists (issue #72) are in a pull request. Screen recording (#17) and the rest of the milestone are not started.
**Goal:** the interface is consistent and uncrowded: the same thing is done the same way on every screen, and the 135 pixels of height go to content.
## The help key (issue #69)
Every screen used to say something about its keys, differently: a footer of abbreviations in one place (`c x v:paste r:name d:del n:new i:info s:sort`), a line under a text field in another (`Enter: save \`: cancel`), `Tab: sky` in a corner, and nothing at all in several. About 30 such strings, each costing a line of a small screen, and none of them complete.
### Decisions (design round 2026-10-07)
| # | Decision |
|---|---|
| Q196 | **Fn+h, on every screen,** text fields included (Fn is held, so nothing is typed). **`?` too, outside Text Entry.** |
| Q197 | It opens **a panel over the content area**, titled with where you are: the screen's own keys, then an "Everywhere" group (Back, Home, the arrows, the help key). The arrows scroll it; any other key closes it and is not passed on. |
| Q198 | **Each App answers "what are your keys right now?"** for the state it is in; pages, viewers, dialogs and text fields answer for themselves, with shared lists for dialogs, lists and text entry. The lists are constants; the panel's rows exist only while it is open. |
| Q199 | **Every hint that names a key goes,** text fields included. What stays is state: `REC 12 points`, `LOG 42`, `sort:signal`, `typing`/`saved`, what is waiting to be pasted, the Sweep's floor. Messages were reworded where they named a key ("v pastes a copy of…" is "Copied …: paste it where you like"). |
| Q200 | **The first-start Setup keeps its hints,** and is the one place that does: someone in their first minute doesn't know the help key yet. It tells them about it on its first and last screens. |
| Q201 | **Loud everywhere else:** the user guide opens with it, the FAQ has it first, and a device set up before this firmware gets one Toast, once: "Fn+h: the keys of any screen". |
| Q202 | `key help` over the consoles. Generating the website's key tables from the same lists is a follow-up, not this issue. |
| Q203 | The key and the panel first, host-tested; then one App at a time, declaring its keys and losing its hints in the same step; then every screen looked at on the device. |
### As built
- **`Key::Help`** from the key mapper: Fn+h in both modes, `?` only outside Text Entry (`lib/input`, 3 tests).
- **`App::help()` and `App::helpTitle()`** (`lib/core/src/app.h`), `KeyHelp` rows and `HelpModel` (`key_help.h`). The **App manager** opens the panel, appends the "Everywhere" group, and while it is open takes every key: nothing reaches the App, Home included. It closes when the App changes (5 tests).
- **Every App declares its keys by state:** the Launcher, IRC (chat, settings, a field), Wi-Fi Tools (4 views), GNSS, Gemini (page, saved page, address, answer, dialogs), the LoRa Scanner (4 views), Storage (browse, details, a name, the viewer's 6 modes, the editor, Maintenance, busy), Notes (list, editor, a file name), System (5 views), Settings (menu, text, choice, and the Wi-Fi, Firmware and Debug Console pages with their own states), Setup and the widget demo.
- **The hints are gone** from all of them. The footers that remain say state only.
- **It costs** 8.5 KB of flash and 40 bytes of static RAM.
### Checks
| Check | Result |
|---|---|
| Host tests | 476 pass (468 before) |
| On the device, `key help` and a screenshot | The Launcher and all nine Apps, and these states: Storage scrolled, System's tasks, a Settings text field, Wi-Fi Tools' networks. The panel is titled with the scope, lists the right keys, scrolls, and closes on Tab |
| The one-time Toast | `notification: Fn+h: the keys of any screen` on the first start after the update |
### One source for the device and the website (issue #72)
The lists first lived in each App's `help()`, as code. They are now **data, in one file**: `lib/core/src/app_keys.h`, 52 constant tables, each under a comment `// id: Title`. An App's `help()` picks the table of the state it is in. `site/tools/gen_dev_docs.py` reads the same file and writes `site/data/keys.toml`; the `keys` shortcode shows a screen's tables on its guide page, and `/guide/keys/` shows all of them. The Site job fails when the data file is out of date, or when a page asks for a table that doesn't exist, and it now runs when `app_keys.h` changes. A key added to an App shows up on the website without anyone editing a page.
Three rows lost their second wording on the way, since a table is constant: GNSS's `Tab` and `r`, and the Scanner's `c`, now say both things they do ("record a Track, or stop it") instead of the one that applies. The guide pages keep their written tables too, where they say more than a key list can; those can still drift, and the generated ones under them are the reference.
**Not checked:** the real Fn+h and `?` on the keyboard (the mapper is host-tested; the device was driven with `key help`); the Setup screens, which only a device that was never set up shows, so their new text has not been seen on a screen; and the states that need something to happen first (a dialog, a copy in progress, a Gemini prompt, a packet's details): their lists were read against the key handling, not looked at.
+9
View File
@@ -2,7 +2,10 @@
#include <cstdint>
#include <vector>
#include "key_event.h"
#include "key_help.h"
namespace roro {
@@ -25,6 +28,12 @@ class App {
// True while the App is editing text: the arrow keys then type ; . , / and need Fn to move.
virtual bool textEntryActive() const { return false; }
// The keys that work right now, for the help panel (Fn+h): the App's own, in the state it's
// in. Back, Home and the arrows are added for it. No screen names keys any other way.
virtual void help(std::vector<KeyHelp>& out) const { (void)out; }
// What the panel is titled with, when the App's name isn't enough ("Notes: editor").
virtual const char* helpTitle() const { return nullptr; }
// Called every main-loop pass while in the foreground (e.g. to refresh live values).
virtual void update(uint32_t nowMs) { (void)nowMs; }
+434
View File
@@ -0,0 +1,434 @@
#pragma once
#include <cstddef>
#include <vector>
#include "key_help.h"
// Every key of every screen, as data (issues #69 and #72). The help panel (Fn+h) shows the table of
// the state an App is in; site/tools/gen_dev_docs.py reads this file to write the website's key
// tables, so the two can't drift.
//
// The format is read by that script, so keep it: a comment `// id: Title`, then one table,
// inline constexpr KeyHelp kName[] = {
// {"keys", "what they do"},
// };
// with one row a line and plain string literals. `id` is what a guide page asks for.
namespace roro::keys {
template <size_t N>
inline void add(std::vector<KeyHelp>& out, const KeyHelp (&rows)[N]) {
out.insert(out.end(), rows, rows + N);
}
// everywhere: Everywhere
inline constexpr KeyHelp kEverywhere[] = {
{"`", "back"},
{"Fn `", "home, the Launcher"},
{"; . , /", "arrows (Fn+ while typing)"},
{"Fn h ?", "these keys (? not typing)"},
};
// dialog: A question
inline constexpr KeyHelp kDialog[] = {
{", /", "the other answer"},
{"Enter", "choose it"},
{"`", "cancel"},
};
// text: A text field
inline constexpr KeyHelp kText[] = {
{"Enter", "save"},
{"`", "cancel"},
{"Del", "delete backwards"},
{"Fn , /", "move the cursor"},
{"opt ' e", "an accent: \xC3\xA9"},
};
// launcher: The Launcher
inline constexpr KeyHelp kLauncher[] = {
{"; .", "up, down"},
{"Enter", "open the App"},
};
// setup: Setup, a step
inline constexpr KeyHelp kSetup[] = {
{"Enter", "continue"},
{"`", "the step before"},
};
// setup-choice: Setup, a choice
inline constexpr KeyHelp kSetupChoice[] = {
{"; .", "up, down"},
{"Enter", "choose it, next step"},
{"`", "the step before"},
};
// setup-text: Setup, a name
inline constexpr KeyHelp kSetupText[] = {
{"Enter", "next step"},
{"`", "the step before"},
{"Del", "delete backwards"},
{"Fn , /", "move the cursor"},
{"opt ' e", "an accent: \xC3\xA9"},
};
// irc: IRC
inline constexpr KeyHelp kIrc[] = {
{"Enter", "send the line"},
{"Tab", "the next buffer"},
{"Alt ; .", "scroll back, forward"},
{"Fn ; .", "lines you sent before"},
{"Fn , /", "move the cursor"},
{"Del", "delete backwards"},
{"/settings", "server, nick, passwords"},
{"/join #x", "join a channel"},
{"/part", "leave it"},
{"/msg nick", "a private chat"},
{"/me", "an action"},
{"/nick", "change your nick"},
{"/topic", "see or set the topic"},
{"/names", "who is there"},
{"/quit", "disconnect, and stay so"},
{"/raw", "a line as it is"},
{"`", "leave: IRC stays connected"},
};
// irc-settings: IRC settings
inline constexpr KeyHelp kIrcSettings[] = {
{"; .", "up, down"},
{"Enter", "edit, switch, or save"},
{"`", "leave without saving"},
};
// irc-field: IRC, a setting
inline constexpr KeyHelp kIrcField[] = {
{"Enter", "keep it"},
{"`", "cancel"},
{"Del", "delete backwards"},
{"Fn , /", "move the cursor"},
};
// wifi-tools: Wi-Fi Tools
inline constexpr KeyHelp kWifiTools[] = {
{"; .", "up, down"},
{"Enter", "open"},
};
// wifi-networks: Networks nearby
inline constexpr KeyHelp kWifiNetworks[] = {
{"; .", "up, down"},
{"Enter", "track its signal"},
{"s", "sort: signal, channel, name"},
{"o", "open networks only"},
{"h", "hide the hidden ones"},
{"w", "strong ones only"},
{"l", "log the scans to the card"},
};
// wifi-tracker: Signal tracker
inline constexpr KeyHelp kWifiTracker[] = {
{"m", "clicks on or off"},
};
// gnss: GNSS
inline constexpr KeyHelp kGnss[] = {
{"Tab", "the position, or the sky"},
{"r", "record a Track, or stop it"},
};
// gemini: Gemini, a page
inline constexpr KeyHelp kGemini[] = {
{"Tab", "the next link"},
{"Aa Tab", "the link before"},
{"Enter", "follow the link"},
{"` Del", "the page before"},
{"; .", "scroll"},
{"Space", "a page down"},
{", /", "sideways, in wide blocks"},
{"g", "type an address"},
{"b", "bookmark this page"},
{"s", "save the page to the card"},
{"S", "...with the pages it links to"},
};
// gemini-saved: Gemini, a Saved Page
inline constexpr KeyHelp kGeminiSaved[] = {
{"Tab", "the next link"},
{"Aa Tab", "the link before"},
{"Enter", "follow the link"},
{"` Del", "the page before"},
{"; .", "scroll"},
{"Space", "a page down"},
{", /", "sideways, in wide blocks"},
{"g", "type an address"},
{"b", "bookmark this page"},
{"r", "refresh this Saved Page"},
{"d", "delete this Saved Page"},
};
// gemini-address: Gemini, an address
inline constexpr KeyHelp kGeminiAddress[] = {
{"Enter", "go there"},
{"`", "cancel"},
{"Del", "delete backwards"},
{"Fn , /", "move the cursor"},
};
// gemini-answer: Gemini, an answer to a page
inline constexpr KeyHelp kGeminiAnswer[] = {
{"Enter", "send it"},
{"`", "cancel"},
{"Del", "delete backwards"},
{"Fn , /", "move the cursor"},
};
// lora: LoRa Scanner, the packets
inline constexpr KeyHelp kLora[] = {
{"; .", "up, down"},
{"Enter", "the packet's details"},
{"p", "pick a Meshtastic preset"},
{"c", "start a Capture, or stop it"},
{"Tab", "the Sweep"},
};
// lora-packet: LoRa Scanner, a packet
inline constexpr KeyHelp kLoraPacket[] = {
{"; .", "scroll"},
{"Enter", "back to the list"},
};
// lora-presets: LoRa Scanner, the presets
inline constexpr KeyHelp kLoraPresets[] = {
{"; .", "up, down"},
{"Enter", "listen with this preset"},
};
// lora-sweep: LoRa Scanner, the Sweep
inline constexpr KeyHelp kLoraSweep[] = {
{"Tab", "the Sniffer"},
};
// storage: Storage, a folder
inline constexpr KeyHelp kStorage[] = {
{"; .", "up, down"},
{"Enter", "open the folder or the file"},
{", /", "a page up, down"},
{"c x", "copy, cut"},
{"v", "paste here"},
{"r", "rename"},
{"d Del", "delete, after asking"},
{"n", "a new folder"},
{"i", "details: size, date, type"},
{"s", "sort: name, date, size"},
{"m", "Maintenance: clean-up, erase"},
{"`", "the folder above"},
};
// storage-details: Storage, an item's details
inline constexpr KeyHelp kStorageDetails[] = {
{"; .", "scroll"},
{"Enter", "back to the folder"},
};
// storage-name: Storage, a name
inline constexpr KeyHelp kStorageName[] = {
{"Enter", "rename it, or make the folder"},
{"`", "cancel"},
{"Del", "delete backwards"},
{"Fn , /", "move the cursor"},
};
// storage-busy: Storage, while it copies or deletes
inline constexpr KeyHelp kStorageBusy[] = {
{"`", "stop the copy or the delete"},
};
// maintenance: Storage, Maintenance
inline constexpr KeyHelp kMaintenance[] = {
{"; .", "up, down"},
{"Enter", "open, or choose"},
};
// viewer-text: A file, as text
inline constexpr KeyHelp kViewerText[] = {
{"; .", "a line up, down"},
{", /", "a page up, down"},
{"t b", "the top, the end"},
{"e", "edit it (up to 16 KB)"},
{"Tab", "the file as hex, or back"},
};
// viewer-hex: A file, as hex
inline constexpr KeyHelp kViewerHex[] = {
{"; .", "a line up, down"},
{", /", "a page up, down"},
{"t b", "the top, the end"},
{"Tab", "the file as text, or back"},
};
// viewer-pcap: A Capture
inline constexpr KeyHelp kViewerPcap[] = {
{"; .", "up, down"},
{"Enter", "the packet"},
{", /", "a page up, down"},
{"Tab", "the file as hex"},
};
// viewer-packet: A Capture's packet
inline constexpr KeyHelp kViewerPacket[] = {
{"; .", "scroll"},
{"Enter", "back to the packets"},
};
// viewer-gpx: A Track
inline constexpr KeyHelp kViewerGpx[] = {
{"Tab", "the file as text"},
};
// viewer-ota: An Update File
inline constexpr KeyHelp kViewerOta[] = {
{"Enter", "install it, if it's genuine"},
{"Tab", "the file as hex"},
};
// notes: Notes, the list
inline constexpr KeyHelp kNotes[] = {
{"; .", "up, down"},
{"Enter", "open the note"},
{", /", "a page up, down"},
{"n", "a new note"},
{"r", "rename its file"},
{"d Del", "delete it"},
{"s", "sort: newest, or by name"},
};
// notes-editor: Notes, the editor
inline constexpr KeyHelp kNotesEditor[] = {
{"Enter", "a new line"},
{"Del", "delete backwards"},
{"Tab", "two spaces"},
{"Fn ; . , /", "move the cursor"},
{"Alt Fn ; .", "a page up, down"},
{"Ctrl a e", "start, end of the line"},
{"opt ' e", "an accent: \xC3\xA9"},
{"`", "done: it saves by itself"},
};
// notes-name: Notes, a file name
inline constexpr KeyHelp kNotesName[] = {
{"Enter", "rename the file"},
{"`", "cancel"},
{"Del", "delete backwards"},
{"Fn , /", "move the cursor"},
};
// system: System, any view
inline constexpr KeyHelp kSystem[] = {
{"Tab", "the next view"},
{"Aa Tab", "the view before"},
};
// system-tasks: System, the tasks
inline constexpr KeyHelp kSystemTasks[] = {
{"Tab", "the next view"},
{"Aa Tab", "the view before"},
{"; .", "scroll"},
{"s", "sort: cpu, stack, name"},
};
// system-system: System, the system view
inline constexpr KeyHelp kSystemSystem[] = {
{"Tab", "the next view"},
{"Aa Tab", "the view before"},
{"; .", "scroll"},
};
// settings: Settings
inline constexpr KeyHelp kSettings[] = {
{"; .", "up, down"},
{"Enter", "edit, or open the page"},
{", /", "change a switch or a slider"},
};
// settings-choice: Settings, a choice
inline constexpr KeyHelp kSettingsChoice[] = {
{"; .", "up, down"},
{"Enter", "choose it"},
};
// wifi: Settings, Wi-Fi
inline constexpr KeyHelp kWifi[] = {
{"; .", "up, down"},
{"Enter", "open, or change"},
{", /", "switch Wi-Fi on or off"},
};
// wifi-servers: Settings, DNS and NTP
inline constexpr KeyHelp kWifiServers[] = {
{"; .", "up, down"},
{"Enter", "edit"},
{", /", "Always use my DNS: on, off"},
};
// wifi-network: Settings, a saved network
inline constexpr KeyHelp kWifiNetwork[] = {
{"; .", "up, down"},
{"Enter", "edit, or forget"},
{", /", "Automatic or Fixed"},
};
// wifi-status: Settings, the Wi-Fi status
inline constexpr KeyHelp kWifiStatus[] = {
{"Enter", "back"},
};
// wifi-scan: Settings, adding a network
inline constexpr KeyHelp kWifiScan[] = {
{"; .", "up, down"},
{"Enter", "choose this network"},
};
// wifi-name: Settings, a hidden network's name
inline constexpr KeyHelp kWifiName[] = {
{"Enter", "next: the password"},
{"`", "cancel"},
{"Del", "delete backwards"},
{"Fn , /", "move the cursor"},
};
// firmware: Settings, Firmware
inline constexpr KeyHelp kFirmware[] = {
{"; .", "up, down"},
{"Enter", "check, open, or install"},
{"c", "look for a newer release"},
};
// firmware-release: Settings, a release
inline constexpr KeyHelp kFirmwareRelease[] = {
{"; .", "scroll"},
{"Enter", "install it"},
{"c", "check again"},
};
// firmware-older: Settings, older releases
inline constexpr KeyHelp kFirmwareOlder[] = {
{"; .", "up, down"},
{"Enter", "its details"},
{"c", "read the list again"},
};
// debug-console: Settings, Debug Console
inline constexpr KeyHelp kDebugConsole[] = {
{"; .", "up, down"},
{"Enter", "switch, or open"},
{", /", "switch the console on or off"},
};
// demo: The widget demo
inline constexpr KeyHelp kDemo[] = {
{"; .", "up, down"},
{"Enter", "try the widget"},
};
} // namespace roro::keys
+19
View File
@@ -1,5 +1,7 @@
#include "app_manager.h"
#include "app_keys.h"
#include <cstring>
namespace roro {
@@ -52,6 +54,22 @@ void AppManager::endModal() {
}
void AppManager::handleKey(const KeyEvent& event) {
if (help_.isOpen()) { // scrolls or closes; nothing reaches the App, Home included
help_.onKey(event);
redraw_ = true;
return;
}
if (event.key == Key::Help) {
std::vector<KeyHelp> rows;
foreground_->help(rows);
rows.push_back({"Everywhere", nullptr});
keys::add(rows, keys::kEverywhere);
const char* scope = foreground_->helpTitle();
const char* app = foregroundTitle();
help_.open(scope ? scope : app ? app : "Launcher", std::move(rows));
redraw_ = true;
return;
}
if (modal_) {
foreground_->onKey(event);
return;
@@ -77,6 +95,7 @@ bool AppManager::takeRedraw() {
}
void AppManager::switchTo(App& app) {
help_.close(); // an App opened from elsewhere (a Notification, a command): its keys, not the last one's
if (&app == foreground_) return;
foreground_->onExit();
foreground_ = &app;
+4
View File
@@ -37,6 +37,9 @@ class AppManager {
App& foreground() const { return *foreground_; }
const char* foregroundTitle() const; // nullptr for the Launcher
// The help panel (Fn+h): open, it takes every key, and the App sees none of them.
const HelpModel& help() const { return help_; }
// True once after the screen needs redrawing (App switch, or the App asked for it).
bool takeRedraw();
@@ -47,6 +50,7 @@ class AppManager {
App& launcher_;
App* foreground_;
std::vector<AppInfo> apps_;
HelpModel help_;
bool redraw_ = true;
bool modal_ = false;
};
+1
View File
@@ -16,6 +16,7 @@ enum class Key : uint8_t {
Home,
Tab,
Delete,
Help, // Fn+h anywhere, or ? outside Text Entry: the keys of this screen (issue #69)
};
struct KeyEvent {
+63
View File
@@ -0,0 +1,63 @@
#pragma once
#include <algorithm>
#include <string>
#include <vector>
#include "key_event.h"
// The help panel (issue #69, docs/milestones/U1.md): Fn+h on any screen lists the keys that work
// there. Every App says what its keys are in the state it's in; nothing on a screen names keys.
namespace roro {
// One line of the panel: a key (or keys) and what it does. A line with no action is a heading.
struct KeyHelp {
const char* keys;
const char* action;
};
// The panel itself: what it lists, and how far it's scrolled. The keys it takes while open are the
// arrows; any other key closes it, and none reaches the App.
class HelpModel {
public:
explicit HelpModel(int visibleRows = 8) : visible_(visibleRows) {}
void open(const std::string& title, std::vector<KeyHelp> rows) {
title_ = title;
rows_ = std::move(rows);
top_ = 0;
open_ = true;
}
void close() {
open_ = false;
rows_.clear();
rows_.shrink_to_fit(); // nothing is kept while it's closed
}
bool isOpen() const { return open_; }
void onKey(const KeyEvent& e) {
int last = std::max(0, static_cast<int>(rows_.size()) - visible_);
switch (e.key) {
case Key::Up: top_ = std::max(0, top_ - 1); break;
case Key::Down: top_ = std::min(last, top_ + 1); break;
case Key::Left: top_ = std::max(0, top_ - visible_); break;
case Key::Right: top_ = std::min(last, top_ + visible_); break;
default: close(); break;
}
}
const std::string& title() const { return title_; }
const std::vector<KeyHelp>& rows() const { return rows_; }
int top() const { return top_; }
int visibleRows() const { return visible_; }
private:
std::string title_;
std::vector<KeyHelp> rows_;
int top_ = 0;
int visible_;
bool open_ = false;
};
} // namespace roro
+14
View File
@@ -99,6 +99,20 @@ void KeyMapper::onChar(char c, const RawKeys& keys, std::vector<KeyEvent>& out)
return;
}
break;
case 'h':
case 'H':
if (keys.fn) { // Fn+h: help, while typing too
out.push_back(KeyEvent::of(Key::Help));
return;
}
break;
case '?':
if (!textEntry_ && !keys.fn) { // ? alone, when it wouldn't be typed
out.push_back(KeyEvent::of(Key::Help));
return;
}
if (keys.fn) return;
break;
default:
if (keys.fn) return; // other Fn combos are unassigned
break;
+1 -1
View File
@@ -23,7 +23,7 @@ struct RawKeys {
// Turns keyboard state changes into logical KeyEvents: only newly pressed keys produce events;
// Fn + ; . , / are arrows, and so are ; . , / alone when no text is being entered; ` is Back and
// Fn + ` is Home; the Compose Key (opt) followed by an accent and a letter types the accented
// Fn + ` is Home; Fn + h is Help anywhere, and so is ? when no text is being entered; the Compose Key (opt) followed by an accent and a letter types the accented
// letter (opt ' e -> é).
class KeyMapper {
public:
+1
View File
@@ -44,6 +44,7 @@ const Definition kDefinitions[] = {
{"check_updates", Kind::Bool, 1, nullptr, 0, 1}, // on: it only looks, and says so (R1, Q165)
{"debug_on", Kind::Bool, 0, nullptr, 0, 1}, // off: nothing listens until the owner says so (Q189)
{"debug_token", Kind::String, 0, "", 0, 64}, // empty, or a valid token
{"help_told", Kind::Bool, 0, nullptr, 0, 1},
};
static_assert(sizeof(kDefinitions) / sizeof(kDefinitions[0]) == static_cast<size_t>(Setting::Count),
"every Setting needs a definition");
+1
View File
@@ -33,6 +33,7 @@ enum class Setting : uint8_t {
CheckUpdates, // bool: look for a newer release on Gitea once a day (R1, Q165)
DebugConsole, // bool: the Debug Console listens on Wi-Fi (ADR 0010, Q189: off unless switched on)
DebugToken, // string: its token, tidied (debug_auth.h); empty until the console is first switched on
HelpTold, // bool: this device has been told about the help key once (issue #69, Q201)
Count
};
+2 -2
View File
@@ -28,7 +28,7 @@ gnss quiet on|off pause the GNSS receiver while the LoRa radio listens (it cos
gnss status | gnss restart | gnss track start|stop | gnss nmea on|off | gnss send <sentence without $ and checksum>
crash the last crash: firmware, reason, task, backtrace
coredump erase forget the core dump in flash
key <name|char> press a key: up down left right select back home del tab space, or one character
key <name|char> press a key: up down left right select back home del tab space help, or one character
wifi status | wifi add <ssid><TAB><password>
wifi ip <ssid> dhcp | wifi ip <ssid> <address>/<prefix> [gateway] a Saved Network's IP setting
wifi dns <a> [b] | wifi dns always on|off | wifi ntp <a> [b] DNS and NTP servers
@@ -60,7 +60,7 @@ In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few r
| Command | Effect |
|---|---|
| `burst` | Publishes 5 Notifications at once |
| `key up\|down\|left\|right\|select\|back\|home`, or `key <char>` | Injects a key press |
| `key up\|down\|left\|right\|select\|back\|home\|del\|tab\|space\|help`, or `key <char>` | Injects a key press (`help` is Fn+h: the keys of the screen that is showing) |
| `sound on` / `sound off` | Toggles the Sound setting (beep + LED) |
| `short` / `normal` | Screen timeouts 5 s / 10 s, or 30 s / 60 s |
| `wifi add <ssid><TAB><password>` | Adds a Saved Network (so credentials stay out of the repo) |
+3 -1
View File
@@ -11,7 +11,7 @@ Everything the keyboard can do, a command can do, and everything on the screen c
## Keys
```
key up|down|left|right|select|back|home|del|tab|space
key up|down|left|right|select|back|home|del|tab|space|help
key a # any single character: it is typed
```
@@ -22,6 +22,8 @@ Two things to know before you use them:
`Fn` combinations, modifiers and the compose key have no command: the arrows are `key up|down|left|right`, and `key back` is the back key (`` ` `` on the device). Text is typed one character at a time.
**`key help` opens the help panel** (<kbd>Fn</kbd>+<kbd>h</kbd> on the device): the keys of the screen that is showing. A screenshot of it is the quickest way to learn what a screen accepts, and it is how every screen's list was checked. Any key but the arrows closes it.
## Look before you press
**Take a screenshot before any key that deletes, renames or installs.** A blind sequence of `key` commands goes wrong the moment the screen is not where you think it is, and the screen is often not where you think it is: a Toast, a dialog that has not closed, a different App. A sequence that was meant to open a note once renamed real data instead.
+54
View File
@@ -0,0 +1,54 @@
+++
title = "Look and feel"
description = "The interface is consistent and uncrowded: the same thing is done the same way on every screen, and the 135 pixels of height go to content."
weight = 90
[extra]
docs = true
source = "docs/milestones/U1.md"
tag = "U1"
+++
**Status:** in progress. The help key (issue #69) is merged; the website's key tables generated from the same lists (issue #72) are in a pull request. Screen recording (#17) and the rest of the milestone are not started.
**Goal:** the interface is consistent and uncrowded: the same thing is done the same way on every screen, and the 135 pixels of height go to content.
## The help key (issue #69)
Every screen used to say something about its keys, differently: a footer of abbreviations in one place (`c x v:paste r:name d:del n:new i:info s:sort`), a line under a text field in another (`Enter: save \`: cancel`), `Tab: sky` in a corner, and nothing at all in several. About 30 such strings, each costing a line of a small screen, and none of them complete.
### Decisions (design round 2026-10-07)
| # | Decision |
|---|---|
| Q196 | **Fn+h, on every screen,** text fields included (Fn is held, so nothing is typed). **`?` too, outside Text Entry.** |
| Q197 | It opens **a panel over the content area**, titled with where you are: the screen's own keys, then an "Everywhere" group (Back, Home, the arrows, the help key). The arrows scroll it; any other key closes it and is not passed on. |
| Q198 | **Each App answers "what are your keys right now?"** for the state it is in; pages, viewers, dialogs and text fields answer for themselves, with shared lists for dialogs, lists and text entry. The lists are constants; the panel's rows exist only while it is open. |
| Q199 | **Every hint that names a key goes,** text fields included. What stays is state: `REC 12 points`, `LOG 42`, `sort:signal`, `typing`/`saved`, what is waiting to be pasted, the Sweep's floor. Messages were reworded where they named a key ("v pastes a copy of…" is "Copied …: paste it where you like"). |
| Q200 | **The first-start Setup keeps its hints,** and is the one place that does: someone in their first minute doesn't know the help key yet. It tells them about it on its first and last screens. |
| Q201 | **Loud everywhere else:** the user guide opens with it, the FAQ has it first, and a device set up before this firmware gets one Toast, once: "Fn+h: the keys of any screen". |
| Q202 | `key help` over the consoles. Generating the website's key tables from the same lists is a follow-up, not this issue. |
| Q203 | The key and the panel first, host-tested; then one App at a time, declaring its keys and losing its hints in the same step; then every screen looked at on the device. |
### As built
- **`Key::Help`** from the key mapper: Fn+h in both modes, `?` only outside Text Entry (`lib/input`, 3 tests).
- **`App::help()` and `App::helpTitle()`** (`lib/core/src/app.h`), `KeyHelp` rows and `HelpModel` (`key_help.h`). The **App manager** opens the panel, appends the "Everywhere" group, and while it is open takes every key: nothing reaches the App, Home included. It closes when the App changes (5 tests).
- **Every App declares its keys by state:** the Launcher, IRC (chat, settings, a field), Wi-Fi Tools (4 views), GNSS, Gemini (page, saved page, address, answer, dialogs), the LoRa Scanner (4 views), Storage (browse, details, a name, the viewer's 6 modes, the editor, Maintenance, busy), Notes (list, editor, a file name), System (5 views), Settings (menu, text, choice, and the Wi-Fi, Firmware and Debug Console pages with their own states), Setup and the widget demo.
- **The hints are gone** from all of them. The footers that remain say state only.
- **It costs** 8.5 KB of flash and 40 bytes of static RAM.
### Checks
| Check | Result |
|---|---|
| Host tests | 476 pass (468 before) |
| On the device, `key help` and a screenshot | The Launcher and all nine Apps, and these states: Storage scrolled, System's tasks, a Settings text field, Wi-Fi Tools' networks. The panel is titled with the scope, lists the right keys, scrolls, and closes on Tab |
| The one-time Toast | `notification: Fn+h: the keys of any screen` on the first start after the update |
### One source for the device and the website (issue #72)
The lists first lived in each App's `help()`, as code. They are now **data, in one file**: `lib/core/src/app_keys.h`, 52 constant tables, each under a comment `// id: Title`. An App's `help()` picks the table of the state it is in. `site/tools/gen_dev_docs.py` reads the same file and writes `site/data/keys.toml`; the `keys` shortcode shows a screen's tables on its guide page, and `/guide/keys/` shows all of them. The Site job fails when the data file is out of date, or when a page asks for a table that doesn't exist, and it now runs when `app_keys.h` changes. A key added to an App shows up on the website without anyone editing a page.
Three rows lost their second wording on the way, since a table is constant: GNSS's `Tab` and `r`, and the Scanner's `c`, now say both things they do ("record a Track, or stop it") instead of the one that applies. The guide pages keep their written tables too, where they say more than a key list can; those can still drift, and the generated ones under them are the reference.
**Not checked:** the real Fn+h and `?` on the keyboard (the mapper is host-tested; the device was driven with `key help`); the Setup screens, which only a device that was never set up shows, so their new text has not been seen on a screen; and the states that need something to happen first (a dialog, a copy in progress, a Gemini prompt, a packet's details): their lists were read against the key handling, not looked at.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

@@ -0,0 +1,266 @@
+++
title = '''It was off'''
description = '''roro9stack gets a website, and writing down how its Debug Console works shows that only one person could ever use it. So the Debug Build is retired: one firmware, with the console in it, off until its owner switches it on. Then an evening of chasing a console that wouldn't come back and a memory leak, neither of which existed.'''
date = 2026-10-07T00:30:00+02:00
[extra]
topics = '''ESP32-S3 · Debugging · Security'''
read_label = '''Read what was off →'''
uid = '''<b>debug:</b> on, token set, client connected'''
dek = "A day of writing for [roro9stack](/devlog/roro9stack/), my firmware for the M5Stack Cardputer: a website, a user guide, and developer docs about the thing I like best in it, a console over Wi-Fi. Documenting it properly made one fact hard to miss: nobody else could have it. Fixing that deleted more than it added, and then cost me an evening looking for two bugs that turned out to be a switch in the Off position and TCP minding its own business."
byline = '''designed by interrogation, round twelve: fourteen questions thrown away, eight kept'''
[extra.sign]
label = "Tokens leaked by the feature built to protect them"
note = "In a screenshot, taken over the console, of the one page that shows the token."
count = "1"
tone = "red"
[[extra.cast]]
name = "The Debug Build"
role = "retired, v0.3.0 to v0.11.0"
text = "The same firmware plus a console over Wi-Fi, with its builder's token compiled in. Which is why it could never be published, and why the firmware I tested was never the one I released."
[[extra.cast]]
name = "Port 3232"
role = "the Update Service, in every build since v0.3.0"
text = "Listens on the network in release builds, guarded by a signature. The counter-example to \"in a release, nothing listens\" that had been sitting there all along."
[[extra.cast]]
name = "The token"
role = "100 bits, 20 characters"
text = "Made by the device, shown on one page of Settings and nowhere else. Read off a 240-pixel screen by a human, which is where the trouble started."
[[extra.cast]]
name = "The dialog"
role = "\"Switch it on?\""
text = "Opens with Cancel selected, as a question about a remote control should. Enter, Enter: still off. Works exactly as designed."
[[extra.cast]]
name = "TIME_WAIT"
role = "two minutes, per closed connection"
text = "What TCP does with a connection it has closed, in case a late packet turns up. About 270 bytes each. Looks exactly like a leak if you only watch for one minute."
+++
## TL;DR
- **roro9stack has a website:** [roro9stack.net](/), with an Install page that flashes a Cardputer from the browser, a [user guide](/guide/), [how-tos](/howto/), an [FAQ](/faq/), [developer docs](/dev/) generated from the repository, and this devlog, which moved here.
- **The Debug Build is gone.** There is one firmware (**v0.12.0**), and the Debug Console is in it: **off** until you switch it on in Settings, with a token the device makes itself.
- **The token never crosses the network.** The device sends a challenge, the client answers with an HMAC. Five wrong answers close the console for a minute.
- **It costs** 30 KB of flash and 88 bytes of RAM over the old release build. Off, nothing listens and nothing is allocated.
- Then I spent an evening on **a console that wouldn't come back on** (it was off) and **a memory leak of 230 bytes per reopening** (it was TCP). Both produced a real fix on the way, and one design change I had to undo.
- 468 host tests, 12 more than last time. The decision is [ADR 0010](/dev/decisions/0010-debug-console-in-every-build/).
## The cast
{{ cast() }}
## A site, in four phases
The [last post](/devlog/roro9stack-f1-r1/) ended with a device that installs its own releases. That makes it something another person could use, and another person needs somewhere to start that isn't a Gitea README. So the first half of the day was a website: a home page, an Install page, then a guide to each App, how-tos, an FAQ, and developer docs.
Three things from that half are worth keeping.
**Flashing from the browser, without a copy of the firmware.** The Install page uses ESP Web Tools and Web Serial. The obvious way is to copy the firmware image next to the page; then every release needs a rebuild of the site. Instead the page asks Gitea's API for the latest release, in the browser, and hands the flasher a manifest it builds on the spot. That only needed one header: Caddy, in front of Gitea, adds `Access-Control-Allow-Origin: https://roro9stack.net` to the release downloads and to the releases API, which are public anyway. A new release shows up on the page the moment it exists.
**"Generated from the repository" met Zola.** The developer docs were to be built from `docs/`, the README and the firmware's own `help` text, not copied by hand. Zola refuses to read a file outside its own folder, and it resolves symlinks before deciding, so that door is closed too. So a small script writes those pages, they are committed, and CI fails when one is out of date. The command reference is the part I like: it is parsed from the `kHelp` string in `main.cpp`, so the site can't describe a command the firmware doesn't have.
**The posts you're reading had `<style>` in them.** This devlog came over from my blog, seventeen diagrams included, each an inline SVG with its own `<style>` block. The site's Content-Security-Policy is `style-src 'self'`, which refuses exactly that. The rules moved into a stylesheet and the `style="…"` attributes became classes. Tested in a real browser with the production policy on every response: no violations.
The site says only what the firmware does today, which meant writing the same sentence several times: the mesh messenger isn't built. The radio listens. It doesn't talk yet.
## Documenting a feature only I could use
The part of the developer docs I cared most about is the Debug Console: the serial console over Wi-Fi, with key presses, screenshots, file transfer and crash dumps. I wrote six pages on it, checked the protocol against the live device, and by the end had described, in detail, a feature with this property:
> A Debug Build carries its builder's token, so Debug Builds are never published.
Everything in those pages was for whoever builds the firmware. That's me.
The issue I filed was the obvious patch: make the token configurable, so that Debug Builds can be published. The design round for it had fourteen questions. Where does a published Debug Build go, so that devices in the field don't install it by accident (v0.11.0 takes the last `.ota` it finds in a release)? What is its tag called, so that CI doesn't start itself again? How does a device that runs one get the next?
Then one question from the other side of the table replaced all fourteen: *why have Debug Builds at all?*
## The argument that was never whole
[ADR 0004](/dev/decisions/0004-debug-console-in-debug-builds/) kept the console out of release builds with one sentence, which I was rather proud of:
> A console that runs commands is a remote control: in a release build, nothing listens.
Except something does. The Update Service has listened on TCP 3232 in every build since v0.3.0, guarded by a signature. "Nothing listens" was never true; "nothing listens without a lock on it" was. A console that is off by default, behind a secret the device made itself, is the same trade.
And the split had been charging rent the whole time:
- **What I tested wasn't what I shipped.** I lived on Debug Builds. Releases were a different binary that nobody ran before it was published.
- **Rules that only protected the console.** A Debug Build refused to install a release (it would have lost its console), so there was `update install … force` to do it anyway, and advice about which firmware to keep in the other slot.
- **Versions ending in `+debug`** that had to compare equal to their release.
- **Two firmwares built by CI** on every pull request and every tag.
One firmware deletes all four.
## What off means
The new argument only holds if "off" is as good as "not compiled in". So:
- **Off is the default,** and what a missing or damaged setting falls back to.
- **Off, nothing exists:** no socket, no task, and no 4 KB buffer, which used to be a static array and is now allocated when the console is switched on.
- **On needs someone at the device,** or on the USB cable. Over the console itself, `debug on` and `debug token` answer `over USB serial only`: it can switch itself off, not open itself wider.
{% table() %}
| Build | Flash | Static RAM |
|---|---|---|
| The release build, before | 1,851,387 | 54,612 |
| The Debug Build, before | 1,874,887 | 58,780 |
| **The one firmware** | **1,881,799** | **54,700** |
{% end %}
Thirty kilobytes of flash, and 88 bytes of RAM, for a console in every device. The Debug Build's extra 4 KB of RAM was the buffer, sitting there whether anyone connected or not.
## A token you can read, and one you can't steal
The token used to be 32 hex digits in a file on my laptop, compiled in. Now the device makes it, the first time the console is switched on: 100 bits from the hardware generator, as twenty characters of Crockford's base32, the alphabet that leaves out I, L, O and U because people misread them.
The old login sent the token as the first line of a plain TCP connection. That was fine for a secret that lived on one laptop and one device on one network. It's not fine for a secret in every device, on whatever Wi-Fi its owner uses: mine is called `knbg-guests`, and anyone with the Wi-Fi password can watch it.
So the token no longer travels:
{% code(caption="The whole login. A recorded answer is no use for the next challenge.") %}
```
device: roro9stack debug console, challenge 3f9a…c1 (16 random bytes)
client: HMAC-SHA256(key = token, message = those bytes), in hex
device: roro9stack v0.12.0 debug console. 'help' lists the commands. Backlog follows.
```
{% end %}
HMAC is twenty lines on top of the SHA-256 the firmware already had for update files, checked against the RFC's vectors and against the same vector as the Python client. Five wrong answers in a row and the console answers `locked` to everyone for a minute:
{% code(caption="Six tries with a wrong token, then the right one.") %}
```
try 1 at 1s: wrong token
try 2 at 3s: wrong token
try 3 at 4s: wrong token
try 4 at 5s: wrong token
try 5 at 6s: wrong token
try 6 at 6s: closed for a minute after too many wrong tokens
right token: closed for a minute after too many wrong tokens
```
{% end %}
This breaks every old client. I'm the only user, and v1 is a long way off; this is exactly when to break things.
## Three ways it went wrong
### I couldn't read my own token
First login, on the real device: `wrong token`. The firmware was right. I had typed one character wrong, reading a 20-character token drawn in bold at the normal size, where 8 and B, 5 and S, 2 and Z are a matter of opinion.
The token is now drawn at twice the size, in regular weight, on two lines. And what I should have done from the start: Crockford's rule says that if someone types an O, an I or an L, they meant 0 or 1, because the alphabet doesn't have those letters. Both ends now apply it.
### The screenshot
To check the new layout I did what I always do: took a screenshot over the console.
{{ figure(src="token.png", alt="The Cardputer's Settings, Debug Console page at 2x: Debug Console On, Connect to 10.39.39.12:2323, New token, Type a token, and below them a token in large type on two lines, T49R-HQXB-NDJX then BNSB-XSWD. The Status Bar shows DBG in blue.", width=480, height=270, caption="The page that shows the token \"and nowhere else\", in a file on my laptop. This token was replaced within the hour.") }}
The documentation I had written an hour earlier says the token is shown on one page and never printed anywhere. And there it is, in a PNG. No escalation: whoever can take a screenshot already has the token. But a secret now sits in a file because of a habit, and the sign at the top of this post is for that. The docs now say it in so many words: a screenshot of that page is a copy of the token.
### The console that wouldn't come back
The last test was `debug off`, sent over the console: the client is dropped, the port refuses connections. Good. I switched it back on at the device, made a new token, and tried to log in.
Connection refused.
The device was up. Its update port answered. And it stayed refused across another firmware push and a restart. So, I reasoned, the console fails to come back once it has been closed, and I went looking:
- **The framework's `begin()` returns nothing.** `NetworkServer::begin()` can fail at `socket()`, `bind()` or `listen()` and says nothing either way; my code set `listening = true` and never asked. A real hole. Fixed: it asks, complains on the console, and tries again every two seconds.
- **I couldn't reproduce it from my desk.** Switching the console on is for the device and the cable only, by design, so the one path I wanted to test was the one I had made unreachable. Hence `debug off <seconds>`: the console closes and comes back by itself after the pause. It can't open anything wider (same state, same token), and it makes closing and reopening something a script can do twenty times.
Then I went and looked at the device's screen.
It said **Off**.
The "Switch it on?" dialog opens with **Cancel** selected. Enter on the row, Enter on the dialog: nothing changes, and the page says so in plain letters that I hadn't read. I had filed a bug against a switch for being in the position I left it in.
### The leak that was TCP
With `debug off <seconds>` I could finally hammer the path. It came back every time. And free memory fell by about 230 bytes per cycle.
A leak in code I had just written, in a firmware where 836 bytes was [once all that was left](/devlog/roro9stack-f1-r1/). I had a suspect: the console's task is deleted when the console goes off and made again when it comes back. Twenty plain logins leaked nothing, so it wasn't connections. I changed the design: the task stays, asleep, while the console is off. That breaks the promise that off means nothing is there, but a leak is worse.
It leaked exactly as much as before.
So I did what I should have done first, and measured for longer:
{% table() %}
| | Free heap |
|---|---|
| Before | 107,248 |
| Right after 15 closings and reopenings | 103,180 |
| 60 s later | 105,008 |
| 120 s later | 107,004 |
| 240 s later | 106,924 |
{% end %}
All of it comes back. When the device closes a connection, TCP keeps it for two minutes in case a late packet arrives, and each one holds about 270 bytes. Fifteen cycles in a minute and a half look like a leak; fifteen cycles and a cup of tea look like nothing at all.
The design change went back out. Off means the task is gone too, as decided.
## What held
{{ figure(src="dbg.png", alt="The Cardputer's Launcher at 2x, listing IRC, Wi-Fi Tools, GNSS, Gemini, LoRa Scanner, Storage, Notes, System and Settings. The Status Bar shows DBG in blue between the GNSS and Wi-Fi indicators.", width=480, height=270, caption="`DBG` in the Status Bar: there while the console listens, bright while someone is connected. Taken over the console, so: bright.") }}
Checked on the device, not just in tests:
- **Off by default.** Pushed over a Debug Build, the new firmware came up with port 2323 refusing connections.
- **The setting survives updates.** Four pushes later the console came back by itself each time.
- **Safe Mode has the console.** Three `crash abort` in a row, and the device started in Safe Mode with 178 KB free and the console reachable. `ls /` answered `not available in Safe Mode`, and the crash decoded to the line of `main.cpp` with `abort()` on it.
- **25 closings and reopenings,** each back a second after its pause.
- **More memory than before.** 108 KB free with the console on and a client connected, against 104 KB on the Debug Build.
And what I didn't check: `scripts/flash.sh --debug`, which now sets a device up over USB, with no cable attached to the VM that night; typing a token of my own on the device; and the notification on the screen during a lockout, which nobody connected can see, the console being closed.
## By the numbers
{% table() %}
| | |
|---|---|
| Design questions written, then thrown away | 14 |
| Design questions kept | 8 |
| Host tests | 468 |
| Bits in a token | 100 |
| Wrong answers before the console closes | 5 |
| Flash it costs every device | 30,412 bytes |
| RAM it costs every device, off | 88 bytes |
| Bytes TCP holds for a closed connection, for two minutes | about 270 |
| Pages on the website | 66 |
| Bugs found in the console that evening | 1 (the silent `begin()`) |
| Bugs I thought I'd found | 3 |
{% end %}
## Where it stands
{% steps() %}
1. ~~M0 and M1: the skeleton, Wi-Fi, IRC, Wi-Fi Tools.~~ v0.1.0 to v0.2.1, [the first post](/devlog/roro9stack/).
2. ~~Updates and debugging over the air.~~ v0.3.0, [Look, no cables](/devlog/roro9stack-ota/).
3. ~~M2: GNSS.~~ v0.4.0, [Seventeen satellites](/devlog/roro9stack-gnss/).
4. ~~G1: Gemini.~~ v0.5.0, [A browser in the RAM IRC left over](/devlog/roro9stack-gemini/).
5. ~~M3: the LoRa radio, listening.~~ v0.6.0, [The loudest thing it hears is itself](/devlog/roro9stack-lora/).
6. ~~S1: the card, fixed addresses, the System App.~~ v0.6.1 to v0.8.1, [One byte too early](/devlog/roro9stack-s1/).
7. ~~F1 and the start of R1: files, notes, signed releases, updates from Gitea.~~ v0.9.0 to v0.11.0, [836 bytes](/devlog/roro9stack-f1-r1/).
8. ~~W1: the website.~~ You're on it.
9. ~~One firmware, with the Debug Console in it.~~ v0.12.0, this post.
10. Next: notes of any size, a shell on the device itself for the same commands, and one help key everywhere instead of a hint line on every screen. And M4, the mesh, which still wants a second node.
{% end %}
{% signoff() %}
I wrote a feature whose whole point is that it's off until you switch it on, and then spent an evening debugging it for being off. The switch works. So does TCP. The only thing that leaked was the token, and I did that myself.
{% end %}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

+4
View File
@@ -6,6 +6,10 @@ template = "guide-page.html"
toc = true
+++
## Which keys work on this screen?
Press **<kbd>Fn</kbd> + <kbd>h</kbd>**, on any screen: it lists the keys that work there, then the ones that work everywhere. <kbd>?</kbd> does the same when you are not typing text. The screens themselves never name their keys, so this is the one key to remember. See [The basics](/guide/basics/).
## Can I send messages over the mesh?
**Not yet.** The LoRa Scanner **listens** to Meshtastic traffic and shows what it hears, but nothing is transmitted. A mesh messenger is the goal and is planned in two milestones, one for receiving and one for transmitting; it waits for a second node to test against.
+1 -1
View File
@@ -6,7 +6,7 @@ sort_by = "weight"
page_template = "guide-page.html"
+++
This guide says what the firmware does **today** and nothing else. Start with the basics (the keys, the Launcher, the first start), then read the page of any App. It describes the latest release; the numbers and key names come from the firmware's own source.
This guide says what the firmware does **today** and nothing else. Start with the basics (the keys, the Launcher, the first start), then read the page of any App. **On the device itself, <kbd>Fn</kbd> + <kbd>h</kbd> lists the keys of whatever screen you are on:** nothing else on a screen names them. It describes the latest release; the numbers and key names come from the firmware's own source.
**The mesh messenger is planned, not built.** The LoRa Scanner listens to Meshtastic traffic and shows it, but the device sends nothing yet: that is the next milestone and waits for a second node to test with.
+17 -2
View File
@@ -6,6 +6,14 @@ weight = 1
tag = "Start here"
+++
## One key to remember
**<kbd>Fn</kbd> + <kbd>h</kbd>, on any screen, lists the keys that work there.** No screen names its keys: that key does. It works everywhere, in a text field too, and <kbd>?</kbd> does the same whenever you are not typing. The arrows scroll the list; any other key closes it.
The list is for *the screen you are on*: in Storage it is the file keys, in a dialog it is the dialog's, in a text field it is the editing keys. Each list ends with the keys that work everywhere.
If you only read one paragraph of this guide, this was it.
## The keys
The Cardputer's keyboard has no arrow keys and no Escape, so the firmware gives a few keys a second job:
@@ -17,11 +25,12 @@ The Cardputer's keyboard has no arrow keys and no Escape, so the firmware gives
| <kbd>Fn</kbd> + <kbd>`</kbd> | **Home**: back to the Launcher |
| <kbd>Fn</kbd> + <kbd>;</kbd> <kbd>.</kbd> <kbd>,</kbd> <kbd>/</kbd> | The arrows: up, down, left, right |
| <kbd>;</kbd> <kbd>.</kbd> <kbd>,</kbd> <kbd>/</kbd> alone | The same arrows, as long as you are **not** typing text |
| <kbd>Fn</kbd> + <kbd>h</kbd>, or <kbd>?</kbd> when not typing | **Help:** the keys of the screen you are on |
| <kbd>Tab</kbd> | Switches view in an App that has more than one |
| <kbd>Del</kbd> | Deletes backwards when you type |
| <kbd>opt</kbd> then an accent, then a letter | Types an accented letter: <kbd>opt</kbd> <kbd>'</kbd> <kbd>e</kbd> gives é |
While you type text (a note, an IRC line, a setting), `;` `.` `,` `/` type their own characters and you need <kbd>Fn</kbd> for the arrows. The bottom of the screen shows `opt` while a compose is waiting for its letter.
While you type text (a note, an IRC line, a setting), `;` `.` `,` `/` type their own characters and you need <kbd>Fn</kbd> for the arrows. The Status Bar shows `opt` while a compose is waiting for its letter.
## The Launcher
@@ -50,7 +59,7 @@ News from a background service (an IRC mention, a Storage warning, an update tha
## The first start
On a new device a short Setup asks four things, then never appears again:
On a new device a short Setup asks four things, then never appears again. It also tells you about <kbd>Fn</kbd> + <kbd>h</kbd>, twice, and it is the only part of the firmware that names keys on the screen:
1. **Long name**, up to 39 bytes.
2. **Short name**, up to 4 characters.
@@ -62,3 +71,9 @@ On a new device a short Setup asks four things, then never appears again:
Put a microSD card in the Cardputer. Without one the radio, GNSS, Wi-Fi and IRC still work, but nothing can be saved: no notes, IRC logs, Wi-Fi scan logs, GPX tracks, LoRa captures or saved Gemini pages. The [Storage App](/guide/storage/) shows what is on the card.
The firmware keeps its own folders at the top of the card (`captures`, `gemini`, `gnss`, `irc`, `updates`, `wifi`, plus `notes`). You can use the card in a computer too, but those names are the firmware's.
## The keys, as the device lists them
What <kbd>Fn</kbd> + <kbd>h</kbd> shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones.
{{ keys(scopes=["launcher", "everywhere", "dialog", "text"]) }}
+6
View File
@@ -40,3 +40,9 @@ Most capsules sign their own certificate. The first certificate seen for a host
## Big pages and memory
With a card, every page streams to the card first, so a page larger than the device's memory still arrives whole and is read from the card as you scroll. Without a card, a page is limited to what fits in memory. If there is not enough free memory to open a secure connection, the fetch says so instead of failing silently; stopping IRC frees the most.
## The keys, as the device lists them
What <kbd>Fn</kbd> + <kbd>h</kbd> shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones.
{{ keys(scopes=["gemini", "gemini-saved", "gemini-address", "gemini-answer"]) }}
+6
View File
@@ -28,3 +28,9 @@ Once there is a fix, the device's clock follows it.
## Tracks
<kbd>r</kbd> starts recording a **Track**: the route is saved as a **GPX** file on the SD card, in `/gnss/tracks` (named by date and time), and the Status Bar shows `REC`. Press <kbd>r</kbd> again to stop. A Track keeps recording with the App closed. It needs a card and a clock (a fix or Wi-Fi sets it); if it cannot start, the App says why: `GNSS is off`, `No SD card` or `Waiting for the time`. The [Storage App](/guide/storage/) opens a `.gpx` file and shows its points, start, duration and distance.
## The keys, as the device lists them
What <kbd>Fn</kbd> + <kbd>h</kbd> shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones.
{{ keys(scopes=["gnss"]) }}
+6
View File
@@ -49,3 +49,9 @@ Every buffer is logged to the SD card, one file per day, under `/irc`. Logs stop
- IRC **pauses** while Wi-Fi is monitoring (the Status Bar shows `MON`) and while the device **installs an update**. It reconnects and rejoins its channels afterwards; the App says `paused` in the meantime.
- A secure connection costs memory: IRC's takes about 40 KB of the 107 KB the device has, and an update's download needs about 52 KB more. That is why IRC steps aside for an update, and why the daily update check waits for IRC to be disconnected (see [Updates](/guide/updates/)).
## The keys, as the device lists them
What <kbd>Fn</kbd> + <kbd>h</kbd> shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones.
{{ keys(scopes=["irc", "irc-settings", "irc-field"]) }}
+13
View File
@@ -0,0 +1,13 @@
+++
title = "Every key"
description = "The keys of every screen of the firmware, as the help panel lists them on the device: one table for each screen and state."
weight = 12
[extra]
tag = "Reference"
+++
On the device, <kbd>Fn</kbd> + <kbd>h</kbd> lists the keys of the screen you are on (and <kbd>?</kbd> does, when you are not typing). This page is all of those lists at once, generated from the same source the firmware reads: `lib/core/src/app_keys.h`.
In the tables, `; . , /` are the arrow keys (up, down, left, right): alone when you are not typing, with <kbd>Fn</kbd> when you are. `` ` `` is Back, `Aa` is Shift, and two keys separated by spaces are two keys that do the two things listed.
{{ keys(all=true) }}
+6
View File
@@ -33,3 +33,9 @@ A capture records packets into a **pcap** file with LoRaTap headers in `/capture
## The GNSS receiver raises the noise
The GNSS receiver on the same Cap makes the radio's noise floor about **8 dB** worse while it runs. **Settings → Pause GNSS for LoRa** (off by default) puts the receiver on standby while the radio listens, except while a Track is being recorded.
## The keys, as the device lists them
What <kbd>Fn</kbd> + <kbd>h</kbd> shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones.
{{ keys(scopes=["lora", "lora-packet", "lora-presets", "lora-sweep"]) }}
+6
View File
@@ -35,3 +35,9 @@ A new note has no file until you type something. The file is then named after it
## Limits
A note holds up to **16 KB** while it is edited. A bigger text file opens read-only in the [Storage App](/guide/storage/); editing a file of any size is planned. In Storage, <kbd>e</kbd> on a text file opens it in the same editor, anywhere on the card, unless the file is read-only. Notes are never offered for deletion by the clean-up.
## The keys, as the device lists them
What <kbd>Fn</kbd> + <kbd>h</kbd> shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones.
{{ keys(scopes=["notes", "notes-editor", "notes-name"]) }}
+6
View File
@@ -41,3 +41,9 @@ A network normally gives the device its address by itself (DHCP, **Automatic**).
### DNS and NTP
Two DNS servers (9.9.9.9 and 1.1.1.1 by default), used on Fixed networks, or on every network if **Always use my DNS** is on; and two NTP servers (pool.ntp.org and time.cloudflare.com), used after any that the network's DHCP offers. <kbd>Enter</kbd> on **Status** shows what is in use and where each value came from.
## The keys, as the device lists them
What <kbd>Fn</kbd> + <kbd>h</kbd> shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones.
{{ keys(scopes=["settings", "settings-choice", "wifi", "wifi-servers", "wifi-network", "wifi-status", "wifi-scan", "wifi-name", "debug-console"]) }}
+7 -1
View File
@@ -13,7 +13,7 @@ Storage shows what is on the SD card: each folder's entries with their size and
| Key | Does |
|---|---|
| <kbd>c</kbd> / <kbd>x</kbd> | Copies or cuts the selected file or folder; the footer shows what <kbd>v</kbd> would paste |
| <kbd>c</kbd> / <kbd>x</kbd> | Copies or cuts the selected file or folder; the footer shows what is waiting to be pasted |
| <kbd>v</kbd> | Pastes it into the folder shown. A copy next to its original is named `name (2).txt`; anything in the way is asked about first |
| <kbd>r</kbd> | Renames |
| <kbd>d</kbd> | Deletes, after saying what is inside: "Delete saved and its 42 files (1.2 MB)?" |
@@ -45,3 +45,9 @@ The App says why when it refuses.
At the top of the card, the last row, **Maintenance** (or <kbd>m</kbd>), shows the card's usage and holds **Storage clean-up** and **Erase SD card**. They delete for good, so they sit behind a warning. Clean-up deletes old logs and captures by category and age, showing the space it would free first. Notes and Saved Pages are never offered.
The firmware warns once per start when the card passes **80%** full; past **90%**, logs stop being written so that the rest is kept for captures.
## The keys, as the device lists them
What <kbd>Fn</kbd> + <kbd>h</kbd> shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones.
{{ keys(scopes=["storage", "storage-details", "storage-name", "storage-busy", "maintenance", "viewer-text", "viewer-hex", "viewer-pcap", "viewer-packet", "viewer-gpx", "viewer-ota"]) }}
+6
View File
@@ -18,3 +18,9 @@ System changes nothing: it shows. It samples once a second and keeps its history
## Why it exists
The device has about 107 KB of free memory and no PSRAM, so memory is the resource that decides what can run together: a secure connection takes about 52 KB at its peak. System makes that visible, and is how the project measures its own changes.
## The keys, as the device lists them
What <kbd>Fn</kbd> + <kbd>h</kbd> shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones.
{{ keys(scopes=["system", "system-tasks", "system-system"]) }}
+6
View File
@@ -42,3 +42,9 @@ The connection to the project's server is checked against the two root certifica
## For developers
Updates can also be pushed from a PC over Wi-Fi, with `scripts/flash.sh --ota <ip>`, or put on the card with `scripts/sd_put.sh`: see the [README](https://git.twis.la/twisla/roro9stack#firmware-updates-over-wi-fi-ota). The firmware's console can be reached over Wi-Fi too: see [The Debug Console](/dev/debug/).
## The keys, as the device lists them
What <kbd>Fn</kbd> + <kbd>h</kbd> shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones.
{{ keys(scopes=["firmware", "firmware-release", "firmware-older"]) }}
+6
View File
@@ -34,3 +34,9 @@ One bar for each of the 13 Wi-Fi channels shows how busy it is: how many network
## Signal tracker
Follows one network: its name, address and channel, then the signal in dBm in large type, a strength bar, and a graph of the recent readings, so you can walk towards the strongest signal. <kbd>m</kbd> turns audible clicks on and off. If the network is no longer heard the screen says `lost`. <kbd>`</kbd> (Back) returns to the list.
## The keys, as the device lists them
What <kbd>Fn</kbd> + <kbd>h</kbd> shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones.
{{ keys(scopes=["wifi-tools", "wifi-networks", "wifi-tracker"]) }}
+516
View File
@@ -0,0 +1,516 @@
# Generated by site/tools/gen_dev_docs.py from lib/core/src/app_keys.h: the keys of every screen, as the
# help panel (Fn+h) lists them on the device. Edit that header, not this file.
[[scope]]
id = "everywhere"
title = "Everywhere"
rows = [
["`", "back"],
["Fn `", "home, the Launcher"],
["; . , /", "arrows (Fn+ while typing)"],
["Fn h ?", "these keys (? not typing)"],
]
[[scope]]
id = "dialog"
title = "A question"
rows = [
[", /", "the other answer"],
["Enter", "choose it"],
["`", "cancel"],
]
[[scope]]
id = "text"
title = "A text field"
rows = [
["Enter", "save"],
["`", "cancel"],
["Del", "delete backwards"],
["Fn , /", "move the cursor"],
["opt ' e", "an accent: é"],
]
[[scope]]
id = "launcher"
title = "The Launcher"
rows = [
["; .", "up, down"],
["Enter", "open the App"],
]
[[scope]]
id = "setup"
title = "Setup, a step"
rows = [
["Enter", "continue"],
["`", "the step before"],
]
[[scope]]
id = "setup-choice"
title = "Setup, a choice"
rows = [
["; .", "up, down"],
["Enter", "choose it, next step"],
["`", "the step before"],
]
[[scope]]
id = "setup-text"
title = "Setup, a name"
rows = [
["Enter", "next step"],
["`", "the step before"],
["Del", "delete backwards"],
["Fn , /", "move the cursor"],
["opt ' e", "an accent: é"],
]
[[scope]]
id = "irc"
title = "IRC"
rows = [
["Enter", "send the line"],
["Tab", "the next buffer"],
["Alt ; .", "scroll back, forward"],
["Fn ; .", "lines you sent before"],
["Fn , /", "move the cursor"],
["Del", "delete backwards"],
["/settings", "server, nick, passwords"],
["/join #x", "join a channel"],
["/part", "leave it"],
["/msg nick", "a private chat"],
["/me", "an action"],
["/nick", "change your nick"],
["/topic", "see or set the topic"],
["/names", "who is there"],
["/quit", "disconnect, and stay so"],
["/raw", "a line as it is"],
["`", "leave: IRC stays connected"],
]
[[scope]]
id = "irc-settings"
title = "IRC settings"
rows = [
["; .", "up, down"],
["Enter", "edit, switch, or save"],
["`", "leave without saving"],
]
[[scope]]
id = "irc-field"
title = "IRC, a setting"
rows = [
["Enter", "keep it"],
["`", "cancel"],
["Del", "delete backwards"],
["Fn , /", "move the cursor"],
]
[[scope]]
id = "wifi-tools"
title = "Wi-Fi Tools"
rows = [
["; .", "up, down"],
["Enter", "open"],
]
[[scope]]
id = "wifi-networks"
title = "Networks nearby"
rows = [
["; .", "up, down"],
["Enter", "track its signal"],
["s", "sort: signal, channel, name"],
["o", "open networks only"],
["h", "hide the hidden ones"],
["w", "strong ones only"],
["l", "log the scans to the card"],
]
[[scope]]
id = "wifi-tracker"
title = "Signal tracker"
rows = [
["m", "clicks on or off"],
]
[[scope]]
id = "gnss"
title = "GNSS"
rows = [
["Tab", "the position, or the sky"],
["r", "record a Track, or stop it"],
]
[[scope]]
id = "gemini"
title = "Gemini, a page"
rows = [
["Tab", "the next link"],
["Aa Tab", "the link before"],
["Enter", "follow the link"],
["` Del", "the page before"],
["; .", "scroll"],
["Space", "a page down"],
[", /", "sideways, in wide blocks"],
["g", "type an address"],
["b", "bookmark this page"],
["s", "save the page to the card"],
["S", "...with the pages it links to"],
]
[[scope]]
id = "gemini-saved"
title = "Gemini, a Saved Page"
rows = [
["Tab", "the next link"],
["Aa Tab", "the link before"],
["Enter", "follow the link"],
["` Del", "the page before"],
["; .", "scroll"],
["Space", "a page down"],
[", /", "sideways, in wide blocks"],
["g", "type an address"],
["b", "bookmark this page"],
["r", "refresh this Saved Page"],
["d", "delete this Saved Page"],
]
[[scope]]
id = "gemini-address"
title = "Gemini, an address"
rows = [
["Enter", "go there"],
["`", "cancel"],
["Del", "delete backwards"],
["Fn , /", "move the cursor"],
]
[[scope]]
id = "gemini-answer"
title = "Gemini, an answer to a page"
rows = [
["Enter", "send it"],
["`", "cancel"],
["Del", "delete backwards"],
["Fn , /", "move the cursor"],
]
[[scope]]
id = "lora"
title = "LoRa Scanner, the packets"
rows = [
["; .", "up, down"],
["Enter", "the packet's details"],
["p", "pick a Meshtastic preset"],
["c", "start a Capture, or stop it"],
["Tab", "the Sweep"],
]
[[scope]]
id = "lora-packet"
title = "LoRa Scanner, a packet"
rows = [
["; .", "scroll"],
["Enter", "back to the list"],
]
[[scope]]
id = "lora-presets"
title = "LoRa Scanner, the presets"
rows = [
["; .", "up, down"],
["Enter", "listen with this preset"],
]
[[scope]]
id = "lora-sweep"
title = "LoRa Scanner, the Sweep"
rows = [
["Tab", "the Sniffer"],
]
[[scope]]
id = "storage"
title = "Storage, a folder"
rows = [
["; .", "up, down"],
["Enter", "open the folder or the file"],
[", /", "a page up, down"],
["c x", "copy, cut"],
["v", "paste here"],
["r", "rename"],
["d Del", "delete, after asking"],
["n", "a new folder"],
["i", "details: size, date, type"],
["s", "sort: name, date, size"],
["m", "Maintenance: clean-up, erase"],
["`", "the folder above"],
]
[[scope]]
id = "storage-details"
title = "Storage, an item's details"
rows = [
["; .", "scroll"],
["Enter", "back to the folder"],
]
[[scope]]
id = "storage-name"
title = "Storage, a name"
rows = [
["Enter", "rename it, or make the folder"],
["`", "cancel"],
["Del", "delete backwards"],
["Fn , /", "move the cursor"],
]
[[scope]]
id = "storage-busy"
title = "Storage, while it copies or deletes"
rows = [
["`", "stop the copy or the delete"],
]
[[scope]]
id = "maintenance"
title = "Storage, Maintenance"
rows = [
["; .", "up, down"],
["Enter", "open, or choose"],
]
[[scope]]
id = "viewer-text"
title = "A file, as text"
rows = [
["; .", "a line up, down"],
[", /", "a page up, down"],
["t b", "the top, the end"],
["e", "edit it (up to 16 KB)"],
["Tab", "the file as hex, or back"],
]
[[scope]]
id = "viewer-hex"
title = "A file, as hex"
rows = [
["; .", "a line up, down"],
[", /", "a page up, down"],
["t b", "the top, the end"],
["Tab", "the file as text, or back"],
]
[[scope]]
id = "viewer-pcap"
title = "A Capture"
rows = [
["; .", "up, down"],
["Enter", "the packet"],
[", /", "a page up, down"],
["Tab", "the file as hex"],
]
[[scope]]
id = "viewer-packet"
title = "A Capture's packet"
rows = [
["; .", "scroll"],
["Enter", "back to the packets"],
]
[[scope]]
id = "viewer-gpx"
title = "A Track"
rows = [
["Tab", "the file as text"],
]
[[scope]]
id = "viewer-ota"
title = "An Update File"
rows = [
["Enter", "install it, if it's genuine"],
["Tab", "the file as hex"],
]
[[scope]]
id = "notes"
title = "Notes, the list"
rows = [
["; .", "up, down"],
["Enter", "open the note"],
[", /", "a page up, down"],
["n", "a new note"],
["r", "rename its file"],
["d Del", "delete it"],
["s", "sort: newest, or by name"],
]
[[scope]]
id = "notes-editor"
title = "Notes, the editor"
rows = [
["Enter", "a new line"],
["Del", "delete backwards"],
["Tab", "two spaces"],
["Fn ; . , /", "move the cursor"],
["Alt Fn ; .", "a page up, down"],
["Ctrl a e", "start, end of the line"],
["opt ' e", "an accent: é"],
["`", "done: it saves by itself"],
]
[[scope]]
id = "notes-name"
title = "Notes, a file name"
rows = [
["Enter", "rename the file"],
["`", "cancel"],
["Del", "delete backwards"],
["Fn , /", "move the cursor"],
]
[[scope]]
id = "system"
title = "System, any view"
rows = [
["Tab", "the next view"],
["Aa Tab", "the view before"],
]
[[scope]]
id = "system-tasks"
title = "System, the tasks"
rows = [
["Tab", "the next view"],
["Aa Tab", "the view before"],
["; .", "scroll"],
["s", "sort: cpu, stack, name"],
]
[[scope]]
id = "system-system"
title = "System, the system view"
rows = [
["Tab", "the next view"],
["Aa Tab", "the view before"],
["; .", "scroll"],
]
[[scope]]
id = "settings"
title = "Settings"
rows = [
["; .", "up, down"],
["Enter", "edit, or open the page"],
[", /", "change a switch or a slider"],
]
[[scope]]
id = "settings-choice"
title = "Settings, a choice"
rows = [
["; .", "up, down"],
["Enter", "choose it"],
]
[[scope]]
id = "wifi"
title = "Settings, Wi-Fi"
rows = [
["; .", "up, down"],
["Enter", "open, or change"],
[", /", "switch Wi-Fi on or off"],
]
[[scope]]
id = "wifi-servers"
title = "Settings, DNS and NTP"
rows = [
["; .", "up, down"],
["Enter", "edit"],
[", /", "Always use my DNS: on, off"],
]
[[scope]]
id = "wifi-network"
title = "Settings, a saved network"
rows = [
["; .", "up, down"],
["Enter", "edit, or forget"],
[", /", "Automatic or Fixed"],
]
[[scope]]
id = "wifi-status"
title = "Settings, the Wi-Fi status"
rows = [
["Enter", "back"],
]
[[scope]]
id = "wifi-scan"
title = "Settings, adding a network"
rows = [
["; .", "up, down"],
["Enter", "choose this network"],
]
[[scope]]
id = "wifi-name"
title = "Settings, a hidden network's name"
rows = [
["Enter", "next: the password"],
["`", "cancel"],
["Del", "delete backwards"],
["Fn , /", "move the cursor"],
]
[[scope]]
id = "firmware"
title = "Settings, Firmware"
rows = [
["; .", "up, down"],
["Enter", "check, open, or install"],
["c", "look for a newer release"],
]
[[scope]]
id = "firmware-release"
title = "Settings, a release"
rows = [
["; .", "scroll"],
["Enter", "install it"],
["c", "check again"],
]
[[scope]]
id = "firmware-older"
title = "Settings, older releases"
rows = [
["; .", "up, down"],
["Enter", "its details"],
["c", "read the list again"],
]
[[scope]]
id = "debug-console"
title = "Settings, Debug Console"
rows = [
["; .", "up, down"],
["Enter", "switch, or open"],
[", /", "switch the console on or off"],
]
[[scope]]
id = "demo"
title = "The widget demo"
rows = [
["; .", "up, down"],
["Enter", "try the widget"],
]
+9
View File
@@ -254,3 +254,12 @@ footer small { display: block; max-width: 760px; }
.source { max-width: 820px; font-size: 14px; line-height: 20px; }
.source code { background: var(--s2); padding: 0 6px; }
.card.featured { flex-basis: 100%; }
/* The keys of a screen, as the device's help panel lists them (the `keys` shortcode) */
.keys { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px 32px; }
.keys-scope { background: var(--s2); padding: 16px 20px; }
.keys-scope h4 { font: 500 12px/16px var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--cyantext); margin: 0 0 8px; }
.prose .keys table { width: 100%; }
.prose .keys td { padding: 4px 8px 4px 0; border-bottom: 0; font-size: 15px; }
.prose .keys td:first-child { white-space: nowrap; width: 1%; padding-right: 16px; }
.prose .keys kbd { white-space: pre; }
+18
View File
@@ -0,0 +1,18 @@
{#- The keys of one or more screens, as the help panel (Fn+h) lists them on the device:
{{ keys(scopes=["storage", "storage-details"]) }}, or {{ keys(all=true) }} for every screen.
The tables come from site/data/keys.toml, generated from lib/core/src/app_keys.h (issue #72):
a key added to an App shows up here without anyone editing a page. -#}
{%- set data = load_data(path="data/keys.toml", format="toml") -%}
{%- set everything = all is defined and all -%}
<div class="keys">
{%- for s in data.scope %}{% if everything or (scopes is defined and s.id in scopes) %}
<div class="keys-scope">
<h4 id="keys-{{ s.id }}">{{ s.title }}</h4>
<table>
{%- for r in s.rows %}
<tr><td><kbd>{{ r.0 }}</kbd></td><td>{{ r.1 }}</td></tr>
{%- endfor %}
</table>
</div>
{%- endif %}{% endfor %}
</div>
+53 -5
View File
@@ -9,6 +9,8 @@ Zola cannot read a file outside its own folder, so the pages are generated and c
milestones/ one page for each docs/milestones/*.md
build/build-and-test, build/flash sections of README.md
debug/commands the commands the firmware's `help` prints (parsed from src/main.cpp), then README's table of them
site/data/keys.toml every screen's keys, from lib/core/src/app_keys.h: what the help panel (Fn+h) shows on the
device, for the `keys` shortcode of the user guide (issue #72)
Every generated page says where it comes from; edit that file, not the page. Hand-written pages sit next to them.
"""
import json
@@ -23,7 +25,7 @@ REPO = SITE.parent
OUT = SITE / "content" / "dev"
REPO_URL = re.search(r'repo\s*=\s*"([^"]+)"', (SITE / "config.toml").read_text()).group(1)
MILESTONES = ["OTA", "M2", "G1", "M3", "S1", "F1", "R1", "W1"] # in the order they were done
MILESTONES = ["OTA", "M2", "G1", "M3", "S1", "F1", "R1", "W1", "U1"] # in the order they were done
# Left out on purpose: docs/milestones/M0.md, M1.md and CONTEXT.md (the glossary) describe Wi-Fi monitoring, which this site does not publish.
# They stay in the repository.
@@ -139,8 +141,38 @@ def safe_mode_commands():
return exact, prefix
def key_tables():
"""Every table of lib/core/src/app_keys.h: [(id, title, [(keys, action), ...])], in the file's order."""
src = (REPO / "lib" / "core" / "src" / "app_keys.h").read_text()
def text(literal): # a C string literal's contents: \xHH bytes are UTF-8
raw = re.sub(r"\\x([0-9A-Fa-f]{2})", lambda m: chr(int(m.group(1), 16)), literal).replace('\\"', '"')
return raw.encode("latin-1").decode("utf-8")
tables = []
for m in re.finditer(r"// ([a-z0-9-]+): ([^\n]+)\ninline constexpr KeyHelp k\w+\[\] = \{\n(.*?)\n\};", src, re.S):
rows = [(text(k), text(a)) for k, a in re.findall(r'\{"((?:[^"\\]|\\.)*)", "((?:[^"\\]|\\.)*)"\},', m.group(3))]
if len(rows) != len([l for l in m.group(3).splitlines() if l.strip()]):
sys.exit(f"gen_dev_docs: a row of `{m.group(1)}` in app_keys.h isn't in the form {{\"keys\", \"action\"}},")
tables.append((m.group(1), m.group(2).strip(), rows))
declared = len(re.findall(r"^inline constexpr KeyHelp k\w+\[\]", src, re.M))
if len(tables) != declared or len({t[0] for t in tables}) != len(tables):
sys.exit(f"gen_dev_docs: app_keys.h has {declared} tables, {len(tables)} with an `// id: Title` comment and a unique id")
return tables
def keys_toml():
out = ["# Generated by site/tools/gen_dev_docs.py from lib/core/src/app_keys.h: the keys of every screen, as the",
"# help panel (Fn+h) lists them on the device. Edit that header, not this file.", ""]
for ident, title, rows in key_tables():
out += ["[[scope]]", f"id = {json.dumps(ident)}", f"title = {json.dumps(title, ensure_ascii=False)}", "rows = ["]
out += [f" [{json.dumps(k, ensure_ascii=False)}, {json.dumps(a, ensure_ascii=False)}]," for k, a in rows]
out += ["]", ""]
return "\n".join(out)
def build():
pages = {}
pages = {"../../data/keys.toml": keys_toml()}
for path in sorted((REPO / "docs" / "adr").glob("*.md")):
title, body = title_and_body(path.read_text())
@@ -179,9 +211,25 @@ def build():
return pages
def unknown_scopes():
"""Scopes a page asks the `keys` shortcode for that app_keys.h doesn't have."""
known = {t[0] for t in key_tables()}
bad = []
for path in sorted((SITE / "content").rglob("*.md")):
for call in re.findall(r"keys\(scopes=\[([^\]]*)\]", path.read_text()):
for ident in re.findall(r'"([^"]+)"', call):
if ident not in known:
bad.append(f"{path.relative_to(SITE)}: no key table `{ident}` in lib/core/src/app_keys.h")
return bad
def main():
check = "--check" in sys.argv
pages = build()
for problem in unknown_scopes():
print("gen_dev_docs:", problem)
if unknown_scopes():
sys.exit(1)
stale = []
for rel, text in sorted(pages.items()):
path = OUT / rel
@@ -193,10 +241,10 @@ def main():
path.write_text(text)
if check:
for rel in stale:
print(f"gen_dev_docs: content/dev/{rel} is out of date: run site/tools/gen_dev_docs.py and commit the result")
print(f"gen_dev_docs: {len(pages)} pages, {len(stale)} out of date")
print(f"gen_dev_docs: {os.path.normpath(os.path.join('site/content/dev', rel))} is out of date: run site/tools/gen_dev_docs.py and commit the result")
print(f"gen_dev_docs: {len(pages)} files, {len(stale)} out of date")
sys.exit(1 if stale else 0)
print(f"gen_dev_docs: {len(pages)} pages, {len(stale)} written")
print(f"gen_dev_docs: {len(pages)} files, {len(stale)} written")
if __name__ == "__main__":
+7 -2
View File
@@ -1,3 +1,4 @@
#include "app_keys.h"
#include "apps/debug_console_page.h"
#include "debug_auth.h"
@@ -74,6 +75,12 @@ bool DebugConsolePage::onKey(const KeyEvent& e) {
return true;
}
void DebugConsolePage::help(std::vector<KeyHelp>& out) const {
if (confirm_) return keys::add(out, keys::kDialog);
if (typing_) return keys::add(out, keys::kText);
keys::add(out, keys::kDebugConsole);
}
void DebugConsolePage::draw(Canvas& c) {
const auto& area = theme::kContent;
c.setTextDatum(top_left);
@@ -84,8 +91,6 @@ void DebugConsolePage::draw(Canvas& c) {
widgets::lineEditor(c, editor_, {4, area.y + 22, area.w - 8, 0});
c.setTextColor(refusal_.empty() ? theme::kMuted : theme::kWarning);
c.drawString(refusal_.empty() ? "16 to 64 characters. Capitals or not, it's the same." : refusal_.c_str(), 4, area.y + 44);
c.setTextColor(theme::kMuted);
c.drawString("Enter: save `: cancel", 4, area.y + 44 + theme::kLineHeight);
return;
}
+3
View File
@@ -5,6 +5,7 @@
#include "dialog_model.h"
#include "key_event.h"
#include "key_help.h"
#include "line_editor.h"
#include "list_model.h"
#include "services/wifi_service.h"
@@ -25,6 +26,8 @@ class DebugConsolePage {
bool onKey(const KeyEvent& e); // false: leave the page
bool textEntryActive() const { return typing_; }
void draw(Canvas& c);
void help(std::vector<KeyHelp>& out) const;
const char* helpTitle() const { return typing_ ? "A token of your own" : "Debug Console"; }
private:
enum Row { kSwitch, kAddress, kNewToken, kTypeToken, kRows };
+8 -1
View File
@@ -1,3 +1,4 @@
#include "app_keys.h"
#include "demo_app.h"
#include "ui/widgets.h"
@@ -25,6 +26,12 @@ void DemoApp::notify(const char* text, NotificationLevel level) {
bus_.publish(Event::withText(EventType::Notification, text, static_cast<int32_t>(level)));
}
void DemoApp::help(std::vector<KeyHelp>& out) const {
if (dialog_) return keys::add(out, keys::kDialog);
if (page_ == Page::Editor) return keys::add(out, keys::kText);
if (page_ == Page::Menu) keys::add(out, keys::kDemo);
}
bool DemoApp::onKey(const KeyEvent& e) {
requestRedraw();
if (dialog_) {
@@ -97,7 +104,7 @@ void DemoApp::draw(Canvas& c) {
}
case Page::Editor:
c.setTextColor(theme::kMuted);
c.drawString("Type (opt ' e = \xC3\xA9). Enter: toast", 4, area.y + 4);
c.drawString("A line editor", 4, area.y + 4);
widgets::lineEditor(c, editor_, {4, area.y + 22, area.w - 8, 0});
c.setTextColor(theme::kMuted);
c.drawString((std::to_string(editor_.text().size()) + "/39 bytes").c_str(), 4, area.y + 44);
+1
View File
@@ -21,6 +21,7 @@ class DemoApp : public App {
bool onKey(const KeyEvent& e) override;
bool textEntryActive() const override { return page_ == Page::Editor && !dialog_; }
void draw(Canvas& c) override;
void help(std::vector<KeyHelp>& out) const override;
private:
enum class Page { Menu, Text, Editor };
+15 -7
View File
@@ -1,3 +1,4 @@
#include "app_keys.h"
#include "file_viewer.h"
#include <Arduino.h>
@@ -265,6 +266,18 @@ void FileViewer::openPacket(int index) {
mode_ = Mode::Packet;
}
void FileViewer::help(std::vector<KeyHelp>& out) const {
if (confirm_) return keys::add(out, keys::kDialog);
switch (mode_) {
case Mode::Text: keys::add(out, keys::kViewerText); break;
case Mode::Hex: keys::add(out, keys::kViewerHex); break;
case Mode::Pcap: keys::add(out, keys::kViewerPcap); break;
case Mode::Packet: keys::add(out, keys::kViewerPacket); break;
case Mode::Gpx: keys::add(out, keys::kViewerGpx); break;
case Mode::Ota: keys::add(out, keys::kViewerOta); break;
}
}
bool FileViewer::onKey(const KeyEvent& e) {
if (confirm_) {
confirm_->onKey(e);
@@ -380,7 +393,7 @@ void FileViewer::draw(Canvas& c) {
refresh();
c.setTextDatum(top_left);
theme::Rect body{area.x, area.y + 10, area.w, kRows * theme::kLineHeight};
std::string right, keys = "Tab: hex";
std::string right;
bool scanning = scan_ && !scan_->done;
switch (mode_) {
@@ -396,8 +409,6 @@ void FileViewer::draw(Canvas& c) {
c.fillRect(area.w - 2, barY, 2, barH, theme::kMuted);
}
right = formatBytes(size_);
keys = std::string("Tab: ") + (mode_ != base_ ? "back" : mode_ == Mode::Text ? "hex" : "text") + " t: top b: end";
if (mode_ == Mode::Text) keys += " e: edit";
break;
}
case Mode::Gpx: {
@@ -407,7 +418,6 @@ void FileViewer::draw(Canvas& c) {
else if (scan_) lines = scan_->gpx.lines();
widgets::textLines(c, lines, 0, body);
right = formatBytes(size_);
keys = "Tab: the file as text";
break;
}
case Mode::Pcap: {
@@ -423,7 +433,6 @@ void FileViewer::draw(Canvas& c) {
return row < shown_.size() ? shown_[row] : std::string();
});
right = std::to_string(scan_->packets.size()) + (scan_->morePackets ? "+ packets" : " packets");
keys = "Enter: the packet Tab: hex";
}
break;
}
@@ -447,7 +456,6 @@ void FileViewer::draw(Canvas& c) {
}
widgets::textLines(c, lines, 0, body);
right = formatBytes(size_);
keys = ok ? "Enter: install Tab: hex" : "Tab: hex";
if (confirm_) {
widgets::dialog(c, "Install update?", "The device restarts into " + scan_->version + " once it's written.", *confirm_);
return;
@@ -464,7 +472,7 @@ void FileViewer::draw(Canvas& c) {
c.drawString(right.c_str(), area.w - 3, area.y + 1);
c.setTextDatum(top_left);
if (!message_.empty()) c.setTextColor(theme::kWarning);
c.drawString(message_.empty() ? keys.c_str() : message_.c_str(), 4, area.y + area.h - 9);
if (!message_.empty()) c.drawString(message_.c_str(), 4, area.y + area.h - 9);
}
} // namespace roro
+2
View File
@@ -7,6 +7,7 @@
#include <vector>
#include "dialog_model.h"
#include "key_help.h"
#include "key_event.h"
#include "list_model.h"
#include "services/storage_service.h"
@@ -27,6 +28,7 @@ class FileViewer {
void open(const std::string& path, uint32_t size);
void close();
bool onKey(const KeyEvent& e); // false: leave the viewer
void help(std::vector<KeyHelp>& out) const;
bool update(uint32_t nowMs); // true: draw again
void draw(Canvas& c);
+17 -6
View File
@@ -1,3 +1,4 @@
#include "app_keys.h"
#include "firmware_page.h"
#include <SD.h>
@@ -63,9 +64,9 @@ std::string FirmwarePage::latestText(bool& warn) const {
release::Release r;
if (g.status() == GiteaReleases::Status::Failed && !g.latest(r)) {
warn = true;
return "failed: Enter retries";
return "the check failed";
}
if (!g.latest(r)) return "Enter: check";
if (!g.latest(r)) return "not checked yet";
return r.tag + (release::isNewer(r, update_.runningVersion()) ? " (new)" : " (current)");
}
@@ -202,7 +203,7 @@ void FirmwarePage::drawRelease(Canvas& c) {
c.setFont(&fonts::small);
c.setTextDatum(top_left);
c.setTextColor(theme::kMuted);
c.drawString(ok ? "Enter: install c: check again" : (why + " c: check").c_str(), 4, area.y + area.h - 9);
if (!ok) c.drawString(why.c_str(), 4, area.y + area.h - 9); // why it can't be installed from here
}
void FirmwarePage::drawOlder(Canvas& c) {
@@ -232,9 +233,19 @@ void FirmwarePage::drawOlder(Canvas& c) {
return r.tag + (r.tag == running ? " (running)" : release::versionNewer(r.tag, running) ? " (new)" : "");
},
[&](int i) { return olderReleases_[i].date(); });
c.setFont(&fonts::small);
c.setTextColor(theme::kMuted);
c.drawString("Enter: details c: look again", 4, area.y + area.h - 9);
}
void FirmwarePage::help(std::vector<KeyHelp>& out) const {
if (confirm_) return keys::add(out, keys::kDialog);
switch (view_) {
case View::Main: keys::add(out, keys::kFirmware); break;
case View::Release: keys::add(out, keys::kFirmwareRelease); break;
case View::Older: keys::add(out, keys::kFirmwareOlder); break;
}
}
const char* FirmwarePage::helpTitle() const {
return view_ == View::Release ? "A release" : view_ == View::Older ? "Older releases" : "Firmware";
}
void FirmwarePage::drawMain(Canvas& c) {
+3
View File
@@ -7,6 +7,7 @@
#include "dialog_model.h"
#include "key_event.h"
#include "key_help.h"
#include "list_model.h"
#include "release_info.h"
#include "services/storage_service.h"
@@ -28,6 +29,8 @@ class FirmwarePage {
void enter();
bool onKey(const KeyEvent& e); // false: leave the page
void draw(Canvas& c);
void help(std::vector<KeyHelp>& out) const;
const char* helpTitle() const;
private:
enum Row { kVersion, kStatus, kAddress, kLatest, kOlder, kSdHeader, kFixed };
+17 -4
View File
@@ -1,3 +1,4 @@
#include "app_keys.h"
#include "gemini_app.h"
#include <Arduino.h>
@@ -171,7 +172,7 @@ void GeminiApp::update(uint32_t nowMs) {
else {
std::string mime = result.header.mimeType();
showMessage(url, "# Not a text page\nThis is " + mime +
", which the Cardputer can't show. s saves it to the card, in "
", which the Cardputer can't show. Saving it puts it on the card, in "
"/gemini/downloads.\n");
page_.header.meta = mime; // so s knows to download it
}
@@ -302,6 +303,18 @@ void GeminiApp::selectLink(int direction) {
requestRedraw();
}
void GeminiApp::help(std::vector<KeyHelp>& out) const {
if (certDialog_ || deleteDialog_) return keys::add(out, keys::kDialog);
if (inputOpen_) return keys::add(out, keys::kGeminiAnswer);
if (addressOpen_) return keys::add(out, keys::kGeminiAddress);
if (isSaved()) keys::add(out, keys::kGeminiSaved);
else keys::add(out, keys::kGemini);
}
const char* GeminiApp::helpTitle() const {
return inputOpen_ ? "Gemini: an answer" : addressOpen_ ? "Gemini: an address" : nullptr;
}
bool GeminiApp::onKey(const KeyEvent& e) {
requestRedraw();
if (certDialog_) {
@@ -388,7 +401,7 @@ bool GeminiApp::onKey(const KeyEvent& e) {
if (isGemini(page_.base())) gemini_.addBookmark(page_.base(), titleOf());
break;
case 's': // Q82, Q73
if (isSaved()) status_ = "Already saved; r refreshes it";
if (isSaved()) status_ = "Already saved: it can be refreshed";
else if (!isGemini(page_.url)) status_ = "Only Gemini pages can be saved";
else if (page_.header.mimeType().rfind("text/", 0) == 0) gemini_.save(page_.url);
else gemini_.download(page_.url);
@@ -485,7 +498,7 @@ void GeminiApp::draw(Canvas& c) {
c.fillRect(box.x - 2, box.y - 12, box.w + 4, box.h + 14, theme::kBackground);
c.setFont(&fonts::small);
c.setTextColor(theme::kMuted);
c.drawString("Go to (Enter, or ` to cancel)", box.x, box.y - 10);
c.drawString("Go to", box.x, box.y - 10);
widgets::lineEditor(c, address_, box);
}
if (inputOpen_) {
@@ -496,7 +509,7 @@ void GeminiApp::draw(Canvas& c) {
c.drawString(displayText(inputPrompt_).substr(0, 39).c_str(), box.x, box.y - 22);
c.setFont(&fonts::small);
c.setTextColor(theme::kMuted);
c.drawString(inputSensitive_ ? "(hidden) Enter sends, ` cancels" : "Enter sends, ` cancels", box.x, box.y - 9);
if (inputSensitive_) c.drawString("(hidden)", box.x, box.y - 9);
if (inputSensitive_) {
LineEditor masked(1024);
masked.setText(std::string(input_.text().size(), '*'));
+2
View File
@@ -24,6 +24,8 @@ class GeminiApp : public App {
bool textEntryActive() const override { return addressOpen_ || inputOpen_; }
void update(uint32_t nowMs) override;
void draw(Canvas& c) override;
void help(std::vector<KeyHelp>& out) const override;
const char* helpTitle() const override;
private:
struct Visit {
+7 -10
View File
@@ -1,3 +1,4 @@
#include "app_keys.h"
#include "gnss_app.h"
#include <Arduino.h>
@@ -80,11 +81,10 @@ void GnssApp::draw(Canvas& c) {
return;
}
sky_ ? drawSky(c) : drawPosition(c);
c.setFont(&fonts::small);
c.setTextColor(theme::kMuted);
c.setTextDatum(top_right);
c.drawString(sky_ ? "Tab: position" : "Tab: sky", area.w - 3, area.y + area.h - 9);
c.setTextDatum(top_left);
}
void GnssApp::help(std::vector<KeyHelp>& out) const {
keys::add(out, keys::kGnss);
}
void GnssApp::drawPosition(Canvas& c) {
@@ -139,15 +139,12 @@ void GnssApp::drawPosition(Canvas& c) {
c.setFont(&fonts::small);
if (gnss_.tracking()) {
c.setTextColor(theme::kWarning);
std::snprintf(line, sizeof line, "REC %d point%s, %s r: stop", gnss_.trackPoints(),
std::snprintf(line, sizeof line, "REC %d point%s, %s", gnss_.trackPoints(),
gnss_.trackPoints() == 1 ? "" : "s", duration(now - gnss_.trackStartMs()).c_str());
} else if (!refusal_.empty() && now - refusalMs_ < 4000) {
c.setTextColor(theme::kWarning);
std::snprintf(line, sizeof line, "%s", refusal_.c_str());
} else {
c.setTextColor(theme::kMuted);
std::snprintf(line, sizeof line, "r: record a Track");
}
} else line[0] = 0;
c.drawString(line, 4, area.y + area.h - 9);
}
+1
View File
@@ -17,6 +17,7 @@ class GnssApp : public App {
bool onKey(const KeyEvent& e) override;
void update(uint32_t nowMs) override;
void draw(Canvas& c) override;
void help(std::vector<KeyHelp>& out) const override;
private:
void drawPosition(Canvas& c);
+12 -2
View File
@@ -1,3 +1,4 @@
#include "app_keys.h"
#include "irc_app.h"
#include "ui/fonts.h"
@@ -299,6 +300,16 @@ void IrcApp::drawChat(Canvas& c) {
widgets::lineEditor(c, input_, {2, area.y + area.h - inputH, area.w - 4, 0});
}
void IrcApp::help(std::vector<KeyHelp>& out) const {
if (page_ == Page::Chat) return keys::add(out, keys::kIrc);
if (editing_) return keys::add(out, keys::kIrcField);
keys::add(out, keys::kIrcSettings);
}
const char* IrcApp::helpTitle() const {
return page_ == Page::Settings ? (editing_ ? "IRC: a setting" : "IRC settings") : nullptr;
}
void IrcApp::drawSettings(Canvas& c) {
const auto& area = theme::kContent;
if (editing_) {
@@ -306,8 +317,7 @@ void IrcApp::drawSettings(Canvas& c) {
c.setTextColor(theme::kMuted);
c.drawString(fieldLabel(fields_.selected()).c_str(), 4, area.y + 4);
widgets::lineEditor(c, fieldEditor_, {4, area.y + 22, area.w - 8, 0});
c.drawString(fields_.selected() == kAutojoin ? "e.g. #roro, #private key" : "Enter: OK `: cancel", 4,
area.y + 44);
if (fields_.selected() == kAutojoin) c.drawString("e.g. #roro, #private key", 4, area.y + 44);
return;
}
widgets::list(
+2
View File
@@ -24,6 +24,8 @@ class IrcApp : public App {
void update(uint32_t nowMs) override;
bool textEntryActive() const override { return page_ == Page::Chat || editing_; }
void draw(Canvas& c) override;
void help(std::vector<KeyHelp>& out) const override;
const char* helpTitle() const override;
private:
enum class Page { Chat, Settings };
+2
View File
@@ -1,6 +1,7 @@
#pragma once
#include "app.h"
#include "app_keys.h"
#include "app_manager.h"
#include "list_model.h"
#include "ui/theme.h"
@@ -14,6 +15,7 @@ class LauncherApp : public App {
void onEnter() override;
bool onKey(const KeyEvent& e) override;
void draw(Canvas& c) override;
void help(std::vector<KeyHelp>& out) const override { keys::add(out, keys::kLauncher); }
private:
AppManager* manager_ = nullptr;
+22 -4
View File
@@ -1,3 +1,4 @@
#include "app_keys.h"
#include "lora_scanner_app.h"
#include <Arduino.h>
@@ -165,6 +166,24 @@ void LoraScannerApp::update(uint32_t nowMs) {
requestRedraw();
}
void LoraScannerApp::help(std::vector<KeyHelp>& out) const {
switch (view_) {
case View::Packets: keys::add(out, keys::kLora); break;
case View::Details: keys::add(out, keys::kLoraPacket); break;
case View::Presets: keys::add(out, keys::kLoraPresets); break;
case View::Sweep: keys::add(out, keys::kLoraSweep); break;
}
}
const char* LoraScannerApp::helpTitle() const {
switch (view_) {
case View::Details: return "A LoRa packet";
case View::Presets: return "LoRa presets";
case View::Sweep: return "LoRa Sweep";
default: return nullptr;
}
}
void LoraScannerApp::draw(Canvas& c) {
lastDrawMs_ = millis();
c.setTextDatum(top_left);
@@ -234,8 +253,7 @@ void LoraScannerApp::drawPackets(Canvas& c) {
c.setFont(&fonts::small);
bool showMessage = !message_.empty() && millis() - messageMs_ < kMessageMs;
c.setTextColor(showMessage ? theme::kWarning : theme::kMuted);
std::string keys = std::string("Enter: details p: preset Tab: sweep c: ") + (capture_.capturing() ? "stop" : "capture");
c.drawString(showMessage ? message_.c_str() : keys.c_str(), 4, area.y + area.h - 9);
if (showMessage) c.drawString(message_.c_str(), 4, area.y + area.h - 9);
}
void LoraScannerApp::drawDetails(Canvas& c) {
@@ -329,9 +347,9 @@ void LoraScannerApp::drawSweep(Canvas& c) {
c.drawString(line, x0 + f.steps * cell, wfTop + kWaterfallRows + 1);
c.setTextDatum(top_left);
if (st.peaks.empty())
std::snprintf(line, sizeof line, "floor %d dBm, nothing above it Tab: sniffer", st.floor);
std::snprintf(line, sizeof line, "floor %d dBm, nothing above it", st.floor);
else
std::snprintf(line, sizeof line, "floor %d, %.1f MHz at %d dBm Tab: sniffer", st.floor, st.peaks[0].hz / 1e6,
std::snprintf(line, sizeof line, "floor %d, %.1f MHz at %d dBm", st.floor, st.peaks[0].hz / 1e6,
st.peaks[0].dbm);
c.setTextColor(theme::kMuted);
c.drawString(line, 4, y);
+2
View File
@@ -26,6 +26,8 @@ class LoraScannerApp : public App {
bool onKey(const KeyEvent& e) override;
void update(uint32_t nowMs) override;
void draw(Canvas& c) override;
void help(std::vector<KeyHelp>& out) const override;
const char* helpTitle() const override;
private:
enum class View { Packets, Details, Presets, Sweep };
+6
View File
@@ -1,3 +1,4 @@
#include "app_keys.h"
#include "maintenance_page.h"
#include "ui/widgets.h"
@@ -35,6 +36,11 @@ CleanupPlan MaintenancePage::planFor(int age) const {
return CleanupPlan::make(listing_[categories_.selected()], cutoff, today);
}
void MaintenancePage::help(std::vector<KeyHelp>& out) const {
if (dialog_) return keys::add(out, keys::kDialog);
keys::add(out, keys::kMaintenance);
}
bool MaintenancePage::onKey(const KeyEvent& e) {
if (dialog_) {
dialog_->onKey(e);
+2
View File
@@ -5,6 +5,7 @@
#include "cleanup_plan.h"
#include "dialog_model.h"
#include "key_help.h"
#include "event_bus.h"
#include "key_event.h"
#include "list_model.h"
@@ -24,6 +25,7 @@ class MaintenancePage {
void enter();
bool onKey(const KeyEvent& e); // false: leave the page
void draw(Canvas& c);
void help(std::vector<KeyHelp>& out) const;
private:
enum class View { Main, Categories, Ages };
+7 -1
View File
@@ -1,3 +1,4 @@
#include "app_keys.h"
#include "note_editor.h"
#include <Arduino.h>
@@ -182,6 +183,11 @@ bool NoteEditor::save() {
return true;
}
void NoteEditor::help(std::vector<KeyHelp>& out) const {
if (dialog_) return keys::add(out, keys::kDialog);
keys::add(out, keys::kNotesEditor);
}
bool NoteEditor::onKey(const KeyEvent& e) {
if (!text_) return false;
redraw_ = true;
@@ -290,7 +296,7 @@ void NoteEditor::draw(Canvas& c) {
c.setFont(&fonts::small);
bool showMessage = !message_.empty() && millis() - messageMs_ < kMessageMs;
c.setTextColor(showMessage ? theme::kWarning : theme::kMuted);
c.drawString(showMessage ? message_.c_str() : "Fn+arrows: move Ctrl+A/E: line Back: done", 4, area.y + area.h - 9);
if (showMessage) c.drawString(message_.c_str(), 4, area.y + area.h - 9);
if (dialog_ && ask_ == Ask::Recover)
widgets::dialog(c, "Unsaved copy", "A save of this note was cut short. Its copy has " + formatBytes(recovered_.size()) + ", the note " +
+2
View File
@@ -5,6 +5,7 @@
#include <vector>
#include "dialog_model.h"
#include "key_help.h"
#include "key_event.h"
#include "note_text.h"
#include "services/clock_service.h"
@@ -33,6 +34,7 @@ class NoteEditor {
const std::string& path() const { return path_; } // "" for a new note nothing was typed in
bool onKey(const KeyEvent& e); // false: done, and saved
void help(std::vector<KeyHelp>& out) const;
bool update(uint32_t nowMs); // true: draw again
void draw(Canvas& c);
+14 -2
View File
@@ -1,3 +1,4 @@
#include "app_keys.h"
#include "notes_app.h"
#include <Arduino.h>
@@ -173,6 +174,18 @@ std::string NotesApp::titleOf(int row) {
return files::fitName(list_.name(notes_[row]), kTitleChars); // an empty note, or not a text
}
void NotesApp::help(std::vector<KeyHelp>& out) const {
if (view_ == View::Edit) return editor_.help(out);
if (dialog_) return keys::add(out, keys::kDialog);
if (view_ == View::Name) return keys::add(out, keys::kNotesName);
if (view_ == View::NoMemory) return;
keys::add(out, keys::kNotes);
}
const char* NotesApp::helpTitle() const {
return view_ == View::Edit ? "Notes: the editor" : view_ == View::Name ? "Notes: a file name" : nullptr;
}
bool NotesApp::onKey(const KeyEvent& e) {
requestRedraw();
if (view_ == View::NoMemory) return false;
@@ -315,7 +328,6 @@ void NotesApp::draw(Canvas& c) {
} else if (notes_.empty()) {
c.setTextColor(theme::kMuted);
c.drawString("No notes yet.", 4, rows.y + 4);
c.drawString("n starts one.", 4, rows.y + 4 + theme::kLineHeight);
} else {
readTitles();
widgets::list(
@@ -329,7 +341,7 @@ void NotesApp::draw(Canvas& c) {
c.setFont(&fonts::small);
bool showMessage = !message_.empty() && millis() - messageMs_ < kMessageMs;
c.setTextColor(showMessage ? theme::kWarning : theme::kMuted);
c.drawString(showMessage ? message_.c_str() : "n: new Enter: open r: name d: del s: sort", 4, area.y + area.h - 9);
if (showMessage) c.drawString(message_.c_str(), 4, area.y + area.h - 9);
if (view_ == View::Name) {
theme::Rect box{16, 44, theme::kWidth - 32, 48};
+2
View File
@@ -28,6 +28,8 @@ class NotesApp : public App {
bool textEntryActive() const override { return view_ == View::Edit || view_ == View::Name; }
void update(uint32_t nowMs) override;
void draw(Canvas& c) override;
void help(std::vector<KeyHelp>& out) const override;
const char* helpTitle() const override;
private:
enum class View { List, Edit, Name, NoMemory };
+25 -4
View File
@@ -1,3 +1,4 @@
#include "app_keys.h"
#include "settings_app.h"
#include <Arduino.h>
@@ -129,6 +130,28 @@ bool SettingsApp::onAboutKey(const KeyEvent& e) {
return true;
}
void SettingsApp::help(std::vector<KeyHelp>& out) const {
switch (page_) {
case Page::Menu: keys::add(out, keys::kSettings); break;
case Page::Text: keys::add(out, keys::kText); break;
case Page::Choice: keys::add(out, keys::kSettingsChoice); break;
case Page::About: break;
case Page::Wifi: wifiPage_.help(out); break;
case Page::Firmware: firmwarePage_.help(out); break;
case Page::Debug: debugPage_.help(out); break;
}
}
const char* SettingsApp::helpTitle() const {
switch (page_) {
case Page::Wifi: return wifiPage_.helpTitle();
case Page::Firmware: return firmwarePage_.helpTitle();
case Page::Debug: return debugPage_.helpTitle();
case Page::About: return "About";
default: return nullptr;
}
}
void SettingsApp::update(uint32_t nowMs) {
// Live values on About and Firmware.
bool live = page_ == Page::About || page_ == Page::Firmware || page_ == Page::Debug ||
@@ -177,10 +200,8 @@ void SettingsApp::draw(Canvas& c) {
c.setTextColor(theme::kMuted);
c.drawString(menu_.label(editingRow_).c_str(), 4, area.y + 4);
widgets::lineEditor(c, editor_, {4, area.y + 22, area.w - 8, 0});
c.drawString(("Enter: save `: cancel " + std::to_string(editor_.text().size()) + "/" +
std::to_string(editor_.maxBytes()) + " bytes")
.c_str(),
4, area.y + 44);
c.drawString((std::to_string(editor_.text().size()) + " of " + std::to_string(editor_.maxBytes()) + " bytes").c_str(), 4,
area.y + 44);
break;
case Page::Choice: {
auto options = menu_.choices(editingRow_);
+2
View File
@@ -50,6 +50,8 @@ class SettingsApp : public App {
(page_ == Page::Debug && debugPage_.textEntryActive());
}
void draw(Canvas& c) override;
void help(std::vector<KeyHelp>& out) const override;
const char* helpTitle() const override;
private:
enum class Page { Menu, Text, Choice, About, Wifi, Firmware, Debug };
+18 -2
View File
@@ -1,3 +1,4 @@
#include "app_keys.h"
#include "setup_app.h"
#include "platform/identity.h"
@@ -44,6 +45,18 @@ bool SetupApp::onKey(const KeyEvent& e) {
return true;
}
void SetupApp::help(std::vector<KeyHelp>& out) const {
switch (wizard_.step()) {
case Step::LongName:
case Step::ShortName: keys::add(out, keys::kSetupText); break;
case Step::Region:
case Step::Timezone: keys::add(out, keys::kSetupChoice); break;
default: keys::add(out, keys::kSetup); break;
}
}
// The one place that names keys on the screen (Q200): someone in their first minute doesn't know
// the help key yet, and this is where they learn it.
void SetupApp::draw(Canvas& c) {
const auto& area = theme::kContent;
auto heading = [&](const char* text) {
@@ -63,6 +76,7 @@ void SetupApp::draw(Canvas& c) {
heading("Welcome to roro9stack");
note("Let's set up this device: your names on the mesh, your radio region and your timezone.",
area.y + 22, theme::kText);
note("One key to know: Fn + h lists the keys of the screen you're on.", area.y + 22 + 4 * theme::kLineHeight, theme::kAccent);
note("Enter: continue", area.y + area.h - 14);
break;
case Step::LongName:
@@ -88,8 +102,10 @@ void SetupApp::draw(Canvas& c) {
[this](int i) { return wizard_.choiceLabel(i); });
break;
case Step::Done:
heading("All set");
note("You can change all of this later in Settings.", area.y + 22, theme::kText);
heading("All set. One key to remember:");
note("Fn + h, on any screen, lists the keys that work there. No screen names its keys: that key does. Try it now.",
area.y + 22, theme::kAccent);
note("The rest can be changed later in Settings.", area.y + 22 + 4 * theme::kLineHeight, theme::kText);
note("Enter: start `: go back", area.y + area.h - 14);
break;
}
+2
View File
@@ -17,6 +17,8 @@ class SetupApp : public App {
return wizard_.step() == SetupWizard::Step::LongName || wizard_.step() == SetupWizard::Step::ShortName;
}
void draw(Canvas& c) override;
void help(std::vector<KeyHelp>& out) const override;
const char* helpTitle() const override { return "Setup"; }
private:
AppManager& apps_;
+33 -9
View File
@@ -1,3 +1,4 @@
#include "app_keys.h"
#include "storage_app.h"
#include <Arduino.h>
@@ -199,7 +200,7 @@ void StorageApp::onFinished(const FileOps::Status& s) {
}
void StorageApp::paste(bool replace) {
if (!clip_) return say("Nothing to paste: c copies, x cuts");
if (!clip_) return say("Nothing to paste: copy or cut something first");
std::string name = baseName(clip_.path), why;
if (cut_) {
why = ops_.move(clip_.path, clip_.folder, joinPath(cwd_, name), replace);
@@ -273,6 +274,32 @@ void StorageApp::showDetails(const Item& item) {
view_ = View::Details;
}
void StorageApp::help(std::vector<KeyHelp>& out) const {
switch (view_) {
case View::NoMemory: return;
case View::Maintenance: return maintenance_.help(out);
case View::Editor: return noteEditor_.help(out);
case View::Viewer: return viewer_.help(out);
case View::Details: return keys::add(out, keys::kStorageDetails);
default: break;
}
if (dialog_) return keys::add(out, keys::kDialog);
if (wantList_ || wait_ != Wait::None) return keys::add(out, keys::kStorageBusy);
if (view_ == View::Name) return keys::add(out, keys::kStorageName);
keys::add(out, keys::kStorage);
}
const char* StorageApp::helpTitle() const {
switch (view_) {
case View::Maintenance: return "Storage: maintenance";
case View::Editor: return "Storage: the editor";
case View::Viewer: return "Storage: a file";
case View::Details: return "Storage: details";
case View::Name: return "Storage: a name";
default: return nullptr;
}
}
bool StorageApp::onKey(const KeyEvent& e) {
requestRedraw();
if (view_ == View::NoMemory) return false;
@@ -434,7 +461,7 @@ bool StorageApp::onBrowseKey(const KeyEvent& e) {
if (ch == 'c') {
clip_ = item;
cut_ = false;
say("v pastes a copy of " + name);
say("Copied " + name + ": paste it where you like");
return true;
}
// Cut, rename and delete change the original: the rules of Q130 first.
@@ -446,7 +473,7 @@ bool StorageApp::onBrowseKey(const KeyEvent& e) {
if (ch == 'x') {
clip_ = item;
cut_ = true;
say("v moves " + name + " here");
say("Cut " + name + ": paste it where it should go");
} else if (ch == 'r') {
target_ = item;
renaming_ = true;
@@ -542,7 +569,6 @@ void StorageApp::drawBrowse(Canvas& c) {
} else if (rows_.count() == 0) {
c.setTextColor(theme::kMuted);
c.drawString("Nothing in this folder.", 4, rows.y + 4);
c.drawString("n makes a folder, v pastes.", 4, rows.y + 4 + theme::kLineHeight);
} else {
int entries = static_cast<int>(list_.count());
auto value = [&](int i) {
@@ -558,13 +584,13 @@ void StorageApp::drawBrowse(Canvas& c) {
value);
}
// Footer: a message for a few seconds, what v would paste, or the keys.
// Footer: a message for a few seconds, or what a paste would do. Never the keys (Fn+h).
c.setFont(&fonts::small);
bool showMessage = !message_.empty() && millis() - messageMs_ < kMessageMs;
c.setTextColor(showMessage ? theme::kWarning : theme::kMuted);
std::string keys = "c x v:paste r:name d:del n:new i:info s:sort";
std::string keys;
if ((wantList_ || wait_ == Wait::List) && millis() - waitSinceMs_ >= kQuietMs) keys = "Reading " + files::fitName(want_, 30) + "...";
else if (clip_) keys = std::string("v: ") + (cut_ ? "move " : "copy ") + files::fitName(baseName(clip_.path), 20) + " here";
else if (clip_) keys = std::string(cut_ ? "Cut: " : "Copied: ") + files::fitName(baseName(clip_.path), 28);
c.drawString(showMessage ? message_.c_str() : keys.c_str(), 4, area.y + area.h - 9);
}
@@ -589,8 +615,6 @@ void StorageApp::drawProgress(Canvas& c) {
line = formatBytes(s.doneBytes) + " of " + formatBytes(s.totalBytes);
}
c.drawString(line.c_str(), box.x + 6, box.y + 31);
c.setFont(&fonts::small);
c.drawString("Back: cancel", box.x + 6, box.y + box.h - 10);
}
} // namespace roro
+2
View File
@@ -34,6 +34,8 @@ class StorageApp : public App {
bool textEntryActive() const override { return view_ == View::Name || view_ == View::Editor; }
void update(uint32_t nowMs) override;
void draw(Canvas& c) override;
void help(std::vector<KeyHelp>& out) const override;
const char* helpTitle() const override;
private:
enum class View { Browse, Details, Name, Viewer, Editor, Maintenance, NoMemory };
+20 -6
View File
@@ -1,3 +1,4 @@
#include "app_keys.h"
#include "system_app.h"
#include <Arduino.h>
@@ -73,6 +74,22 @@ void SystemApp::onExit() { // nothing is kept while it's closed (Q120)
load_[0] = load_[1] = -1;
}
void SystemApp::help(std::vector<KeyHelp>& out) const {
if (view_ == View::Tasks) keys::add(out, keys::kSystemTasks);
else if (view_ == View::System) keys::add(out, keys::kSystemSystem);
else keys::add(out, keys::kSystem);
}
const char* SystemApp::helpTitle() const {
switch (view_) {
case View::Tasks: return "System: tasks";
case View::Memory: return "System: memory";
case View::Network: return "System: network";
case View::System: return "System: system";
default: return "System: overview";
}
}
bool SystemApp::onKey(const KeyEvent& e) {
if (e.key == Key::Tab) {
int n = static_cast<int>(View::Count), v = static_cast<int>(view_);
@@ -219,7 +236,6 @@ void SystemApp::drawOverview(Canvas& c) {
}
}
}
footer(c, "Tab: tasks, memory, network, system");
}
void SystemApp::drawTasks(Canvas& c) {
@@ -249,8 +265,7 @@ void SystemApp::drawTasks(Canvas& c) {
c.drawString(std::to_string(r.stackFree).c_str(), area.w - 4, y);
}
c.setTextDatum(top_left);
std::string keys = std::string("s: by ") + (sort_ == TaskSort::Share ? "stack" : sort_ == TaskSort::Stack ? "name" : "cpu") +
" " + std::to_string(rows_.size()) + " tasks Tab: memory";
std::string keys = std::to_string(rows_.size()) + " tasks, by " + (sort_ == TaskSort::Share ? "cpu" : sort_ == TaskSort::Stack ? "stack" : "name");
footer(c, keys.c_str());
}
@@ -288,7 +303,7 @@ void SystemApp::drawMemory(Canvas& c) {
int x0 = gx + 1 + static_cast<int>((i - 1) * (gw - 2) / kHistory), x1 = gx + 1 + static_cast<int>(i * (gw - 2) / kHistory);
c.drawLine(x0, yOf(heapHistory_.at(i - 1)), x1, yOf(heapHistory_.at(i)), theme::kAccent);
}
footer(c, "KB free, 2 min, with the floors Tab: network");
footer(c, "KB free, 2 min, with the floors");
}
void SystemApp::drawNetwork(Canvas& c) {
@@ -331,7 +346,7 @@ void SystemApp::drawNetwork(Canvas& c) {
y += theme::kLineHeight;
}
c.setTextDatum(top_left);
footer(c, "bytes since boot Tab: system");
footer(c, "bytes since boot");
}
void SystemApp::drawSystem(Canvas& c) {
@@ -355,7 +370,6 @@ void SystemApp::drawSystem(Canvas& c) {
wrappedLines_ = static_cast<int>(wrapped.size());
systemTop_ = std::clamp(systemTop_, 0, std::max(0, wrappedLines_ - 8));
widgets::textLines(c, wrapped, systemTop_, {area.x + 2, area.y + 2, area.w - 2, area.h - 12});
footer(c, "Tab: overview");
}
} // namespace roro
+2
View File
@@ -28,6 +28,8 @@ class SystemApp : public App {
bool onKey(const KeyEvent& e) override;
void update(uint32_t nowMs) override;
void draw(Canvas& c) override;
void help(std::vector<KeyHelp>& out) const override;
const char* helpTitle() const override;
private:
enum class View : uint8_t { Overview, Tasks, Memory, Network, System, Count };
+31 -5
View File
@@ -1,3 +1,4 @@
#include "app_keys.h"
#include "wifi_settings_page.h"
#include "ipv4.h"
@@ -413,7 +414,6 @@ void WifiSettingsPage::draw(Canvas& c) {
c.setTextColor(theme::kMuted);
c.drawString(editTitle_.c_str(), 4, area.y + 4);
widgets::lineEditor(c, editor_, {4, area.y + 22, area.w - 8, 0});
c.drawString("Enter: save `: cancel", 4, area.y + 44);
break;
}
@@ -427,7 +427,7 @@ void WifiSettingsPage::draw(Canvas& c) {
if (!n.ssid.empty()) found.push_back(n);
if (scan_.count() != static_cast<int>(found.size())) scan_.setCount(static_cast<int>(found.size()));
if (found.empty()) {
widgets::textLines(c, {"No networks found.", "", "`: back"}, 0, area);
widgets::textLines(c, {"No networks found."}, 0, area);
break;
}
widgets::list(
@@ -443,20 +443,46 @@ void WifiSettingsPage::draw(Canvas& c) {
std::string title = view_ == View::Ssid ? "Hidden network name" : "Password for " + newSsid_;
c.drawString(title.c_str(), 4, area.y + 4);
widgets::lineEditor(c, editor_, {4, area.y + 22, area.w - 8, 0});
c.drawString(view_ == View::Ssid ? "Enter: next `: cancel" : "Enter: save (empty = open) `: cancel", 4,
area.y + 44);
if (view_ == View::Password) c.drawString("Leave it empty for an open network.", 4, area.y + 44);
break;
}
}
}
void WifiSettingsPage::help(std::vector<KeyHelp>& out) const {
if (forgetDialog_) return keys::add(out, keys::kDialog);
switch (view_) {
case View::Main: keys::add(out, keys::kWifi); break;
case View::Servers: keys::add(out, keys::kWifiServers); break;
case View::Network: keys::add(out, keys::kWifiNetwork); break;
case View::Details: keys::add(out, keys::kWifiStatus); break;
case View::Scan: keys::add(out, keys::kWifiScan); break;
case View::Ssid: keys::add(out, keys::kWifiName); break;
case View::Password:
case View::Edit: keys::add(out, keys::kText); break;
}
}
const char* WifiSettingsPage::helpTitle() const {
switch (view_) {
case View::Servers: return "DNS and NTP";
case View::Network: return "A saved network";
case View::Details: return "Wi-Fi status";
case View::Scan: return "Add a network";
case View::Ssid: return "Hidden network name";
case View::Password: return "Wi-Fi password";
case View::Edit: return "Wi-Fi: a field";
default: return "Wi-Fi";
}
}
// What the device is using and where each part came from (Q113).
void WifiSettingsPage::drawDetails(Canvas& c) {
const auto& area = theme::kContent;
WifiService::Connection n = wifi_.connection();
std::vector<std::string> lines;
if (!n.connected) {
lines = {statusText(), "", "`: back"};
lines = {statusText()};
} else {
lines.push_back(wifi_.ssid() + ", " + std::to_string(wifi_.rssi()) + " dBm");
lines.push_back("Address " + n.address + "/" + std::to_string(n.prefix) + (n.fixed ? " (fixed)" : " (DHCP)"));
+3
View File
@@ -6,6 +6,7 @@
#include "dialog_model.h"
#include "event_bus.h"
#include "key_event.h"
#include "key_help.h"
#include "line_editor.h"
#include "list_model.h"
#include "saved_networks.h"
@@ -30,6 +31,8 @@ class WifiSettingsPage {
// Redraw periodically: the status, a scan, the connection details.
bool live() const { return view_ == View::Main || view_ == View::Scan || view_ == View::Details; }
void draw(Canvas& c);
void help(std::vector<KeyHelp>& out) const;
const char* helpTitle() const;
private:
enum class View { Main, Scan, Ssid, Password, Details, Servers, Network, Edit };
+21 -2
View File
@@ -1,3 +1,4 @@
#include "app_keys.h"
#include "wifi_tools_app.h"
#include <M5Cardputer.h>
@@ -153,6 +154,24 @@ void WifiToolsApp::draw(Canvas& c) {
}
}
void WifiToolsApp::help(std::vector<KeyHelp>& out) const {
switch (view_) {
case View::Menu: keys::add(out, keys::kWifiTools); break;
case View::Networks: keys::add(out, keys::kWifiNetworks); break;
case View::Channels: break; // nothing but the keys that work everywhere
case View::Tracker: keys::add(out, keys::kWifiTracker); break;
}
}
const char* WifiToolsApp::helpTitle() const {
switch (view_) {
case View::Networks: return "Networks nearby";
case View::Channels: return "Channel occupancy";
case View::Tracker: return "Signal tracker";
default: return nullptr;
}
}
void WifiToolsApp::drawNetworks(Canvas& c) {
const auto& area = theme::kContent;
const auto& scan = wifi_.listScan();
@@ -178,7 +197,7 @@ void WifiToolsApp::drawNetworks(Canvas& c) {
widgets::textLines(c,
{wifi_.listScanDone() ? (any ? "Nothing matches the filter." : "No networks found yet.")
: "Scanning...",
"", "s sort o open h hide-hidden w strong l log"},
},
0, list);
return;
}
@@ -233,7 +252,7 @@ void WifiToolsApp::drawTracker(Canvas& c) {
c.drawString(targetName_.c_str(), 4, area.y + 2);
c.setFont(&fonts::small);
c.setTextColor(theme::kMuted);
std::string sub = targetBssid_ + " ch" + std::to_string(targetChannel_) + " m: clicks " + (clicks_ ? "on" : "off");
std::string sub = targetBssid_ + " ch" + std::to_string(targetChannel_) + " clicks " + (clicks_ ? "on" : "off");
c.drawString(sub.c_str(), 4, area.y + 17);
c.setFont(&fonts::bold);
+2
View File
@@ -27,6 +27,8 @@ class WifiToolsApp : public App {
bool onKey(const KeyEvent& e) override;
void update(uint32_t nowMs) override;
void draw(Canvas& c) override;
void help(std::vector<KeyHelp>& out) const override;
const char* helpTitle() const override;
private:
enum class View { Menu, Networks, Channels, Tracker };
+9 -2
View File
@@ -225,6 +225,13 @@ void setup() {
services.startAll(millis());
apps->begin();
if (!settings.getBool(Setting::SetupDone)) apps->openModal("setup");
// No screen names its keys any more (issue #69): Setup teaches the help key to a new device, and
// one that was set up before this firmware is told once, here.
if (!settings.getBool(Setting::HelpTold)) {
if (settings.getBool(Setting::SetupDone))
bus.publish(Event::withText(EventType::Notification, "Fn+h: the keys of any screen", static_cast<int32_t>(NotificationLevel::Info)));
settings.setBool(Setting::HelpTold, true);
}
console.printf("%s %s ready, free heap %u, last start: %s\n", kProductName, versionString(), ESP.getFreeHeap(),
system_info::resetReason());
if (system_info::resetWasCrash()) {
@@ -555,7 +562,7 @@ static const char* const kHelp =
"gnss status | gnss restart | gnss track start|stop | gnss nmea on|off | gnss send <sentence without $ and checksum>\n"
"crash the last crash: firmware, reason, task, backtrace\n"
"coredump erase forget the core dump in flash\n"
"key <name|char> press a key: up down left right select back home del tab space, or one character\n"
"key <name|char> press a key: up down left right select back home del tab space help, or one character\n"
"wifi status | wifi add <ssid><TAB><password>\n"
"wifi ip <ssid> dhcp | wifi ip <ssid> <address>/<prefix> [gateway] a Saved Network's IP setting\n"
"wifi dns <a> [b] | wifi dns always on|off | wifi ntp <a> [b] DNS and NTP servers\n"
@@ -842,7 +849,7 @@ static void runCommand(String line, bool fromSerial = false) {
String k = line.substring(4);
Key key = k == "up" ? Key::Up : k == "down" ? Key::Down : k == "left" ? Key::Left
: k == "right" ? Key::Right : k == "back" ? Key::Back : k == "home" ? Key::Home
: k == "del" ? Key::Delete : k == "tab" ? Key::Tab : Key::Select;
: k == "del" ? Key::Delete : k == "tab" ? Key::Tab : k == "help" ? Key::Help : Key::Select;
KeyEvent ev = k == "space" ? KeyEvent::character(' ')
: k.length() == 1 && k[0] > ' ' ? KeyEvent::character((unsigned char)k[0])
: KeyEvent::of(key);
+1 -2
View File
@@ -459,8 +459,7 @@ void GeminiService::openSaved(const std::string& path, GeminiPage& page) {
// about:start (Q78, Q84): bookmarks, then Saved Pages by capsule, newest first, then defaults.
void GeminiService::composeStart(GeminiPage& page) {
std::string doc = "# Gemini\nTab picks a link, Enter follows it, Back returns, g types an address. "
"On a page: b bookmarks it, s saves it to read offline, S with the pages it links to.\n";
std::string doc = "# Gemini\nYour bookmarks and Saved Pages, then a few places to start.\n";
std::string bookmarks;
std::vector<SavedEntry> saved;
bool card = storage_.runAndWait([&]() {
+1
View File
@@ -17,6 +17,7 @@ void Screen::render(AppManager& apps, const StatusInfo& status, const Toast* toa
const auto& area = theme::kContent;
canvas_.setClipRect(area.x, area.y, area.w, area.h);
apps.foreground().draw(canvas_);
if (apps.help().isOpen()) widgets::help(canvas_, apps.help()); // over the App, under the Status Bar
canvas_.clearClipRect();
widgets::statusBar(canvas_, status);
if (toast) widgets::toast(canvas_, *toast);
+36
View File
@@ -175,4 +175,40 @@ void toast(Canvas& c, const Toast& t) {
c.clearClipRect();
}
void help(Canvas& c, const HelpModel& model) {
const auto& area = kContent;
c.fillRect(area.x, area.y, area.w, area.h, kBackground);
c.setTextDatum(top_left);
c.setFont(&fonts::bold);
c.setTextColor(kAccent);
c.drawString(("Keys: " + model.title()).c_str(), 4, area.y + 1);
c.drawFastHLine(4, area.y + 13, area.w - 8, kMuted);
const int top = area.y + 16, keysX = 4, actionX = 70;
const auto& rows = model.rows();
int count = static_cast<int>(rows.size());
for (int row = 0; row < model.visibleRows(); row++) {
int i = model.top() + row;
if (i >= count) break;
int y = top + row * kLineHeight;
if (!rows[i].action) { // a heading
c.setFont(&fonts::bold);
c.setTextColor(kMuted);
c.drawString(rows[i].keys, keysX, y);
continue;
}
c.setFont(&fonts::body);
c.setTextColor(kAccent);
c.drawString(rows[i].keys, keysX, y);
c.setTextColor(kText);
c.drawString(rows[i].action, actionX, y);
}
if (count > model.visibleRows()) {
int h = model.visibleRows() * kLineHeight;
int barH = h * model.visibleRows() / count;
int barY = top + (h - barH) * model.top() / (count - model.visibleRows());
c.fillRect(area.x + area.w - 2, barY, 2, barH, kMuted);
}
}
} // namespace roro::widgets
+4
View File
@@ -6,6 +6,7 @@
#include "canvas.h"
#include "dialog_model.h"
#include "key_help.h"
#include "line_editor.h"
#include "list_model.h"
#include "text_wrap.h"
@@ -59,6 +60,9 @@ void lineEditor(Canvas& c, const LineEditor& editor, theme::Rect area);
void dialog(Canvas& c, const std::string& title, const std::string& message, const DialogModel& model);
// The help panel (Fn+h): the keys of the screen under it, over the whole content area.
void help(Canvas& c, const HelpModel& model);
void toast(Canvas& c, const Toast& t);
} // namespace widgets
@@ -20,6 +20,12 @@ struct FakeApp : App {
}
void update(uint32_t nowMs) override { lastUpdate = nowMs; }
void draw(Canvas&) override {}
void help(std::vector<KeyHelp>& out) const override {
if (helpKey) out.push_back({helpKey, "does a thing"});
}
const char* helpTitle() const override { return scope; }
const char* helpKey = nullptr;
const char* scope = nullptr;
uint32_t lastUpdate = 0;
std::vector<KeyEvent> keys;
bool handles = false;
@@ -181,6 +187,79 @@ void test_modal_app_cannot_be_switched_away_by_open() {
TEST_ASSERT_EQUAL_PTR(&f.demo, &f.manager.foreground());
}
// Issue #69: Fn+h lists the foreground App's keys, then the ones that work everywhere.
void test_help_lists_the_apps_keys_then_the_global_ones() {
Fixture f;
f.manager.begin();
f.manager.open("notes");
f.notes.helpKey = "n";
f.manager.handleKey(KeyEvent::of(Key::Help));
TEST_ASSERT_TRUE(f.manager.help().isOpen());
TEST_ASSERT_EQUAL_STRING("Notes", f.manager.help().title().c_str());
const auto& rows = f.manager.help().rows();
TEST_ASSERT_EQUAL_STRING("n", rows[0].keys);
TEST_ASSERT_EQUAL_STRING("Everywhere", rows[1].keys);
TEST_ASSERT_NULL(rows[1].action); // a heading
TEST_ASSERT_TRUE(rows.size() > 3);
TEST_ASSERT_EQUAL(0, f.notes.keys.size()); // the App never saw the key
TEST_ASSERT_TRUE(f.manager.takeRedraw());
}
void test_help_is_titled_with_the_scope_when_the_app_names_one() {
Fixture f;
f.manager.begin();
f.manager.handleKey(KeyEvent::of(Key::Help));
TEST_ASSERT_EQUAL_STRING("Launcher", f.manager.help().title().c_str());
f.manager.handleKey(KeyEvent::of(Key::Select)); // closes it
f.manager.open("notes");
f.notes.scope = "Notes: editor";
f.manager.handleKey(KeyEvent::of(Key::Help));
TEST_ASSERT_EQUAL_STRING("Notes: editor", f.manager.help().title().c_str());
}
void test_open_help_takes_every_key_and_any_but_the_arrows_closes_it() {
Fixture f;
f.manager.begin();
f.manager.open("notes");
f.manager.handleKey(KeyEvent::of(Key::Help));
f.manager.handleKey(KeyEvent::of(Key::Down)); // scrolls
TEST_ASSERT_TRUE(f.manager.help().isOpen());
f.manager.handleKey(KeyEvent::of(Key::Home)); // closes; doesn't go home
TEST_ASSERT_FALSE(f.manager.help().isOpen());
TEST_ASSERT_TRUE(&f.manager.foreground() == &f.notes);
TEST_ASSERT_EQUAL(0, f.notes.keys.size());
f.manager.handleKey(KeyEvent::character('x')); // closed: keys reach the App again
TEST_ASSERT_EQUAL(1, f.notes.keys.size());
}
void test_help_works_in_a_modal_app_and_closes_when_the_app_changes() {
Fixture f;
f.manager.begin();
f.manager.openModal("demo");
f.manager.handleKey(KeyEvent::of(Key::Help));
TEST_ASSERT_TRUE(f.manager.help().isOpen());
f.manager.endModal();
TEST_ASSERT_FALSE(f.manager.help().isOpen());
}
void test_the_help_panel_scrolls_within_its_rows() {
HelpModel m(3);
std::vector<KeyHelp> rows;
for (int i = 0; i < 8; i++) rows.push_back({"k", "a"});
m.open("T", rows);
m.onKey(KeyEvent::of(Key::Up));
TEST_ASSERT_EQUAL(0, m.top());
m.onKey(KeyEvent::of(Key::Right)); // a page
TEST_ASSERT_EQUAL(3, m.top());
for (int i = 0; i < 9; i++) m.onKey(KeyEvent::of(Key::Down));
TEST_ASSERT_EQUAL(5, m.top()); // the last row at the bottom, no further
m.onKey(KeyEvent::of(Key::Left));
TEST_ASSERT_EQUAL(2, m.top());
m.onKey(KeyEvent::of(Key::Tab));
TEST_ASSERT_FALSE(m.isOpen());
TEST_ASSERT_EQUAL(0, m.rows().size());
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_launcher_is_in_foreground_after_begin);
@@ -199,5 +278,10 @@ int main() {
RUN_TEST(test_modal_app_keeps_home_and_back);
RUN_TEST(test_ending_the_modal_returns_to_launcher_and_unlocks_home);
RUN_TEST(test_modal_app_cannot_be_switched_away_by_open);
RUN_TEST(test_help_lists_the_apps_keys_then_the_global_ones);
RUN_TEST(test_help_is_titled_with_the_scope_when_the_app_names_one);
RUN_TEST(test_open_help_takes_every_key_and_any_but_the_arrows_closes_it);
RUN_TEST(test_help_works_in_a_modal_app_and_closes_when_the_app_changes);
RUN_TEST(test_the_help_panel_scrolls_within_its_rows);
return UNITY_END();
}
+39
View File
@@ -220,6 +220,42 @@ void test_shifted_arrow_keys_type_their_symbols_when_not_typing() {
TEST_ASSERT_EQUAL(':', press(m, r)[0].ch);
}
// Issue #69, Q196: Fn+h everywhere, ? when it wouldn't be typed.
void test_fn_h_is_help_in_both_modes() {
for (bool typing : {true, false}) {
KeyMapper m;
m.setTextEntry(typing);
auto ev = press(m, withFn({'h'}));
TEST_ASSERT_EQUAL(1, ev.size());
TEST_ASSERT_EQUAL(static_cast<int>(Key::Help), static_cast<int>(ev[0].key));
}
}
void test_plain_h_still_types() {
for (bool typing : {true, false}) {
KeyMapper m;
m.setTextEntry(typing);
auto ev = press(m, chars({'h'}));
TEST_ASSERT_EQUAL(1, ev.size());
TEST_ASSERT_EQUAL(static_cast<int>(Key::Char), static_cast<int>(ev[0].key));
TEST_ASSERT_EQUAL('h', ev[0].ch);
}
}
void test_question_mark_is_help_only_when_not_typing() {
KeyMapper m;
m.setTextEntry(false);
auto ev = press(m, chars({'?'}));
TEST_ASSERT_EQUAL(1, ev.size());
TEST_ASSERT_EQUAL(static_cast<int>(Key::Help), static_cast<int>(ev[0].key));
release(m);
m.setTextEntry(true);
ev = press(m, chars({'?'}));
TEST_ASSERT_EQUAL(1, ev.size());
TEST_ASSERT_EQUAL(static_cast<int>(Key::Char), static_cast<int>(ev[0].key));
TEST_ASSERT_EQUAL('?', ev[0].ch);
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_plain_character);
@@ -241,5 +277,8 @@ int main() {
RUN_TEST(test_fn_arrows_work_in_both_modes);
RUN_TEST(test_other_characters_still_type_when_not_typing);
RUN_TEST(test_shifted_arrow_keys_type_their_symbols_when_not_typing);
RUN_TEST(test_fn_h_is_help_in_both_modes);
RUN_TEST(test_plain_h_still_types);
RUN_TEST(test_question_mark_is_help_only_when_not_typing);
return UNITY_END();
}