Public Access
Compare commits
11
Commits
console-setting
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c278a06ca1 | ||
|
|
828f7ce821 | ||
|
|
ca5874fe70 | ||
|
|
07ac7ba457 | ||
|
|
942725a047 | ||
|
|
34e6714785 | ||
|
|
82023d36b3 | ||
|
|
7fe8b3d22a | ||
|
|
1c6ae0e04f | ||
|
|
74b7713553 | ||
|
|
6be05b782d |
+49
-8
@@ -1,14 +1,31 @@
|
||||
# CI and releases (docs/milestones/R1.md).
|
||||
# A push to main: the host tests, with their coverage of lib/, and the README's badges
|
||||
# published to the branch `badges`.
|
||||
# A pull request: the same tests and coverage, then the firmware.
|
||||
# A pull request: the same tests and coverage, then the firmware (from the build cache).
|
||||
# A branch's pushes run nothing by themselves: its pull request runs, once.
|
||||
# A tag v*: all of it, then a Gitea release with the signed Update File.
|
||||
# A tag v*: the tests, then the firmware built once, clean, signed and published as a
|
||||
# Gitea release.
|
||||
# Run by hand: the release of a tag that exists already (the ones from before CI).
|
||||
#
|
||||
# The job runs in a plain Python image, as scripts/ci.sh does on a developer's machine, with the
|
||||
# toolchains in a Docker volume the runner allows (container.valid_volumes: roro9stack-pio): that
|
||||
# volume is the cache. No JavaScript actions, so the image needs no Node: the checkout is git.
|
||||
#
|
||||
# What the volume keeps between runs, and what makes each go stale (issue #74, R1.md):
|
||||
# /pio/packages, /pio/platforms toolchains and the framework: by their versions in platformio.ini
|
||||
# .../framework-arduinoespressif32-libs/.roro-sdkconfig.defaults
|
||||
# the mark that the framework is already rebuilt with our SDK settings: the
|
||||
# project's sdkconfig.defaults, which isn't in git, so that every fresh
|
||||
# checkout rebuilt the framework (260 s). The platform checks its hash
|
||||
# against platformio.ini's settings, and rebuilds if they differ. It is kept
|
||||
# inside the libraries it describes, so it goes when they are reinstalled.
|
||||
# /pio/ci/build-cache PlatformIO's build cache (SCons): objects by the signature of their
|
||||
# sources and command line. For pull requests only: a release compiles
|
||||
# its own sources from nothing.
|
||||
# /pio/ci/ccache the host tests' objects (they're built for coverage, which the build
|
||||
# cache can't keep: it would lose the .gcno files)
|
||||
# /pio/ci/venv PlatformIO and gcovr: delete the folder to upgrade them
|
||||
# To start from nothing (a slow run, about 7 minutes): delete /pio/ci and that .roro-sdkconfig.defaults file.
|
||||
name: CI
|
||||
on:
|
||||
push:
|
||||
@@ -37,13 +54,23 @@ jobs:
|
||||
env:
|
||||
PLATFORMIO_CORE_DIR: /pio
|
||||
RORO_NO_DOCKER: 1
|
||||
SDK_MARK: /pio/packages/framework-arduinoespressif32-libs/.roro-sdkconfig.defaults
|
||||
CCACHE_DIR: /pio/ci/ccache
|
||||
CCACHE_MAXSIZE: 1G
|
||||
steps:
|
||||
- name: Tools
|
||||
run: |
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq --no-install-recommends git build-essential openssl >/dev/null
|
||||
pip install -q --no-cache-dir --root-user-action=ignore platformio gcovr
|
||||
pio --version; df -h /pio | tail -1; ls /pio | head
|
||||
apt-get install -y -qq --no-install-recommends git build-essential openssl ccache >/dev/null
|
||||
mkdir -p /pio/ci
|
||||
if [ ! -x /pio/ci/venv/bin/pio ]; then
|
||||
python -m venv /pio/ci/venv
|
||||
/pio/ci/venv/bin/pip install -q --no-cache-dir platformio gcovr
|
||||
fi
|
||||
ln -sf /pio/ci/venv/bin/pio /pio/ci/venv/bin/gcovr /usr/local/bin/
|
||||
pio --version; df -h /pio | tail -1; du -sh /pio/ci/* 2>/dev/null || true
|
||||
# The build cache only grows: start it again past 3 GB (a full set of objects is 160 MB, and each run adds about 40).
|
||||
if [ "$(du -sm /pio/ci/build-cache 2>/dev/null | cut -f1)" -gt 3072 ] 2>/dev/null; then rm -rf /pio/ci/build-cache; fi
|
||||
|
||||
- name: Check out
|
||||
run: |
|
||||
@@ -57,11 +84,20 @@ jobs:
|
||||
|
||||
- name: Host tests, and their coverage of lib/
|
||||
if: github.event_name != 'workflow_dispatch'
|
||||
run: scripts/coverage.sh
|
||||
run: |
|
||||
export PATH="/usr/lib/ccache:$PATH" # gcc and g++ through ccache
|
||||
scripts/coverage.sh
|
||||
ccache -s | grep -E 'Hits|Misses' | head -2
|
||||
|
||||
# A pull request only: a tag's firmware is built once, by the release step below.
|
||||
- name: The firmware
|
||||
if: github.event_name == 'pull_request' || github.ref_type == 'tag'
|
||||
run: scripts/ci.sh builds
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
PLATFORMIO_BUILD_CACHE_DIR: /pio/ci/build-cache
|
||||
run: |
|
||||
[ ! -f "$SDK_MARK" ] || cp "$SDK_MARK" sdkconfig.defaults
|
||||
scripts/ci.sh builds
|
||||
cp sdkconfig.defaults "$SDK_MARK" # what the framework in the volume is rebuilt with, now
|
||||
|
||||
# The README's badges are files on a branch of their own, replaced at each push to main and
|
||||
# at each tag (the release badge says which tag is the latest)
|
||||
@@ -103,7 +139,12 @@ jobs:
|
||||
trap 'rm -f "$RORO_OTA_KEY"' EXIT
|
||||
printf '%s\n' "$OTA_SIGNING_KEY" > "$RORO_OTA_KEY"
|
||||
umask 022
|
||||
# The framework rebuilt with our settings is reused if it matches (the platform checks);
|
||||
# the release's own sources are compiled from nothing, with no build cache.
|
||||
[ ! -f "$SDK_MARK" ] || cp "$SDK_MARK" /tmp/release-src/sdkconfig.defaults
|
||||
scripts/release_build.sh /tmp/release-src dist
|
||||
# An old tag has no SDK settings of its own, and no sdkconfig.defaults afterwards: nothing to mark.
|
||||
[ ! -f /tmp/release-src/sdkconfig.defaults ] || [ ! -d "$(dirname "$SDK_MARK")" ] || cp /tmp/release-src/sdkconfig.defaults "$SDK_MARK"
|
||||
|
||||
- name: Publish the release
|
||||
if: steps.release.outputs.tag != ''
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -12,3 +12,6 @@ sdkconfig.*
|
||||
|
||||
# The built site (site/config.toml sends it here)
|
||||
/public/
|
||||
|
||||
# The version, written by scripts/version.py before each build
|
||||
lib/version/src/version_generated.h
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -22,7 +26,7 @@ This runs the host-side unit tests (`test/`, `native` environment), then builds
|
||||
|
||||
`scripts/coverage.sh` runs the same tests with coverage counters and writes a line-by-line report to `.pio/coverage/index.html`. The badge above is its figure for `main`: the share of the lines of `lib/` that the host tests run. `lib/` is the logic that compiles on a PC; `lib/SD` (the card's driver) and `src/` (the Apps, the Services, everything that needs the device) have no host tests and aren't in that figure.
|
||||
|
||||
The framework is rebuilt with the TLS settings in `platformio.ini` (`custom_sdkconfig`, ADR 0006), so the first build after a fresh checkout takes about 4 minutes; later builds take under a minute.
|
||||
The framework is rebuilt with the TLS settings in `platformio.ini` (`custom_sdkconfig`, ADR 0006), so the first build after a fresh checkout takes about 6 minutes; later builds take about 15 seconds when little has changed. The platform knows the framework is already rebuilt by `sdkconfig.defaults` in the project folder, which it writes and git ignores: delete it and the next build rebuilds the framework. CI keeps that file, a build cache and ccache in its volume (docs/milestones/R1.md).
|
||||
|
||||
## CI and releases
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -172,3 +172,62 @@ The issue asked for a token that could be set, so that Debug Builds could be pub
|
||||
**One false alarm:** after the tests the console "wouldn't come back on". It was off: the setting had been left off by `debug off`, and the page's "Switch it on?" dialog opens on **Cancel**, so Enter twice leaves it off. Nothing was lost.
|
||||
|
||||
**Not checked:** `scripts/flash.sh --debug` over USB (no device on USB here); "New token" and "Type a token" from the page (the code paths are the ones `debug token` uses, which need the cable); the Toast itself on screen (the console is closed while it shows; its Notification is in the log); free memory with the console off, which only the serial port could say (the static figures above are the evidence); fetching a release's ELF, which needs a crash on a released version.
|
||||
|
||||
## CI that doesn't rebuild the world (issue #74)
|
||||
|
||||
A pull request's run took over seven minutes, a tag's thirteen and a half. The goal: a firmware build under a minute.
|
||||
|
||||
### Where the time went (run 81, a pull request, 2026-10-06)
|
||||
|
||||
| Step | Time |
|
||||
|---|---|
|
||||
| Tools (apt, pip) | 15 s |
|
||||
| Check out | 2 s |
|
||||
| Host tests and coverage | 55 s |
|
||||
| **The firmware** | **358 s** |
|
||||
| of which: CMake configuring ESP-IDF | 87 s |
|
||||
| of which: compiling ESP-IDF's libraries | 171 s |
|
||||
| of which: our own build (the Arduino core, the libraries, `src/`) | 91 s |
|
||||
|
||||
A tag's run did the firmware step, then built the same commit again for the release: twice 356 s.
|
||||
|
||||
### Why the framework was rebuilt every time
|
||||
|
||||
The framework is rebuilt with our SDK settings (ADR 0006), and the rebuilt libraries stay in the toolchain volume. But the platform decides whether they match by reading **`sdkconfig.defaults` in the project folder**, whose first line carries a hash of the settings. That file is generated, and not in git. A fresh checkout has none, so the platform concluded "different settings", **reinstalled the framework and rebuilt it**: 260 seconds, at every run, to arrive at the libraries that were already there. On a developer's machine the file is simply still there from the last build, which is why nobody saw it.
|
||||
|
||||
### What changed
|
||||
|
||||
| Change | Effect |
|
||||
|---|---|
|
||||
| **The file is kept in the volume, inside the libraries it describes** (`framework-arduinoespressif32-libs/.roro-sdkconfig.defaults`), copied into the checkout before a build and back after one that passed. The platform still checks its hash against `platformio.ini`: changed settings rebuild, as they must. Kept there and not beside them, it disappears when the libraries are reinstalled, so it can't describe libraries that are gone | 358 s to 92 s |
|
||||
| **The version is no longer a `-D` on every compiler command line.** `scripts/version.py` writes `lib/version/src/version_generated.h` (not in git, written only when it changes), read by one file. Before, every commit recompiled everything, on a developer's machine too, and no cache could have helped | A rebuild with nothing changed: 77 s to 13 s, locally |
|
||||
| **PlatformIO's build cache** (`PLATFORMIO_BUILD_CACHE_DIR`, SCons's CacheDir) in the volume, for the firmware of pull requests: objects by the signature of their sources and command line | 92 s to 26 s, with a new version and one changed file |
|
||||
| **ccache for the host tests.** They are built with coverage counters, and the build cache would return objects without their `.gcno` files; ccache keeps both | 49 s to 33 s. What's left is PlatformIO starting 51 test programs |
|
||||
| **A tag builds its firmware once**, in the release step | minus 6 minutes |
|
||||
| PlatformIO and gcovr in a virtual environment in the volume | a few seconds |
|
||||
|
||||
**A release compiles its own sources from nothing:** it reuses the rebuilt framework (the platform checks the hash) but not the build cache, so no published file contains an object that came from another commit's build.
|
||||
|
||||
### Measured (a development machine, fresh copies of the tree, the same volume)
|
||||
|
||||
| | Before | After |
|
||||
|---|---|---|
|
||||
| The firmware, fresh checkout, nothing cached for it | 358 s | 82 s (it fills the cache) |
|
||||
| The firmware, fresh checkout, a new version and one file changed | 358 s | **27 s** |
|
||||
| Host tests and coverage | 49 s | 33 s |
|
||||
| Rebuilding locally with nothing changed | 77 s | 13 s |
|
||||
|
||||
### Measured on the runner (pull request #76, 2026-10-07)
|
||||
|
||||
| Run | Tools | Tests and coverage | The firmware | The whole job |
|
||||
|---|---|---|---|---|
|
||||
| Before (run 81) | 15 s | 55 s | 358 s | 434 s |
|
||||
| The first with the new workflow: no mark yet, the framework is rebuilt once more and the caches fill | 16 s | 59 s | 354 s | 431 s |
|
||||
| The next commit (only the workflow changed) | 10 s | 36 s | **51 s** | **100 s** |
|
||||
| The same commit again | 10 s | 36 s | **18 s** | **66 s** |
|
||||
|
||||
In the 51-second run, 245 objects came from the cache and 43 were compiled: `version.cpp`, as expected, and all 42 files of `src/`, which had not changed. In the run after it, all 290 came from the cache. So the objects of `src/` made by the run that rebuilt the framework were not reusable by a normal run, and those of a normal run are: the two-pass build that rebuilds the framework compiles `src/` with something different on its command line. It costs one 51-second run after each framework rebuild, which is rare; I did not look for what differs.
|
||||
|
||||
The firmware step with everything cached is 18 seconds: the libraries are downloaded and unpacked (4 s), the dependency scan (5 s), fetching 290 objects, the link and the image (the last 11 s). A pull request that changes a few files should land between that and 51 seconds.
|
||||
|
||||
**What it costs:** the build cache grows by about 40 MB a run (each linked firmware is kept) and is started again past 3 GB; ccache is held to 1 GB.
|
||||
|
||||
@@ -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.
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
#include "version.h"
|
||||
|
||||
// Written by scripts/version.py before each build; not in git.
|
||||
#if __has_include("version_generated.h")
|
||||
#include "version_generated.h"
|
||||
#endif
|
||||
#ifndef RORO_VERSION
|
||||
#define RORO_VERSION "unknown"
|
||||
#endif
|
||||
|
||||
namespace roro {
|
||||
|
||||
const char* versionString() { return RORO_VERSION; }
|
||||
|
||||
} // namespace roro
|
||||
@@ -1,14 +1,12 @@
|
||||
#pragma once
|
||||
|
||||
#ifndef RORO_VERSION
|
||||
#define RORO_VERSION "unknown"
|
||||
#endif
|
||||
|
||||
namespace roro {
|
||||
|
||||
constexpr const char* kProductName = "roro9stack";
|
||||
|
||||
// "roro9stack v0.1.0" — used on the boot screen and in About.
|
||||
inline const char* versionString() { return RORO_VERSION; }
|
||||
// "v0.1.0", from `git describe` (scripts/version.py): used on the boot screen and in About.
|
||||
// A function in one file, not a macro on every compiler command line: a new commit then recompiles
|
||||
// that one file, and everything else comes from the build cache (issue #74).
|
||||
const char* versionString();
|
||||
|
||||
} // namespace roro
|
||||
|
||||
+9
-1
@@ -10,7 +10,15 @@ try:
|
||||
except Exception:
|
||||
version = "unknown"
|
||||
|
||||
env.Append(CPPDEFINES=[("RORO_VERSION", '\\"%s\\"' % version)]) # noqa: F821
|
||||
# The version goes into one generated header, read by one file (lib/version/src/version.cpp). As a -D
|
||||
# on every command line it made each new commit recompile everything, and no build cache could help
|
||||
# (issue #74). Written only when it changes, so an unchanged version rebuilds nothing.
|
||||
import os
|
||||
|
||||
_header = os.path.join(env.subst("$PROJECT_DIR"), "lib", "version", "src", "version_generated.h") # noqa: F821
|
||||
_text = '#define RORO_VERSION "%s"\n' % version
|
||||
if not os.path.exists(_header) or open(_header).read() != _text:
|
||||
open(_header, "w").write(_text)
|
||||
|
||||
|
||||
# Keep every build's ELF, named by version and the first 16 hex digits of its SHA-256 (the core dump
|
||||
|
||||
@@ -22,7 +22,7 @@ This runs the host-side unit tests (`test/`, `native` environment), then builds
|
||||
|
||||
`scripts/coverage.sh` runs the same tests with coverage counters and writes a line-by-line report to `.pio/coverage/index.html`. The badge above is its figure for `main`: the share of the lines of `lib/` that the host tests run. `lib/` is the logic that compiles on a PC; `lib/SD` (the card's driver) and `src/` (the Apps, the Services, everything that needs the device) have no host tests and aren't in that figure.
|
||||
|
||||
The framework is rebuilt with the TLS settings in `platformio.ini` (`custom_sdkconfig`, ADR 0006), so the first build after a fresh checkout takes about 4 minutes; later builds take under a minute.
|
||||
The framework is rebuilt with the TLS settings in `platformio.ini` (`custom_sdkconfig`, ADR 0006), so the first build after a fresh checkout takes about 6 minutes; later builds take about 15 seconds when little has changed. The platform knows the framework is already rebuilt by `sdkconfig.defaults` in the project folder, which it writes and git ignores: delete it and the next build rebuilds the framework. CI keeps that file, a build cache and ccache in its volume (docs/milestones/R1.md).
|
||||
|
||||
## CI and releases
|
||||
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -180,3 +180,62 @@ The issue asked for a token that could be set, so that Debug Builds could be pub
|
||||
**One false alarm:** after the tests the console "wouldn't come back on". It was off: the setting had been left off by `debug off`, and the page's "Switch it on?" dialog opens on **Cancel**, so Enter twice leaves it off. Nothing was lost.
|
||||
|
||||
**Not checked:** `scripts/flash.sh --debug` over USB (no device on USB here); "New token" and "Type a token" from the page (the code paths are the ones `debug token` uses, which need the cable); the Toast itself on screen (the console is closed while it shows; its Notification is in the log); free memory with the console off, which only the serial port could say (the static figures above are the evidence); fetching a release's ELF, which needs a crash on a released version.
|
||||
|
||||
## CI that doesn't rebuild the world (issue #74)
|
||||
|
||||
A pull request's run took over seven minutes, a tag's thirteen and a half. The goal: a firmware build under a minute.
|
||||
|
||||
### Where the time went (run 81, a pull request, 2026-10-06)
|
||||
|
||||
| Step | Time |
|
||||
|---|---|
|
||||
| Tools (apt, pip) | 15 s |
|
||||
| Check out | 2 s |
|
||||
| Host tests and coverage | 55 s |
|
||||
| **The firmware** | **358 s** |
|
||||
| of which: CMake configuring ESP-IDF | 87 s |
|
||||
| of which: compiling ESP-IDF's libraries | 171 s |
|
||||
| of which: our own build (the Arduino core, the libraries, `src/`) | 91 s |
|
||||
|
||||
A tag's run did the firmware step, then built the same commit again for the release: twice 356 s.
|
||||
|
||||
### Why the framework was rebuilt every time
|
||||
|
||||
The framework is rebuilt with our SDK settings (ADR 0006), and the rebuilt libraries stay in the toolchain volume. But the platform decides whether they match by reading **`sdkconfig.defaults` in the project folder**, whose first line carries a hash of the settings. That file is generated, and not in git. A fresh checkout has none, so the platform concluded "different settings", **reinstalled the framework and rebuilt it**: 260 seconds, at every run, to arrive at the libraries that were already there. On a developer's machine the file is simply still there from the last build, which is why nobody saw it.
|
||||
|
||||
### What changed
|
||||
|
||||
| Change | Effect |
|
||||
|---|---|
|
||||
| **The file is kept in the volume, inside the libraries it describes** (`framework-arduinoespressif32-libs/.roro-sdkconfig.defaults`), copied into the checkout before a build and back after one that passed. The platform still checks its hash against `platformio.ini`: changed settings rebuild, as they must. Kept there and not beside them, it disappears when the libraries are reinstalled, so it can't describe libraries that are gone | 358 s to 92 s |
|
||||
| **The version is no longer a `-D` on every compiler command line.** `scripts/version.py` writes `lib/version/src/version_generated.h` (not in git, written only when it changes), read by one file. Before, every commit recompiled everything, on a developer's machine too, and no cache could have helped | A rebuild with nothing changed: 77 s to 13 s, locally |
|
||||
| **PlatformIO's build cache** (`PLATFORMIO_BUILD_CACHE_DIR`, SCons's CacheDir) in the volume, for the firmware of pull requests: objects by the signature of their sources and command line | 92 s to 26 s, with a new version and one changed file |
|
||||
| **ccache for the host tests.** They are built with coverage counters, and the build cache would return objects without their `.gcno` files; ccache keeps both | 49 s to 33 s. What's left is PlatformIO starting 51 test programs |
|
||||
| **A tag builds its firmware once**, in the release step | minus 6 minutes |
|
||||
| PlatformIO and gcovr in a virtual environment in the volume | a few seconds |
|
||||
|
||||
**A release compiles its own sources from nothing:** it reuses the rebuilt framework (the platform checks the hash) but not the build cache, so no published file contains an object that came from another commit's build.
|
||||
|
||||
### Measured (a development machine, fresh copies of the tree, the same volume)
|
||||
|
||||
| | Before | After |
|
||||
|---|---|---|
|
||||
| The firmware, fresh checkout, nothing cached for it | 358 s | 82 s (it fills the cache) |
|
||||
| The firmware, fresh checkout, a new version and one file changed | 358 s | **27 s** |
|
||||
| Host tests and coverage | 49 s | 33 s |
|
||||
| Rebuilding locally with nothing changed | 77 s | 13 s |
|
||||
|
||||
### Measured on the runner (pull request #76, 2026-10-07)
|
||||
|
||||
| Run | Tools | Tests and coverage | The firmware | The whole job |
|
||||
|---|---|---|---|---|
|
||||
| Before (run 81) | 15 s | 55 s | 358 s | 434 s |
|
||||
| The first with the new workflow: no mark yet, the framework is rebuilt once more and the caches fill | 16 s | 59 s | 354 s | 431 s |
|
||||
| The next commit (only the workflow changed) | 10 s | 36 s | **51 s** | **100 s** |
|
||||
| The same commit again | 10 s | 36 s | **18 s** | **66 s** |
|
||||
|
||||
In the 51-second run, 245 objects came from the cache and 43 were compiled: `version.cpp`, as expected, and all 42 files of `src/`, which had not changed. In the run after it, all 290 came from the cache. So the objects of `src/` made by the run that rebuilt the framework were not reusable by a normal run, and those of a normal run are: the two-pass build that rebuilds the framework compiles `src/` with something different on its command line. It costs one 51-second run after each framework rebuild, which is rare; I did not look for what differs.
|
||||
|
||||
The firmware step with everything cached is 18 seconds: the libraries are downloaded and unpacked (4 s), the dependency scan (5 s), fetching 290 objects, the link and the image (the last 11 s). A pull request that changes a few files should land between that and 51 seconds.
|
||||
|
||||
**What it costs:** the build cache grows by about 40 MB a run (each linked firmware is kept) and is started again past 3 GB; ccache is held to 1 GB.
|
||||
|
||||
@@ -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 |
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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"]) }}
|
||||
|
||||
@@ -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"]) }}
|
||||
|
||||
@@ -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"]) }}
|
||||
|
||||
@@ -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"]) }}
|
||||
|
||||
@@ -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) }}
|
||||
@@ -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"]) }}
|
||||
|
||||
@@ -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"]) }}
|
||||
|
||||
@@ -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"]) }}
|
||||
|
||||
@@ -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"]) }}
|
||||
|
||||
@@ -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"]) }}
|
||||
|
||||
@@ -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"]) }}
|
||||
|
||||
@@ -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"]) }}
|
||||
|
||||
@@ -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"],
|
||||
]
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
@@ -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__":
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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(), '*'));
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 " +
|
||||
|
||||
@@ -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
@@ -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};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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_);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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)"));
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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([&]() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user