Public Access
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de8af6ed92 | ||
|
|
10c5291e15 | ||
|
|
12c88c98d3 | ||
|
|
55c9ad2eb4 | ||
|
|
0fdbb5b1ed | ||
|
|
d17d10948d | ||
|
|
7b5df713ad | ||
|
|
3863d28593 |
+13
-1
@@ -4,7 +4,8 @@
|
||||
# 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*: the tests, then the firmware built once, clean, signed and published as a
|
||||
# Gitea release.
|
||||
# Gitea release. The site is then rebuilt: its home page and Downloads name
|
||||
# the latest release when they are built (issue #79).
|
||||
# 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
|
||||
@@ -153,3 +154,14 @@ jobs:
|
||||
GITEA_REPO: ${{ github.repository }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: scripts/release_publish.py dist
|
||||
|
||||
# The site names the latest release on its home page and lists them all on Downloads, both
|
||||
# read when it is built: so it is rebuilt now (issue #79, as the Site workflow does).
|
||||
- name: Refresh the site
|
||||
if: steps.release.outputs.tag != ''
|
||||
env:
|
||||
SITE_DEPLOY_KEY: ${{ secrets.SITE_DEPLOY_KEY }}
|
||||
SITE_DEPLOY_HOST: ${{ secrets.SITE_DEPLOY_HOST }}
|
||||
SITE_DEPLOY_USER: ${{ secrets.SITE_DEPLOY_USER }}
|
||||
SITE_DEPLOY_KNOWN_HOSTS: ${{ secrets.SITE_DEPLOY_KNOWN_HOSTS }}
|
||||
run: scripts/site_refresh.sh
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# The project site (docs/milestones/W1.md): built with Zola to see that it builds and that its pages
|
||||
# are sound. Publishing is the maintainer's: the web server pulls main and runs `zola build`.
|
||||
# are sound. After a push to main it is then published: the job asks the web server, over SSH, to
|
||||
# pull main and rebuild (issue #79, scripts/site_refresh.sh). The key it holds can run that one
|
||||
# command there and nothing else; the server, the user and the keys are secrets, not in this file.
|
||||
#
|
||||
# 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
|
||||
@@ -9,9 +11,9 @@ name: Site
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ['site/**', 'docs/**', 'README.md', 'CONTEXT.md', 'src/main.cpp', 'lib/core/src/app_keys.h', '.gitea/workflows/site.yml']
|
||||
paths: ['site/**', 'docs/**', 'README.md', 'CONTEXT.md', 'src/main.cpp', 'lib/core/src/app_keys.h', '.gitea/workflows/site.yml', 'scripts/site_refresh.sh']
|
||||
pull_request:
|
||||
paths: ['site/**', 'docs/**', 'README.md', 'CONTEXT.md', 'src/main.cpp', 'lib/core/src/app_keys.h', '.gitea/workflows/site.yml']
|
||||
paths: ['site/**', 'docs/**', 'README.md', 'CONTEXT.md', 'src/main.cpp', 'lib/core/src/app_keys.h', '.gitea/workflows/site.yml', 'scripts/site_refresh.sh']
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -51,3 +53,14 @@ jobs:
|
||||
|
||||
- name: Check the pages
|
||||
run: python3 site/tools/check_site.py /tmp/site-out
|
||||
|
||||
# Only what has been merged, and only once it has built and passed the checks above. A pull
|
||||
# request never gets here, and the secrets are given to this step alone.
|
||||
- name: Publish the site
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
env:
|
||||
SITE_DEPLOY_KEY: ${{ secrets.SITE_DEPLOY_KEY }}
|
||||
SITE_DEPLOY_HOST: ${{ secrets.SITE_DEPLOY_HOST }}
|
||||
SITE_DEPLOY_USER: ${{ secrets.SITE_DEPLOY_USER }}
|
||||
SITE_DEPLOY_KNOWN_HOSTS: ${{ secrets.SITE_DEPLOY_KNOWN_HOSTS }}
|
||||
run: scripts/site_refresh.sh
|
||||
|
||||
@@ -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.
|
||||
|
||||
**Shell**:
|
||||
The App that runs the console's commands on the device itself, and shows what the console prints. Trusted like the USB port, not like the network.
|
||||
_Avoid_: terminal, command line, REPL
|
||||
|
||||
**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
|
||||
|
||||
@@ -150,11 +150,15 @@ At the top of the card the last row, **Maintenance** (also `m`), holds the card'
|
||||
|
||||
The Notes App (docs/milestones/F1.md) keeps plain text notes in `/notes` on the SD card. The list shows each note's first line and its date, newest first; `s` switches to by file name. `n` starts a note, Enter opens one, `r` renames its file, `d` deletes it after asking.
|
||||
|
||||
In the editor, type. Enter starts a line, Del deletes backwards, Fn with the arrows moves the cursor through the wrapped text, Ctrl+A and Ctrl+E go to the start and the end of the line, Tab types two spaces, and the Compose Key gives accents as everywhere. **There's no save key:** the note is written five seconds after the last key, on Back, on leaving the App, when the screen turns off and before the device powers off. The top line says "typing" or "saved". Each save writes a temporary file and then puts it in the note's place, so a power cut costs a few seconds of typing and never the note; if a save was cut short, opening the note offers its copy back.
|
||||
In the editor, type. Enter starts a line, Del deletes backwards, Fn with the arrows moves the cursor through the wrapped text, Ctrl+A and Ctrl+E go to the start and the end of the line, Ctrl with Fn+Up and Fn+Down to the start and the end of the note, Tab types two spaces, and the Compose Key gives accents as everywhere. **There's no save key:** the note is written five seconds after the last key, on Back, on leaving the App, when the screen turns off and before the device powers off. The top line says "typing" or "saved". Each save writes a temporary file and then puts it in the note's place, so a power cut costs a few seconds of typing and never the note; if a save was cut short, opening the note offers its copy back.
|
||||
|
||||
A new note has no file until something is typed; its file is then named after its first line (`shopping-list.txt`), or `note-<date>-<time>.txt`.
|
||||
|
||||
A note holds up to 16 KB while it's edited. A bigger text file opens read-only in the Storage App (editing any size is issue #47). The Storage App's text viewer has `e` to edit a file with the same editor, anywhere on the card, unless the file is read-only.
|
||||
**A note can be any size** (issue #47): the editor keeps a window of about 8 KB around the cursor in memory and the rest on the card, so a megabyte opens as fast as a line and uses the same 17 KB. Up to 64 KB a save rewrites the file. Above, the five-second save writes only what changed to a side file, `<note>.edit`, and the file itself is rewritten when the note is left, with a progress bar (about 450 KB a second). After a power cut, opening the note picks the edit up where it was saved. Saving needs room on the card for a second copy. The Storage App's text viewer has `e` to edit a file with the same editor, anywhere on the card, unless the file is read-only.
|
||||
|
||||
## Shell
|
||||
|
||||
The Shell App (docs/milestones/S1.md) runs the commands below on the device's own screen and keyboard: no PC, no cable, no Wi-Fi. **It shows the replies to its own commands and nothing else**: the console knows who each line is printed for, so a listing read by another task a moment later is still the Shell's, and what USB or the Debug Console asked for is not. Ctrl+b shows everything instead. Tab completes a command word by word (`lora st` gives `lora status`) and, past it, a path on the SD card (`ls /no` gives `ls /notes/`), Fn with up and down recalls earlier lines, Alt with up and down scrolls back. **An App's name with a capital opens it** (`Notes`, `Irc`, `Wifi`, `Gnss`, `Gemini`, `Lora`, `Storage`, `System`, `Settings`), from the consoles too. `rm` is Unix's, with a question: a folder needs `-r`; a file, or a folder with something in it, is asked about unless `-f` (`rm -rf`); an empty folder goes without a word. `*` and `?` in a name stand for several files (`rm /notes/*.txt` asks once, with the count; 64 at most). `clear` empties the screen and `quit` leaves. It is trusted like the USB port: `debug on` and `debug token` work from it. It uses about 7 KB of memory while it is open, and none otherwise.
|
||||
|
||||
## Development aids
|
||||
|
||||
@@ -163,7 +167,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\|del\|tab\|space\|help`, or `key <char>` | Injects a key press (`help` is Fn+h: the keys of the screen that is showing) |
|
||||
| `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). `ctrl-`, `alt-` and `shift-` before it hold that key: `key ctrl-down`, `key alt-up`, `key ctrl-b` |
|
||||
| `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) |
|
||||
@@ -181,13 +185,15 @@ A note holds up to 16 KB while it's edited. A bigger text file opens read-only i
|
||||
| `irc say <buffer> <text>` | Types into a Buffer, commands included (`irc say 0 /join #test`) |
|
||||
| `irc dump` | Prints IRC status, memory, and the last lines of each Buffer |
|
||||
| `wifi status` | Prints Wi-Fi state, network, signal, clock and free heap, then the address, gateway, DNS and NTP servers in use and where each came from |
|
||||
| `info` | Firmware, uptime, last start reason, memory, Wi-Fi, the SD card and its write faults since boot, and both app slots with their versions and OTA states |
|
||||
| `info` | Firmware, uptime, last start reason, memory, Wi-Fi, the SD card and its write faults since boot, **which App is in front**, and both app slots with their versions and OTA states |
|
||||
| `Notes`, `Irc`, `Wifi`, `Gnss`, `Gemini`, `Lora`, `Storage`, `Shell`, `System`, `Settings` | Opens that App: a capital letter is an App, not a command |
|
||||
| `tasks` | FreeRTOS tasks over the next second: state, priority, lowest free stack, share of a core, each core's load, and how many passes the main loop made |
|
||||
| `net` | Bytes each network service has read and written since boot |
|
||||
| `reboot` / `boot other` | Restart, or restart into the other app slot (a manual Rollback) |
|
||||
| `log level <0-5>` | ESP-IDF log level |
|
||||
| `ls [folder]` / `du <path>` | Lists a folder of the SD card with sizes and dates, or counts the files and bytes under a path |
|
||||
| `cp [-f] <from> <to>` / `mv [-f] <from> <to>` / `rm <path>` / `mkdir <path>` / `cancel` | What the Storage App does, with its rules: copy (folders too), move or rename, delete (a folder with what's in it), new folder. `-f` replaces a file that's in the way; a tab separates paths that hold spaces; `cancel` stops a copy or a delete |
|
||||
| `screenshot [seconds]` | The screen as a PNG in `/screenshots` on the card, now or after a pause to get to the screen you want (240 x 135, about 33 KB). Over the Debug Console a bare `screenshot` sends the screen to the PC instead |
|
||||
| `cp [-f] <from> <to>` / `mv [-f] <from> <to>` / `rm [-r] [-f] <path>` / `mkdir <path>` / `cancel` | What the Storage App does, with its rules: copy (folders too), move or rename, delete (`rm -r` for a folder and what's in it, as Unix has it), new folder. `-f` replaces a file that's in the way; `*` and `?` in the last part of a path (`ls`, `du`, `rm`, `cp`, `mv`) run the command for each name matched, 64 at most; a tab separates paths that hold spaces; `cancel` stops a copy or a delete |
|
||||
| `install <path>` | Update from SD with that `.ota` file, as Settings → Firmware does |
|
||||
| `update check` / `update list` / `update status` / `update install <tag>` | The project's releases on Gitea: look at the latest, list the last ten, say what's known, or download and install one |
|
||||
| `update pretend <version>` / `update probe <host>` / `update damage cut\|flip <n>` / `update daily` | Pretend to run another version (so a release counts as an update), see whether a server's certificate is accepted, cut or damage the next download, run the daily check again |
|
||||
|
||||
+58
-1
@@ -98,7 +98,7 @@ Plain text notes on the SD card, written on the device. Q30 settled the base: `.
|
||||
| Q141 | A **Notes** App in the Launcher. One row per note: its first line as the title, then the date. Newest first; `s` switches to by name. `n` new, Enter opens, `d` deletes after a confirmation, `r` renames the file. |
|
||||
| Q142 | A new note's file name is never typed: it comes from the first line when the note is first saved (`shopping-list.txt`), or `note-20261006-0919.txt` if that line is empty. It doesn't change afterwards unless the note is renamed. |
|
||||
| Q143 | **Autosave, no "discard changes?" prompt:** five seconds after the last key, on leaving the note or the App, and when the screen turns off. A save writes a temporary file and renames it over the note, so a power cut loses the last few seconds at most. A temporary file left behind is offered back at the next open. |
|
||||
| Q144 | The whole note is in memory while it's edited, up to **16 KB**. A bigger text file opens read-only in the Storage App's viewer. The App refuses to open below the memory floors (Q86). **Editing files of any size must come in a later release: issue #47.** |
|
||||
| Q144 | The whole note is in memory while it's edited, up to **16 KB**. A bigger text file opens read-only in the Storage App's viewer. The App refuses to open below the memory floors (Q86). *(Lifted by issue #47: see "Notes of any size" below.)* **Editing files of any size must come in a later release: issue #47.** |
|
||||
| Q145 | The editor wraps at spaces, 38 columns by 8 rows, with a line for the name and the state. Enter is a new line, Del deletes backwards, Fn+arrows move (the Text Entry rule), Ctrl+A and Ctrl+E go to the start and the end of the line, Tab types two spaces, Back saves and returns. The Compose Key works as elsewhere. |
|
||||
| Q146 | The Storage App's text viewer gets `e`: edit this file with the same editor, for a text file up to 16 KB that isn't read-only. That lifts Q135 without Apps opening each other (#43 stays). |
|
||||
| Q147 | The list is flat: the files directly in `/notes`. Sub-folders are reached through the Storage App. |
|
||||
@@ -159,3 +159,60 @@ Test notes were made in `/notes` and removed afterwards; the folder is left, emp
|
||||
**Not checked:** accents through the Compose Key and Ctrl+A / Ctrl+E (the remote `key` command can't send them; the model's tests cover both), the power button's save (it needs a hand on the device), a missing card, and how typing feels on the keyboard itself.
|
||||
|
||||
**One slip during the checks:** a key sequence sent right after a restart opened IRC instead of Notes, and the test letters went into IRC's input line. Nothing was sent: the line was cleared and the App left. IRC connected to Libera as it does when opened.
|
||||
|
||||
## Notes of any size (issue #47)
|
||||
|
||||
Q144 held the whole note in memory and stopped at 16 KB, for the first version only. This lifts it: the editor opens a text file whatever its size.
|
||||
|
||||
### Decisions (design round 2026-10-07)
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| Q223 | **The note is the file on the card plus one window in memory.** The window is the `NoteText` of before, up to 16 KB around the cursor; the rest is a list of pieces: runs of the file, and runs of a side file. The cursor leaving the window writes it to the side file if it was changed, and loads the next. Typing never fills a note: a full window is written away and loaded smaller. |
|
||||
| Q224 | **The five-second save:** up to 64 KB it rewrites the file, as before (about 150 ms). Above, it appends the window and the list of pieces to `<note>.edit`: 8 KB or so, whatever the note's size. "saved" means "on the card" either way. |
|
||||
| Q225 | **The file itself is rewritten on leaving the note** (Back, Home, another App), with a progress bar. The screen turning off and the device powering off write the side file only: powering off never waits. |
|
||||
| Q226 | **After a power cut, opening the note picks the edit up** where it was last saved, without a question, and says so. Until then the file has the old text for anything else that reads it. |
|
||||
| Q227 | If the file was changed elsewhere meanwhile, the side file no longer fits it: it is **kept as `<note>.edit.lost`** and the editor says so. Typed text is never deleted without a word. |
|
||||
| Q228 | **No limit but the card:** a note over 16 KB needs room for a second copy to be opened for editing. No warning for a big file; the progress bar on leaving tells the cost. |
|
||||
| Q229 | A side file over 1 MB, or a list of over 256 pieces, makes the next save a rewrite. |
|
||||
| Q230 | **CRLF becomes LF** (Q148) for a long file too: in the window as it is read, and in the rest of the file as the rewrite streams it, so a saved file is never of both kinds. |
|
||||
| Q231 | **One path.** A 16 KB note is the case with no pieces: there is no second editor for small notes. |
|
||||
| Q232 | Notes, and `e` in the Storage App's viewer, which no longer says "Too big to edit". |
|
||||
|
||||
### As built
|
||||
|
||||
- **`NoteDocument`** (`lib/notes/src/note_document.h`, host-tested against a card in memory) is the list of pieces, the window's moves, the side file and the recovery. `NoteText` is unchanged but for being refilled.
|
||||
- **The window moves** when the cursor comes within 2 KB of an end of it that isn't an end of the note: it is then 4 KB on each side of the cursor. It starts where a line starts on screen whenever that can be known (after a newline, or where the window before had a line start), so the same text wraps the same from one window to the next, and never in the middle of a character. The cursor keeps its row on screen.
|
||||
- **Looking writes nothing:** a window that wasn't changed goes back as the pieces it was read from.
|
||||
- **The side file** starts with a line of text, the note's size and checksums of its first and last kilobyte, which is how a file changed elsewhere is told. After that, text that left a window, and snapshots of the list of pieces, each with its checksum. The newest snapshot that checks out is the note as last saved; anything after it is ignored.
|
||||
- **The rewrite** streams the pieces and the window into `<note>.tmp`, checks its size, then writes a mark at the end of the side file: from that mark on, the rewrite counts as done, and opening the note finishes it whatever was cut (remove the old file, rename, remove the side file). Before the mark, the note and its side file are still the truth and the temporary file is dropped.
|
||||
- **On the device** the card is reached through an adapter that keeps the file being read and the file being appended to open between calls; every call runs on the storage task while the main loop waits. The rewrite runs in steps of 64 KB with the progress drawn between them.
|
||||
- **The Notes list** doesn't show `.edit` and `.edit.lost` files, and a note's side file is deleted and renamed with it.
|
||||
- **Ctrl with Fn+Up and Fn+Down** go to the start and the end of the note.
|
||||
- **`key ctrl-down`**: the consoles' `key` command takes `ctrl-`, `alt-` and `shift-`, which these checks needed. It also lets the checks S1 couldn't make (Ctrl+b, the Alt scroll) be made.
|
||||
- **Cost:** 15 KB of flash. Memory with a note open is what it was: 17.5 KB, for 62 bytes or for 1.2 MB.
|
||||
|
||||
### Host tests (15, `test/test_note_document`)
|
||||
|
||||
A walk down 3,000 lines and back up through the windows; start and end; an edit in the middle rewritten into the file; 48 KB typed into a new note; a journal picked up after a cut; **a cut at every 997th byte of a sequence of two saves and a rewrite**, after which the note is always one of the three texts it should be, what was reported saved is there, and no stray file is left; a file changed elsewhere; CRLF; windows on text with no space and no newline, made of 2, 3 and 4-byte characters; a full card; and **36,000 random keys** (typing, deleting, moving, jumping, saving, power cuts) on six notes of 30 to 130 KB, compared with a plain string after every key.
|
||||
|
||||
### Checks on the device (2026-10-07, driven over the Debug Console)
|
||||
|
||||
Test notes were copied to `/notes` and removed afterwards; the note that was already there was not touched.
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| A 36 KB note | Opens (it was refused before). Two letters at the top, 400 lines down across the windows, four more: the file fetched back is exactly that, and no other file is left |
|
||||
| A 1.2 MB note | Opens at once. Free memory 104.2 KB before, 86.7 KB with it open |
|
||||
| Its five-second save | `zz-big.txt.edit`, 4 KB; the note's file untouched |
|
||||
| Ctrl with Down, Ctrl with Up | The end and the start, as fast as any key |
|
||||
| A restart with unsaved keys | "Your unsaved changes are back", the cursor where it was, the unsaved keys gone and nothing else |
|
||||
| Leaving it | The progress bar, then one file: **1.2 MB rewritten in 2.6 s**. Fetched back: the original with what was typed at both ends, byte for byte |
|
||||
| A restart in the middle of that rewrite | The note, its side file and an empty `.tmp` remain; opening picks the edit up, leaving rewrites it, the result is right |
|
||||
| A new note | No file until typed in, then `zz-test-note.txt` from its first line |
|
||||
| `e` in the Storage App on the 1.2 MB file | The same editor; edited and rewritten |
|
||||
| The Notes list | Side files are not listed as notes |
|
||||
|
||||
**Not checked:** the power button's path (side file only), the screen turning off, a card pulled while editing, and memory with IRC connected, which wasn't connected for these checks: the editor's own use hasn't changed, and it still refuses to open without a free block of 24 KB. The real keyboard's Ctrl with Fn and the arrows. A file of tens of megabytes. Renaming or deleting a note from the Storage App leaves its side file behind.
|
||||
|
||||
**Measured against what was said:** the first build rewrote 1.2 MB in 3.5 to 4.5 s, with 2 KB blocks. With 4 KB blocks it is 2.6 s, about 450 KB a second, which is what the card gives a plain copy.
|
||||
|
||||
@@ -136,3 +136,69 @@ M3 found the LoRa radio's noise floor about 15 dB above what the chip hears alon
|
||||
**Settings > Pause GNSS for LoRa**, off by default: while the LoRa radio listens or sweeps, the GNSS receiver waits in standby, and wakes when the radio goes back to sleep (a Fix again after about 7 s here). Never during a Track. The GNSS App says "GNSS is paused" meanwhile. `gnss quiet on|off` on the console.
|
||||
|
||||
It's off by default because GNSS on by default was decided in M2 (Q58), and from M4 the radio listens all the time: then "pause while listening" means GNSS mostly off, which is a decision about position, the clock and Tracks, for M4's design round (issue #23).
|
||||
|
||||
## The Shell (issue #67)
|
||||
|
||||
The console's commands could only be typed on a PC: over USB, or over Wi-Fi with the Debug Console. A device in a bag, or on a network that is down, could not be asked anything. The Shell is an App that runs the same commands on the device's own screen and keyboard.
|
||||
|
||||
### Decisions (design round 2026-10-07)
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| Q204 | **An App, "Shell", in the Launcher**, in every firmware: its commands already work over USB for anyone holding the device. |
|
||||
| Q205 | **Trusted like USB serial**, not like the network: `debug on` and `debug token` work from it, as they do in Settings. The token is never shown. |
|
||||
| Q206 | *Revised the same day, after trying it.* **It shows the replies to its own commands, and only those.** The console knows who each line is printed for (`Console::Origin`): a command run from the Shell prints as the Shell's, and so does what answers it later from another task, which notes who asked and takes it back when it prints (`ls`, `tasks`, `du`, `cp`, `update check`, `sd list`, `screenshot`, `gemini get`). What USB or the Debug Console asked for, and the system's own lines, are not the Shell's. **Ctrl+b shows everything** instead. The first version kept whatever was printed in the ten seconds after a command, which was a guess, and a noisy one. |
|
||||
| Q207 | **Nothing while it's closed.** Open, a 4 KB ring of the console's and up to 4 KB of lines; both go when the App is left, with the list of commands for Tab. |
|
||||
| Q208 | Enter runs the line; Fn with up and down recalls the last 16; Alt with up and down scrolls back. **Tab completes** the command, **every word of it** *(the first only, at first)*: `lora st` gives `lora status`, `gnss track ` lists `start stop`, `key ` its eleven names. The words come from the firmware's `help` text, read as it is written, so a new command completes without a table to keep. *(Added the same day)* **past the command, a path on the SD card**: a folder keeps its slash to go on from, a file completed whole gets a space, several candidates are listed. Whatever the case typed, the name's own is taken, since the card doesn't tell them apart. After a file command the first slash is understood (`cat no` is `/no`). A name with a space in it isn't completed. |
|
||||
| Q209 | *Revised the same day.* **`rm` is Unix's, with a question.** A folder needs `-r`, here and over the consoles (where `rm <folder>` used to remove it with what was in it). In the Shell, a file, or a folder with something in it, is asked about unless `-f` (`-rf`, `-r -f`); an empty folder with `-r` goes without a word; what `rm` would refuse anyway, it refuses itself. Over the consoles nothing is asked: scripts delete as before. **`screenshot [seconds]`** saves the screen as a PNG in `/screenshots`, now or after a pause, since from the Shell "now" is the Shell. `get`, `put`, `coredump get` and `reset` answer `Debug Console only`. *(Added the same day)* **`*` and `?` in a name**, for `ls`, `du`, `rm`, `cp` and `mv`, here and over the consoles: `rm /notes/*.txt`, `cp /gnss/2026-10-0?.gpx /backup`. In the last part of the path only, any case, as the card has it. It is the same command once for each name matched, in the name's order; `cp` and `mv` then need a folder that exists to put them in. Over 64 matches is refused whole, as is none. In the Shell `rm` with a pattern asks **once**, with the count. |
|
||||
| Q210 | Commands are echoed as `> command` into the console, so a session reads the same from afar. **A token being set is not echoed.** |
|
||||
| Q211 | **Not in Safe Mode**, which starts no Apps: issue #77. |
|
||||
| Q212 | Its keys are a table in `app_keys.h`, so the help panel and the website have them; a page in the user guide. |
|
||||
| Q213 | *Added the same day.* **An App's name with a capital opens it:** `Irc`, `Wifi`, `Gnss`, `Gemini`, `Lora`, `Storage`, `Notes`, `Shell`, `System`, `Settings` (the App's id, up to its first dash). From the Shell, without going back to the Launcher, and from the consoles too. The capital says "an App": every command of the firmware is in small letters. Tab completes them. |
|
||||
|
||||
### As built
|
||||
|
||||
- **`ShellApp`** (`src/apps/shell_app.cpp`), with its model host-tested in `lib/apps_model/src/shell_log.h`: lines arriving in pieces, the 4 KB limit, the words of every command out of the `help` text (Apps' names included), and Tab.
|
||||
- **Who a line is for:** `Console::As` marks the calling task as printing for the Shell while it lives, and `Console::origin()` lets a command that answers later carry that to wherever it prints. The console has a second ring for the Shell, which gets the lines marked so, or everything.
|
||||
- **The Shell hands its lines to the main loop**, which runs them like the consoles' commands. See below for why.
|
||||
- **`info` says which App is in front** (`app: Shell`): so that a hand driving the device from afar can look before it types.
|
||||
- **`screenshot`** writes the PNG a row at a time with no buffer: 8-bit indexed colour, the 256 colours of RGB332 as the palette, the pixels in one stored deflate block (`lib/files/src/png_rgb332.h`, 4 tests). 33,383 bytes for the 240 x 135 screen. A Toast says so once it is on the card, so the Toast is never in the picture.
|
||||
- **The `help` text lost its shorthand** (`update check | list | status` is written out), since Tab reads its words from there: `|` between two commands, `on|off` between two words, three spaces before the description. The Shell's own words (`clear`, `quit`, `key`'s names) are added in the same notation.
|
||||
- **Tab on a path** reads the folder on the storage task while the main loop waits, so it is bounded: 400 entries looked at, 24 candidates given back, and `...` after the list when there were more.
|
||||
- **A pattern** is matched in `lib/files/src/file_names.h` (`globMatch`, host-tested), and the names it matches are lined up as so many commands, which the main loop runs one after the other as each finishes. `cancel` empties the line-up. 2000 entries looked at, 64 matches at most.
|
||||
- **Cost:** 21 KB of flash, 96 bytes of static RAM. Open: 7 KB of heap (107.6 KB free before, 100.7 with it open, 106.1 after leaving).
|
||||
|
||||
### What went wrong while building it
|
||||
|
||||
**The device crashed, and the test sent a message to an IRC channel.** The first version ran a command from inside the key handler. Driven from the Debug Console, that is: the main loop, a remote command, `key select`, the App manager, the Shell, `runCommand` a second time, the file command, and `printf` under all of it. The main loop has under 2 KB of stack to spare; `rm` on a folder went past it. The crash report decoded to exactly that chain.
|
||||
|
||||
The device restarted into the Launcher, and the test script, which did not look, went on typing. Its next Enter opened IRC, which connected, and a few lines later it typed "No" into a channel and pressed Enter. One word, sent to real people, that can't be taken back.
|
||||
|
||||
Two changes came of it. The Shell now **queues** its line and the main loop runs it, at the same stack depth as a console's command. And `info` reports the App in front, which the test script now checks before every line it types.
|
||||
|
||||
### Checks on the device (2026-10-07, driven over the Debug Console with `key`)
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| Open it from the Launcher, type `ls /`, Enter | The folders are listed |
|
||||
| Tab on `in` | `info install` is shown and the line stays; on `u` it becomes `update `; on `l`, `log ls lora loop` |
|
||||
| Up | The line before comes back |
|
||||
| `screenshot` | `/screenshots/20261007-104357.png`, 33,383 bytes. Fetched and decoded on the PC: 240 x 135, indexed, every chunk's CRC right, the pixels the Shell's screen |
|
||||
| Output | With a Debug Console client connecting and disconnecting for every key, the Shell shows the commands and their replies and nothing else: `info`, `ls /` (answered by the storage task), `tasks` (answered a second later) |
|
||||
| `rm` on a folder, without `-r` | Refused, the folder stays |
|
||||
| `rm -r` on an empty folder | Removed, no question |
|
||||
| `rm -r` on a folder with files | Asks; Cancel leaves it. `rm -rf` removes it without asking |
|
||||
| `rm` on a file | Asks; Delete removes it |
|
||||
| Tab on a path | `ls /no` becomes `ls /notes/`, and again, with one note in it, the note's whole name and a space. `ls /g` lists `gemini/ gnss/`. `cat no` becomes `cat /notes/`. `rm -r /CAP` becomes `rm -r /captures/`. `ls /zz` stays as it is |
|
||||
| Tab past the first word | `lora st` becomes `lora status `; `gnss tr` becomes `gnss track ` and Tab again lists `start stop`; `key ` lists its eleven names; `upd` becomes `update ` and lists `check list status install` |
|
||||
| `ls` with a pattern | `ls /gt/*.txt` the five files, `ls /gt/*2*` the one, `ls /gt3/*6?.txt` ten of seventy. `ls /g*/x`: refused, a pattern goes in the last part |
|
||||
| `cp /gt/file-000?.txt /gt2`, `du /gt2/*1*` | Five copies; one size |
|
||||
| `rm /gt2/*` in the Shell | "The 5 that match /gt2/\*": Cancel leaves the five. `rm /gt3/*6?.txt`, Delete: ten gone, sixty left. `rm -f /gt2/*`: gone without a question |
|
||||
| `rm /gt/*` where one match is a folder | The files go, the folder stays (no `-r`) |
|
||||
| No match, and seventy | `rm: error nothing matches`; `rm: error more than 64 match: a narrower pattern, please`, and all seventy still there |
|
||||
| `No`, Tab, Enter | Completes to `Notes ` and opens Notes. `Shell` from the Debug Console opens the Shell |
|
||||
| `screenshot 4`, then Home | The picture, taken four seconds later, is of the Launcher: the pause works, and the Toast isn't in it |
|
||||
| `quit` | Back to the Launcher, and the memory comes back |
|
||||
| The help panel in the Shell | Its keys, then the ones that work everywhere |
|
||||
|
||||
**Not checked:** Ctrl+b and the Alt scroll, which `key` can't press (no modifiers): the filter itself is host-tested, the key that flips it is not; the real keyboard altogether; the Toast after a screenshot, which was published but not looked at; and `mv` with a pattern and `cancel` in the middle of a line-up, which share their code with `cp` and `rm` but were not run. The main loop's lowest free stack after all of it: 1.8 KB, where it was.
|
||||
|
||||
+44
-1
@@ -21,7 +21,7 @@ The home page was designed on a canvas in a Claude chat (a dark and a light them
|
||||
| Q175 | The site lives in this repository, in `site/`, so the documentation is built from `docs/`, `CONTEXT.md` and the README instead of being copied. |
|
||||
| Q176 | **Zola,** like the blog. The design becomes a template, its tokens CSS custom properties. Dark and light follow the visitor's setting, with a visible switch. No JavaScript except the flasher's. |
|
||||
| Q177 | Domain: **roro9stack.net.** The blog stays at experiments.twis.la. |
|
||||
| Q178 | **Publishing is the blog's way:** the web server pulls `main` and runs `zola build`; that part is the maintainer's. Changes reach `main` through pull requests as everywhere. **CI is split:** a dedicated `site` job builds the site (`zola build`) when `site/`, `docs/`, `README.md` or `CONTEXT.md` change, and the firmware tests and builds skip a change that touches nothing else. A change that touches both runs both. |
|
||||
| Q178 | **Publishing is the blog's way:** the web server pulls `main` and runs `zola build`; that part is the maintainer's. *(Since issue #79, CI asks the server to do it: see "Published by CI" below.)* Changes reach `main` through pull requests as everywhere. **CI is split:** a dedicated `site` job builds the site (`zola build`) when `site/`, `docs/`, `README.md` or `CONTEXT.md` change, and the firmware tests and builds skip a change that touches nothing else. A change that touches both runs both. |
|
||||
| Q179 | Phases, each its own pull request: **1.** the CI split, the home page, an Install page with the browser flasher, downloads and the changelog. **2.** a user guide page per App. **3.** how-tos and the FAQ. **4.** developer docs generated from the repository. |
|
||||
| Q180 | **A browser flasher** (ESP Web Tools), **without copying the firmware.** Caddy, in front of Gitea, adds `Access-Control-Allow-Origin: https://roro9stack.net` (and `Vary: Origin`) to GET and HEAD on `/twisla/roro9stack/releases/download/*` and `/api/v1/repos/twisla/roro9stack/releases*`: both are public already. The Install page asks the API for the latest release in the browser, finds the asset ending `-factory.bin`, and gives ESP Web Tools a manifest built on the spot, so it offers a new release as soon as it exists, with no rebuild. The library is **vendored** into `site/static/` (Apache-2.0), not loaded from a CDN. The file's SHA-256 is shown on the page. Chrome or Edge on a desktop only; other browsers, and visitors without JavaScript, get the `esptool` steps on the same page. |
|
||||
| Q181 | Docs for the latest version only. The changelog is the Gitea releases, read at build time. |
|
||||
@@ -125,3 +125,46 @@ Not one of the planned phases: the blog's seven roro9stack posts, imported into
|
||||
- **Left out on purpose:** the M0 and M1 milestone documents and `CONTEXT.md` (the glossary) describe Wi-Fi monitoring, which the site does not publish. They stay in the repository.
|
||||
- **The Debug Console pages were written against the source and the live console:** the protocol (the token line, the banner, the 4 KB backlog, one client, 8 queued commands, 240-byte lines, `denied` after a second) and the replies shown were checked on a Debug Build, v0.11.0-3, over Wi-Fi. Not run: `crash abort`, `crash wdt` and Safe Mode, which are described from ADR 0005 and the code.
|
||||
- **Found while writing it:** the README's table lacked the `gnss` commands (rows added); piping commands into `rdbg.py` returns before the replies unless the input stays open (documented, not changed); `update install` on a Debug Build needs `force` (documented).
|
||||
|
||||
## Published by CI (issue #79, design round 2026-10-07)
|
||||
|
||||
Q178 left publishing to the maintainer: a merge, then a command typed on the web server. It was forgotten often enough.
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| Q214 | **A plain ed25519 key with a forced command**, not a certificate: one line in the web server user's `authorized_keys`, `restrict,command="/full/path/to/the/refresh"`. `restrict` takes away the terminal and every forwarding. A certificate could carry the same and an expiry date, at the price of a CA to keep and a key to sign again each time: too much for one key and one command. |
|
||||
| Q215 | **The CI sends no command.** The server runs the forced one whatever is asked for, so there is nothing to keep secret about it and nothing a leaked key could choose. The full path is written once, on the server (a command over SSH doesn't get the user's login `PATH`). |
|
||||
| Q216 | Four secrets: `SITE_DEPLOY_KEY`, `SITE_DEPLOY_HOST` (or `host:port`), `SITE_DEPLOY_USER`, and **`SITE_DEPLOY_KNOWN_HOSTS`**, the server's host key: the job connects to that server or to nothing. None of them is in the repository, which is public. |
|
||||
| Q217 | `from="<the runner's address>"` on the same line: the key works from the runner only. |
|
||||
| Q218 | **The last step of the Site workflow**, after the build and the checks, on a push to `main` only. A pull request never reaches it, and the secrets are given to that step alone. |
|
||||
| Q219 | **After a release too.** The Install page asks Gitea for the latest release when it is opened, but the home page and Downloads read it when the site is built: so the release workflow refreshes the site once the release is published. |
|
||||
| Q220 | A refresh that fails makes the run red, with what the server's script printed: it has to exit with an error when it fails. |
|
||||
| Q221 | Two refreshes at once are the server script's to refuse or queue (`flock`). |
|
||||
| Q222 | The key is a file only while the step runs, in the job's container, as the signing key is. |
|
||||
|
||||
### As built
|
||||
|
||||
- **`scripts/site_refresh.sh`** is what both workflows run: it writes the key and the host key to a temporary folder, connects with no configuration but its own line (`-F none`, strict host key checking, that one key, no command), and removes them. With none of the four secrets it does nothing and says so (a fork, or a repository without them); with only some it fails.
|
||||
- **`scripts/site_deploy_keygen.sh`** makes the key pair once, in `~/.config/roro9stack/`, and prints the `authorized_keys` line and what goes in each secret. It never prints the private key.
|
||||
- **The server's script** should start like this, for Q220 and Q221:
|
||||
|
||||
```sh
|
||||
#!/bin/sh
|
||||
set -e
|
||||
exec 9>/tmp/rororefresh.lock
|
||||
flock -w 120 9
|
||||
```
|
||||
|
||||
### Checks (2026-10-07, against an SSH server in a throwaway container)
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| The refresh | The forced command runs as the server's user; the script ends with `site refresh: done` |
|
||||
| The same key, asking for `id; cat /etc/passwd` | The refresh runs instead; what was asked for is only handed to it as text |
|
||||
| A terminal | Refused: `PTY allocation request failed` |
|
||||
| `scp` with the key | Nothing is copied |
|
||||
| Another host key in the secret | `Host key verification failed`, the run fails, nothing is sent |
|
||||
| The server's script exits with an error | So does the step |
|
||||
| No secrets at all; only one of the four | Does nothing and says so; fails and says which are needed |
|
||||
|
||||
**Not checked:** the real web server and the runner, which wait for the key to be installed: whether the runner reaches the server's SSH port is the first thing the first run will tell. Port forwarding, which `restrict` switches off, was not tried. `from=` was not tried either.
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
#include "shell_log.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace roro {
|
||||
|
||||
void ShellLog::push(const std::string& line) {
|
||||
lines_.push_back(line);
|
||||
bytes_ += line.size() + 1;
|
||||
while (bytes_ > kMaxBytes && lines_.size() > 1) {
|
||||
bytes_ -= lines_.front().size() + 1;
|
||||
lines_.pop_front();
|
||||
}
|
||||
revision_++;
|
||||
}
|
||||
|
||||
void ShellLog::add(const std::string& line) { push(line); }
|
||||
|
||||
void ShellLog::feed(const char* data, size_t len) {
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
char c = data[i];
|
||||
if (c == '\r') continue;
|
||||
if (c != '\n') {
|
||||
if (partial_.size() < 512) partial_ += c; // a line that never ends doesn't take the heap
|
||||
continue;
|
||||
}
|
||||
if (partial_.rfind("status: heap ", 0) != 0) push(partial_);
|
||||
partial_.clear();
|
||||
}
|
||||
}
|
||||
|
||||
void ShellLog::clear() {
|
||||
lines_.clear();
|
||||
partial_.clear();
|
||||
bytes_ = 0;
|
||||
revision_++;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
bool isWord(const std::string& w) {
|
||||
if (w.empty()) return false;
|
||||
for (size_t i = 0; i < w.size(); i++) {
|
||||
bool small = w[i] >= 'a' && w[i] <= 'z', capital = w[i] >= 'A' && w[i] <= 'Z';
|
||||
if (!(small || (i == 0 && capital))) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
std::vector<std::string> split(const std::string& text, const std::string& by) {
|
||||
std::vector<std::string> out;
|
||||
size_t at = 0;
|
||||
for (;;) {
|
||||
size_t next = text.find(by, at);
|
||||
out.push_back(text.substr(at, next == std::string::npos ? std::string::npos : next - at));
|
||||
if (next == std::string::npos) return out;
|
||||
at = next + by.size();
|
||||
}
|
||||
}
|
||||
|
||||
// The words a command can have at `index`, given the `index` words before it: added to `out`.
|
||||
void nextWords(const std::string& command, const std::vector<std::string>& before, size_t index, std::vector<std::string>& out) {
|
||||
std::vector<std::string> tokens;
|
||||
for (auto& t : split(command, " "))
|
||||
if (!t.empty()) tokens.push_back(t);
|
||||
for (size_t i = 0; i < tokens.size(); i++) {
|
||||
std::vector<std::string> either = split(tokens[i], "|"); // on|off: either of them
|
||||
bool words = true;
|
||||
for (auto& w : either) words = words && isWord(w);
|
||||
if (!words) return; // an <argument>, an [option], "...": the command's words end here
|
||||
if (i == index) {
|
||||
for (auto& w : either)
|
||||
if (std::find(out.begin(), out.end(), w) == out.end()) out.push_back(w);
|
||||
return;
|
||||
}
|
||||
if (std::find(either.begin(), either.end(), before[i]) == either.end()) return; // another command
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string completeWords(const std::string& typed, const char* helpText, std::vector<std::string>& matches) {
|
||||
matches.clear();
|
||||
std::vector<std::string> words = split(typed, " ");
|
||||
for (size_t i = 0; i + 1 < words.size(); i++)
|
||||
if (words[i].empty()) return typed; // two spaces: not ours to guess
|
||||
const std::string last = words.back();
|
||||
words.pop_back();
|
||||
if (words.empty() && last.empty()) return typed;
|
||||
|
||||
std::vector<std::string> next;
|
||||
for (auto& line : split(helpText ? helpText : "", "\n")) {
|
||||
std::string commands = line.substr(0, line.find(" ")); // the description starts at three spaces
|
||||
for (auto& command : split(commands, " | ")) nextWords(command, words, words.size(), next);
|
||||
}
|
||||
for (auto& w : next)
|
||||
if (w.rfind(last, 0) == 0) matches.push_back(w);
|
||||
if (matches.empty()) return typed;
|
||||
|
||||
std::string common = matches[0];
|
||||
for (auto& m : matches) {
|
||||
size_t n = 0;
|
||||
while (n < common.size() && n < m.size() && common[n] == m[n]) n++;
|
||||
common.resize(n);
|
||||
}
|
||||
std::string head = typed.substr(0, typed.size() - last.size());
|
||||
if (matches.size() == 1) {
|
||||
matches.clear();
|
||||
return head + common + " ";
|
||||
}
|
||||
return head + common;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
char lower(char c) { return c >= 'A' && c <= 'Z' ? static_cast<char>(c - 'A' + 'a') : c; }
|
||||
|
||||
bool startsWithNoCase(const std::string& name, const std::string& prefix) {
|
||||
if (name.size() < prefix.size()) return false;
|
||||
for (size_t i = 0; i < prefix.size(); i++)
|
||||
if (lower(name[i]) != lower(prefix[i])) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool takesAPath(const std::string& command) {
|
||||
static const char* const kCommands[] = {"ls", "du", "mkdir", "rm", "cp", "mv", "cat", "install"};
|
||||
for (auto c : kCommands)
|
||||
if (command == c) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool splitForPath(const std::string& typed, PathToComplete& out) {
|
||||
size_t space = typed.rfind(' ');
|
||||
if (space == std::string::npos) return false; // still the command's own word
|
||||
std::string word = typed.substr(space + 1);
|
||||
if (!word.empty() && word[0] == '-') return false; // a switch
|
||||
if (word.empty() || word[0] != '/') {
|
||||
if (!takesAPath(typed.substr(0, typed.find(' ')))) return false;
|
||||
word = "/" + word;
|
||||
}
|
||||
size_t slash = word.rfind('/');
|
||||
out.head = typed.substr(0, space + 1);
|
||||
out.folder = word.substr(0, slash + 1);
|
||||
out.prefix = word.substr(slash + 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string completePath(const PathToComplete& what, const std::vector<std::string>& names, std::vector<std::string>& matches) {
|
||||
matches.clear();
|
||||
for (auto& n : names)
|
||||
if (startsWithNoCase(n, what.prefix)) matches.push_back(n);
|
||||
if (matches.empty()) return what.head + what.folder + what.prefix;
|
||||
std::string common = matches[0];
|
||||
for (auto& m : matches) {
|
||||
size_t n = 0;
|
||||
while (n < common.size() && n < m.size() && lower(common[n]) == lower(m[n])) n++;
|
||||
common.resize(n);
|
||||
}
|
||||
if (matches.size() == 1) {
|
||||
bool folder = !common.empty() && common.back() == '/';
|
||||
matches.clear();
|
||||
return what.head + what.folder + common + (folder ? "" : " ");
|
||||
}
|
||||
// Several: never shorter than what was typed (cases may differ past the prefix).
|
||||
if (common.size() < what.prefix.size()) common = what.prefix;
|
||||
return what.head + what.folder + common;
|
||||
}
|
||||
|
||||
} // namespace roro
|
||||
@@ -0,0 +1,65 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace roro {
|
||||
|
||||
// What the Shell App shows (issue #67): the lines it was given, with the oldest dropped past a size.
|
||||
// Which lines it is given is the console's business: by default, only what is printed for the
|
||||
// Shell's own commands (Console::Origin).
|
||||
class ShellLog {
|
||||
public:
|
||||
static constexpr size_t kMaxBytes = 4096;
|
||||
|
||||
// Bytes as the console printed them: lines may arrive in pieces. The line with the free heap
|
||||
// every ten seconds is never kept: it would push everything else off a ten-line screen.
|
||||
void feed(const char* data, size_t len);
|
||||
// A line the Shell adds itself.
|
||||
void add(const std::string& line);
|
||||
|
||||
const std::deque<std::string>& lines() const { return lines_; }
|
||||
void clear();
|
||||
uint32_t revision() const { return revision_; } // changes when the lines do
|
||||
|
||||
private:
|
||||
void push(const std::string& line);
|
||||
|
||||
std::deque<std::string> lines_;
|
||||
std::string partial_;
|
||||
size_t bytes_ = 0;
|
||||
uint32_t revision_ = 0;
|
||||
};
|
||||
|
||||
// Tab on a command (issue #67): every word of it, read from the firmware's `help` text each time it
|
||||
// is asked, so nothing is kept in memory for it. A line of that text is commands, three spaces, then
|
||||
// what they do; commands are separated by " | ", a word like on|off is either of them, and a command
|
||||
// stops being words at its first <argument>, [option] or "...":
|
||||
// lora rx on|off | lora preset <name> the radio
|
||||
// gives "lora rx on", "lora rx off" and "lora preset". An App's name starts with a capital.
|
||||
//
|
||||
// The line with its last word completed as far as the commands that fit agree; a word completed
|
||||
// whole gets a space after it. `matches` gets the candidates when there are several. Unchanged, with
|
||||
// no matches, when no command goes on that way: then it may be a path (below).
|
||||
std::string completeWords(const std::string& typed, const char* helpText, std::vector<std::string>& matches);
|
||||
|
||||
// Tab on a later word: a path on the SD card (issue #67). What is being completed, taken apart:
|
||||
// `rm -r /notes/sh` is head "rm -r ", folder "/notes/", prefix "sh". False when the cursor is still
|
||||
// on the first word, when the word is a switch (-r), or when it isn't a path and the command doesn't
|
||||
// take one. After a file command a path may be started without its slash: `cat no` is /no.
|
||||
struct PathToComplete {
|
||||
std::string head, folder, prefix;
|
||||
};
|
||||
bool splitForPath(const std::string& typed, PathToComplete& out);
|
||||
|
||||
// `names` are the folder's entries, a folder's with a slash at its end. The line with the path
|
||||
// completed as far as the entries that start with the prefix agree, whatever their case (the card
|
||||
// doesn't tell cases apart, so the name's own case is taken). A file completed whole gets a space
|
||||
// after it; a folder keeps its slash, to go on from. `matches` gets the candidates when there are
|
||||
// several. Unchanged when nothing matches.
|
||||
std::string completePath(const PathToComplete& what, const std::vector<std::string>& names, std::vector<std::string>& matches);
|
||||
|
||||
} // namespace roro
|
||||
+19
-1
@@ -255,7 +255,7 @@ 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)"},
|
||||
{"e", "edit it"},
|
||||
{"Tab", "the file as hex, or back"},
|
||||
};
|
||||
|
||||
@@ -310,6 +310,7 @@ inline constexpr KeyHelp kNotesEditor[] = {
|
||||
{"Tab", "two spaces"},
|
||||
{"Fn ; . , /", "move the cursor"},
|
||||
{"Alt Fn ; .", "a page up, down"},
|
||||
{"Ctrl Fn ; .", "start, end of the note"},
|
||||
{"Ctrl a e", "start, end of the line"},
|
||||
{"opt ' e", "an accent: \xC3\xA9"},
|
||||
{"`", "done: it saves by itself"},
|
||||
@@ -323,6 +324,23 @@ inline constexpr KeyHelp kNotesName[] = {
|
||||
{"Fn , /", "move the cursor"},
|
||||
};
|
||||
|
||||
// shell: Shell
|
||||
inline constexpr KeyHelp kShell[] = {
|
||||
{"Enter", "run the line"},
|
||||
{"Tab", "complete: a command, a path"},
|
||||
{"* ?", "several files: /notes/*.txt"},
|
||||
{"Fn ; .", "lines you typed before"},
|
||||
{"Alt ; .", "scroll back, forward"},
|
||||
{"Ctrl b", "your replies only, or all"},
|
||||
{"Fn , /", "move the cursor"},
|
||||
{"Del", "delete backwards"},
|
||||
{"help", "every command"},
|
||||
{"clear", "an empty screen"},
|
||||
{"Notes", "an App, by its name"},
|
||||
{"rm -rf", "delete without being asked"},
|
||||
{"quit `", "leave the Shell"},
|
||||
};
|
||||
|
||||
// system: System, any view
|
||||
inline constexpr KeyHelp kSystem[] = {
|
||||
{"Tab", "the next view"},
|
||||
|
||||
@@ -82,6 +82,19 @@ void AppManager::handleKey(const KeyEvent& event) {
|
||||
if (!consumed && event.key == Key::Back) home();
|
||||
}
|
||||
|
||||
std::string AppManager::commandFor(const char* id) {
|
||||
std::string command;
|
||||
for (const char* p = id; *p && *p != '-'; p++) command += *p;
|
||||
if (!command.empty() && command[0] >= 'a' && command[0] <= 'z') command[0] = static_cast<char>(command[0] - 'a' + 'A');
|
||||
return command;
|
||||
}
|
||||
|
||||
const AppInfo* AppManager::byCommand(const std::string& command) const {
|
||||
for (auto& info : apps_)
|
||||
if (!info.hidden && commandFor(info.id) == command) return &info;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const char* AppManager::foregroundTitle() const {
|
||||
for (auto& info : apps_)
|
||||
if (info.app == foreground_) return info.title;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "app.h"
|
||||
@@ -34,6 +35,13 @@ class AppManager {
|
||||
void handleKey(const KeyEvent& event);
|
||||
void update(uint32_t nowMs) { foreground_->update(nowMs); }
|
||||
|
||||
// The command that opens an App from the consoles and the Shell (issue #67): its id with a
|
||||
// capital letter, up to the first dash. "notes" is Notes, "wifi-tools" is Wifi. The capital says
|
||||
// "an App", where every command of the firmware is in small letters.
|
||||
static std::string commandFor(const char* id);
|
||||
// The App that command names, among the ones the Launcher lists; nullptr if there's none.
|
||||
const AppInfo* byCommand(const std::string& command) const;
|
||||
|
||||
App& foreground() const { return *foreground_; }
|
||||
const char* foregroundTitle() const; // nullptr for the Launcher
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
namespace roro::files {
|
||||
|
||||
const char* const kFirmwareFolders[] = {"/irc", "/wifi", "/updates", "/gnss", "/gemini", "/captures", "/notes"};
|
||||
const char* const kFirmwareFolders[] = {"/irc", "/wifi", "/updates", "/gnss", "/gemini", "/captures", "/notes", "/screenshots"};
|
||||
const size_t kFirmwareFolderCount = sizeof kFirmwareFolders / sizeof kFirmwareFolders[0];
|
||||
|
||||
std::string parentOf(const std::string& path) {
|
||||
@@ -96,4 +96,52 @@ bool looksLikeText(const uint8_t* data, size_t len) {
|
||||
return odd * 20 <= len; // a stray control character or two is still text
|
||||
}
|
||||
|
||||
RmArgs parseRm(const std::string& args) {
|
||||
RmArgs out;
|
||||
size_t at = 0;
|
||||
while (at < args.size()) {
|
||||
while (at < args.size() && args[at] == ' ') at++;
|
||||
if (at >= args.size() || args[at] != '-') break;
|
||||
size_t end = args.find(' ', at);
|
||||
std::string flags = args.substr(at + 1, end == std::string::npos ? std::string::npos : end - at - 1);
|
||||
bool known = !flags.empty();
|
||||
for (char c : flags) known = known && (c == 'r' || c == 'R' || c == 'f');
|
||||
if (!known) break; // a name that starts with a dash
|
||||
for (char c : flags) (c == 'f' ? out.force : out.recursive) = true;
|
||||
at = end == std::string::npos ? args.size() : end;
|
||||
}
|
||||
out.path = at < args.size() ? args.substr(at) : "";
|
||||
while (!out.path.empty() && out.path.back() == ' ') out.path.pop_back();
|
||||
return out;
|
||||
}
|
||||
|
||||
bool hasGlob(const std::string& text) { return text.find_first_of("*?") != std::string::npos; }
|
||||
|
||||
bool globMatch(const std::string& pattern, const std::string& name) {
|
||||
auto lower = [](char c) { return c >= 'A' && c <= 'Z' ? static_cast<char>(c - 'A' + 'a') : c; };
|
||||
size_t p = 0, n = 0, star = std::string::npos, mark = 0;
|
||||
while (n < name.size()) {
|
||||
if (p < pattern.size() && (pattern[p] == '?' || lower(pattern[p]) == lower(name[n]))) {
|
||||
p++;
|
||||
n++;
|
||||
} else if (p < pattern.size() && pattern[p] == '*') {
|
||||
star = p++; // try it as nothing first; come back here to let it take one more
|
||||
mark = n;
|
||||
} else if (star != std::string::npos) {
|
||||
p = star + 1;
|
||||
n = ++mark;
|
||||
} else return false;
|
||||
}
|
||||
while (p < pattern.size() && pattern[p] == '*') p++;
|
||||
return p == pattern.size();
|
||||
}
|
||||
|
||||
bool splitGlob(const std::string& path, std::string& folder, std::string& pattern) {
|
||||
size_t slash = path.rfind('/');
|
||||
if (slash == std::string::npos) return false;
|
||||
pattern = path.substr(slash + 1);
|
||||
folder = slash == 0 ? "/" : path.substr(0, slash);
|
||||
return hasGlob(pattern) && !hasGlob(folder);
|
||||
}
|
||||
|
||||
} // namespace roro::files
|
||||
|
||||
@@ -39,4 +39,20 @@ FileKind kindOf(const std::string& name);
|
||||
bool opensAtEnd(const std::string& name); // logs
|
||||
bool looksLikeText(const uint8_t* data, size_t len);
|
||||
|
||||
// `rm`'s arguments, as Unix has them (issue #67): -r for a folder and what's in it, -f for no
|
||||
// question, alone or together (-rf, -fr, -r -f), then the path, which may hold spaces.
|
||||
struct RmArgs {
|
||||
bool recursive = false, force = false;
|
||||
std::string path;
|
||||
};
|
||||
RmArgs parseRm(const std::string& args);
|
||||
|
||||
// Patterns in a path (issue #67): * for any run of characters, ? for one, in the last part of the
|
||||
// path only (/notes/*.txt, not /*/a.txt). Cases aren't told apart, as on the card.
|
||||
bool hasGlob(const std::string& text);
|
||||
bool globMatch(const std::string& pattern, const std::string& name);
|
||||
// "/notes/*.txt" taken apart: the folder ("/notes", or "/" at the top) and the pattern ("*.txt").
|
||||
// False if there's no pattern in it, or if the folder has one too.
|
||||
bool splitGlob(const std::string& path, std::string& folder, std::string& pattern);
|
||||
|
||||
} // namespace roro::files
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
#include "png_rgb332.h"
|
||||
|
||||
namespace roro::png {
|
||||
|
||||
uint32_t crc32(uint32_t crc, const uint8_t* data, size_t len) {
|
||||
crc = ~crc;
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
crc ^= data[i];
|
||||
for (int bit = 0; bit < 8; bit++) crc = (crc >> 1) ^ (0xEDB88320u & (0u - (crc & 1)));
|
||||
}
|
||||
return ~crc;
|
||||
}
|
||||
|
||||
uint32_t adler32(uint32_t adler, const uint8_t* data, size_t len) {
|
||||
uint32_t a = adler & 0xFFFF, b = adler >> 16;
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
a = (a + data[i]) % 65521;
|
||||
b = (b + a) % 65521;
|
||||
}
|
||||
return (b << 16) | a;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
void be32(uint8_t* out, uint32_t v) {
|
||||
out[0] = static_cast<uint8_t>(v >> 24);
|
||||
out[1] = static_cast<uint8_t>(v >> 16);
|
||||
out[2] = static_cast<uint8_t>(v >> 8);
|
||||
out[3] = static_cast<uint8_t>(v);
|
||||
}
|
||||
|
||||
size_t rawSize(int w, int h) { return static_cast<size_t>(w + 1) * h; } // a filter byte before each row
|
||||
size_t idatSize(int w, int h) { return 2 + 5 + rawSize(w, h) + 4; } // zlib header, block header, data, adler
|
||||
|
||||
} // namespace
|
||||
|
||||
size_t Rgb332Writer::fileSize(int w, int h) {
|
||||
return 8 + (12 + 13) + (12 + 768) + (12 + idatSize(w, h)) + 12; // signature, IHDR, PLTE, IDAT, IEND
|
||||
}
|
||||
|
||||
bool Rgb332Writer::put(const uint8_t* data, size_t len, bool inIdat) {
|
||||
if (inIdat) crc_ = crc32(crc_, data, len);
|
||||
return sink_(data, len);
|
||||
}
|
||||
|
||||
bool Rgb332Writer::put32(uint32_t value, bool inIdat) {
|
||||
uint8_t b[4];
|
||||
be32(b, value);
|
||||
return put(b, 4, inIdat);
|
||||
}
|
||||
|
||||
bool Rgb332Writer::begin() {
|
||||
if (w_ <= 0 || h_ <= 0 || rawSize(w_, h_) > 65535) return false;
|
||||
static const uint8_t signature[] = {0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n'};
|
||||
if (!sink_(signature, sizeof signature)) return false;
|
||||
|
||||
uint8_t ihdr[4 + 13] = {'I', 'H', 'D', 'R'};
|
||||
be32(ihdr + 4, static_cast<uint32_t>(w_));
|
||||
be32(ihdr + 8, static_cast<uint32_t>(h_));
|
||||
ihdr[12] = 8; // bits a pixel
|
||||
ihdr[13] = 3; // indexed colour
|
||||
ihdr[14] = ihdr[15] = ihdr[16] = 0;
|
||||
uint8_t word[4];
|
||||
be32(word, 13);
|
||||
if (!sink_(word, 4) || !sink_(ihdr, sizeof ihdr)) return false;
|
||||
be32(word, crc32(0, ihdr, sizeof ihdr));
|
||||
if (!sink_(word, 4)) return false;
|
||||
|
||||
// The palette: every RGB332 value is its own index, as scripts/rdbg.py expands them. Sixteen
|
||||
// colours at a time: this runs on a task with a small stack.
|
||||
be32(word, 768);
|
||||
const uint8_t plteKind[] = {'P', 'L', 'T', 'E'};
|
||||
if (!sink_(word, 4) || !sink_(plteKind, 4)) return false;
|
||||
uint32_t plteCrc = crc32(0, plteKind, 4);
|
||||
for (int first = 0; first < 256; first += 16) {
|
||||
uint8_t piece[48];
|
||||
for (int i = 0; i < 16; i++) {
|
||||
int v = first + i;
|
||||
piece[i * 3] = static_cast<uint8_t>((v >> 5) * 255 / 7);
|
||||
piece[i * 3 + 1] = static_cast<uint8_t>(((v >> 2) & 7) * 255 / 7);
|
||||
piece[i * 3 + 2] = static_cast<uint8_t>((v & 3) * 255 / 3);
|
||||
}
|
||||
plteCrc = crc32(plteCrc, piece, sizeof piece);
|
||||
if (!sink_(piece, sizeof piece)) return false;
|
||||
}
|
||||
be32(word, plteCrc);
|
||||
if (!sink_(word, 4)) return false;
|
||||
|
||||
// IDAT: a zlib stream of one stored block. Its length is known, so it can be written first.
|
||||
size_t raw = rawSize(w_, h_);
|
||||
be32(word, static_cast<uint32_t>(idatSize(w_, h_)));
|
||||
if (!sink_(word, 4)) return false;
|
||||
crc_ = 0;
|
||||
const uint8_t head[] = {'I', 'D', 'A', 'T', 0x78, 0x01, 0x01, static_cast<uint8_t>(raw), static_cast<uint8_t>(raw >> 8),
|
||||
static_cast<uint8_t>(~raw), static_cast<uint8_t>(~raw >> 8)};
|
||||
return put(head, sizeof head, true);
|
||||
}
|
||||
|
||||
bool Rgb332Writer::row(const uint8_t* pixels) {
|
||||
if (rows_ >= h_) return false;
|
||||
rows_++;
|
||||
const uint8_t filter = 0; // none
|
||||
adler_ = adler32(adler_, &filter, 1);
|
||||
adler_ = adler32(adler_, pixels, static_cast<size_t>(w_));
|
||||
return put(&filter, 1, true) && put(pixels, static_cast<size_t>(w_), true);
|
||||
}
|
||||
|
||||
bool Rgb332Writer::end() {
|
||||
if (rows_ != h_) return false;
|
||||
if (!put32(adler_, true)) return false;
|
||||
uint8_t word[4];
|
||||
be32(word, crc_);
|
||||
if (!sink_(word, 4)) return false;
|
||||
static const uint8_t iend[] = {0, 0, 0, 0, 'I', 'E', 'N', 'D', 0xAE, 0x42, 0x60, 0x82};
|
||||
return sink_(iend, sizeof iend);
|
||||
}
|
||||
|
||||
} // namespace roro::png
|
||||
@@ -0,0 +1,38 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
|
||||
// A PNG of the screen, written a row at a time with almost no memory (issue #67, Q209): 8-bit
|
||||
// indexed colour with the 256 colours of RGB332 as its palette, and the pixels stored, not
|
||||
// compressed (a "stored" deflate block), so there is nothing to compress with and nothing to buffer.
|
||||
// One block holds at most 65,535 bytes: enough for the 240 x 135 screen (32,535 with its row bytes).
|
||||
namespace roro::png {
|
||||
|
||||
uint32_t crc32(uint32_t crc, const uint8_t* data, size_t len); // running; start from 0
|
||||
uint32_t adler32(uint32_t adler, const uint8_t* data, size_t len); // running; start from 1
|
||||
|
||||
class Rgb332Writer {
|
||||
public:
|
||||
using Sink = std::function<bool(const uint8_t* data, size_t len)>; // false: writing failed
|
||||
|
||||
Rgb332Writer(int width, int height, Sink sink) : w_(width), h_(height), sink_(std::move(sink)) {}
|
||||
|
||||
// The file's size, known before a byte is written.
|
||||
static size_t fileSize(int width, int height);
|
||||
|
||||
bool begin(); // false: too big for one block, or the sink refused
|
||||
bool row(const uint8_t* pixels); // `width` bytes, RRRGGGBB each
|
||||
bool end();
|
||||
|
||||
private:
|
||||
bool put(const uint8_t* data, size_t len, bool inIdat);
|
||||
bool put32(uint32_t value, bool inIdat);
|
||||
|
||||
int w_, h_, rows_ = 0;
|
||||
Sink sink_;
|
||||
uint32_t crc_ = 0, adler_ = 1;
|
||||
};
|
||||
|
||||
} // namespace roro::png
|
||||
@@ -0,0 +1,622 @@
|
||||
#include "note_document.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
namespace roro::notes {
|
||||
|
||||
namespace {
|
||||
// The side file: this line, the note's size and two checksums of it (its first and last
|
||||
// kilobyte), then, in any order, text that left a window and snapshots of the list of pieces.
|
||||
// snapshot: "RSNP" cursor count { src at len }... crc32 length "PNSR" (numbers: 32 bits, low byte first)
|
||||
// The newest snapshot that checks out is the note as it was last saved. kDone at the very end:
|
||||
// the rewrite this file was for is complete in `<note>.tmp`, and only has to take the note's place.
|
||||
const char kMagic[] = "roro9stack note edits 1\n";
|
||||
constexpr size_t kMagicLen = sizeof(kMagic) - 1;
|
||||
constexpr size_t kHeaderLen = kMagicLen + 12;
|
||||
const char kSnap[] = "RSNP", kSnapEnd[] = "PNSR", kDone[] = "RDONE1\n\n";
|
||||
constexpr size_t kDoneLen = 8;
|
||||
constexpr size_t kCheck = 1024; // of each end of the note, in the header
|
||||
constexpr uint32_t kStepBytes = 64 * 1024; // a rewrite's step
|
||||
constexpr size_t kBlock = 4096;
|
||||
constexpr uint32_t kSeekNewline = 1024;
|
||||
constexpr size_t kMaxSnapshot = 12 + 9 * 4096 + 12;
|
||||
|
||||
bool continuation(int c) { return (c & 0xC0) == 0x80; }
|
||||
|
||||
uint32_t crc32(uint32_t crc, const uint8_t* data, size_t len) {
|
||||
crc = ~crc;
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
crc ^= data[i];
|
||||
for (int k = 0; k < 8; k++) crc = (crc >> 1) ^ (0xEDB88320u & (0u - (crc & 1)));
|
||||
}
|
||||
return ~crc;
|
||||
}
|
||||
|
||||
void put32(std::string& s, uint32_t v) {
|
||||
for (int i = 0; i < 4; i++) s += static_cast<char>((v >> (8 * i)) & 0xFF);
|
||||
}
|
||||
|
||||
uint32_t get32(const uint8_t* p) { return p[0] | (p[1] << 8) | (p[2] << 16) | (static_cast<uint32_t>(p[3]) << 24); }
|
||||
|
||||
const uint8_t* bytes(const std::string& s) { return reinterpret_cast<const uint8_t*>(s.data()); }
|
||||
} // namespace
|
||||
|
||||
NoteDocument::NoteDocument(NoteCard& card, int cols, int rows) : card_(card), text_(cols, rows) { openNew(); }
|
||||
|
||||
void NoteDocument::reset() {
|
||||
path_.clear();
|
||||
sidePath_.clear();
|
||||
pieces_.clear();
|
||||
loaded_.clear();
|
||||
win_ = 0;
|
||||
windowLoaded_ = false;
|
||||
before_ = after_ = 0;
|
||||
droppedAtLoad_ = newlinesAtLoad_ = 0;
|
||||
flushedSinceSave_ = sidePending_ = false;
|
||||
sideSize_ = 0;
|
||||
windowSaved_.valid = false;
|
||||
rw_.active = false;
|
||||
std::vector<uint8_t>().swap(rw_.block);
|
||||
}
|
||||
|
||||
void NoteDocument::openNew() {
|
||||
reset();
|
||||
text_.buffer().clear();
|
||||
text_.refilled(0, 0);
|
||||
windowLoaded_ = true;
|
||||
loadedRevision_ = savedRevision_ = text_.revision();
|
||||
}
|
||||
|
||||
std::string NoteDocument::open(const std::string& path, std::string* told) {
|
||||
openNew();
|
||||
path_ = path;
|
||||
sidePath_ = side();
|
||||
uint32_t sideSize = 0, fileSize = 0, other = 0;
|
||||
bool hasSide = card_.size(sidePath_, sideSize);
|
||||
if (hasSide && sideIsDone(sideSize)) { // a rewrite was cut after its last write: finish it
|
||||
if (card_.size(tmp(), other)) {
|
||||
if (card_.size(path_, fileSize)) card_.remove(path_);
|
||||
card_.rename(tmp(), path_);
|
||||
}
|
||||
card_.remove(sidePath_);
|
||||
hasSide = false;
|
||||
}
|
||||
if (!card_.size(path_, fileSize)) {
|
||||
card_.done();
|
||||
openNew();
|
||||
return "The card refused to open it";
|
||||
}
|
||||
if (fileSize > NoteText::kMaxBytes && card_.freeBytes() < static_cast<uint64_t>(fileSize) + 16 * 1024) {
|
||||
card_.done();
|
||||
openNew();
|
||||
return "Not enough room on the card: saving it needs a second copy";
|
||||
}
|
||||
uint32_t cursor = 0;
|
||||
bool resumed = false;
|
||||
if (hasSide) {
|
||||
if (card_.size(tmp(), other)) card_.remove(tmp()); // a rewrite that didn't get that far
|
||||
sideSize_ = sideSize;
|
||||
Resume r = resume(fileSize, cursor);
|
||||
if (r == Resume::Ok) {
|
||||
resumed = sidePending_ = true;
|
||||
if (told) *told = "Your unsaved changes are back";
|
||||
} else {
|
||||
sideSize_ = 0;
|
||||
pieces_.clear();
|
||||
if (r == Resume::Mismatch) { // typed text is never thrown away without a word (Q227)
|
||||
std::string lost = sidePath_ + ".lost";
|
||||
card_.remove(lost);
|
||||
card_.rename(sidePath_, lost);
|
||||
if (told) *told = "The file changed: unsaved edits kept as .edit.lost";
|
||||
} else {
|
||||
card_.remove(sidePath_);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!resumed && fileSize) pieces_.push_back({0, 0, fileSize});
|
||||
windowLoaded_ = false;
|
||||
bool ok = load(cursor, cursor ? 1000 : 0, -1); // an edit picked up: its last lines above the cursor
|
||||
card_.done();
|
||||
if (!ok) {
|
||||
openNew();
|
||||
return "The card refused to read it";
|
||||
}
|
||||
savedRevision_ = text_.revision();
|
||||
return "";
|
||||
}
|
||||
|
||||
uint32_t NoteDocument::piecesBytes() const {
|
||||
uint32_t n = 0;
|
||||
for (const Piece& p : pieces_) n += p.len;
|
||||
return n;
|
||||
}
|
||||
|
||||
int NoteDocument::percent() const {
|
||||
uint32_t all = size();
|
||||
return all ? static_cast<int>(static_cast<uint64_t>(before_ + text_.top()) * 100 / all) : 0;
|
||||
}
|
||||
|
||||
size_t NoteDocument::readDoc(uint32_t at, uint8_t* into, size_t len) {
|
||||
size_t got = 0;
|
||||
uint32_t pos = 0;
|
||||
for (const Piece& p : pieces_) {
|
||||
if (got == len) break;
|
||||
if (at < pos + p.len) {
|
||||
uint32_t skip = at - pos;
|
||||
size_t n = std::min<size_t>(len - got, p.len - skip);
|
||||
size_t r = card_.read(fileOf(p), p.at + skip, into + got, n);
|
||||
got += r;
|
||||
at += static_cast<uint32_t>(r);
|
||||
if (r != n) break;
|
||||
}
|
||||
pos += p.len;
|
||||
}
|
||||
return got;
|
||||
}
|
||||
|
||||
int NoteDocument::byteAt(uint32_t at) {
|
||||
uint8_t b;
|
||||
return readDoc(at, &b, 1) == 1 ? b : -1;
|
||||
}
|
||||
|
||||
size_t NoteDocument::splitAt(uint32_t at) {
|
||||
uint32_t pos = 0;
|
||||
for (size_t i = 0; i < pieces_.size(); i++) {
|
||||
if (at == pos) return i;
|
||||
Piece& p = pieces_[i];
|
||||
if (at < pos + p.len) {
|
||||
uint32_t first = at - pos;
|
||||
Piece rest{p.src, p.at + first, p.len - first};
|
||||
p.len = first;
|
||||
pieces_.insert(pieces_.begin() + static_cast<long>(i) + 1, rest);
|
||||
return i + 1;
|
||||
}
|
||||
pos += p.len;
|
||||
}
|
||||
return pieces_.size();
|
||||
}
|
||||
|
||||
void NoteDocument::merge() {
|
||||
size_t kept = 0;
|
||||
for (size_t i = 0; i < pieces_.size(); i++) {
|
||||
const Piece p = pieces_[i];
|
||||
if (!p.len) continue;
|
||||
if (kept && pieces_[kept - 1].src == p.src && pieces_[kept - 1].at + pieces_[kept - 1].len == p.at) pieces_[kept - 1].len += p.len;
|
||||
else pieces_[kept++] = p;
|
||||
}
|
||||
pieces_.resize(kept);
|
||||
}
|
||||
|
||||
// The window dropped the CRs of the file's CRLFs when it was read. If it's put back untouched,
|
||||
// the cursor is further along in the file than in the window: by one for each line before it.
|
||||
uint32_t NoteDocument::noteCursor() const {
|
||||
size_t c = text_.cursor();
|
||||
if (windowLoaded_ && droppedAtLoad_ && text_.revision() == loadedRevision_) {
|
||||
const std::string& t = text_.text();
|
||||
if (droppedAtLoad_ == newlinesAtLoad_) c += static_cast<size_t>(std::count(t.begin(), t.begin() + static_cast<long>(c), '\n'));
|
||||
else if (!t.empty()) c += droppedAtLoad_ * c / t.size(); // a file of both kinds of line: near enough
|
||||
}
|
||||
return before_ + static_cast<uint32_t>(c);
|
||||
}
|
||||
|
||||
bool NoteDocument::headerFor(std::string& header) {
|
||||
uint32_t fileSize = 0;
|
||||
if (path_.empty() || !card_.size(path_, fileSize)) return false;
|
||||
std::vector<uint8_t> buf(kCheck);
|
||||
size_t n = std::min<size_t>(kCheck, fileSize);
|
||||
if (card_.read(path_, 0, buf.data(), n) != n) return false;
|
||||
uint32_t head = crc32(0, buf.data(), n);
|
||||
if (card_.read(path_, fileSize - static_cast<uint32_t>(n), buf.data(), n) != n) return false;
|
||||
uint32_t tail = crc32(0, buf.data(), n);
|
||||
header.assign(kMagic, kMagicLen);
|
||||
put32(header, fileSize);
|
||||
put32(header, head);
|
||||
put32(header, tail);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool NoteDocument::ensureSide(std::string& why) {
|
||||
if (sideSize_) return true;
|
||||
std::string header;
|
||||
if (!headerFor(header)) {
|
||||
why = path_.empty() ? "the note has no file yet" : "the card refused to read the note";
|
||||
return false;
|
||||
}
|
||||
if (!card_.create(sidePath_) || !card_.append(sidePath_, bytes(header), header.size())) {
|
||||
card_.remove(sidePath_);
|
||||
why = "the card refused a write";
|
||||
return false;
|
||||
}
|
||||
sideSize_ = static_cast<uint32_t>(header.size());
|
||||
return true;
|
||||
}
|
||||
|
||||
bool NoteDocument::putBack(std::string& why) {
|
||||
if (!windowLoaded_) return true;
|
||||
if (text_.revision() == loadedRevision_) {
|
||||
pieces_.insert(pieces_.begin() + static_cast<long>(win_), loaded_.begin(), loaded_.end());
|
||||
} else {
|
||||
Piece p{1, 0, static_cast<uint32_t>(text_.size())};
|
||||
if (windowSaved_.valid && windowSaved_.revision == text_.revision()) {
|
||||
p.at = windowSaved_.at;
|
||||
} else if (p.len) {
|
||||
if (!ensureSide(why)) return false;
|
||||
if (!card_.append(sidePath_, bytes(text_.text()), p.len)) {
|
||||
card_.done();
|
||||
if (!card_.size(sidePath_, sideSize_)) sideSize_ = 0;
|
||||
why = "the card refused a write";
|
||||
return false;
|
||||
}
|
||||
p.at = sideSize_;
|
||||
sideSize_ += p.len;
|
||||
}
|
||||
if (p.len) pieces_.insert(pieces_.begin() + static_cast<long>(win_), p);
|
||||
flushedSinceSave_ = sidePending_ = true;
|
||||
}
|
||||
windowLoaded_ = false;
|
||||
loaded_.clear();
|
||||
windowSaved_.valid = false;
|
||||
before_ = after_ = 0;
|
||||
merge();
|
||||
return true;
|
||||
}
|
||||
|
||||
// The window's start is where a line starts on screen whenever that can be known: after a
|
||||
// newline, or where the window before had a line start. Otherwise the same text could wrap
|
||||
// differently from one window to the next.
|
||||
bool NoteDocument::load(uint32_t cursor, int row, int64_t startHint) {
|
||||
uint32_t total = piecesBytes();
|
||||
cursor = std::min(cursor, total);
|
||||
uint32_t s = 0, e = total;
|
||||
if (total > NoteText::kMaxBytes - kEdge) {
|
||||
uint32_t c = cursor > kHalf ? cursor - kHalf : 0;
|
||||
if (c == 0) {
|
||||
s = 0;
|
||||
} else if (startHint >= 0 && startHint <= static_cast<int64_t>(c)) {
|
||||
s = static_cast<uint32_t>(startHint);
|
||||
} else {
|
||||
uint8_t buf[128];
|
||||
uint32_t at = c, limit = std::min(c + kSeekNewline, cursor);
|
||||
bool found = false;
|
||||
while (at < limit && !found) {
|
||||
size_t n = readDoc(at, buf, std::min<size_t>(sizeof buf, limit - at));
|
||||
if (!n) break;
|
||||
for (size_t i = 0; i < n && !found; i++)
|
||||
if (buf[i] == '\n') {
|
||||
s = at + static_cast<uint32_t>(i) + 1;
|
||||
found = true;
|
||||
}
|
||||
at += static_cast<uint32_t>(n);
|
||||
}
|
||||
if (!found) {
|
||||
s = c;
|
||||
for (int k = 0; k < 3 && s < cursor && continuation(byteAt(s)); k++) s++;
|
||||
}
|
||||
}
|
||||
e = std::min(total, cursor + kHalf);
|
||||
for (int k = 0; k < 3 && e < total && continuation(byteAt(e)); k++) e++;
|
||||
if (e < total && e > 0 && byteAt(e) == '\n' && byteAt(e - 1) == '\r') e++;
|
||||
e = std::min<uint32_t>(e, s + NoteText::kMaxBytes);
|
||||
}
|
||||
size_t i0 = splitAt(s), i1 = splitAt(e);
|
||||
loaded_.assign(pieces_.begin() + static_cast<long>(i0), pieces_.begin() + static_cast<long>(i1));
|
||||
pieces_.erase(pieces_.begin() + static_cast<long>(i0), pieces_.begin() + static_cast<long>(i1));
|
||||
win_ = i0;
|
||||
before_ = s;
|
||||
after_ = total - e;
|
||||
std::string& b = text_.buffer();
|
||||
b.resize(e - s);
|
||||
size_t got = 0;
|
||||
bool ok = true;
|
||||
for (const Piece& p : loaded_) {
|
||||
size_t n = card_.read(fileOf(p), p.at, reinterpret_cast<uint8_t*>(&b[got]), p.len);
|
||||
got += n;
|
||||
if (n != p.len) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!ok) { // the note is whole in its pieces: stand on an empty window where the cursor was
|
||||
pieces_.insert(pieces_.begin() + static_cast<long>(i0), loaded_.begin(), loaded_.end());
|
||||
loaded_.clear();
|
||||
win_ = splitAt(cursor);
|
||||
before_ = cursor;
|
||||
after_ = total - cursor;
|
||||
s = cursor;
|
||||
b.clear();
|
||||
}
|
||||
droppedAtLoad_ = text_.refilled(cursor - s, row);
|
||||
newlinesAtLoad_ = static_cast<size_t>(std::count(b.begin(), b.end(), '\n'));
|
||||
loadedRevision_ = text_.revision();
|
||||
windowLoaded_ = true;
|
||||
windowSaved_.valid = false;
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool NoteDocument::wantsMove() const {
|
||||
if (!windowLoaded_) return true;
|
||||
size_t n = text_.size(), c = text_.cursor();
|
||||
if (n + kSpare >= NoteText::kMaxBytes) return true;
|
||||
if (before_ && c < kEdge) return true;
|
||||
return after_ && n - c < kEdge;
|
||||
}
|
||||
|
||||
bool NoteDocument::move(std::string& why) {
|
||||
uint32_t cursor = noteCursor();
|
||||
int row = text_.cursorRow();
|
||||
int64_t hint = -1;
|
||||
if (windowLoaded_ && !droppedAtLoad_ && cursor > kHalf) {
|
||||
uint32_t c = cursor - kHalf;
|
||||
if (c >= before_ && c < before_ + text_.size()) hint = static_cast<int64_t>(before_) + static_cast<int64_t>(text_.startOfLine(c - before_));
|
||||
}
|
||||
if (!putBack(why)) return false;
|
||||
bool ok = load(cursor, row, hint);
|
||||
card_.done();
|
||||
if (!ok) why = "the card refused to read";
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool NoteDocument::jump(uint32_t to, std::string& why) {
|
||||
to = std::min(to, size());
|
||||
if (windowLoaded_ && to == 0 && !before_) return text_.toStart(), true;
|
||||
if (windowLoaded_ && to == size() && !after_) return text_.toEnd(), true;
|
||||
if (!putBack(why)) return false;
|
||||
bool ok = load(to, to ? 1000 : 0, -1);
|
||||
card_.done();
|
||||
if (!ok) why = "the card refused to read";
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool NoteDocument::wantsRewrite() const { return size() <= kWholeLimit || sideSize_ > kSideLimit || pieces_.size() > kManyPieces; }
|
||||
|
||||
bool NoteDocument::journal(std::string& why) {
|
||||
if (path_.empty()) {
|
||||
why = "the note has no file yet";
|
||||
return false;
|
||||
}
|
||||
bool modified = text_.revision() != loadedRevision_;
|
||||
Piece w{1, 0, static_cast<uint32_t>(text_.size())};
|
||||
uint32_t cursor = noteCursor();
|
||||
if (!ensureSide(why)) return false;
|
||||
bool wrote = true;
|
||||
if (modified && w.len) {
|
||||
if (windowSaved_.valid && windowSaved_.revision == text_.revision()) {
|
||||
w.at = windowSaved_.at;
|
||||
} else if ((wrote = card_.append(sidePath_, bytes(text_.text()), w.len))) {
|
||||
w.at = sideSize_;
|
||||
sideSize_ += w.len;
|
||||
windowSaved_.valid = true;
|
||||
windowSaved_.at = w.at;
|
||||
windowSaved_.len = w.len;
|
||||
windowSaved_.revision = text_.revision();
|
||||
}
|
||||
}
|
||||
if (wrote) {
|
||||
std::vector<Piece> all(pieces_.begin(), pieces_.begin() + static_cast<long>(win_));
|
||||
if (!modified) all.insert(all.end(), loaded_.begin(), loaded_.end());
|
||||
else if (w.len) all.push_back(w);
|
||||
all.insert(all.end(), pieces_.begin() + static_cast<long>(win_), pieces_.end());
|
||||
std::string rec(kSnap, 4);
|
||||
put32(rec, cursor);
|
||||
put32(rec, static_cast<uint32_t>(all.size()));
|
||||
for (const Piece& p : all) {
|
||||
rec += static_cast<char>(p.src);
|
||||
put32(rec, p.at);
|
||||
put32(rec, p.len);
|
||||
}
|
||||
put32(rec, crc32(0, bytes(rec), rec.size()));
|
||||
put32(rec, static_cast<uint32_t>(rec.size()) + 8);
|
||||
rec.append(kSnapEnd, 4);
|
||||
wrote = card_.append(sidePath_, bytes(rec), rec.size());
|
||||
if (wrote) sideSize_ += static_cast<uint32_t>(rec.size());
|
||||
}
|
||||
card_.done();
|
||||
if (!wrote) {
|
||||
windowSaved_.valid = false;
|
||||
if (!card_.size(sidePath_, sideSize_)) sideSize_ = 0;
|
||||
why = "the card refused a write";
|
||||
return false;
|
||||
}
|
||||
savedRevision_ = text_.revision();
|
||||
flushedSinceSave_ = false;
|
||||
sidePending_ = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool NoteDocument::rewriteStart(std::string& why) {
|
||||
if (path_.empty()) {
|
||||
why = "the note has no file yet";
|
||||
return false;
|
||||
}
|
||||
if (card_.freeBytes() < static_cast<uint64_t>(size()) + 16 * 1024) {
|
||||
why = "the card is full";
|
||||
return false;
|
||||
}
|
||||
if (!card_.create(tmp())) {
|
||||
why = "the card refused to open a file";
|
||||
return false;
|
||||
}
|
||||
rw_.active = true;
|
||||
rw_.stage = 0;
|
||||
rw_.index = 0;
|
||||
rw_.offset = rw_.done = rw_.wrote = rw_.wroteBefore = rw_.wroteAfter = 0;
|
||||
rw_.total = size();
|
||||
rw_.revision = text_.revision();
|
||||
rw_.carry = false;
|
||||
rw_.block.resize(kBlock);
|
||||
return true;
|
||||
}
|
||||
|
||||
int NoteDocument::rewriteStep(std::string& why) {
|
||||
if (!rw_.active) return -1;
|
||||
auto failed = [&](const char* what) {
|
||||
card_.done();
|
||||
card_.remove(tmp());
|
||||
rw_.active = false;
|
||||
std::vector<uint8_t>().swap(rw_.block);
|
||||
why = what;
|
||||
return -1;
|
||||
};
|
||||
auto out = [&](const uint8_t* data, size_t len) {
|
||||
if (!len) return true;
|
||||
if (!card_.append(tmp(), data, len)) return false;
|
||||
rw_.wrote += static_cast<uint32_t>(len);
|
||||
if (rw_.stage == 0) rw_.wroteBefore += static_cast<uint32_t>(len);
|
||||
if (rw_.stage == 2) rw_.wroteAfter += static_cast<uint32_t>(len);
|
||||
return true;
|
||||
};
|
||||
const uint8_t cr = '\r';
|
||||
uint32_t budget = kStepBytes;
|
||||
while (budget > 0 && rw_.stage < 3) {
|
||||
if (rw_.stage == 1) {
|
||||
uint32_t left = static_cast<uint32_t>(text_.size()) - rw_.offset;
|
||||
if (!left) {
|
||||
rw_.stage = 2;
|
||||
rw_.index = win_;
|
||||
rw_.offset = 0;
|
||||
continue;
|
||||
}
|
||||
uint32_t n = std::min(left, budget);
|
||||
if (!out(bytes(text_.text()) + rw_.offset, n)) return failed("the card refused a write");
|
||||
rw_.offset += n;
|
||||
rw_.done += n;
|
||||
budget -= n;
|
||||
continue;
|
||||
}
|
||||
size_t end = rw_.stage == 0 ? win_ : pieces_.size();
|
||||
bool pieceOver = rw_.index < end && rw_.offset >= pieces_[rw_.index].len;
|
||||
if (rw_.index >= end || pieceOver) {
|
||||
if (rw_.carry && !out(&cr, 1)) return failed("the card refused a write"); // a CR that ended its piece stays
|
||||
rw_.carry = false;
|
||||
rw_.offset = 0;
|
||||
if (pieceOver) rw_.index++;
|
||||
else rw_.stage++;
|
||||
continue;
|
||||
}
|
||||
const Piece& p = pieces_[rw_.index];
|
||||
uint32_t n = std::min<uint32_t>(std::min<uint32_t>(p.len - rw_.offset, budget), static_cast<uint32_t>(rw_.block.size()));
|
||||
uint8_t* b = rw_.block.data();
|
||||
if (card_.read(fileOf(p), p.at + rw_.offset, b, n) != n) return failed("the card refused to read the note");
|
||||
size_t m = n;
|
||||
if (p.src == 0) { // CRLF becomes LF (Q148, Q230), in the file's own text: what was typed has none
|
||||
if (rw_.carry && b[0] != '\n' && !out(&cr, 1)) return failed("the card refused a write");
|
||||
rw_.carry = false;
|
||||
m = 0;
|
||||
for (uint32_t i = 0; i < n; i++) {
|
||||
if (b[i] == '\r') {
|
||||
if (i + 1 == n) {
|
||||
rw_.carry = true;
|
||||
continue;
|
||||
}
|
||||
if (b[i + 1] == '\n') continue;
|
||||
}
|
||||
b[m++] = b[i];
|
||||
}
|
||||
}
|
||||
if (!out(b, m)) return failed("the card refused a write");
|
||||
rw_.offset += n;
|
||||
rw_.done += n;
|
||||
budget -= n;
|
||||
}
|
||||
if (rw_.stage < 3) return std::min(99, static_cast<int>(static_cast<uint64_t>(rw_.done) * 100 / std::max<uint32_t>(1, rw_.total)));
|
||||
|
||||
// All of it is in the temporary file. From the mark in the side file on, the rewrite counts as
|
||||
// done: whatever is cut after that, opening the note finishes it.
|
||||
card_.done();
|
||||
uint32_t written = 0, old = 0;
|
||||
if (!card_.size(tmp(), written) || written != rw_.wrote) return failed("the card refused a write");
|
||||
if (sideSize_) {
|
||||
if (!card_.append(sidePath_, reinterpret_cast<const uint8_t*>(kDone), kDoneLen)) return failed("the card refused a write");
|
||||
sideSize_ += kDoneLen;
|
||||
card_.done();
|
||||
}
|
||||
rw_.active = false;
|
||||
std::vector<uint8_t>().swap(rw_.block);
|
||||
// FAT can't rename onto a file. Between these two lines only the temporary file exists: the
|
||||
// Notes list puts such a file back under its name.
|
||||
if ((card_.size(path_, old) && !card_.remove(path_)) || !card_.rename(tmp(), path_)) {
|
||||
card_.done();
|
||||
why = "the card refused to replace the note";
|
||||
return -1;
|
||||
}
|
||||
if (sideSize_) card_.remove(sidePath_);
|
||||
card_.done();
|
||||
sideSize_ = 0;
|
||||
uint32_t window = static_cast<uint32_t>(text_.size());
|
||||
pieces_.clear();
|
||||
loaded_.clear();
|
||||
if (rw_.wroteBefore) pieces_.push_back({0, 0, rw_.wroteBefore});
|
||||
win_ = pieces_.size();
|
||||
if (rw_.wroteAfter) pieces_.push_back({0, rw_.wroteBefore + window, rw_.wroteAfter});
|
||||
if (window) loaded_.push_back({0, rw_.wroteBefore, window});
|
||||
before_ = rw_.wroteBefore;
|
||||
after_ = rw_.wroteAfter;
|
||||
loadedRevision_ = savedRevision_ = rw_.revision;
|
||||
droppedAtLoad_ = 0;
|
||||
windowLoaded_ = true;
|
||||
flushedSinceSave_ = sidePending_ = false;
|
||||
windowSaved_.valid = false;
|
||||
return 100;
|
||||
}
|
||||
|
||||
bool NoteDocument::sideIsDone(uint32_t sideSize) {
|
||||
uint8_t tail[kDoneLen];
|
||||
return sideSize >= kHeaderLen + kDoneLen && card_.read(sidePath_, sideSize - kDoneLen, tail, kDoneLen) == kDoneLen &&
|
||||
std::memcmp(tail, kDone, kDoneLen) == 0;
|
||||
}
|
||||
|
||||
NoteDocument::Resume NoteDocument::resume(uint32_t fileSize, uint32_t& cursor) {
|
||||
uint8_t header[kHeaderLen];
|
||||
if (sideSize_ < kHeaderLen || card_.read(sidePath_, 0, header, kHeaderLen) != kHeaderLen || std::memcmp(header, kMagic, kMagicLen) != 0)
|
||||
return Resume::Nothing;
|
||||
|
||||
// The newest snapshot that checks out, looking back from the end: after it there may be text
|
||||
// that left a window, or a write the power cut short.
|
||||
auto snapshotEndingAt = [&](uint32_t end) {
|
||||
uint8_t lenBytes[4];
|
||||
if (end < kHeaderLen + 24 || card_.read(sidePath_, end - 8, lenBytes, 4) != 4) return false;
|
||||
uint32_t len = get32(lenBytes);
|
||||
if (len < 24 || len > kMaxSnapshot || len > end - kHeaderLen || (len - 24) % 9) return false;
|
||||
std::string rec(len, '\0');
|
||||
if (card_.read(sidePath_, end - len, reinterpret_cast<uint8_t*>(&rec[0]), len) != len) return false;
|
||||
const uint8_t* r = bytes(rec);
|
||||
if (std::memcmp(r, kSnap, 4) != 0 || get32(r + len - 12) != crc32(0, r, len - 12)) return false;
|
||||
uint32_t count = get32(r + 8);
|
||||
if (count != (len - 24) / 9) return false;
|
||||
std::vector<Piece> list;
|
||||
list.reserve(count);
|
||||
for (uint32_t i = 0; i < count; i++) {
|
||||
const uint8_t* q = r + 12 + 9 * i;
|
||||
Piece p{q[0], get32(q + 1), get32(q + 5)};
|
||||
uint64_t stop = static_cast<uint64_t>(p.at) + p.len;
|
||||
if (p.src > 1 || !p.len) return false;
|
||||
if (p.src == 1 && (p.at < kHeaderLen || stop > end - len)) return false;
|
||||
list.push_back(p);
|
||||
}
|
||||
pieces_.swap(list);
|
||||
cursor = get32(r + 4);
|
||||
return true;
|
||||
};
|
||||
bool found = false;
|
||||
std::vector<uint8_t> buf(1024 + 3);
|
||||
for (uint32_t end = sideSize_; end > kHeaderLen && !found;) {
|
||||
uint32_t a = end > 1024 + kHeaderLen ? end - 1024 : static_cast<uint32_t>(kHeaderLen);
|
||||
size_t n = card_.read(sidePath_, a, buf.data(), std::min<size_t>(buf.size(), sideSize_ - a));
|
||||
for (size_t i = n >= 4 ? n - 4 + 1 : 0; i-- > 0 && !found;)
|
||||
if (std::memcmp(buf.data() + i, kSnapEnd, 4) == 0) found = snapshotEndingAt(a + static_cast<uint32_t>(i) + 4);
|
||||
end = a;
|
||||
}
|
||||
if (!found) return Resume::Nothing;
|
||||
std::string expect;
|
||||
if (!headerFor(expect) || std::memcmp(header, expect.data(), kHeaderLen) != 0) {
|
||||
pieces_.clear();
|
||||
return Resume::Mismatch;
|
||||
}
|
||||
for (const Piece& p : pieces_)
|
||||
if (p.src == 0 && static_cast<uint64_t>(p.at) + p.len > fileSize) return pieces_.clear(), Resume::Mismatch;
|
||||
merge();
|
||||
return Resume::Ok;
|
||||
}
|
||||
|
||||
} // namespace roro::notes
|
||||
@@ -0,0 +1,130 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "note_text.h"
|
||||
|
||||
namespace roro::notes {
|
||||
|
||||
// The card, as a note needs it. On the device every call is made on the storage task.
|
||||
class NoteCard {
|
||||
public:
|
||||
virtual ~NoteCard() = default;
|
||||
virtual bool size(const std::string& path, uint32_t& size) = 0; // false: no such file
|
||||
virtual size_t read(const std::string& path, uint32_t at, uint8_t* into, size_t len) = 0;
|
||||
virtual bool create(const std::string& path) = 0; // an empty file, in place of what was there
|
||||
virtual bool append(const std::string& path, const uint8_t* data, size_t len) = 0;
|
||||
virtual bool remove(const std::string& path) = 0;
|
||||
virtual bool rename(const std::string& from, const std::string& to) = 0;
|
||||
virtual uint64_t freeBytes() = 0;
|
||||
virtual void done() {} // what was appended is on the card now (files kept open are closed)
|
||||
};
|
||||
|
||||
// A text file of any size, edited (issue #47, F1 Q223-Q232). The file stays on the card; what is
|
||||
// in memory is one window of it, a NoteText of up to 16 KB around the cursor, and a list of pieces
|
||||
// saying what the rest is made of: runs of bytes of the file, and runs of the side file
|
||||
// `<note>.edit`, where a window that was changed is written when the cursor leaves it.
|
||||
//
|
||||
// the note = pieces before the window + the window + pieces after it
|
||||
//
|
||||
// Saving comes in two kinds. `journal` appends the window and the list of pieces to the side
|
||||
// file: quick whatever the note's size, and enough to pick the edit up after a power cut.
|
||||
// `rewrite` streams the whole note into `<note>.tmp` and puts it in the note's place: the file is
|
||||
// then the note again, and the side file goes. A note of up to 64 KB is always rewritten.
|
||||
//
|
||||
// Every method marked [card] reads or writes the card.
|
||||
class NoteDocument {
|
||||
public:
|
||||
static constexpr uint32_t kHalf = 4096; // loaded on each side of the cursor
|
||||
static constexpr uint32_t kEdge = 2048; // this near an end of the window, it moves
|
||||
static constexpr uint32_t kSpare = 256; // this near full, it moves
|
||||
static constexpr uint32_t kWholeLimit = 64 * 1024; // up to here a save is a rewrite
|
||||
static constexpr uint32_t kSideLimit = 1024 * 1024; // a side file this big asks for a rewrite
|
||||
static constexpr size_t kManyPieces = 256; // and so does a list this long
|
||||
|
||||
NoteDocument(NoteCard& card, int cols, int rows);
|
||||
|
||||
// [card] "" or why not. `told`: something the user should read (an edit picked up, or set aside).
|
||||
std::string open(const std::string& path, std::string* told = nullptr);
|
||||
void openNew(); // nothing on the card until the first rewrite
|
||||
const std::string& path() const { return path_; }
|
||||
void setPath(const std::string& path) { // a new note's, before its first rewrite
|
||||
path_ = path;
|
||||
sidePath_ = path.empty() ? "" : side();
|
||||
}
|
||||
|
||||
NoteText& text() { return text_; }
|
||||
const NoteText& text() const { return text_; }
|
||||
uint32_t size() const { return before_ + static_cast<uint32_t>(text_.size()) + after_; }
|
||||
uint32_t cursor() const { return before_ + static_cast<uint32_t>(text_.cursor()); } // in the note
|
||||
int percent() const;
|
||||
bool windowed() const { return before_ || after_; } // the note is more than its window
|
||||
|
||||
// After each key: the cursor is near an end of the window that isn't an end of the note, or
|
||||
// the window is nearly full.
|
||||
bool wantsMove() const;
|
||||
bool move(std::string& why); // [card] the window, around the cursor
|
||||
bool jump(uint32_t to, std::string& why); // [card] the cursor, anywhere in the note
|
||||
|
||||
bool dirty() const { return text_.revision() != savedRevision_ || flushedSinceSave_; } // the card doesn't have it
|
||||
bool filePending() const { return sidePending_; } // saved, but in the side file: a rewrite is owed
|
||||
bool wantsRewrite() const; // the next save should be a rewrite
|
||||
bool journal(std::string& why); // [card]
|
||||
bool rewriteStart(std::string& why); // [card]
|
||||
int rewriteStep(std::string& why); // [card] percent done; 100: the file is the note; -1: failed
|
||||
bool rewriting() const { return rw_.active; }
|
||||
|
||||
private:
|
||||
struct Piece {
|
||||
uint8_t src; // 0: the note's file, 1: the side file
|
||||
uint32_t at, len;
|
||||
};
|
||||
enum class Resume { Ok, Mismatch, Nothing };
|
||||
|
||||
std::string side() const { return path_ + ".edit"; }
|
||||
std::string tmp() const { return path_ + ".tmp"; }
|
||||
const std::string& fileOf(const Piece& p) const { return p.src ? sidePath_ : path_; }
|
||||
uint32_t piecesBytes() const;
|
||||
size_t readDoc(uint32_t at, uint8_t* into, size_t len); // from the pieces: the window is put back first
|
||||
int byteAt(uint32_t at);
|
||||
size_t splitAt(uint32_t at); // the index of the piece that starts there
|
||||
void merge();
|
||||
uint32_t noteCursor() const; // where the cursor is among the pieces once the window is put back
|
||||
bool putBack(std::string& why);
|
||||
bool load(uint32_t cursor, int row, int64_t startHint); // false: the card refused, and the window is empty
|
||||
bool ensureSide(std::string& why);
|
||||
bool headerFor(std::string& header);
|
||||
Resume resume(uint32_t fileSize, uint32_t& cursor);
|
||||
bool sideIsDone(uint32_t sideSize);
|
||||
void reset();
|
||||
|
||||
NoteCard& card_;
|
||||
NoteText text_;
|
||||
std::string path_, sidePath_;
|
||||
std::vector<Piece> pieces_; // without the window while it's loaded
|
||||
std::vector<Piece> loaded_; // what the window was read from
|
||||
size_t win_ = 0; // the window sits before pieces_[win_]
|
||||
bool windowLoaded_ = false;
|
||||
uint32_t before_ = 0, after_ = 0;
|
||||
uint32_t loadedRevision_ = 0, savedRevision_ = 0;
|
||||
size_t droppedAtLoad_ = 0, newlinesAtLoad_ = 0;
|
||||
bool flushedSinceSave_ = false, sidePending_ = false;
|
||||
uint32_t sideSize_ = 0; // 0: no side file
|
||||
struct {
|
||||
bool valid = false;
|
||||
uint32_t at = 0, len = 0, revision = 0;
|
||||
} windowSaved_; // the window as the side file already has it
|
||||
struct {
|
||||
bool active = false;
|
||||
int stage = 0; // 0: pieces before, 1: the window, 2: pieces after
|
||||
size_t index = 0;
|
||||
uint32_t offset = 0, done = 0, total = 0, wrote = 0, wroteBefore = 0, wroteAfter = 0, revision = 0;
|
||||
bool carry = false; // a CR at the end of a block, waiting to see what follows
|
||||
std::vector<uint8_t> block;
|
||||
} rw_;
|
||||
};
|
||||
|
||||
} // namespace roro::notes
|
||||
@@ -16,11 +16,24 @@ NoteText::NoteText(int cols, int rows, std::string&& text) : cols_(std::max(1, c
|
||||
text_.reserve(kMaxBytes);
|
||||
}
|
||||
|
||||
void NoteText::dropCarriageReturns() {
|
||||
size_t kept = 0;
|
||||
for (size_t i = 0; i < text_.size(); i++)
|
||||
if (!(text_[i] == '\r' && i + 1 < text_.size() && text_[i + 1] == '\n')) text_[kept++] = text_[i];
|
||||
size_t NoteText::dropCarriageReturns(size_t* follow) {
|
||||
size_t kept = 0, size = text_.size(), place = follow ? *follow : 0;
|
||||
for (size_t i = 0; i < size; i++) {
|
||||
if (follow && i == place) *follow = kept;
|
||||
if (!(text_[i] == '\r' && i + 1 < size && text_[i + 1] == '\n')) text_[kept++] = text_[i];
|
||||
}
|
||||
if (follow && place >= size) *follow = kept;
|
||||
text_.resize(kept);
|
||||
return size - kept;
|
||||
}
|
||||
|
||||
size_t NoteText::refilled(size_t cursor, int row) {
|
||||
size_t dropped = dropCarriageReturns(&cursor);
|
||||
cursor_ = std::min(cursor, text_.size());
|
||||
while (cursor_ > 0 && cursor_ < text_.size() && continuation(text_[cursor_])) cursor_--;
|
||||
top_ = lineOf(cursor_);
|
||||
for (int i = 0; i < row && top_ > 0; i++) top_ = lineOf(top_ - 1);
|
||||
return dropped;
|
||||
}
|
||||
|
||||
bool NoteText::setText(const std::string& text) {
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
|
||||
namespace roro::notes {
|
||||
|
||||
// The text of a note while it's edited (F1, Q144, Q145): UTF-8 held whole in memory, a cursor, and
|
||||
// the part of it on screen. Lines wrap at spaces, `cols` characters wide; a line owns the space or
|
||||
// The text of a note while it's edited (F1, Q144, Q145): UTF-8 held in memory, a cursor, and the
|
||||
// part of it on screen. Up to 16 KB: a longer note is edited through NoteDocument (note_document.h),
|
||||
// which keeps this as its window on the file. Lines wrap at spaces, `cols` characters wide; a line owns the space or
|
||||
// the newline it ends with, so every byte of the text belongs to exactly one line. No index of
|
||||
// lines is kept (a note of newlines alone would need twice its size): where a line starts is
|
||||
// worked out from the start of its paragraph, which is never far.
|
||||
@@ -27,8 +28,17 @@ class NoteText {
|
||||
bool setText(const std::string& text);
|
||||
const std::string& text() const { return text_; }
|
||||
size_t cursor() const { return cursor_; }
|
||||
size_t size() const { return text_.size(); }
|
||||
uint32_t revision() const { return revision_; } // changes with every edit: is it saved?
|
||||
|
||||
// For a window on a longer text (issue #47): the caller refills the buffer, then says where
|
||||
// the cursor is in it and which row of the screen it should be on. CRLF becomes LF as in
|
||||
// setText; returns how many CRs went. The revision doesn't change: nothing was edited.
|
||||
std::string& buffer() { return text_; }
|
||||
size_t refilled(size_t cursor, int row);
|
||||
size_t startOfLine(size_t pos) const { return lineOf(pos); }
|
||||
size_t top() const { return top_; }
|
||||
|
||||
bool insert(uint32_t codePoint); // false: the note is full
|
||||
bool insertText(const std::string& s); // all of it or nothing
|
||||
void backspace();
|
||||
@@ -61,7 +71,7 @@ class NoteText {
|
||||
bool hasLineAfter(size_t start) const;
|
||||
void moved(bool keepGoal = false);
|
||||
void follow(); // scrolls so the cursor is on screen
|
||||
void dropCarriageReturns();
|
||||
size_t dropCarriageReturns(size_t* follow = nullptr); // how many; `follow` is a place in the text, kept on its character
|
||||
|
||||
int cols_, rows_;
|
||||
std::string text_;
|
||||
|
||||
Executable
+52
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
# Creates the key CI uses to ask the web server for a site refresh, once (issue #79,
|
||||
# docs/milestones/W1.md), and says where each half goes. The private key stays in
|
||||
# ~/.config/roro9stack/ until it is pasted into the Gitea secret; it is never committed and this
|
||||
# script doesn't print it.
|
||||
#
|
||||
# scripts/site_deploy_keygen.sh [/full/path/to/rororefresh.sh] [the runner's address]
|
||||
set -euo pipefail
|
||||
KEY="${RORO_SITE_DEPLOY_KEY:-$HOME/.config/roro9stack/site-deploy-key}"
|
||||
COMMAND="${1:-/full/path/to/rororefresh.sh}"
|
||||
FROM="${2:-}"
|
||||
|
||||
if [ -e "$KEY" ]; then
|
||||
echo "A site deploy key already exists at $KEY; not overwriting it." >&2
|
||||
else
|
||||
mkdir -p "$(dirname "$KEY")"
|
||||
( umask 077; ssh-keygen -q -t ed25519 -N "" -C roro9stack-ci-site-refresh -f "$KEY" )
|
||||
fi
|
||||
|
||||
options="restrict,command=\"$COMMAND\""
|
||||
[ -z "$FROM" ] || options="from=\"$FROM\",$options"
|
||||
|
||||
cat <<TEXT
|
||||
|
||||
1. On the web server, as the user that runs the refresh, add this one line to ~/.ssh/authorized_keys:
|
||||
|
||||
$options $(cat "$KEY.pub")
|
||||
|
||||
restrict: no terminal, no forwarding of any kind. command=: whatever the client asks for, this
|
||||
runs instead.$([ -n "$FROM" ] || printf '\n Give the runner'"'"'s address as the second argument to add from="...": the key then works from there only.')
|
||||
|
||||
2. In Gitea, the repository's Settings > Actions > Secrets:
|
||||
|
||||
SITE_DEPLOY_KEY the whole of $KEY (the private key, with its BEGIN and END lines)
|
||||
SITE_DEPLOY_HOST the server's address as the runner reaches it, or address:port
|
||||
SITE_DEPLOY_USER that user's name
|
||||
SITE_DEPLOY_KNOWN_HOSTS the server's host key, one line, from a machine you trust the network of:
|
||||
ssh-keyscan -t ed25519 <address> (or: -p <port> <address>)
|
||||
and compare it with the server's own:
|
||||
ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub (on the server)
|
||||
ssh-keyscan -t ed25519 <address> | ssh-keygen -lf - (here)
|
||||
|
||||
3. Try it, from here, with the same four values in the environment:
|
||||
|
||||
SITE_DEPLOY_KEY="\$(cat $KEY)" SITE_DEPLOY_HOST=... SITE_DEPLOY_USER=... \\
|
||||
SITE_DEPLOY_KNOWN_HOSTS="\$(ssh-keyscan -t ed25519 ... 2>/dev/null)" scripts/site_refresh.sh
|
||||
|
||||
(with from= set, this works from the runner's address only.) Then, to see that the key can do
|
||||
nothing else: ssh -i $KEY <user>@<address> id must run the refresh, not \`id\`.
|
||||
|
||||
Once the secret is in Gitea, the copy at $KEY can be deleted.
|
||||
TEXT
|
||||
Executable
+51
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
# Asks the web server to rebuild the site (issue #79, docs/milestones/W1.md). Run by CI after a push
|
||||
# to main that changed the site, and after a release is published (the home page and Downloads
|
||||
# name the latest release when they are built).
|
||||
#
|
||||
# It only connects: the server's authorized_keys line forces the one command this key may run, so
|
||||
# nothing sent from here chooses what happens there. From the environment (Gitea secrets):
|
||||
# SITE_DEPLOY_KEY the private key (scripts/site_deploy_keygen.sh makes it)
|
||||
# SITE_DEPLOY_HOST the server, or server:port
|
||||
# SITE_DEPLOY_USER the user there
|
||||
# SITE_DEPLOY_KNOWN_HOSTS the server's host key, as a known_hosts line: nothing else is trusted
|
||||
# With none of them set it does nothing (a fork, or before the key is installed); with only some, it fails.
|
||||
set -euo pipefail
|
||||
|
||||
set_count=0
|
||||
for v in SITE_DEPLOY_KEY SITE_DEPLOY_HOST SITE_DEPLOY_USER SITE_DEPLOY_KNOWN_HOSTS; do
|
||||
[ -z "${!v:-}" ] || set_count=$((set_count + 1))
|
||||
done
|
||||
if [ "$set_count" = 0 ]; then
|
||||
echo "site refresh: no SITE_DEPLOY_* secrets here, nothing done"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$set_count" != 4 ]; then
|
||||
echo "site refresh: SITE_DEPLOY_KEY, _HOST, _USER and _KNOWN_HOSTS are needed, and only $set_count of them are set" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v ssh >/dev/null; then
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq --no-install-recommends openssh-client >/dev/null
|
||||
fi
|
||||
|
||||
host="$SITE_DEPLOY_HOST" port=22
|
||||
case "$host" in
|
||||
*:*) port="${host##*:}" host="${host%:*}" ;;
|
||||
esac
|
||||
|
||||
# The key and the host key exist as files only while this runs, in a container that goes with the job.
|
||||
umask 077
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
printf '%s\n' "$SITE_DEPLOY_KEY" > "$tmp/key"
|
||||
printf '%s\n' "$SITE_DEPLOY_KNOWN_HOSTS" > "$tmp/known_hosts"
|
||||
|
||||
# -F none: no configuration but this line. -T and no command: the server's forced command runs.
|
||||
ssh -F none -T -p "$port" -i "$tmp/key" \
|
||||
-o IdentitiesOnly=yes -o BatchMode=yes \
|
||||
-o StrictHostKeyChecking=yes -o UserKnownHostsFile="$tmp/known_hosts" -o GlobalKnownHostsFile=/dev/null \
|
||||
-o ConnectTimeout=20 -o ServerAliveInterval=15 -o ServerAliveCountMax=8 \
|
||||
"$SITE_DEPLOY_USER@$host"
|
||||
echo "site refresh: done"
|
||||
@@ -8,6 +8,6 @@ sort_by = "weight"
|
||||
eyebrow = "Developer docs"
|
||||
+++
|
||||
|
||||
The firmware is open source (GPL-3.0) and lives on [Gitea](https://git.twis.la/twisla/roro9stack). It is built for one device, the M5Stack Cardputer ADV with the Cap LoRa-1262, and it is built to be **worked on without touching the device**: install a build over Wi-Fi, read its console, press its keys, take screenshots of it, copy files to and from its SD card, and fetch its crash dumps, all from a PC on the same network. The first section is about exactly that.
|
||||
The firmware is open source (GPL-3.0) and lives on [Gitea](https://git.twis.la/twisla/roro9stack). It is built for one device, the M5Stack Cardputer ADV with the Cap LoRa-1262, and it is built to be **worked on without touching the device**: install a build over Wi-Fi, read its console, press its keys, take screenshots of it, copy files to and from its SD card, and fetch its crash dumps, all from a PC on the same network. The first section is about exactly that. The same commands also run on the device itself, in the [Shell](/guide/shell/).
|
||||
|
||||
Some of these pages are written by hand. The rest are **generated from the repository's own documents** (the decisions, the milestone plans, the README and the firmware's own `help` text), so they are never out of date: each says which file it comes from.
|
||||
|
||||
@@ -19,24 +19,26 @@ net bytes each network service has read and written since boot
|
||||
reboot restart
|
||||
boot other restart into the other app slot (manual Rollback)
|
||||
log level <0-5> ESP-IDF log level (0 none ... 5 verbose)
|
||||
ls [folder] | du <path> | mkdir <path> | rm <path> | cp [-f] <from> <to> | mv [-f] <from> <to> | cancel the SD card, with the Storage App's rules
|
||||
ls [folder] | du <path> | mkdir <path> | rm [-r] [-f] <path> | cp [-f] <from> <to> | mv [-f] <from> <to> | cancel the SD card, with the Storage App's rules (rm -r for a folder; -f: the Shell doesn't ask; * and ? in a name: /notes/*.txt)
|
||||
screenshot [seconds] the screen as a PNG in /screenshots on the card, now or after a pause
|
||||
lora probe | lora status | lora rx on|off | lora preset <name> the LoRa radio, receive only
|
||||
lora capture start|stop a LoRa Capture to /captures/lora (pcap, LoRaTap)
|
||||
lora sweep on [from MHz] [to MHz] [step kHz] | off | dump RSSI across a band (863 870 100)
|
||||
lora sweep on [from MHz] [to MHz] [step kHz] | lora sweep off | lora sweep dump RSSI across a band (863 870 100)
|
||||
lora custom <MHz> <BW kHz> <SF> <CR 5-8> <sync hex> [preamble] e.g. 868.1 125 7 5 34 8 (LoRaWAN)
|
||||
gnss quiet on|off pause the GNSS receiver while the LoRa radio listens (it costs the radio 8 dB)
|
||||
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 help, or one character
|
||||
key <name|char> press a key: up down left right select back home del tab space help, or one character; ctrl- alt- shift- before it (key ctrl-down)
|
||||
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
|
||||
gemini get <url> fetch a Gemini page and report header, size, certificate, heap
|
||||
irc start | irc stop | irc dump | irc say <buffer> <text>
|
||||
install <path.ota> Update from SD
|
||||
update check | list | status | install <tag> the project's releases on Gitea
|
||||
update check | update list | update status | update install <tag> the project's releases on Gitea
|
||||
sd card | sd list | cat <path> | log <text> | burst | sound on|off | short | normal
|
||||
Irc | Wifi | Gnss | Gemini | Lora | Storage | Notes | Shell | System | Settings open that App: a capital letter is an App, not a command
|
||||
debug status | debug off [seconds] the Debug Console over Wi-Fi (Settings > Debug Console); with seconds, it comes back
|
||||
debug on | debug token <16 to 64 characters> | debug token new (USB serial only) switch it on, set its token
|
||||
crash abort|wdt crash on purpose (to test crash reports and Safe Mode)
|
||||
@@ -47,7 +49,7 @@ lora inject <hex> [rssi] [snr] a packet into the LoRa Scanner as if received (
|
||||
sd fill <folder> <count> makes that many small files there, to test a crowded folder
|
||||
coredump get (Debug Console only) send the raw core dump: use scripts/rdbg.py coredump
|
||||
reset (Debug Console only) restart at once, even if the main loop is stuck
|
||||
get <path> | put <path> <size> <sha256> | screenshot (Debug Console only) binary, see rdbg.py
|
||||
get <path> | put <path> <size> <sha256> | screenshot (Debug Console only) binary, see rdbg.py: there, a bare `screenshot` sends the screen instead of saving it
|
||||
quit close the Debug Console connection
|
||||
```
|
||||
|
||||
@@ -60,7 +62,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\|del\|tab\|space\|help`, or `key <char>` | Injects a key press (`help` is Fn+h: the keys of the screen that is showing) |
|
||||
| `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). `ctrl-`, `alt-` and `shift-` before it hold that key: `key ctrl-down`, `key alt-up`, `key ctrl-b` |
|
||||
| `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) |
|
||||
@@ -78,13 +80,15 @@ In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few r
|
||||
| `irc say <buffer> <text>` | Types into a Buffer, commands included (`irc say 0 /join #test`) |
|
||||
| `irc dump` | Prints IRC status, memory, and the last lines of each Buffer |
|
||||
| `wifi status` | Prints Wi-Fi state, network, signal, clock and free heap, then the address, gateway, DNS and NTP servers in use and where each came from |
|
||||
| `info` | Firmware, uptime, last start reason, memory, Wi-Fi, the SD card and its write faults since boot, and both app slots with their versions and OTA states |
|
||||
| `info` | Firmware, uptime, last start reason, memory, Wi-Fi, the SD card and its write faults since boot, **which App is in front**, and both app slots with their versions and OTA states |
|
||||
| `Notes`, `Irc`, `Wifi`, `Gnss`, `Gemini`, `Lora`, `Storage`, `Shell`, `System`, `Settings` | Opens that App: a capital letter is an App, not a command |
|
||||
| `tasks` | FreeRTOS tasks over the next second: state, priority, lowest free stack, share of a core, each core's load, and how many passes the main loop made |
|
||||
| `net` | Bytes each network service has read and written since boot |
|
||||
| `reboot` / `boot other` | Restart, or restart into the other app slot (a manual Rollback) |
|
||||
| `log level <0-5>` | ESP-IDF log level |
|
||||
| `ls [folder]` / `du <path>` | Lists a folder of the SD card with sizes and dates, or counts the files and bytes under a path |
|
||||
| `cp [-f] <from> <to>` / `mv [-f] <from> <to>` / `rm <path>` / `mkdir <path>` / `cancel` | What the Storage App does, with its rules: copy (folders too), move or rename, delete (a folder with what's in it), new folder. `-f` replaces a file that's in the way; a tab separates paths that hold spaces; `cancel` stops a copy or a delete |
|
||||
| `screenshot [seconds]` | The screen as a PNG in `/screenshots` on the card, now or after a pause to get to the screen you want (240 x 135, about 33 KB). Over the Debug Console a bare `screenshot` sends the screen to the PC instead |
|
||||
| `cp [-f] <from> <to>` / `mv [-f] <from> <to>` / `rm [-r] [-f] <path>` / `mkdir <path>` / `cancel` | What the Storage App does, with its rules: copy (folders too), move or rename, delete (`rm -r` for a folder and what's in it, as Unix has it), new folder. `-f` replaces a file that's in the way; `*` and `?` in the last part of a path (`ls`, `du`, `rm`, `cp`, `mv`) run the command for each name matched, 64 at most; a tab separates paths that hold spaces; `cancel` stops a copy or a delete |
|
||||
| `install <path>` | Update from SD with that `.ota` file, as Settings → Firmware does |
|
||||
| `update check` / `update list` / `update status` / `update install <tag>` | The project's releases on Gitea: look at the latest, list the last ten, say what's known, or download and install one |
|
||||
| `update pretend <version>` / `update probe <host>` / `update damage cut\|flip <n>` / `update daily` | Pretend to run another version (so a release counts as an update), see whether a server's certificate is accepted, cut or damage the next download, run the daily check again |
|
||||
|
||||
@@ -20,10 +20,20 @@ Two things to know before you use them:
|
||||
1. **A name `key` does not know is `select`.** `key sleect` presses Enter. A single character is typed as that character; anything else that is not a known name is treated as Enter. Check what you type.
|
||||
2. **A key that wakes a dark screen only wakes it.** The device's power policy swallows the key press that turns the screen back on, as it does for the real keyboard: the first `key` after the screen went off does nothing else. Send `key back` (harmless) first, or keep the screen on with the `normal` and `short` commands below.
|
||||
|
||||
`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.
|
||||
**Ctrl, Alt and Shift** go before the name: `key ctrl-down`, `key alt-up`, `key ctrl-b`, `key shift-alt-down`. `Fn` combinations and the compose key have no command: the arrows are `key up|down|left|right` (what `Fn` with `;` `.` `,` `/` gives on the device), 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.
|
||||
|
||||
## Open an App by its name
|
||||
|
||||
```
|
||||
Notes # an App's name, with a capital: opens it
|
||||
Shell # Irc Wifi Gnss Gemini Lora Storage Notes Shell System Settings
|
||||
info # ...and `app: Notes` says which App is in front
|
||||
```
|
||||
|
||||
Far better than `key home`, some `key down` and `key select`: it doesn't depend on where the Launcher's selection was.
|
||||
|
||||
## 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.
|
||||
@@ -36,6 +46,7 @@ scripts/rdbg.py key select
|
||||
scripts/rdbg.py screenshot b.png # look again before the next destructive step
|
||||
```
|
||||
|
||||
- **Check the App in front before typing anything.** `info` prints `app: <name>`. A crash restarts the device into the Launcher, and a script that goes on typing is typing somewhere else: one of this project's own test scripts sent a word to an IRC channel that way.
|
||||
- Prefer **reading a state** to assuming it: `info`, `ls <folder>`, `cat <file>`, `irc dump`, `gnss status`, `lora status`, `wifi status`, `update status`.
|
||||
- Test on a **scratch folder** on the card, not on your real files.
|
||||
- For anything that deletes (`rm`, a delete dialog), `ls` first and `ls` after.
|
||||
|
||||
@@ -45,6 +45,7 @@ The device sends `screenshot: rgb332 <width> <height>` and then **one byte per p
|
||||
|
||||
- It is read **as it stands**, while the UI may be drawing, so it can **tear**. It is for looking at, not for pixel-exact comparison.
|
||||
- It is the real thing: the screenshots on this site, in the [user guide](/guide/) and the [devlog](/devlog/), were taken this way.
|
||||
- **With a number, it saves to the card instead:** `screenshot 5` (or `screenshot 0`) writes a PNG to `/screenshots` on the SD card after that many seconds, as the [Shell](/guide/shell/) does. A bare `screenshot` over the console is the binary one above.
|
||||
- Its main use is in a loop: send a key, wait a moment, take a screenshot, look. See [Drive the UI](/dev/debug/drive-the-ui/).
|
||||
|
||||
## `coredump get`: the crash dump
|
||||
|
||||
@@ -106,7 +106,7 @@ Plain text notes on the SD card, written on the device. Q30 settled the base: `.
|
||||
| Q141 | A **Notes** App in the Launcher. One row per note: its first line as the title, then the date. Newest first; `s` switches to by name. `n` new, Enter opens, `d` deletes after a confirmation, `r` renames the file. |
|
||||
| Q142 | A new note's file name is never typed: it comes from the first line when the note is first saved (`shopping-list.txt`), or `note-20261006-0919.txt` if that line is empty. It doesn't change afterwards unless the note is renamed. |
|
||||
| Q143 | **Autosave, no "discard changes?" prompt:** five seconds after the last key, on leaving the note or the App, and when the screen turns off. A save writes a temporary file and renames it over the note, so a power cut loses the last few seconds at most. A temporary file left behind is offered back at the next open. |
|
||||
| Q144 | The whole note is in memory while it's edited, up to **16 KB**. A bigger text file opens read-only in the Storage App's viewer. The App refuses to open below the memory floors (Q86). **Editing files of any size must come in a later release: issue #47.** |
|
||||
| Q144 | The whole note is in memory while it's edited, up to **16 KB**. A bigger text file opens read-only in the Storage App's viewer. The App refuses to open below the memory floors (Q86). *(Lifted by issue #47: see "Notes of any size" below.)* **Editing files of any size must come in a later release: issue #47.** |
|
||||
| Q145 | The editor wraps at spaces, 38 columns by 8 rows, with a line for the name and the state. Enter is a new line, Del deletes backwards, Fn+arrows move (the Text Entry rule), Ctrl+A and Ctrl+E go to the start and the end of the line, Tab types two spaces, Back saves and returns. The Compose Key works as elsewhere. |
|
||||
| Q146 | The Storage App's text viewer gets `e`: edit this file with the same editor, for a text file up to 16 KB that isn't read-only. That lifts Q135 without Apps opening each other (#43 stays). |
|
||||
| Q147 | The list is flat: the files directly in `/notes`. Sub-folders are reached through the Storage App. |
|
||||
@@ -167,3 +167,60 @@ Test notes were made in `/notes` and removed afterwards; the folder is left, emp
|
||||
**Not checked:** accents through the Compose Key and Ctrl+A / Ctrl+E (the remote `key` command can't send them; the model's tests cover both), the power button's save (it needs a hand on the device), a missing card, and how typing feels on the keyboard itself.
|
||||
|
||||
**One slip during the checks:** a key sequence sent right after a restart opened IRC instead of Notes, and the test letters went into IRC's input line. Nothing was sent: the line was cleared and the App left. IRC connected to Libera as it does when opened.
|
||||
|
||||
## Notes of any size (issue #47)
|
||||
|
||||
Q144 held the whole note in memory and stopped at 16 KB, for the first version only. This lifts it: the editor opens a text file whatever its size.
|
||||
|
||||
### Decisions (design round 2026-10-07)
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| Q223 | **The note is the file on the card plus one window in memory.** The window is the `NoteText` of before, up to 16 KB around the cursor; the rest is a list of pieces: runs of the file, and runs of a side file. The cursor leaving the window writes it to the side file if it was changed, and loads the next. Typing never fills a note: a full window is written away and loaded smaller. |
|
||||
| Q224 | **The five-second save:** up to 64 KB it rewrites the file, as before (about 150 ms). Above, it appends the window and the list of pieces to `<note>.edit`: 8 KB or so, whatever the note's size. "saved" means "on the card" either way. |
|
||||
| Q225 | **The file itself is rewritten on leaving the note** (Back, Home, another App), with a progress bar. The screen turning off and the device powering off write the side file only: powering off never waits. |
|
||||
| Q226 | **After a power cut, opening the note picks the edit up** where it was last saved, without a question, and says so. Until then the file has the old text for anything else that reads it. |
|
||||
| Q227 | If the file was changed elsewhere meanwhile, the side file no longer fits it: it is **kept as `<note>.edit.lost`** and the editor says so. Typed text is never deleted without a word. |
|
||||
| Q228 | **No limit but the card:** a note over 16 KB needs room for a second copy to be opened for editing. No warning for a big file; the progress bar on leaving tells the cost. |
|
||||
| Q229 | A side file over 1 MB, or a list of over 256 pieces, makes the next save a rewrite. |
|
||||
| Q230 | **CRLF becomes LF** (Q148) for a long file too: in the window as it is read, and in the rest of the file as the rewrite streams it, so a saved file is never of both kinds. |
|
||||
| Q231 | **One path.** A 16 KB note is the case with no pieces: there is no second editor for small notes. |
|
||||
| Q232 | Notes, and `e` in the Storage App's viewer, which no longer says "Too big to edit". |
|
||||
|
||||
### As built
|
||||
|
||||
- **`NoteDocument`** (`lib/notes/src/note_document.h`, host-tested against a card in memory) is the list of pieces, the window's moves, the side file and the recovery. `NoteText` is unchanged but for being refilled.
|
||||
- **The window moves** when the cursor comes within 2 KB of an end of it that isn't an end of the note: it is then 4 KB on each side of the cursor. It starts where a line starts on screen whenever that can be known (after a newline, or where the window before had a line start), so the same text wraps the same from one window to the next, and never in the middle of a character. The cursor keeps its row on screen.
|
||||
- **Looking writes nothing:** a window that wasn't changed goes back as the pieces it was read from.
|
||||
- **The side file** starts with a line of text, the note's size and checksums of its first and last kilobyte, which is how a file changed elsewhere is told. After that, text that left a window, and snapshots of the list of pieces, each with its checksum. The newest snapshot that checks out is the note as last saved; anything after it is ignored.
|
||||
- **The rewrite** streams the pieces and the window into `<note>.tmp`, checks its size, then writes a mark at the end of the side file: from that mark on, the rewrite counts as done, and opening the note finishes it whatever was cut (remove the old file, rename, remove the side file). Before the mark, the note and its side file are still the truth and the temporary file is dropped.
|
||||
- **On the device** the card is reached through an adapter that keeps the file being read and the file being appended to open between calls; every call runs on the storage task while the main loop waits. The rewrite runs in steps of 64 KB with the progress drawn between them.
|
||||
- **The Notes list** doesn't show `.edit` and `.edit.lost` files, and a note's side file is deleted and renamed with it.
|
||||
- **Ctrl with Fn+Up and Fn+Down** go to the start and the end of the note.
|
||||
- **`key ctrl-down`**: the consoles' `key` command takes `ctrl-`, `alt-` and `shift-`, which these checks needed. It also lets the checks S1 couldn't make (Ctrl+b, the Alt scroll) be made.
|
||||
- **Cost:** 15 KB of flash. Memory with a note open is what it was: 17.5 KB, for 62 bytes or for 1.2 MB.
|
||||
|
||||
### Host tests (15, `test/test_note_document`)
|
||||
|
||||
A walk down 3,000 lines and back up through the windows; start and end; an edit in the middle rewritten into the file; 48 KB typed into a new note; a journal picked up after a cut; **a cut at every 997th byte of a sequence of two saves and a rewrite**, after which the note is always one of the three texts it should be, what was reported saved is there, and no stray file is left; a file changed elsewhere; CRLF; windows on text with no space and no newline, made of 2, 3 and 4-byte characters; a full card; and **36,000 random keys** (typing, deleting, moving, jumping, saving, power cuts) on six notes of 30 to 130 KB, compared with a plain string after every key.
|
||||
|
||||
### Checks on the device (2026-10-07, driven over the Debug Console)
|
||||
|
||||
Test notes were copied to `/notes` and removed afterwards; the note that was already there was not touched.
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| A 36 KB note | Opens (it was refused before). Two letters at the top, 400 lines down across the windows, four more: the file fetched back is exactly that, and no other file is left |
|
||||
| A 1.2 MB note | Opens at once. Free memory 104.2 KB before, 86.7 KB with it open |
|
||||
| Its five-second save | `zz-big.txt.edit`, 4 KB; the note's file untouched |
|
||||
| Ctrl with Down, Ctrl with Up | The end and the start, as fast as any key |
|
||||
| A restart with unsaved keys | "Your unsaved changes are back", the cursor where it was, the unsaved keys gone and nothing else |
|
||||
| Leaving it | The progress bar, then one file: **1.2 MB rewritten in 2.6 s**. Fetched back: the original with what was typed at both ends, byte for byte |
|
||||
| A restart in the middle of that rewrite | The note, its side file and an empty `.tmp` remain; opening picks the edit up, leaving rewrites it, the result is right |
|
||||
| A new note | No file until typed in, then `zz-test-note.txt` from its first line |
|
||||
| `e` in the Storage App on the 1.2 MB file | The same editor; edited and rewritten |
|
||||
| The Notes list | Side files are not listed as notes |
|
||||
|
||||
**Not checked:** the power button's path (side file only), the screen turning off, a card pulled while editing, and memory with IRC connected, which wasn't connected for these checks: the editor's own use hasn't changed, and it still refuses to open without a free block of 24 KB. The real keyboard's Ctrl with Fn and the arrows. A file of tens of megabytes. Renaming or deleting a note from the Storage App leaves its side file behind.
|
||||
|
||||
**Measured against what was said:** the first build rewrote 1.2 MB in 3.5 to 4.5 s, with 2 KB blocks. With 4 KB blocks it is 2.6 s, about 450 KB a second, which is what the card gives a plain copy.
|
||||
|
||||
@@ -144,3 +144,69 @@ M3 found the LoRa radio's noise floor about 15 dB above what the chip hears alon
|
||||
**Settings > Pause GNSS for LoRa**, off by default: while the LoRa radio listens or sweeps, the GNSS receiver waits in standby, and wakes when the radio goes back to sleep (a Fix again after about 7 s here). Never during a Track. The GNSS App says "GNSS is paused" meanwhile. `gnss quiet on|off` on the console.
|
||||
|
||||
It's off by default because GNSS on by default was decided in M2 (Q58), and from M4 the radio listens all the time: then "pause while listening" means GNSS mostly off, which is a decision about position, the clock and Tracks, for M4's design round (issue #23).
|
||||
|
||||
## The Shell (issue #67)
|
||||
|
||||
The console's commands could only be typed on a PC: over USB, or over Wi-Fi with the Debug Console. A device in a bag, or on a network that is down, could not be asked anything. The Shell is an App that runs the same commands on the device's own screen and keyboard.
|
||||
|
||||
### Decisions (design round 2026-10-07)
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| Q204 | **An App, "Shell", in the Launcher**, in every firmware: its commands already work over USB for anyone holding the device. |
|
||||
| Q205 | **Trusted like USB serial**, not like the network: `debug on` and `debug token` work from it, as they do in Settings. The token is never shown. |
|
||||
| Q206 | *Revised the same day, after trying it.* **It shows the replies to its own commands, and only those.** The console knows who each line is printed for (`Console::Origin`): a command run from the Shell prints as the Shell's, and so does what answers it later from another task, which notes who asked and takes it back when it prints (`ls`, `tasks`, `du`, `cp`, `update check`, `sd list`, `screenshot`, `gemini get`). What USB or the Debug Console asked for, and the system's own lines, are not the Shell's. **Ctrl+b shows everything** instead. The first version kept whatever was printed in the ten seconds after a command, which was a guess, and a noisy one. |
|
||||
| Q207 | **Nothing while it's closed.** Open, a 4 KB ring of the console's and up to 4 KB of lines; both go when the App is left, with the list of commands for Tab. |
|
||||
| Q208 | Enter runs the line; Fn with up and down recalls the last 16; Alt with up and down scrolls back. **Tab completes** the command, **every word of it** *(the first only, at first)*: `lora st` gives `lora status`, `gnss track ` lists `start stop`, `key ` its eleven names. The words come from the firmware's `help` text, read as it is written, so a new command completes without a table to keep. *(Added the same day)* **past the command, a path on the SD card**: a folder keeps its slash to go on from, a file completed whole gets a space, several candidates are listed. Whatever the case typed, the name's own is taken, since the card doesn't tell them apart. After a file command the first slash is understood (`cat no` is `/no`). A name with a space in it isn't completed. |
|
||||
| Q209 | *Revised the same day.* **`rm` is Unix's, with a question.** A folder needs `-r`, here and over the consoles (where `rm <folder>` used to remove it with what was in it). In the Shell, a file, or a folder with something in it, is asked about unless `-f` (`-rf`, `-r -f`); an empty folder with `-r` goes without a word; what `rm` would refuse anyway, it refuses itself. Over the consoles nothing is asked: scripts delete as before. **`screenshot [seconds]`** saves the screen as a PNG in `/screenshots`, now or after a pause, since from the Shell "now" is the Shell. `get`, `put`, `coredump get` and `reset` answer `Debug Console only`. *(Added the same day)* **`*` and `?` in a name**, for `ls`, `du`, `rm`, `cp` and `mv`, here and over the consoles: `rm /notes/*.txt`, `cp /gnss/2026-10-0?.gpx /backup`. In the last part of the path only, any case, as the card has it. It is the same command once for each name matched, in the name's order; `cp` and `mv` then need a folder that exists to put them in. Over 64 matches is refused whole, as is none. In the Shell `rm` with a pattern asks **once**, with the count. |
|
||||
| Q210 | Commands are echoed as `> command` into the console, so a session reads the same from afar. **A token being set is not echoed.** |
|
||||
| Q211 | **Not in Safe Mode**, which starts no Apps: issue #77. |
|
||||
| Q212 | Its keys are a table in `app_keys.h`, so the help panel and the website have them; a page in the user guide. |
|
||||
| Q213 | *Added the same day.* **An App's name with a capital opens it:** `Irc`, `Wifi`, `Gnss`, `Gemini`, `Lora`, `Storage`, `Notes`, `Shell`, `System`, `Settings` (the App's id, up to its first dash). From the Shell, without going back to the Launcher, and from the consoles too. The capital says "an App": every command of the firmware is in small letters. Tab completes them. |
|
||||
|
||||
### As built
|
||||
|
||||
- **`ShellApp`** (`src/apps/shell_app.cpp`), with its model host-tested in `lib/apps_model/src/shell_log.h`: lines arriving in pieces, the 4 KB limit, the words of every command out of the `help` text (Apps' names included), and Tab.
|
||||
- **Who a line is for:** `Console::As` marks the calling task as printing for the Shell while it lives, and `Console::origin()` lets a command that answers later carry that to wherever it prints. The console has a second ring for the Shell, which gets the lines marked so, or everything.
|
||||
- **The Shell hands its lines to the main loop**, which runs them like the consoles' commands. See below for why.
|
||||
- **`info` says which App is in front** (`app: Shell`): so that a hand driving the device from afar can look before it types.
|
||||
- **`screenshot`** writes the PNG a row at a time with no buffer: 8-bit indexed colour, the 256 colours of RGB332 as the palette, the pixels in one stored deflate block (`lib/files/src/png_rgb332.h`, 4 tests). 33,383 bytes for the 240 x 135 screen. A Toast says so once it is on the card, so the Toast is never in the picture.
|
||||
- **The `help` text lost its shorthand** (`update check | list | status` is written out), since Tab reads its words from there: `|` between two commands, `on|off` between two words, three spaces before the description. The Shell's own words (`clear`, `quit`, `key`'s names) are added in the same notation.
|
||||
- **Tab on a path** reads the folder on the storage task while the main loop waits, so it is bounded: 400 entries looked at, 24 candidates given back, and `...` after the list when there were more.
|
||||
- **A pattern** is matched in `lib/files/src/file_names.h` (`globMatch`, host-tested), and the names it matches are lined up as so many commands, which the main loop runs one after the other as each finishes. `cancel` empties the line-up. 2000 entries looked at, 64 matches at most.
|
||||
- **Cost:** 21 KB of flash, 96 bytes of static RAM. Open: 7 KB of heap (107.6 KB free before, 100.7 with it open, 106.1 after leaving).
|
||||
|
||||
### What went wrong while building it
|
||||
|
||||
**The device crashed, and the test sent a message to an IRC channel.** The first version ran a command from inside the key handler. Driven from the Debug Console, that is: the main loop, a remote command, `key select`, the App manager, the Shell, `runCommand` a second time, the file command, and `printf` under all of it. The main loop has under 2 KB of stack to spare; `rm` on a folder went past it. The crash report decoded to exactly that chain.
|
||||
|
||||
The device restarted into the Launcher, and the test script, which did not look, went on typing. Its next Enter opened IRC, which connected, and a few lines later it typed "No" into a channel and pressed Enter. One word, sent to real people, that can't be taken back.
|
||||
|
||||
Two changes came of it. The Shell now **queues** its line and the main loop runs it, at the same stack depth as a console's command. And `info` reports the App in front, which the test script now checks before every line it types.
|
||||
|
||||
### Checks on the device (2026-10-07, driven over the Debug Console with `key`)
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| Open it from the Launcher, type `ls /`, Enter | The folders are listed |
|
||||
| Tab on `in` | `info install` is shown and the line stays; on `u` it becomes `update `; on `l`, `log ls lora loop` |
|
||||
| Up | The line before comes back |
|
||||
| `screenshot` | `/screenshots/20261007-104357.png`, 33,383 bytes. Fetched and decoded on the PC: 240 x 135, indexed, every chunk's CRC right, the pixels the Shell's screen |
|
||||
| Output | With a Debug Console client connecting and disconnecting for every key, the Shell shows the commands and their replies and nothing else: `info`, `ls /` (answered by the storage task), `tasks` (answered a second later) |
|
||||
| `rm` on a folder, without `-r` | Refused, the folder stays |
|
||||
| `rm -r` on an empty folder | Removed, no question |
|
||||
| `rm -r` on a folder with files | Asks; Cancel leaves it. `rm -rf` removes it without asking |
|
||||
| `rm` on a file | Asks; Delete removes it |
|
||||
| Tab on a path | `ls /no` becomes `ls /notes/`, and again, with one note in it, the note's whole name and a space. `ls /g` lists `gemini/ gnss/`. `cat no` becomes `cat /notes/`. `rm -r /CAP` becomes `rm -r /captures/`. `ls /zz` stays as it is |
|
||||
| Tab past the first word | `lora st` becomes `lora status `; `gnss tr` becomes `gnss track ` and Tab again lists `start stop`; `key ` lists its eleven names; `upd` becomes `update ` and lists `check list status install` |
|
||||
| `ls` with a pattern | `ls /gt/*.txt` the five files, `ls /gt/*2*` the one, `ls /gt3/*6?.txt` ten of seventy. `ls /g*/x`: refused, a pattern goes in the last part |
|
||||
| `cp /gt/file-000?.txt /gt2`, `du /gt2/*1*` | Five copies; one size |
|
||||
| `rm /gt2/*` in the Shell | "The 5 that match /gt2/\*": Cancel leaves the five. `rm /gt3/*6?.txt`, Delete: ten gone, sixty left. `rm -f /gt2/*`: gone without a question |
|
||||
| `rm /gt/*` where one match is a folder | The files go, the folder stays (no `-r`) |
|
||||
| No match, and seventy | `rm: error nothing matches`; `rm: error more than 64 match: a narrower pattern, please`, and all seventy still there |
|
||||
| `No`, Tab, Enter | Completes to `Notes ` and opens Notes. `Shell` from the Debug Console opens the Shell |
|
||||
| `screenshot 4`, then Home | The picture, taken four seconds later, is of the Launcher: the pause works, and the Toast isn't in it |
|
||||
| `quit` | Back to the Launcher, and the memory comes back |
|
||||
| The help panel in the Shell | Its keys, then the ones that work everywhere |
|
||||
|
||||
**Not checked:** Ctrl+b and the Alt scroll, which `key` can't press (no modifiers): the filter itself is host-tested, the key that flips it is not; the real keyboard altogether; the Toast after a screenshot, which was published but not looked at; and `mv` with a pattern and `cancel` in the middle of a line-up, which share their code with `cp` and `rm` but were not run. The main loop's lowest free stack after all of it: 1.8 KB, where it was.
|
||||
|
||||
@@ -29,7 +29,7 @@ The home page was designed on a canvas in a Claude chat (a dark and a light them
|
||||
| Q175 | The site lives in this repository, in `site/`, so the documentation is built from `docs/`, `CONTEXT.md` and the README instead of being copied. |
|
||||
| Q176 | **Zola,** like the blog. The design becomes a template, its tokens CSS custom properties. Dark and light follow the visitor's setting, with a visible switch. No JavaScript except the flasher's. |
|
||||
| Q177 | Domain: **roro9stack.net.** The blog stays at experiments.twis.la. |
|
||||
| Q178 | **Publishing is the blog's way:** the web server pulls `main` and runs `zola build`; that part is the maintainer's. Changes reach `main` through pull requests as everywhere. **CI is split:** a dedicated `site` job builds the site (`zola build`) when `site/`, `docs/`, `README.md` or `CONTEXT.md` change, and the firmware tests and builds skip a change that touches nothing else. A change that touches both runs both. |
|
||||
| Q178 | **Publishing is the blog's way:** the web server pulls `main` and runs `zola build`; that part is the maintainer's. *(Since issue #79, CI asks the server to do it: see "Published by CI" below.)* Changes reach `main` through pull requests as everywhere. **CI is split:** a dedicated `site` job builds the site (`zola build`) when `site/`, `docs/`, `README.md` or `CONTEXT.md` change, and the firmware tests and builds skip a change that touches nothing else. A change that touches both runs both. |
|
||||
| Q179 | Phases, each its own pull request: **1.** the CI split, the home page, an Install page with the browser flasher, downloads and the changelog. **2.** a user guide page per App. **3.** how-tos and the FAQ. **4.** developer docs generated from the repository. |
|
||||
| Q180 | **A browser flasher** (ESP Web Tools), **without copying the firmware.** Caddy, in front of Gitea, adds `Access-Control-Allow-Origin: https://roro9stack.net` (and `Vary: Origin`) to GET and HEAD on `/twisla/roro9stack/releases/download/*` and `/api/v1/repos/twisla/roro9stack/releases*`: both are public already. The Install page asks the API for the latest release in the browser, finds the asset ending `-factory.bin`, and gives ESP Web Tools a manifest built on the spot, so it offers a new release as soon as it exists, with no rebuild. The library is **vendored** into `site/static/` (Apache-2.0), not loaded from a CDN. The file's SHA-256 is shown on the page. Chrome or Edge on a desktop only; other browsers, and visitors without JavaScript, get the `esptool` steps on the same page. |
|
||||
| Q181 | Docs for the latest version only. The changelog is the Gitea releases, read at build time. |
|
||||
@@ -133,3 +133,46 @@ Not one of the planned phases: the blog's seven roro9stack posts, imported into
|
||||
- **Left out on purpose:** the M0 and M1 milestone documents and `CONTEXT.md` (the glossary) describe Wi-Fi monitoring, which the site does not publish. They stay in the repository.
|
||||
- **The Debug Console pages were written against the source and the live console:** the protocol (the token line, the banner, the 4 KB backlog, one client, 8 queued commands, 240-byte lines, `denied` after a second) and the replies shown were checked on a Debug Build, v0.11.0-3, over Wi-Fi. Not run: `crash abort`, `crash wdt` and Safe Mode, which are described from ADR 0005 and the code.
|
||||
- **Found while writing it:** the README's table lacked the `gnss` commands (rows added); piping commands into `rdbg.py` returns before the replies unless the input stays open (documented, not changed); `update install` on a Debug Build needs `force` (documented).
|
||||
|
||||
## Published by CI (issue #79, design round 2026-10-07)
|
||||
|
||||
Q178 left publishing to the maintainer: a merge, then a command typed on the web server. It was forgotten often enough.
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| Q214 | **A plain ed25519 key with a forced command**, not a certificate: one line in the web server user's `authorized_keys`, `restrict,command="/full/path/to/the/refresh"`. `restrict` takes away the terminal and every forwarding. A certificate could carry the same and an expiry date, at the price of a CA to keep and a key to sign again each time: too much for one key and one command. |
|
||||
| Q215 | **The CI sends no command.** The server runs the forced one whatever is asked for, so there is nothing to keep secret about it and nothing a leaked key could choose. The full path is written once, on the server (a command over SSH doesn't get the user's login `PATH`). |
|
||||
| Q216 | Four secrets: `SITE_DEPLOY_KEY`, `SITE_DEPLOY_HOST` (or `host:port`), `SITE_DEPLOY_USER`, and **`SITE_DEPLOY_KNOWN_HOSTS`**, the server's host key: the job connects to that server or to nothing. None of them is in the repository, which is public. |
|
||||
| Q217 | `from="<the runner's address>"` on the same line: the key works from the runner only. |
|
||||
| Q218 | **The last step of the Site workflow**, after the build and the checks, on a push to `main` only. A pull request never reaches it, and the secrets are given to that step alone. |
|
||||
| Q219 | **After a release too.** The Install page asks Gitea for the latest release when it is opened, but the home page and Downloads read it when the site is built: so the release workflow refreshes the site once the release is published. |
|
||||
| Q220 | A refresh that fails makes the run red, with what the server's script printed: it has to exit with an error when it fails. |
|
||||
| Q221 | Two refreshes at once are the server script's to refuse or queue (`flock`). |
|
||||
| Q222 | The key is a file only while the step runs, in the job's container, as the signing key is. |
|
||||
|
||||
### As built
|
||||
|
||||
- **`scripts/site_refresh.sh`** is what both workflows run: it writes the key and the host key to a temporary folder, connects with no configuration but its own line (`-F none`, strict host key checking, that one key, no command), and removes them. With none of the four secrets it does nothing and says so (a fork, or a repository without them); with only some it fails.
|
||||
- **`scripts/site_deploy_keygen.sh`** makes the key pair once, in `~/.config/roro9stack/`, and prints the `authorized_keys` line and what goes in each secret. It never prints the private key.
|
||||
- **The server's script** should start like this, for Q220 and Q221:
|
||||
|
||||
```sh
|
||||
#!/bin/sh
|
||||
set -e
|
||||
exec 9>/tmp/rororefresh.lock
|
||||
flock -w 120 9
|
||||
```
|
||||
|
||||
### Checks (2026-10-07, against an SSH server in a throwaway container)
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| The refresh | The forced command runs as the server's user; the script ends with `site refresh: done` |
|
||||
| The same key, asking for `id; cat /etc/passwd` | The refresh runs instead; what was asked for is only handed to it as text |
|
||||
| A terminal | Refused: `PTY allocation request failed` |
|
||||
| `scp` with the key | Nothing is copied |
|
||||
| Another host key in the secret | `Host key verification failed`, the run fails, nothing is sent |
|
||||
| The server's script exits with an error | So does the step |
|
||||
| No secrets at all; only one of the four | Does nothing and says so; fails and says which are needed |
|
||||
|
||||
**Not checked:** the real web server and the runner, which wait for the key to be installed: whether the runner reaches the server's SSH port is the first thing the first run will tell. Port forwarding, which `restrict` switches off, was not tried. `from=` was not tried either.
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ For the radio, GNSS position, Wi-Fi tools, IRC chat and Gemini browsing, no. For
|
||||
|
||||
## How big can a note be?
|
||||
|
||||
Up to 16 KB while it is edited. A larger text file opens read-only in the [Storage App](/guide/storage/). Editing a text file of any size is planned.
|
||||
Any size the card has room for. The editor only keeps the part around the cursor in memory, so a megabyte of text opens at once. A long note is rewritten when you leave it, which takes about a second for each 450 KB. See [Long notes](/guide/notes/#long-notes).
|
||||
|
||||
## Why can't I rename or delete some folders?
|
||||
|
||||
|
||||
@@ -68,9 +68,9 @@ On a new device a short Setup asks four things, then never appears again. It als
|
||||
|
||||
## The SD card
|
||||
|
||||
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.
|
||||
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, screenshots 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 firmware keeps its own folders at the top of the card (`captures`, `gemini`, `gnss`, `irc`, `notes`, `screenshots`, `updates`, `wifi`). You can use the card in a computer too, but those names are the firmware's.
|
||||
|
||||
## The keys, as the device lists them
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
+++
|
||||
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
|
||||
weight = 13
|
||||
[extra]
|
||||
tag = "Reference"
|
||||
+++
|
||||
|
||||
@@ -22,7 +22,7 @@ Each note shows its first line and its date, newest first. <kbd>s</kbd> switches
|
||||
|
||||
## The editor
|
||||
|
||||
Type. <kbd>Enter</kbd> starts a line and <kbd>Del</kbd> deletes backwards. <kbd>Fn</kbd> with the arrow keys moves the cursor through the wrapped text, <kbd>Ctrl</kbd>+<kbd>A</kbd> and <kbd>Ctrl</kbd>+<kbd>E</kbd> go to the start and the end of the line, <kbd>Tab</kbd> types two spaces, and the compose key gives accents as everywhere.
|
||||
Type. <kbd>Enter</kbd> starts a line and <kbd>Del</kbd> deletes backwards. <kbd>Fn</kbd> with the arrow keys moves the cursor through the wrapped text, <kbd>Ctrl</kbd>+<kbd>A</kbd> and <kbd>Ctrl</kbd>+<kbd>E</kbd> go to the start and the end of the line, <kbd>Ctrl</kbd> with <kbd>Fn</kbd> and up or down to the start and the end of the note, <kbd>Tab</kbd> types two spaces, and the compose key gives accents as everywhere.
|
||||
|
||||
**There is no save key.** The note is written five seconds after your last key, when you press Back, when you leave the App, when the screen turns off and before the device powers off. The top line says `typing` or `saved`.
|
||||
|
||||
@@ -32,9 +32,21 @@ Each save writes a temporary file and then puts it in the note's place, so a pow
|
||||
|
||||
A new note has no file until you type something. The file is then named after its first line (`shopping-list.txt`), or `note-<date>-<time>.txt` if that line gives no usable name.
|
||||
|
||||
## Long notes
|
||||
|
||||
**A note can be any size.** The editor keeps the part around the cursor in memory and the rest on the card, so a file of a megabyte opens as fast as a short one and uses no more memory.
|
||||
|
||||
What changes with size is how it is saved:
|
||||
|
||||
- **Up to 64 KB**, every save rewrites the file, as above.
|
||||
- **Above**, the five-second save writes only what you changed, to a file next to the note (`<note>.edit`). The note itself is rewritten **when you leave it**, with a progress bar: about a second for each 450 KB.
|
||||
- **After a power cut**, or if the device was switched off with the note open, opening the note again brings your saved changes back, and says so. Until then the file itself still has the old text, if you look at it from a computer.
|
||||
|
||||
Saving a long note needs room on the card for a second copy of it. If the file was replaced by something else while its changes were waiting, they can't be applied: they are kept as `<note>.edit.lost` and the editor tells you.
|
||||
|
||||
## 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.
|
||||
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
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
+++
|
||||
title = "Settings"
|
||||
description = "The device's names, region, screen, sound, GNSS and Wi-Fi, and where firmware updates are found."
|
||||
weight = 10
|
||||
weight = 11
|
||||
[extra]
|
||||
tag = "Settings"
|
||||
+++
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
+++
|
||||
title = "Shell"
|
||||
description = "The firmware's own commands, typed on the device: look at its state, the SD card, the radio and the network with no PC and no cable."
|
||||
weight = 9
|
||||
[extra]
|
||||
tag = "Shell"
|
||||
+++
|
||||
|
||||
The firmware has a set of **commands**, made for working on it from a PC. The Shell runs them **on the device itself**: no computer, no cable, no Wi-Fi. It is the tool for the day something is wrong and you are nowhere near a desk.
|
||||
|
||||
It can do real damage: `rm` deletes, `reboot` restarts, `debug on` opens the device to the network. It is the same trust as holding the device, and nothing more.
|
||||
|
||||
## Using it
|
||||
|
||||
Type a command and press <kbd>Enter</kbd>. `help` lists them all; the [command reference](/dev/debug/commands/) says what each does. A few to start with:
|
||||
|
||||
| Command | Shows |
|
||||
|---|---|
|
||||
| `info` | The firmware's version, uptime, memory, Wi-Fi, the SD card and both firmware slots |
|
||||
| `wifi status` | The network, the address, and where the DNS and time servers came from |
|
||||
| `ls /notes` | A folder of the SD card, with sizes and dates |
|
||||
| `crash` | The last crash, if there was one |
|
||||
| `update check` | Whether a newer release exists |
|
||||
| `lora status` | What the radio is set to and what it has heard |
|
||||
|
||||
- <kbd>Tab</kbd> **completes** what you are typing: the command, word by word (`lora st` gives `lora status`, and `gnss track ` with Tab lists `start stop`), then **a path on the SD card**. `ls /no` and Tab gives `ls /notes/`; Tab again goes on inside the folder. If several names fit, it completes as far as they agree and lists them. You can type the name in any case, and after a file command you can leave out the first slash: `cat no` and Tab gives `cat /notes/`. A name with a space in it isn't completed.
|
||||
- <kbd>Fn</kbd> with up and down brings back **lines you typed before**.
|
||||
- <kbd>Alt</kbd> with up and down **scrolls back** through what was printed.
|
||||
- `clear` empties the screen, and `quit` (or Back) leaves.
|
||||
|
||||
## What you see
|
||||
|
||||
**The replies to your own commands, and nothing else.** The firmware prints a lot besides: IRC connecting, a packet received, whatever a PC on the USB port or the Debug Console is asking for. None of that reaches the Shell. The firmware knows who each line is printed for, so an answer that comes a moment later from another part of it (a folder listing, `tasks`) is still yours.
|
||||
|
||||
<kbd>Ctrl</kbd> + <kbd>b</kbd> shows **everything** the firmware prints instead, and `all` shows in the corner. Press it again to go back.
|
||||
|
||||
## Opening an App
|
||||
|
||||
Type an App's name **with a capital letter** to open it, without going back to the Launcher:
|
||||
|
||||
`Irc` `Wifi` `Gnss` `Gemini` `Lora` `Storage` `Notes` `System` `Settings`
|
||||
|
||||
The capital is the difference: every command is in small letters, every App starts with a capital. <kbd>Tab</kbd> completes them too.
|
||||
|
||||
## Deleting
|
||||
|
||||
`rm` works as it does on Unix, with one addition: it asks.
|
||||
|
||||
| You type | What happens |
|
||||
|---|---|
|
||||
| `rm /notes/a.txt` | Asks, then deletes the file |
|
||||
| `rm -f /notes/a.txt` | Deletes it without asking |
|
||||
| `rm /captures/old` | Refused: it is a folder, and a folder needs `-r` |
|
||||
| `rm -r /captures/old` | Removed at once if it is empty. If not, asks first |
|
||||
| `rm -rf /captures/old` | Removed with everything in it, without asking |
|
||||
|
||||
## Several files at once
|
||||
|
||||
`*` stands for any run of characters in a name and `?` for exactly one, in `ls`, `du`, `rm`, `cp` and `mv`:
|
||||
|
||||
| You type | What happens |
|
||||
|---|---|
|
||||
| `ls /notes/*.txt` | Only the notes ending in `.txt` |
|
||||
| `du /captures/lora/*.pcap` | The size of each capture |
|
||||
| `cp /gnss/2026-10-0?.gpx /backup` | Copies the tracks of the 1st to the 9th into `/backup`, which must exist |
|
||||
| `rm /screenshots/2026*` | Asks **once**, saying how many match, then deletes them |
|
||||
| `rm -f /screenshots/*` | Deletes them all without asking |
|
||||
|
||||
The pattern goes in the **last part** of the path (`/notes/*.txt`, not `/*/a.txt`), and capitals don't matter. A folder that matches is left alone by `rm` unless you add `-r`. At most 64 names at a time: past that nothing is done, and you are asked for a narrower pattern. `cancel` stops what is left.
|
||||
|
||||
The folders the firmware keeps its own files in can't be removed, as in the [Storage App](/guide/storage/).
|
||||
|
||||
## Screenshots
|
||||
|
||||
```
|
||||
screenshot the screen, now
|
||||
screenshot 5 the screen in 5 seconds: time to go to another App
|
||||
```
|
||||
|
||||
The picture is saved as a PNG in `/screenshots` on the SD card, named by date and time, and a Toast says so once it is written (so the Toast is never in the picture). From the Shell, "now" is always a picture of the Shell: use the pause to get to the screen you want. The [Storage App](/guide/storage/) shows the files; to look at them, take the card to a computer.
|
||||
|
||||
## What it costs
|
||||
|
||||
Nothing while it is closed. Open, about 7 KB of memory, given back when you leave: with IRC connected and a Gemini page open, that can be the difference (see [the memory limit](/howto/not-enough-memory/)).
|
||||
|
||||
## The keys, as the device lists them
|
||||
|
||||
What <kbd>Fn</kbd> + <kbd>h</kbd> shows on this screen. This table is generated from the firmware's own lists, so it is always the current one.
|
||||
|
||||
{{ keys(scopes=["shell"]) }}
|
||||
@@ -1,7 +1,7 @@
|
||||
+++
|
||||
title = "System"
|
||||
description = "What the device is doing right now: load, tasks, memory, network traffic, battery and temperature. Live, and read-only."
|
||||
weight = 9
|
||||
weight = 10
|
||||
[extra]
|
||||
tag = "System"
|
||||
screens = ["system.png"]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
+++
|
||||
title = "Updates"
|
||||
description = "How the device updates itself from the project's releases, from the SD card or from a PC, and how it protects itself when an update goes wrong."
|
||||
weight = 11
|
||||
weight = 12
|
||||
[extra]
|
||||
tag = "Firmware"
|
||||
screens = ["update.png"]
|
||||
|
||||
@@ -19,6 +19,7 @@ Everything the firmware writes goes in a folder at the top of the card. Switch t
|
||||
| Gemini bookmarks | `/gemini/bookmarks.gmi` | gemtext |
|
||||
| Files saved from Gemini that are not text | `/gemini/downloads` | whatever they were |
|
||||
| Update files | `/updates` | `.ota` |
|
||||
| Screenshots (the Shell's `screenshot`) | `/screenshots` | `.png`, named by date and time |
|
||||
|
||||
## Rules worth knowing
|
||||
|
||||
|
||||
+21
-1
@@ -291,7 +291,7 @@ rows = [
|
||||
["; .", "a line up, down"],
|
||||
[", /", "a page up, down"],
|
||||
["t b", "the top, the end"],
|
||||
["e", "edit it (up to 16 KB)"],
|
||||
["e", "edit it"],
|
||||
["Tab", "the file as hex, or back"],
|
||||
]
|
||||
|
||||
@@ -360,6 +360,7 @@ rows = [
|
||||
["Tab", "two spaces"],
|
||||
["Fn ; . , /", "move the cursor"],
|
||||
["Alt Fn ; .", "a page up, down"],
|
||||
["Ctrl Fn ; .", "start, end of the note"],
|
||||
["Ctrl a e", "start, end of the line"],
|
||||
["opt ' e", "an accent: é"],
|
||||
["`", "done: it saves by itself"],
|
||||
@@ -375,6 +376,25 @@ rows = [
|
||||
["Fn , /", "move the cursor"],
|
||||
]
|
||||
|
||||
[[scope]]
|
||||
id = "shell"
|
||||
title = "Shell"
|
||||
rows = [
|
||||
["Enter", "run the line"],
|
||||
["Tab", "complete: a command, a path"],
|
||||
["* ?", "several files: /notes/*.txt"],
|
||||
["Fn ; .", "lines you typed before"],
|
||||
["Alt ; .", "scroll back, forward"],
|
||||
["Ctrl b", "your replies only, or all"],
|
||||
["Fn , /", "move the cursor"],
|
||||
["Del", "delete backwards"],
|
||||
["help", "every command"],
|
||||
["clear", "an empty screen"],
|
||||
["Notes", "an App, by its name"],
|
||||
["rm -rf", "delete without being asked"],
|
||||
["quit `", "leave the Shell"],
|
||||
]
|
||||
|
||||
[[scope]]
|
||||
id = "system"
|
||||
title = "System, any view"
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p class="cards-note">Plus Settings, with its Wi-Fi and Firmware pages. Every App has a page in the <a class="accent-link" href="/guide/">user guide</a>.</p>
|
||||
<p class="cards-note">Plus a Shell that runs the firmware's commands on the device, and Settings, with its Wi-Fi and Firmware pages. Every App has a page in the <a class="accent-link" href="/guide/">user guide</a>.</p>
|
||||
</section>
|
||||
|
||||
<section class="wrap" id="screens" aria-labelledby="screens-title">
|
||||
|
||||
+183
-78
@@ -9,24 +9,108 @@
|
||||
#include "cleanup_plan.h"
|
||||
#include "file_list.h"
|
||||
#include "file_names.h"
|
||||
#include "platform/console.h"
|
||||
#include "ui/fonts.h"
|
||||
#include "ui/theme.h"
|
||||
#include "ui/widgets.h"
|
||||
|
||||
namespace roro {
|
||||
|
||||
using notes::NoteDocument;
|
||||
using notes::NoteText;
|
||||
|
||||
std::function<void(const std::string&, int)> NoteEditor::onProgress;
|
||||
|
||||
// The SD card for a NoteDocument. Every call is made on the storage task. The file being read and
|
||||
// the file being appended to stay open between calls (a window is read in a few pieces, a rewrite
|
||||
// appends some five hundred blocks to the megabyte), until done().
|
||||
class NoteEditor::Card : public notes::NoteCard {
|
||||
public:
|
||||
explicit Card(StorageService& storage) : storage_(storage) {}
|
||||
|
||||
bool size(const std::string& path, uint32_t& size) override {
|
||||
close(path);
|
||||
File f = SD.open(path.c_str(), FILE_READ);
|
||||
if (!f || f.isDirectory()) return false;
|
||||
size = static_cast<uint32_t>(f.size());
|
||||
f.close();
|
||||
return true;
|
||||
}
|
||||
size_t read(const std::string& path, uint32_t at, uint8_t* into, size_t len) override {
|
||||
if (appendPath_ == path) closeAppend(); // what was appended has to be there to read
|
||||
if (readPath_ != path || !read_) {
|
||||
closeRead();
|
||||
read_ = SD.open(path.c_str(), FILE_READ);
|
||||
if (!read_) return 0;
|
||||
readPath_ = path;
|
||||
}
|
||||
if (!read_.seek(at)) return 0;
|
||||
int n = read_.read(into, len);
|
||||
return n > 0 ? static_cast<size_t>(n) : 0;
|
||||
}
|
||||
bool create(const std::string& path) override {
|
||||
close(path);
|
||||
File f = SD.open(path.c_str(), FILE_WRITE);
|
||||
if (!f) return false;
|
||||
f.close();
|
||||
return true;
|
||||
}
|
||||
bool append(const std::string& path, const uint8_t* data, size_t len) override {
|
||||
if (readPath_ == path) closeRead();
|
||||
if (appendPath_ != path || !append_) {
|
||||
closeAppend();
|
||||
append_ = SD.open(path.c_str(), FILE_APPEND);
|
||||
if (!append_) return false;
|
||||
appendPath_ = path;
|
||||
}
|
||||
return append_.write(data, len) == len;
|
||||
}
|
||||
bool remove(const std::string& path) override {
|
||||
close(path);
|
||||
return SD.remove(path.c_str());
|
||||
}
|
||||
bool rename(const std::string& from, const std::string& to) override {
|
||||
done();
|
||||
return SD.rename(from.c_str(), to.c_str());
|
||||
}
|
||||
uint64_t freeBytes() override {
|
||||
StorageState s = storage_.state();
|
||||
return s.totalBytes > s.usedBytes ? s.totalBytes - s.usedBytes : 0;
|
||||
}
|
||||
void done() override {
|
||||
closeRead();
|
||||
closeAppend();
|
||||
}
|
||||
|
||||
private:
|
||||
void close(const std::string& path) {
|
||||
if (readPath_ == path) closeRead();
|
||||
if (appendPath_ == path) closeAppend();
|
||||
}
|
||||
void closeRead() {
|
||||
if (read_) read_.close();
|
||||
readPath_.clear();
|
||||
}
|
||||
void closeAppend() {
|
||||
if (append_) append_.close();
|
||||
appendPath_.clear();
|
||||
}
|
||||
|
||||
StorageService& storage_;
|
||||
File read_, append_;
|
||||
std::string readPath_, appendPath_;
|
||||
};
|
||||
|
||||
namespace {
|
||||
constexpr uint32_t kMessageMs = 4000;
|
||||
|
||||
// One block the size of a full note, and something left: without it, nothing is opened. Free
|
||||
// One block the size of the window, and something left: without it, nothing is opened. Free
|
||||
// memory in total isn't the measure: with IRC connected the largest free block is about 31 KB.
|
||||
constexpr size_t kRoomWanted = NoteText::kMaxBytes + 8 * 1024;
|
||||
const char* const kNoRoom = "Not enough memory to edit: close IRC or a Gemini page";
|
||||
|
||||
// On the storage task. Reads a file of up to `limit` bytes into a string that has that capacity
|
||||
// already; false if it can't be read whole.
|
||||
// On the storage task. Reads a file of up to `limit` bytes into a string; false if it can't be
|
||||
// read whole.
|
||||
bool readWhole(const std::string& path, std::string& into, size_t limit) {
|
||||
File f = SD.open(path.c_str(), FILE_READ);
|
||||
if (!f) return false;
|
||||
@@ -51,42 +135,43 @@ void NoteEditor::say(const std::string& text) {
|
||||
std::string NoteEditor::open(const std::string& path) {
|
||||
close();
|
||||
if (ESP.getMaxAllocHeap() < kRoomWanted) return kNoRoom;
|
||||
std::string body, left, why;
|
||||
body.reserve(NoteText::kMaxBytes); // the note's own buffer from here on: read into, then handed over
|
||||
card_ = std::make_shared<Card>(storage_);
|
||||
doc_.reset(new NoteDocument(*card_, kCols, kRows));
|
||||
std::string left, why, told;
|
||||
bool ran = storage_.runAndWait([&]() {
|
||||
File f = SD.open(path.c_str(), FILE_READ);
|
||||
if (!f) {
|
||||
why = "The card refused to open it";
|
||||
return;
|
||||
}
|
||||
size_t size = f.size();
|
||||
f.close();
|
||||
if (size > NoteText::kMaxBytes) why = "Too big to edit: 16 KB at most";
|
||||
else if (!readWhole(path, body, NoteText::kMaxBytes)) why = "The card refused to read it";
|
||||
if (!why.empty()) return;
|
||||
// A save that never finished: its temporary file is offered back (Q143), if there's the
|
||||
// memory to look at it now. If not, it stays for the next time.
|
||||
// A save of a small note that never finished: its temporary file is offered back (Q143),
|
||||
// if there's the memory to look at it now. A bigger note's unfinished saves are in its
|
||||
// side file, and the document picks them up by itself.
|
||||
std::string tmp = path + ".tmp";
|
||||
File t = SD.open(tmp.c_str(), FILE_READ);
|
||||
if (!t) return;
|
||||
size_t tmpSize = t.size();
|
||||
t.close();
|
||||
if (tmpSize > 0 && tmpSize <= NoteText::kMaxBytes && ESP.getMaxAllocHeap() < tmpSize + 8 * 1024) return;
|
||||
left.reserve(tmpSize <= NoteText::kMaxBytes ? tmpSize : 0);
|
||||
if (!readWhole(tmp, left, NoteText::kMaxBytes) || left == body || left.empty()) {
|
||||
size_t tmpSize = t ? t.size() : 0;
|
||||
bool hasTmp = static_cast<bool>(t);
|
||||
if (t) t.close();
|
||||
bool hasSide = SD.exists((path + ".edit").c_str());
|
||||
if (hasTmp && !hasSide && tmpSize > 0 && tmpSize <= NoteText::kMaxBytes && ESP.getMaxAllocHeap() >= kRoomWanted + tmpSize) {
|
||||
left.reserve(tmpSize);
|
||||
if (!readWhole(tmp, left, NoteText::kMaxBytes)) std::string().swap(left);
|
||||
}
|
||||
why = doc_->open(path, &told);
|
||||
if (!why.empty()) return;
|
||||
if (hasTmp && !hasSide && (left.empty() || doc_->windowed() || left == doc_->text().text())) {
|
||||
std::string().swap(left);
|
||||
SD.remove(tmp.c_str());
|
||||
}
|
||||
});
|
||||
if (!ran) return "No SD card";
|
||||
if (!why.empty()) return why;
|
||||
text_.reset(new NoteText(kCols, kRows, std::move(body)));
|
||||
if (!ran) why = "No SD card";
|
||||
if (!why.empty()) {
|
||||
doc_.reset();
|
||||
card_.reset();
|
||||
return why;
|
||||
}
|
||||
path_ = path;
|
||||
folder_ = files::parentOf(path);
|
||||
savedRevision_ = text_->revision();
|
||||
problem_.clear();
|
||||
message_.clear();
|
||||
givenUp_ = false;
|
||||
lastKeyMs_ = millis();
|
||||
if (!told.empty()) say(told);
|
||||
if (!left.empty()) {
|
||||
recovered_ = std::move(left);
|
||||
ask_ = Ask::Recover;
|
||||
@@ -98,36 +183,38 @@ std::string NoteEditor::open(const std::string& path) {
|
||||
std::string NoteEditor::openNew(const std::string& folder) {
|
||||
close();
|
||||
if (ESP.getMaxAllocHeap() < kRoomWanted) return kNoRoom;
|
||||
text_.reset(new NoteText(kCols, kRows));
|
||||
card_ = std::make_shared<Card>(storage_);
|
||||
doc_.reset(new NoteDocument(*card_, kCols, kRows));
|
||||
path_.clear();
|
||||
folder_ = folder;
|
||||
savedRevision_ = text_->revision();
|
||||
problem_.clear();
|
||||
message_.clear();
|
||||
givenUp_ = false;
|
||||
lastKeyMs_ = millis();
|
||||
return "";
|
||||
}
|
||||
|
||||
// Leaving rewrites the file, however long the note (Q225). Not when the device is powering off:
|
||||
// then a long note's edits go to its side file, which is quick, and are picked up the next time.
|
||||
void NoteEditor::close() {
|
||||
if (dirty()) save();
|
||||
text_.reset();
|
||||
if (doc_ && !givenUp_ && owed()) save(!power_.poweringOff());
|
||||
doc_.reset();
|
||||
card_.reset();
|
||||
dialog_.reset();
|
||||
ask_ = Ask::None;
|
||||
std::string().swap(recovered_);
|
||||
}
|
||||
|
||||
// On the main loop, waiting for the storage task: no second copy of the note is made, and at
|
||||
// 16 KB the wait is a fraction of a second, when nobody has typed for five.
|
||||
bool NoteEditor::save() {
|
||||
if (!text_) return true;
|
||||
// On the main loop, waiting for the storage task: no second copy of the text is made. A note of
|
||||
// up to 64 KB is rewritten in a fraction of a second, when nobody has typed for five; a longer
|
||||
// one takes a second for each 400 KB or so, and shows how far it is.
|
||||
bool NoteEditor::save(bool whole) {
|
||||
if (!doc_) return true;
|
||||
lastTryMs_ = millis();
|
||||
const std::string& body = text_->text();
|
||||
if (path_.empty() && body.empty()) { // a new note nothing was typed in: no file
|
||||
savedRevision_ = text_->revision();
|
||||
return true;
|
||||
}
|
||||
if (path_.empty() && doc_->size() == 0) return true; // a new note nothing was typed in: no file
|
||||
std::string path = path_, why;
|
||||
if (path.empty()) {
|
||||
bool fresh = path_.empty();
|
||||
if (fresh) {
|
||||
char stamp[20] = "new";
|
||||
int64_t now = clock_.utcNow();
|
||||
if (now >= 0) {
|
||||
@@ -137,10 +224,15 @@ bool NoteEditor::save() {
|
||||
std::snprintf(stamp, sizeof stamp, "%04d%02d%02d-%02d%02d", local.tm_year + 1900, local.tm_mon + 1, local.tm_mday, local.tm_hour,
|
||||
local.tm_min);
|
||||
}
|
||||
path = files::joinPath(folder_, notes::nameFromFirstLine(text_->firstLine(), stamp) + ".txt");
|
||||
path = files::joinPath(folder_, notes::nameFromFirstLine(doc_->text().firstLine(), stamp) + ".txt");
|
||||
}
|
||||
bool fresh = path_.empty();
|
||||
bool rewrite = whole || fresh || doc_->wantsRewrite();
|
||||
int percent = 0;
|
||||
bool ran = storage_.runAndWait([&]() {
|
||||
if (!rewrite) {
|
||||
doc_->journal(why);
|
||||
return;
|
||||
}
|
||||
if (!SD.exists(folder_.c_str()) && !SD.mkdir(folder_.c_str())) {
|
||||
why = "the card refused to make " + folder_;
|
||||
return;
|
||||
@@ -148,48 +240,59 @@ bool NoteEditor::save() {
|
||||
if (fresh) { // a name nothing has yet: "list (2).txt"
|
||||
std::string name = files::baseName(path);
|
||||
for (int n = 2; n < 100 && SD.exists(path.c_str()); n++) path = files::joinPath(folder_, files::copyName(name, n));
|
||||
doc_->setPath(path);
|
||||
}
|
||||
std::string tmp = path + ".tmp";
|
||||
File f = SD.open(tmp.c_str(), FILE_WRITE);
|
||||
if (!f) {
|
||||
why = "the card refused to open a file";
|
||||
return;
|
||||
}
|
||||
size_t wrote = body.empty() ? 0 : f.write(reinterpret_cast<const uint8_t*>(body.data()), body.size());
|
||||
f.close();
|
||||
File check = SD.open(tmp.c_str(), FILE_READ);
|
||||
bool whole = wrote == body.size() && check && check.size() == body.size();
|
||||
if (check) check.close();
|
||||
if (!whole) {
|
||||
SD.remove(tmp.c_str());
|
||||
why = "the card refused a write";
|
||||
return;
|
||||
}
|
||||
// FAT can't rename onto a file. Between these two lines only the temporary file exists:
|
||||
// the Notes list puts such a file back under its name.
|
||||
if (SD.exists(path.c_str())) SD.remove(path.c_str());
|
||||
if (!SD.rename(tmp.c_str(), path.c_str())) why = "the card refused to rename the file";
|
||||
if (doc_->rewriteStart(why)) percent = doc_->rewriteStep(why);
|
||||
if (fresh && !why.empty()) doc_->setPath("");
|
||||
});
|
||||
bool show = doc_->size() > NoteDocument::kWholeLimit;
|
||||
uint32_t started = millis();
|
||||
while (ran && rewrite && why.empty() && percent >= 0 && percent < 100) {
|
||||
if (show && onProgress) onProgress(files::baseName(path), percent);
|
||||
ran = storage_.runAndWait([&]() { percent = doc_->rewriteStep(why); });
|
||||
}
|
||||
if (!ran) why = "no SD card";
|
||||
if (!why.empty()) {
|
||||
if (problem_ != why) say("Not saved: " + why);
|
||||
problem_ = why;
|
||||
redraw_ = true;
|
||||
return false;
|
||||
}
|
||||
if (rewrite && show) console.printf("notes: rewrote %s, %u bytes in %.1f s\n", path.c_str(), (unsigned)doc_->size(), (millis() - started) / 1000.0);
|
||||
path_ = path;
|
||||
problem_.clear();
|
||||
savedRevision_ = text_->revision();
|
||||
redraw_ = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
void NoteEditor::settle() {
|
||||
if (!doc_ || !doc_->wantsMove()) return;
|
||||
// A window that leaves memory needs a file to belong to: a new note is saved first.
|
||||
if (path_.empty() && !save(true)) return;
|
||||
std::string why;
|
||||
bool ran = storage_.runAndWait([&]() { doc_->move(why); });
|
||||
if (!ran) why = "no SD card";
|
||||
if (!why.empty() && problem_ != why) say("The card: " + why);
|
||||
if (!why.empty()) problem_ = why;
|
||||
}
|
||||
|
||||
bool NoteEditor::jump(bool toEnd) {
|
||||
std::string why;
|
||||
uint32_t to = toEnd ? doc_->size() : 0;
|
||||
bool ran = storage_.runAndWait([&]() { doc_->jump(to, why); });
|
||||
if (!ran) why = "no SD card";
|
||||
if (!why.empty()) say("The card: " + why);
|
||||
return why.empty();
|
||||
}
|
||||
|
||||
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;
|
||||
if (!doc_) return false;
|
||||
NoteText* text_ = &doc_->text();
|
||||
redraw_ = true;
|
||||
if (dialog_) {
|
||||
dialog_->onKey(e);
|
||||
@@ -210,7 +313,7 @@ bool NoteEditor::onKey(const KeyEvent& e) {
|
||||
return true;
|
||||
}
|
||||
if (asked == Ask::LeaveUnsaved && result == 1) {
|
||||
savedRevision_ = text_->revision(); // given up on
|
||||
givenUp_ = true;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -225,37 +328,38 @@ bool NoteEditor::onKey(const KeyEvent& e) {
|
||||
else if (lower == 'e') text_->lineEnd();
|
||||
break;
|
||||
}
|
||||
if (!text_->insert(e.ch)) say("This note is full: 16 KB");
|
||||
if (!text_->insert(e.ch)) say("Can't type: " + problem_);
|
||||
break;
|
||||
}
|
||||
case Key::Select:
|
||||
if (!text_->insert('\n')) say("This note is full: 16 KB");
|
||||
if (!text_->insert('\n')) say("Can't type: " + problem_);
|
||||
break;
|
||||
case Key::Tab:
|
||||
if (!text_->insertText(" ")) say("This note is full: 16 KB");
|
||||
if (!text_->insertText(" ")) say("Can't type: " + problem_);
|
||||
break;
|
||||
case Key::Delete: text_->backspace(); break;
|
||||
case Key::Left: text_->left(); break;
|
||||
case Key::Right: text_->right(); break;
|
||||
case Key::Up: page ? text_->pageUp() : text_->up(); break;
|
||||
case Key::Down: page ? text_->pageDown() : text_->down(); break;
|
||||
case Key::Up: e.ctrl ? void(jump(false)) : page ? text_->pageUp() : text_->up(); break;
|
||||
case Key::Down: e.ctrl ? void(jump(true)) : page ? text_->pageDown() : text_->down(); break;
|
||||
case Key::Back:
|
||||
if (!dirty() || save()) return false;
|
||||
if (!owed() || save(true)) return false;
|
||||
ask_ = Ask::LeaveUnsaved;
|
||||
dialog_.reset(new DialogModel({"Stay", "Leave"}));
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
settle();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool NoteEditor::update(uint32_t) {
|
||||
if (!text_) return false;
|
||||
if (!doc_) return false;
|
||||
uint32_t now = millis();
|
||||
// A save that failed is tried again every five seconds, not at every pass.
|
||||
if (dirty() && !dialog_ && now - lastTryMs_ >= kSaveAfterMs &&
|
||||
(now - lastKeyMs_ >= kSaveAfterMs || power_.screen() == ScreenState::Off))
|
||||
save();
|
||||
save(false);
|
||||
if (!message_.empty() && now - messageMs_ >= kMessageMs) {
|
||||
message_.clear();
|
||||
redraw_ = true;
|
||||
@@ -266,7 +370,8 @@ bool NoteEditor::update(uint32_t) {
|
||||
}
|
||||
|
||||
void NoteEditor::draw(Canvas& c) {
|
||||
if (!text_) return;
|
||||
if (!doc_) return;
|
||||
NoteText* text_ = &doc_->text();
|
||||
const auto& area = theme::kContent;
|
||||
c.setTextDatum(top_left);
|
||||
|
||||
@@ -275,7 +380,7 @@ void NoteEditor::draw(Canvas& c) {
|
||||
c.setTextColor(theme::kMuted);
|
||||
std::string name = path_.empty() ? "New note" : files::fitName(files::baseName(path_), 26);
|
||||
c.drawString(name.c_str(), 4, area.y + 1);
|
||||
std::string state = formatBytes(text_->text().size()) + (dirty() ? (problem_.empty() ? ", typing" : ", NOT SAVED") : ", saved");
|
||||
std::string state = formatBytes(doc_->size()) + (dirty() ? (problem_.empty() ? ", typing" : ", NOT SAVED") : ", saved");
|
||||
if (path_.empty() && !dirty()) state = "empty";
|
||||
c.setTextDatum(top_right);
|
||||
c.setTextColor(dirty() && !problem_.empty() ? theme::kWarning : theme::kMuted);
|
||||
@@ -288,9 +393,9 @@ void NoteEditor::draw(Canvas& c) {
|
||||
c.setTextColor(theme::kText);
|
||||
for (size_t i = 0; i < rows.size(); i++) c.drawString(rows[i].c_str(), 4, top + 1 + static_cast<int>(i) * theme::kLineHeight);
|
||||
c.fillRect(3 + text_->cursorCol() * 6, top + text_->cursorRow() * theme::kLineHeight, 1, theme::kLineHeight, theme::kAccent);
|
||||
if (text_->text().size() > static_cast<size_t>(kCols * kRows)) { // more than a screen: where we are in it
|
||||
if (doc_->size() > static_cast<uint32_t>(kCols * kRows)) { // more than a screen: where we are in it
|
||||
int h = kRows * theme::kLineHeight, barH = 12;
|
||||
c.fillRect(area.w - 2, top + (h - barH) * text_->percent() / 100, 2, barH, theme::kMuted);
|
||||
c.fillRect(area.w - 2, top + (h - barH) * doc_->percent() / 100, 2, barH, theme::kMuted);
|
||||
}
|
||||
|
||||
c.setFont(&fonts::small);
|
||||
|
||||
+26
-10
@@ -7,7 +7,9 @@
|
||||
#include "dialog_model.h"
|
||||
#include "key_help.h"
|
||||
#include "key_event.h"
|
||||
#include "note_text.h"
|
||||
#include <functional>
|
||||
|
||||
#include "note_document.h"
|
||||
#include "services/clock_service.h"
|
||||
#include "services/power_service.h"
|
||||
#include "services/storage_service.h"
|
||||
@@ -15,10 +17,12 @@
|
||||
|
||||
namespace roro {
|
||||
|
||||
// Edits one text file of up to 16 KB (F1, Q143-Q145): the Notes App's editor, and the Storage
|
||||
// App's for `e`. It saves by itself: five seconds after the last key, when the screen turns off,
|
||||
// and on close. A save writes `<file>.tmp`, then puts it in the note's place, so the note on the
|
||||
// card is always a whole one.
|
||||
// Edits one text file of any size (F1, Q143-Q145; issue #47, Q223-Q232): the Notes App's editor,
|
||||
// and the Storage App's for `e`. The text is a notes::NoteDocument: a window of the file in
|
||||
// memory, the rest on the card. It saves by itself: five seconds after the last key, when the
|
||||
// screen turns off, and on close. Up to 64 KB a save writes `<file>.tmp` and puts it in the
|
||||
// note's place; a bigger note's saves go to `<file>.edit`, and the file is rewritten on leaving.
|
||||
// Either way the note on the card is always a whole one.
|
||||
class NoteEditor {
|
||||
public:
|
||||
static constexpr int kCols = 38, kRows = 8;
|
||||
@@ -30,9 +34,13 @@ class NoteEditor {
|
||||
std::string open(const std::string& path); // "" or why it can't be edited
|
||||
std::string openNew(const std::string& folder); // the same; no file until there's something to save (Q142)
|
||||
void close(); // saves what isn't yet
|
||||
bool isOpen() const { return static_cast<bool>(text_); }
|
||||
bool isOpen() const { return static_cast<bool>(doc_); }
|
||||
const std::string& path() const { return path_; } // "" for a new note nothing was typed in
|
||||
|
||||
// A long rewrite shows how far it is: the editor is waiting for the card meanwhile, so the
|
||||
// screen is drawn from here (set once, in main).
|
||||
static std::function<void(const std::string& name, int percent)> onProgress;
|
||||
|
||||
bool onKey(const KeyEvent& e); // false: done, and saved
|
||||
void help(std::vector<KeyHelp>& out) const;
|
||||
bool update(uint32_t nowMs); // true: draw again
|
||||
@@ -41,16 +49,24 @@ class NoteEditor {
|
||||
private:
|
||||
enum class Ask { None, Recover, LeaveUnsaved };
|
||||
|
||||
bool dirty() const { return text_ && text_->revision() != savedRevision_; }
|
||||
bool save(); // true if the card has it now (or there was nothing to save)
|
||||
class Card;
|
||||
bool dirty() const { return doc_ && doc_->dirty(); }
|
||||
bool owed() const { return doc_ && (doc_->dirty() || doc_->filePending()); } // the file isn't the note yet
|
||||
// True if the card has it now (or there was nothing to save). `whole`: the file itself is
|
||||
// rewritten; otherwise a note over 64 KB only gets its side file written, which is quick.
|
||||
bool save(bool whole);
|
||||
void settle(); // after a key: moves the window if the cursor is near an end of it
|
||||
bool jump(bool toEnd);
|
||||
void say(const std::string& text);
|
||||
|
||||
StorageService& storage_;
|
||||
ClockService& clock_;
|
||||
PowerService& power_;
|
||||
std::unique_ptr<notes::NoteText> text_;
|
||||
std::shared_ptr<Card> card_;
|
||||
std::unique_ptr<notes::NoteDocument> doc_;
|
||||
std::string path_, folder_;
|
||||
uint32_t savedRevision_ = 0, lastKeyMs_ = 0, lastTryMs_ = 0;
|
||||
uint32_t lastKeyMs_ = 0, lastTryMs_ = 0;
|
||||
bool givenUp_ = false; // "Leave" after a save that failed
|
||||
std::string recovered_; // what a temporary file left behind holds, until the user has chosen
|
||||
Ask ask_ = Ask::None;
|
||||
std::unique_ptr<DialogModel> dialog_;
|
||||
|
||||
+10
-1
@@ -25,6 +25,10 @@ bool endsWith(const std::string& s, const char* tail) {
|
||||
size_t n = std::strlen(tail);
|
||||
return s.size() >= n && s.compare(s.size() - n, n, tail) == 0;
|
||||
}
|
||||
|
||||
// Beside a note, and not one: a save cut short (.tmp), a long note's unsaved edits (.edit), and
|
||||
// edits that no longer fit their file, kept for whoever wants to look (.edit.lost).
|
||||
bool notANote(const std::string& name) { return endsWith(name, ".tmp") || endsWith(name, ".edit") || endsWith(name, ".edit.lost"); }
|
||||
} // namespace
|
||||
|
||||
void NotesApp::onEnter() {
|
||||
@@ -125,6 +129,7 @@ void NotesApp::onFinished(const FileOps::Status& s) {
|
||||
if (list_.find(name.substr(0, name.size() - 4)) < 0) orphans.push_back(name);
|
||||
continue;
|
||||
}
|
||||
if (notANote(name)) continue;
|
||||
notes_.push_back(static_cast<uint16_t>(i));
|
||||
}
|
||||
if (!orphans.empty() && !mended_) {
|
||||
@@ -202,6 +207,8 @@ bool NotesApp::onKey(const KeyEvent& e) {
|
||||
selectIndex_ = rows_.selected();
|
||||
selectAfter_.clear();
|
||||
std::string why = ops_.remove(target_, false);
|
||||
std::string side = target_ + ".edit"; // a long note's unsaved edits go with it (issue #47)
|
||||
if (why.empty()) storage_.runJob([side]() { SD.remove(side.c_str()); });
|
||||
if (why.empty()) wait_ = Wait::Work;
|
||||
else say(why);
|
||||
}
|
||||
@@ -225,6 +232,8 @@ bool NotesApp::onNameKey(const KeyEvent& e) {
|
||||
if (why.empty() && name != baseName(target_)) {
|
||||
why = ops_.move(target_, false, joinPath(kFolder, name));
|
||||
if (why.empty()) {
|
||||
std::string side = target_ + ".edit", sideTo = joinPath(kFolder, name) + ".edit";
|
||||
storage_.runJob([side, sideTo]() { SD.rename(side.c_str(), sideTo.c_str()); });
|
||||
wait_ = Wait::Work;
|
||||
selectAfter_ = name;
|
||||
}
|
||||
@@ -268,7 +277,7 @@ bool NotesApp::onListKey(const KeyEvent& e) {
|
||||
list_.sort(sort_);
|
||||
notes_.clear();
|
||||
for (size_t i = 0; i < list_.count(); i++)
|
||||
if (!list_.folder(i) && !endsWith(list_.name(i), ".tmp")) notes_.push_back(static_cast<uint16_t>(i));
|
||||
if (!list_.folder(i) && !notANote(list_.name(i))) notes_.push_back(static_cast<uint16_t>(i));
|
||||
for (size_t i = 0; i < notes_.size(); i++)
|
||||
if (keep == list_.name(notes_[i])) rows_.select(static_cast<int>(i));
|
||||
titlesFrom_ = -1;
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
#include "apps/shell_app.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "app_keys.h"
|
||||
#include "file_names.h"
|
||||
#include "platform/console.h"
|
||||
#include "ui/fonts.h"
|
||||
#include "ui/theme.h"
|
||||
#include "ui/widgets.h"
|
||||
|
||||
namespace roro {
|
||||
|
||||
namespace {
|
||||
|
||||
bool startsWith(const std::string& s, const char* prefix) { return s.rfind(prefix, 0) == 0; }
|
||||
|
||||
} // namespace
|
||||
|
||||
void ShellApp::onEnter() {
|
||||
open_ = console.openShellRing();
|
||||
console.shellShowsAll(false); // its own replies only, each time it's opened
|
||||
ringPos_ = 0;
|
||||
scroll_ = 0;
|
||||
confirm_.reset();
|
||||
// What Tab completes besides the firmware's own commands, written as `help` writes them.
|
||||
ownHelp_ = "help | clear | quit | exit\nkey up|down|left|right|select|back|home|del|tab|space|help\n";
|
||||
log_.clear();
|
||||
log_.add(open_ ? "The console's commands. `help` lists them." : "No memory for the Shell: leave an App, or stop IRC.");
|
||||
}
|
||||
|
||||
// Nothing is kept once it's left: the ring, the lines and the list of commands all go (Q207).
|
||||
void ShellApp::onExit() {
|
||||
console.closeShellRing();
|
||||
console.shellShowsAll(false);
|
||||
open_ = false;
|
||||
log_.clear();
|
||||
std::string().swap(ownHelp_);
|
||||
confirm_.reset();
|
||||
}
|
||||
|
||||
void ShellApp::update(uint32_t) {
|
||||
uint8_t buf[256];
|
||||
uint32_t skipped = 0;
|
||||
size_t n;
|
||||
while ((n = console.readShellSince(ringPos_, buf, sizeof buf, skipped)) > 0) {
|
||||
if (skipped) log_.add("[... " + std::to_string(skipped) + " bytes lost: more was printed than fits]");
|
||||
log_.feed(reinterpret_cast<const char*>(buf), n);
|
||||
}
|
||||
if (log_.revision() != seenRevision_) {
|
||||
seenRevision_ = log_.revision();
|
||||
requestRedraw();
|
||||
}
|
||||
}
|
||||
|
||||
void ShellApp::runNow(const std::string& line) {
|
||||
// The runner echoes the line into the console, where the Shell reads it back with the reply,
|
||||
// except a token being set, which goes nowhere (Q210): that one is shown here, masked.
|
||||
if (startsWith(line, "debug token ") && line != "debug token new") log_.add("> debug token ...");
|
||||
run_(line);
|
||||
}
|
||||
|
||||
void ShellApp::enter(const std::string& line) {
|
||||
history_.add(line);
|
||||
scroll_ = 0;
|
||||
if (line == "quit" || line == "exit") return apps_.home();
|
||||
if (line == "clear") return log_.clear();
|
||||
// Q209: `rm` as Unix has it, with a question where Unix has none, since a slip of the finger is
|
||||
// a key away here. A file, or a folder with something in it, is asked about unless -f says not
|
||||
// to. An empty folder goes without a word; anything `rm` would refuse anyway, it refuses itself.
|
||||
if (startsWith(line, "rm ")) {
|
||||
files::RmArgs args = files::parseRm(line.substr(3));
|
||||
bool ask = false;
|
||||
if (args.force || args.path.empty()) {
|
||||
} else if (files::hasGlob(args.path)) { // a pattern: one question for all it matches
|
||||
bool more = false;
|
||||
int n = count_(args.path, more);
|
||||
ask = n > 0 && !more; // none, or too many: `rm` says so itself
|
||||
question_ = "The " + std::to_string(n) + " that match " + args.path + (args.recursive ? ", folders and what's in them too" : "") + ". It can't be undone.";
|
||||
} else {
|
||||
Target target = probe_(args.path);
|
||||
ask = target == Target::File || (target == Target::FullFolder && args.recursive);
|
||||
question_ = target == Target::File ? args.path + ". It can't be undone." : args.path + " and everything in it. It can't be undone.";
|
||||
}
|
||||
if (ask) {
|
||||
pending_ = line;
|
||||
confirm_.reset(new DialogModel({"Cancel", "Delete"}));
|
||||
return;
|
||||
}
|
||||
}
|
||||
runNow(line);
|
||||
}
|
||||
|
||||
bool ShellApp::onKey(const KeyEvent& e) {
|
||||
requestRedraw();
|
||||
if (confirm_) {
|
||||
confirm_->onKey(e);
|
||||
if (confirm_->result() == DialogModel::kPending) return true;
|
||||
if (confirm_->result() == 1) runNow(pending_);
|
||||
else log_.add("Not deleted.");
|
||||
confirm_.reset();
|
||||
return true;
|
||||
}
|
||||
// Alt + ; / Alt + . scroll back and forward; Up / Down (Fn + ; / Fn + .) recall earlier lines.
|
||||
if (e.key == Key::Char && e.alt && (e.ch == ';' || e.ch == '.')) {
|
||||
if (e.ch == ';') scroll_++;
|
||||
else if (scroll_ > 0) scroll_--;
|
||||
return true;
|
||||
}
|
||||
if (e.key == Key::Char && e.ctrl && (e.ch == 'b' || e.ch == 'B')) { // Q206
|
||||
console.shellShowsAll(!console.shellShowsAll());
|
||||
log_.add(console.shellShowsAll() ? "Showing everything the console prints." : "Showing only the replies to your commands.");
|
||||
return true;
|
||||
}
|
||||
std::string recalled;
|
||||
switch (e.key) {
|
||||
case Key::Char: input_.insert(e.ch); break;
|
||||
case Key::Delete: input_.backspace(); break;
|
||||
case Key::Left: input_.left(); break;
|
||||
case Key::Right: input_.right(); break;
|
||||
case Key::Up:
|
||||
if (history_.up(input_.text(), recalled)) input_.setText(recalled);
|
||||
break;
|
||||
case Key::Down:
|
||||
if (history_.down(recalled)) input_.setText(recalled);
|
||||
break;
|
||||
case Key::Tab: { // the command, every word of it; where its words end, a path on the card
|
||||
std::vector<std::string> matches;
|
||||
PathToComplete path;
|
||||
bool more = false;
|
||||
const std::string typed = input_.text();
|
||||
std::string done = completeWords(typed, (std::string(helpText_) + ownHelp_).c_str(), matches);
|
||||
if (done == typed && matches.empty() && splitForPath(typed, path)) done = completePath(path, list_(path.folder, path.prefix, more), matches);
|
||||
input_.setText(done);
|
||||
if (matches.size() > 1) {
|
||||
std::string all;
|
||||
for (auto& m : matches) all += (all.empty() ? "" : " ") + m;
|
||||
log_.add(all + (more ? " ..." : ""));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case Key::Select: {
|
||||
std::string line = input_.text();
|
||||
input_.setText("");
|
||||
if (!line.empty()) enter(line);
|
||||
break;
|
||||
}
|
||||
default: return false; // Back leaves the Shell
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void ShellApp::help(std::vector<KeyHelp>& out) const {
|
||||
if (confirm_) return keys::add(out, keys::kDialog);
|
||||
keys::add(out, keys::kShell);
|
||||
}
|
||||
|
||||
void ShellApp::draw(Canvas& c) {
|
||||
const auto& area = theme::kContent;
|
||||
const int inputH = theme::kLineHeight + 4;
|
||||
const theme::Rect output{area.x, area.y, area.w, area.h - inputH - 1};
|
||||
const int rows = output.h / theme::kLineHeight;
|
||||
|
||||
// Wrapped from the newest line backwards, only as far as the screen and the scroll need.
|
||||
std::vector<std::pair<std::string, uint16_t>> shown; // newest first
|
||||
auto measure = widgets::bodyMeasure(c);
|
||||
c.setFont(&fonts::body);
|
||||
const auto& lines = log_.lines();
|
||||
int needed = rows + scroll_;
|
||||
for (auto it = lines.rbegin(); it != lines.rend() && static_cast<int>(shown.size()) < needed; ++it) {
|
||||
uint16_t color = it->rfind("> ", 0) == 0 ? theme::kAccent : theme::kText;
|
||||
auto wrapped = wrapText(it->empty() ? std::string(" ") : *it, output.w - 8, measure);
|
||||
for (auto w = wrapped.rbegin(); w != wrapped.rend(); ++w) shown.push_back({*w, color});
|
||||
}
|
||||
int total = static_cast<int>(shown.size());
|
||||
if (scroll_ > total - rows) scroll_ = total > rows ? total - rows : 0;
|
||||
c.setClipRect(output.x, output.y, output.w, output.h);
|
||||
for (int r = 0; r < rows; r++) {
|
||||
int i = scroll_ + (rows - 1 - r); // the row at the bottom is the newest
|
||||
if (i >= total) continue;
|
||||
c.setTextColor(shown[i].second);
|
||||
c.drawString(shown[i].first.c_str(), 4, output.y + r * theme::kLineHeight + 1);
|
||||
}
|
||||
c.clearClipRect();
|
||||
|
||||
// State, not keys: how far back it's scrolled, and whether background lines are hidden.
|
||||
c.setFont(&fonts::small);
|
||||
c.setTextDatum(top_right);
|
||||
if (scroll_ > 0) {
|
||||
c.setTextColor(theme::kWarning);
|
||||
c.drawString(("^ " + std::to_string(scroll_)).c_str(), area.w - 3, output.y + 1);
|
||||
} else if (console.shellShowsAll()) {
|
||||
c.setTextColor(theme::kMuted);
|
||||
c.drawString("all", area.w - 3, output.y + 1);
|
||||
}
|
||||
c.setTextDatum(top_left);
|
||||
|
||||
widgets::lineEditor(c, input_, {2, area.y + area.h - inputH, area.w - 4, 0});
|
||||
if (confirm_) widgets::dialog(c, "Delete?", question_, *confirm_);
|
||||
}
|
||||
|
||||
} // namespace roro
|
||||
@@ -0,0 +1,68 @@
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "app.h"
|
||||
#include "app_manager.h"
|
||||
#include "dialog_model.h"
|
||||
#include "input_history.h"
|
||||
#include "line_editor.h"
|
||||
#include "shell_log.h"
|
||||
|
||||
namespace roro {
|
||||
|
||||
// The Shell (issue #67): the console's commands on the device's own screen and keyboard. A third
|
||||
// place to type them, after USB serial and the Debug Console, and trusted like the first: whoever
|
||||
// holds the device can do all of it in Settings anyway (Q205).
|
||||
//
|
||||
// It shows the replies to its own commands, and only those unless asked otherwise (Q206): the
|
||||
// console knows who each line was printed for (Console::Origin) and fills a ring that exists only
|
||||
// while the App is open. Nothing is kept once it is left.
|
||||
class ShellApp : public App {
|
||||
public:
|
||||
using Run = std::function<void(const std::string& line)>;
|
||||
// What `rm` is pointed at: it decides whether the Shell asks first.
|
||||
enum class Target { Missing, File, EmptyFolder, FullFolder };
|
||||
using Probe = std::function<Target(const std::string& path)>;
|
||||
// A folder's entries that start with `prefix`, whatever their case, a folder's with a slash at its
|
||||
// end: what Tab completes a path from. `more` when there were too many to give them all.
|
||||
using List = std::function<std::vector<std::string>(const std::string& folder, const std::string& prefix, bool& more)>;
|
||||
// How many names a pattern matches (/notes/*.txt), for the question `rm` asks; `more` past the limit.
|
||||
using Count = std::function<int(const std::string& pattern, bool& more)>;
|
||||
|
||||
ShellApp(Run run, Probe probe, List list, Count count, const char* helpText, AppManager& apps)
|
||||
: run_(std::move(run)), probe_(std::move(probe)), list_(std::move(list)), count_(std::move(count)), helpText_(helpText), apps_(apps) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
bool onKey(const KeyEvent& e) override;
|
||||
bool textEntryActive() const override { return !confirm_; }
|
||||
void update(uint32_t nowMs) override;
|
||||
void draw(Canvas& c) override;
|
||||
void help(std::vector<KeyHelp>& out) const override;
|
||||
|
||||
private:
|
||||
void enter(const std::string& line);
|
||||
void runNow(const std::string& line);
|
||||
|
||||
Run run_;
|
||||
Probe probe_;
|
||||
List list_;
|
||||
Count count_;
|
||||
const char* helpText_;
|
||||
AppManager& apps_;
|
||||
ShellLog log_;
|
||||
std::string ownHelp_; // the Shell's own commands and the keys `key` takes, in the form of `help`'s text
|
||||
LineEditor input_{240};
|
||||
InputHistory history_{16};
|
||||
std::unique_ptr<DialogModel> confirm_;
|
||||
std::string pending_, question_; // the `rm` being asked about, and what's asked
|
||||
uint32_t ringPos_ = 0, seenRevision_ = 0;
|
||||
int scroll_ = 0; // wrapped lines scrolled back from the bottom
|
||||
bool open_ = false;
|
||||
};
|
||||
|
||||
} // namespace roro
|
||||
@@ -322,7 +322,6 @@ bool StorageApp::onKey(const KeyEvent& e) {
|
||||
if (view_ == View::Viewer && viewer_.showingText() && e.key == Key::Char && (e.ch == 'e' || e.ch == 'E')) {
|
||||
// Edit it (Q146), if the rules and its size allow.
|
||||
std::string path = viewer_.path(), why = ops_.whyReadOnly(path, false);
|
||||
if (why.empty() && viewer_.size() > notes::NoteText::kMaxBytes) why = "Too big to edit: 16 KB at most";
|
||||
if (why.empty()) {
|
||||
viewer_.close();
|
||||
why = noteEditor_.open(path);
|
||||
|
||||
+289
-15
@@ -3,11 +3,15 @@
|
||||
#include <SD.h>
|
||||
|
||||
|
||||
#include <deque>
|
||||
#include <atomic>
|
||||
#include <memory>
|
||||
|
||||
#include "app_manager.h"
|
||||
#include "png_rgb332.h"
|
||||
#include "apps/demo_app.h"
|
||||
#include "apps/note_editor.h"
|
||||
#include "apps/shell_app.h"
|
||||
#include "apps/gemini_app.h"
|
||||
#include "apps/gnss_app.h"
|
||||
#include "apps/irc_app.h"
|
||||
@@ -151,6 +155,11 @@ extern "C" bool verifyRollbackLater() { return true; } // C linkage, or the wea
|
||||
size_t getArduinoLoopTaskStackSize() { return 6144; }
|
||||
|
||||
static void setupSafeMode(int crashes);
|
||||
static const char* helpText();
|
||||
static void shellRun(const std::string& line);
|
||||
static ShellApp::Target shellProbe(const std::string& path);
|
||||
static std::vector<std::string> shellList(const std::string& folder, const std::string& prefix, bool& more);
|
||||
static int shellCount(const std::string& pattern, bool& more);
|
||||
|
||||
void setup() {
|
||||
nvs.begin();
|
||||
@@ -209,8 +218,11 @@ void setup() {
|
||||
apps->registerApp({"lora", "LoRa Scanner", false, new LoraScannerApp(*radioService, *loraCapture, settings, *clockService)});
|
||||
apps->registerApp({"storage", "Storage", false, new StorageApp(*fileOps, *storageService, *clockService, *update, *power, bus)});
|
||||
apps->registerApp({"notes", "Notes", false, new NotesApp(*fileOps, *storageService, *clockService, *power)});
|
||||
apps->registerApp({"shell", "Shell", false, new ShellApp(shellRun, shellProbe, shellList, shellCount, helpText(), *apps)});
|
||||
// Leaving the foreground App makes it save: a note being typed, when the device is powered off.
|
||||
power->beforePowerOff = []() { apps->home(); };
|
||||
// A long note being rewritten (issue #47): the editor waits for the card, so it draws from there.
|
||||
NoteEditor::onProgress = [](const std::string& name, int percent) { screen.renderUpdate("Saving", name, percent); };
|
||||
apps->registerApp({"system", "System", false,
|
||||
new SystemApp(*wifi, *battery, *storageService, *radioService, *gnssService, nvs)});
|
||||
apps->registerApp({"settings", "Settings", false,
|
||||
@@ -270,10 +282,13 @@ static void setupSafeMode(int crashes) {
|
||||
|
||||
// Dev aid: commands to drive the UI without the keyboard, from the serial port or the Debug Console.
|
||||
static bool listingWanted = false;
|
||||
// Who asked for each reply that is printed later (Console::Origin): the Shell sees its own, only.
|
||||
static Console::Origin listingFrom, fileOpFrom, updateFrom, tasksFrom;
|
||||
|
||||
static void printListingWhenReady() {
|
||||
if (!listingWanted || !storageService->listingReady()) return;
|
||||
listingWanted = false;
|
||||
Console::As as(listingFrom);
|
||||
auto listing = storageService->listing();
|
||||
for (size_t i = 0; i < listing.size(); i++) {
|
||||
uint64_t bytes = 0;
|
||||
@@ -422,10 +437,53 @@ static std::vector<std::string> filesInUse(const std::string& path, bool folder)
|
||||
// `cp`, `mv`, `rm`, `mkdir`, `du` run as the Storage App's operations do; the result prints here.
|
||||
static bool consoleFileOp = false;
|
||||
|
||||
// A pattern in a path (issue #67): `rm /notes/*.txt` is one `rm` for each name it matches, run one
|
||||
// after the other, each when the one before is done. 64 at most: more is refused, not started.
|
||||
constexpr size_t kMaxGlob = 64;
|
||||
struct QueuedFileCommand {
|
||||
std::string line;
|
||||
Console::Origin from;
|
||||
};
|
||||
static std::deque<QueuedFileCommand> fileQueue;
|
||||
static void fileCommand(const String& line);
|
||||
|
||||
// The paths a pattern matches, sorted. `more` when there were over kMaxGlob (or over 2000 entries to
|
||||
// look through); empty when the pattern isn't in the last part of the path alone.
|
||||
static std::vector<std::string> expandGlob(const std::string& path, bool& more) {
|
||||
std::vector<std::string> out;
|
||||
std::string folder, pattern;
|
||||
more = false;
|
||||
if (!files::splitGlob(path, folder, pattern) || !storageService->state().present) return out;
|
||||
storageService->runAndWait([&]() {
|
||||
File dir = SD.open(folder.c_str());
|
||||
if (!dir || !dir.isDirectory()) return;
|
||||
int seen = 0;
|
||||
for (File f = dir.openNextFile(); f; f = dir.openNextFile()) {
|
||||
if (++seen > 2000 || out.size() > kMaxGlob) {
|
||||
more = true;
|
||||
break;
|
||||
}
|
||||
if (files::globMatch(pattern, f.name())) out.push_back(files::joinPath(folder, f.name()));
|
||||
}
|
||||
});
|
||||
if (out.size() > kMaxGlob) more = true;
|
||||
std::sort(out.begin(), out.end());
|
||||
return out;
|
||||
}
|
||||
|
||||
static void fileQueueStep() {
|
||||
if (fileQueue.empty() || consoleFileOp) return; // the one before is still running
|
||||
QueuedFileCommand next = std::move(fileQueue.front());
|
||||
fileQueue.pop_front();
|
||||
Console::As as(next.from);
|
||||
fileCommand(next.line.c_str());
|
||||
}
|
||||
|
||||
static void fileOpsStep() {
|
||||
FileOps::Status s;
|
||||
if (!consoleFileOp || !fileOps->finished(s)) return;
|
||||
consoleFileOp = false;
|
||||
Console::As as(fileOpFrom);
|
||||
static const char* const kNames[] = {"", "ls", "du", "cp", "mv", "rm", "mkdir"};
|
||||
const char* name = kNames[static_cast<int>(s.op)];
|
||||
if (!s.error.empty()) console.printf("%s: error %s\n", name, s.error.c_str());
|
||||
@@ -459,6 +517,7 @@ static void printReleases(bool list) {
|
||||
|
||||
static void updateStep() {
|
||||
if (!updateWatch || update->giteaBusy()) return;
|
||||
Console::As as(updateFrom);
|
||||
if (updateWatch == 3) {
|
||||
console.printf("update: probe: %s\n", update->probeResult().c_str());
|
||||
updateWatch = 0;
|
||||
@@ -473,6 +532,7 @@ static void updateCommand(const String& args) {
|
||||
if (update->giteaBusy()) return (void)console.println("update: busy");
|
||||
args == "check" ? update->requestCheck() : update->requestList();
|
||||
updateWatch = args == "check" ? 1 : 2;
|
||||
updateFrom = console.origin();
|
||||
} else if (args == "status") {
|
||||
GiteaReleases& g = update->gitea();
|
||||
console.printf("update: running %s, failed here before: %s, daily check %s, heap %u\n", update->runningVersion().c_str(),
|
||||
@@ -489,6 +549,7 @@ static void updateCommand(const String& args) {
|
||||
int space = rest.indexOf(' ');
|
||||
update->requestProbe((space < 0 ? rest : rest.substring(0, space)).c_str(), space < 0 ? "/" : rest.substring(space + 1).c_str());
|
||||
updateWatch = 3;
|
||||
updateFrom = console.origin();
|
||||
} else if (args.startsWith("damage ")) { // update damage cut <bytes> | flip <offset>: for the next download
|
||||
long n = args.substring(args.lastIndexOf(' ') + 1).toInt();
|
||||
args.startsWith("damage cut") ? update->damageNextDownload(n, -1) : update->damageNextDownload(-1, n);
|
||||
@@ -508,6 +569,7 @@ static void updateCommand(const String& args) {
|
||||
static void fileCommand(const String& line) {
|
||||
int space = line.indexOf(' ');
|
||||
std::string command = line.substring(0, space).c_str(), rest = line.substring(space + 1).c_str();
|
||||
const std::string everything = rest; // rm reads its own switches
|
||||
bool force = rest.rfind("-f ", 0) == 0; // replace what's there
|
||||
if (force) rest = rest.substr(3);
|
||||
size_t split = rest.find('\t'); // two paths: a tab between them if either has a space
|
||||
@@ -515,13 +577,42 @@ static void fileCommand(const String& line) {
|
||||
std::string a = rest.substr(0, split), b = split == std::string::npos ? "" : rest.substr(split + 1);
|
||||
std::string why;
|
||||
bool exists = false, folder = false;
|
||||
if (command == "cancel") return fileOps->cancel();
|
||||
if (command == "cancel") {
|
||||
fileQueue.clear(); // and what a pattern had lined up
|
||||
return fileOps->cancel();
|
||||
}
|
||||
// A pattern: the same command for each name it matches.
|
||||
files::RmArgs rmArgs = command == "rm" ? files::parseRm(everything) : files::RmArgs();
|
||||
const std::string globbed = command == "rm" ? rmArgs.path : command == "du" ? rest : command == "cp" || command == "mv" ? a : "";
|
||||
if (files::hasGlob(globbed) && storageService->state().present) {
|
||||
bool more = false, intoExists = false;
|
||||
std::string folder, pattern;
|
||||
std::vector<std::string> paths = expandGlob(globbed, more);
|
||||
if (!files::splitGlob(globbed, folder, pattern)) why = "a pattern goes in the last part of a path: /notes/*.txt";
|
||||
else if (more) why = "more than 64 match: a narrower pattern, please";
|
||||
else if (paths.empty()) why = "nothing matches";
|
||||
else if ((command == "cp" || command == "mv") && (b.empty() || !fileOps->isFolder(b, intoExists) || !intoExists)) why = "several files go into a folder that exists";
|
||||
if (!why.empty()) return (void)console.printf("%s: error %s\n", command.c_str(), why.c_str());
|
||||
console.printf("%s: %u match %s\n", command.c_str(), (unsigned)paths.size(), globbed.c_str());
|
||||
Console::Origin from = console.origin();
|
||||
for (auto& p : paths) {
|
||||
std::string one = command == "rm" ? std::string("rm ") + (rmArgs.recursive ? "-r " : "") + p
|
||||
: command == "du" ? "du " + p
|
||||
: command + (force ? " -f " : " ") + p + "\t" + b; // a tab between two paths: either may hold a space
|
||||
fileQueue.push_back({one, from});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!storageService->state().present) why = "no SD card";
|
||||
else if (command == "du") why = fileOps->count(rest);
|
||||
else if (command == "mkdir") why = fileOps->makeFolder(rest);
|
||||
else if (command == "rm") {
|
||||
folder = fileOps->isFolder(rest, exists);
|
||||
why = exists ? fileOps->remove(rest, folder) : "It isn't there";
|
||||
else if (command == "rm") { // as Unix has it: a folder needs -r. The questions are the Shell's (-f is its)
|
||||
files::RmArgs args = files::parseRm(everything);
|
||||
folder = fileOps->isFolder(args.path, exists);
|
||||
if (args.path.empty()) why = "rm [-r] [-f] <path>";
|
||||
else if (!exists) why = "It isn't there";
|
||||
else if (folder && !args.recursive) why = "It's a folder: rm -r removes it and what's in it";
|
||||
else why = fileOps->remove(args.path, folder);
|
||||
} else if (b.empty()) why = "two paths, please";
|
||||
else {
|
||||
folder = fileOps->isFolder(a, exists);
|
||||
@@ -533,6 +624,7 @@ static void fileCommand(const String& line) {
|
||||
}
|
||||
if (!why.empty()) return (void)console.printf("%s: error %s\n", command.c_str(), why.c_str());
|
||||
consoleFileOp = true;
|
||||
fileOpFrom = console.origin();
|
||||
}
|
||||
|
||||
static uint32_t loopPasses = 0; // counted in loop(), for `tasks` (issue #40)
|
||||
@@ -542,6 +634,7 @@ static uint32_t tasksPasses = 0;
|
||||
static void tasksStep() {
|
||||
if (!tasksPending || static_cast<int32_t>(millis() - tasksDueMs) < 0) return;
|
||||
tasksPending = false;
|
||||
Console::As as(tasksFrom);
|
||||
system_info::printTasks(console, tasksBefore, tasksTotal);
|
||||
console.printf("loop: %lu passes in the last second, chip %.1f C\n", (unsigned long)(loopPasses - tasksPasses), temperatureRead());
|
||||
std::vector<TaskSample>().swap(tasksBefore);
|
||||
@@ -553,24 +646,26 @@ static const char* const kHelp =
|
||||
"reboot restart\n"
|
||||
"boot other restart into the other app slot (manual Rollback)\n"
|
||||
"log level <0-5> ESP-IDF log level (0 none ... 5 verbose)\n"
|
||||
"ls [folder] | du <path> | mkdir <path> | rm <path> | cp [-f] <from> <to> | mv [-f] <from> <to> | cancel the SD card, with the Storage App's rules\n"
|
||||
"ls [folder] | du <path> | mkdir <path> | rm [-r] [-f] <path> | cp [-f] <from> <to> | mv [-f] <from> <to> | cancel the SD card, with the Storage App's rules (rm -r for a folder; -f: the Shell doesn't ask; * and ? in a name: /notes/*.txt)\n"
|
||||
"screenshot [seconds] the screen as a PNG in /screenshots on the card, now or after a pause\n"
|
||||
"lora probe | lora status | lora rx on|off | lora preset <name> the LoRa radio, receive only\n"
|
||||
"lora capture start|stop a LoRa Capture to /captures/lora (pcap, LoRaTap)\n"
|
||||
"lora sweep on [from MHz] [to MHz] [step kHz] | off | dump RSSI across a band (863 870 100)\n"
|
||||
"lora sweep on [from MHz] [to MHz] [step kHz] | lora sweep off | lora sweep dump RSSI across a band (863 870 100)\n"
|
||||
"lora custom <MHz> <BW kHz> <SF> <CR 5-8> <sync hex> [preamble] e.g. 868.1 125 7 5 34 8 (LoRaWAN)\n"
|
||||
"gnss quiet on|off pause the GNSS receiver while the LoRa radio listens (it costs the radio 8 dB)\n"
|
||||
"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 help, or one character\n"
|
||||
"key <name|char> press a key: up down left right select back home del tab space help, or one character; ctrl- alt- shift- before it (key ctrl-down)\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"
|
||||
"gemini get <url> fetch a Gemini page and report header, size, certificate, heap\n"
|
||||
"irc start | irc stop | irc dump | irc say <buffer> <text>\n"
|
||||
"install <path.ota> Update from SD\n"
|
||||
"update check | list | status | install <tag> the project's releases on Gitea\n"
|
||||
"update check | update list | update status | update install <tag> the project's releases on Gitea\n"
|
||||
"sd card | sd list | cat <path> | log <text> | burst | sound on|off | short | normal\n"
|
||||
"Irc | Wifi | Gnss | Gemini | Lora | Storage | Notes | Shell | System | Settings open that App: a capital letter is an App, not a command\n"
|
||||
"debug status | debug off [seconds] the Debug Console over Wi-Fi (Settings > Debug Console); with seconds, it comes back\n"
|
||||
"debug on | debug token <16 to 64 characters> | debug token new (USB serial only) switch it on, set its token\n"
|
||||
"crash abort|wdt crash on purpose (to test crash reports and Safe Mode)\n"
|
||||
@@ -581,10 +676,64 @@ static const char* const kHelp =
|
||||
"sd fill <folder> <count> makes that many small files there, to test a crowded folder\n"
|
||||
"coredump get (Debug Console only) send the raw core dump: use scripts/rdbg.py coredump\n"
|
||||
"reset (Debug Console only) restart at once, even if the main loop is stuck\n"
|
||||
"get <path> | put <path> <size> <sha256> | screenshot (Debug Console only) binary, see rdbg.py\n"
|
||||
"get <path> | put <path> <size> <sha256> | screenshot (Debug Console only) binary, see rdbg.py: there, a bare `screenshot` sends the screen instead of saving it\n"
|
||||
"quit close the Debug Console connection\n"
|
||||
;
|
||||
|
||||
static const char* helpText() { return kHelp; }
|
||||
|
||||
// `screenshot [seconds]` (issue #67, Q209): the frame as it is composed, as a PNG on the card. The
|
||||
// pause is for getting to the screen you want: from the Shell, "now" is always the Shell.
|
||||
static bool shotPending = false;
|
||||
static Console::Origin shotFrom;
|
||||
static uint32_t shotDueMs = 0;
|
||||
static std::atomic<bool> shotSaved{false};
|
||||
|
||||
static void saveScreenshot() {
|
||||
if (!storageService || !storageService->state().present) return (void)console.println("screenshot: error no SD card");
|
||||
char name[40];
|
||||
int64_t now = clockService ? clockService->utcNow() : -1;
|
||||
if (now >= 0) {
|
||||
time_t t = static_cast<time_t>(now);
|
||||
struct tm local;
|
||||
localtime_r(&t, &local);
|
||||
snprintf(name, sizeof name, "/screenshots/%04d%02d%02d-%02d%02d%02d.png", local.tm_year + 1900, local.tm_mon + 1, local.tm_mday,
|
||||
local.tm_hour, local.tm_min, local.tm_sec);
|
||||
} else snprintf(name, sizeof name, "/screenshots/shot-%lu.png", (unsigned long)(millis() / 1000)); // no clock yet
|
||||
std::string path = name;
|
||||
Console::Origin from = shotFrom;
|
||||
storageService->runJob([path, from]() {
|
||||
Console::As as(from);
|
||||
Canvas& frame = screen.canvas();
|
||||
const uint8_t* pixels = static_cast<const uint8_t*>(frame.getBuffer());
|
||||
int w = frame.width(), h = frame.height();
|
||||
if (!pixels) return (void)console.println("screenshot: error no frame");
|
||||
if (!SD.exists("/screenshots")) SD.mkdir("/screenshots");
|
||||
File f = SD.open(path.c_str(), FILE_WRITE);
|
||||
if (!f) return (void)console.printf("screenshot: error the card refused to make %s\n", path.c_str());
|
||||
png::Rgb332Writer writer(w, h, [&f](const uint8_t* data, size_t len) { return f.write(data, len) == len; });
|
||||
bool ok = writer.begin();
|
||||
for (int y = 0; ok && y < h; y++) ok = writer.row(pixels + y * w); // read as it stands: it may tear
|
||||
ok = ok && writer.end();
|
||||
f.close();
|
||||
if (!ok) {
|
||||
SD.remove(path.c_str());
|
||||
return (void)console.println("screenshot: error the card refused a write");
|
||||
}
|
||||
console.printf("screenshot: saved %s (%u bytes)\n", path.c_str(), (unsigned)png::Rgb332Writer::fileSize(w, h));
|
||||
shotSaved = true; // the main loop says so on the screen, after the picture is taken
|
||||
});
|
||||
}
|
||||
|
||||
static void screenshotStep() {
|
||||
if (shotPending && static_cast<int32_t>(millis() - shotDueMs) >= 0) {
|
||||
shotPending = false;
|
||||
saveScreenshot();
|
||||
}
|
||||
if (shotSaved.exchange(false))
|
||||
bus.publish(Event::withText(EventType::Notification, "Screenshot saved in /screenshots", static_cast<int32_t>(NotificationLevel::Info)));
|
||||
}
|
||||
|
||||
// Commands that only touch what Safe Mode starts.
|
||||
static bool safeModeCommand(const String& line) {
|
||||
return line == "help" || line == "info" || line == "tasks" || line == "net" || line == "reboot" || line == "boot other" ||
|
||||
@@ -639,11 +788,34 @@ static void runCommand(String line, bool fromSerial = false) {
|
||||
if (line.isEmpty()) return;
|
||||
if (safeMode && !safeModeCommand(line)) return (void)console.println("not available in Safe Mode");
|
||||
if (line == "help") console.print(kHelp);
|
||||
if (line[0] >= 'A' && line[0] <= 'Z') { // an App, by its name with a capital
|
||||
const AppInfo* app = apps ? apps->byCommand(line.c_str()) : nullptr;
|
||||
if (!app) {
|
||||
console.printf("No App called %s:", line.c_str());
|
||||
if (apps)
|
||||
for (auto* a : apps->visibleApps()) console.printf(" %s", AppManager::commandFor(a->id).c_str());
|
||||
console.println();
|
||||
} else if (apps->open(app->id)) console.printf("%s\n", app->title);
|
||||
else console.println("Not now: Setup is running");
|
||||
return;
|
||||
}
|
||||
if (line.startsWith("debug ")) return debugCommand(line.substring(6), fromSerial);
|
||||
if (line == "screenshot" || line.startsWith("screenshot ")) {
|
||||
uint32_t seconds = constrain(line.substring(10).toInt(), 0, 60);
|
||||
shotPending = true;
|
||||
shotFrom = console.origin();
|
||||
shotDueMs = millis() + seconds * 1000;
|
||||
if (seconds) console.printf("screenshot: in %lu s\n", (unsigned long)seconds);
|
||||
return;
|
||||
}
|
||||
// Over the Debug Console these never get here: its own task answers them.
|
||||
if (line == "coredump get" || line == "reset" || line.startsWith("get ") || line.startsWith("put "))
|
||||
return (void)console.println("Debug Console only: scripts/rdbg.py speaks it");
|
||||
if (line == "info") {
|
||||
system_info::printSystem(console);
|
||||
console.printf("wifi: %s, ip %s, rssi %d | sd: %s, %u write faults\n", wifi->ssid().c_str(), wifi->ip().c_str(),
|
||||
wifi->rssi(), storageService->state().present ? "present" : "none", (unsigned)sdLastFault().count);
|
||||
if (apps) console.printf("app: %s%s\n", apps->foregroundTitle() ? apps->foregroundTitle() : "Launcher", apps->help().isOpen() ? " (the help panel is open)" : "");
|
||||
console.printf("update: %s | debug console: %s\n", update->onProbation() ? "on probation" : "confirmed",
|
||||
!debugConsole->on() ? "off" : debugConsole->clientConnected() ? "on, a client connected" : "on");
|
||||
system_info::printSlots(console, nvs);
|
||||
@@ -657,6 +829,7 @@ static void runCommand(String line, bool fromSerial = false) {
|
||||
tasksDueMs = millis() + 1000;
|
||||
tasksPasses = loopPasses;
|
||||
tasksPending = true;
|
||||
tasksFrom = console.origin();
|
||||
}
|
||||
if (line == "net") { // bytes each service has read and written since boot (S1, Q121)
|
||||
for (int i = 0; i < static_cast<int>(net::User::Count); i++) {
|
||||
@@ -682,14 +855,21 @@ static void runCommand(String line, bool fromSerial = false) {
|
||||
fileCommand(line);
|
||||
if (line == "ls" || line.startsWith("ls ")) {
|
||||
if (!storageService->state().present) return (void)console.println("sd: no card");
|
||||
std::string path = line.length() > 3 ? line.substring(3).c_str() : "/";
|
||||
storageService->runJob([path]() { // card access stays on the storage task
|
||||
std::string path = line.length() > 3 ? line.substring(3).c_str() : "/", pattern;
|
||||
const std::string asked = path;
|
||||
if (files::hasGlob(path) && !files::splitGlob(asked, path, pattern)) // /notes/*.txt: the folder, and what to show of it
|
||||
return (void)console.println("ls: error a pattern goes in the last part of a path: /notes/*.txt");
|
||||
Console::Origin from = console.origin();
|
||||
storageService->runJob([path, pattern, asked, from]() { // card access stays on the storage task
|
||||
Console::As as(from);
|
||||
File dir = SD.open(path.c_str());
|
||||
if (!dir || !dir.isDirectory()) return (void)console.printf("ls: %s is not a folder\n", path.c_str());
|
||||
for (File f = dir.openNextFile(); f; f = dir.openNextFile())
|
||||
for (File f = dir.openNextFile(); f; f = dir.openNextFile()) {
|
||||
if (!pattern.empty() && !files::globMatch(pattern, f.name())) continue;
|
||||
console.printf("%10u %-16s %s%s\n", f.isDirectory() ? 0u : (unsigned)f.size(),
|
||||
files::formatStamp(static_cast<uint32_t>(f.getLastWrite())).c_str(), f.name(), f.isDirectory() ? "/" : "");
|
||||
console.printf("ls: end of %s\n", path.c_str());
|
||||
}
|
||||
console.printf("ls: end of %s\n", asked.c_str());
|
||||
});
|
||||
}
|
||||
if (line.startsWith("install ")) update->installFromSd(line.substring(8).c_str()); // Update from SD
|
||||
@@ -697,7 +877,9 @@ static void runCommand(String line, bool fromSerial = false) {
|
||||
int space = line.lastIndexOf(' ');
|
||||
std::string folder = line.substring(8, space).c_str();
|
||||
int count = constrain(line.substring(space + 1).toInt(), 0, 1000);
|
||||
storageService->runJob([folder, count]() {
|
||||
Console::Origin from = console.origin();
|
||||
storageService->runJob([folder, count, from]() {
|
||||
Console::As as(from);
|
||||
int made = 0;
|
||||
for (int i = 1; i <= count; i++) {
|
||||
char name[24];
|
||||
@@ -847,12 +1029,23 @@ static void runCommand(String line, bool fromSerial = false) {
|
||||
bus.publish(Event::withText(EventType::Notification, ("Burst " + String(i)).c_str(), 0));
|
||||
if (line.startsWith("key ")) { // key up|down|left|right|select|back|home|del|tab
|
||||
String k = line.substring(4);
|
||||
// ctrl- alt- shift- before the name, in any order: `key ctrl-down`, `key alt-up`, `key ctrl-b`.
|
||||
bool ctrl = false, alt = false, shift = false;
|
||||
for (bool more = true; more;) {
|
||||
more = false;
|
||||
if (k.startsWith("ctrl-") && k.length() > 5) ctrl = more = true, k = k.substring(5);
|
||||
if (k.startsWith("alt-") && k.length() > 4) alt = more = true, k = k.substring(4);
|
||||
if (k.startsWith("shift-") && k.length() > 6) shift = more = true, k = k.substring(6);
|
||||
}
|
||||
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 : 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);
|
||||
ev.ctrl = ctrl;
|
||||
ev.alt = alt;
|
||||
ev.shift = shift;
|
||||
if (!power->onKey(millis())) apps->handleKey(ev);
|
||||
}
|
||||
if (line.startsWith("wifi add ")) { // wifi add <ssid>\t<password>: credentials never touch the repo
|
||||
@@ -883,7 +1076,9 @@ static void runCommand(String line, bool fromSerial = false) {
|
||||
if (line.startsWith("sd put ")) startUpload(line.substring(7)); // then raw bytes: see uploadStep()
|
||||
if (line == "sd card") { // what the card says it is, from its CID register
|
||||
if (!storageService->state().present) return (void)console.println("sd card: no card");
|
||||
storageService->runJob([]() {
|
||||
Console::Origin from = console.origin();
|
||||
storageService->runJob([from]() {
|
||||
Console::As as(from);
|
||||
uint8_t cid[16];
|
||||
const char* type = SD.cardType() == CARD_SDHC ? "SDHC/SDXC" : SD.cardType() == CARD_SD ? "SDSC" : "MMC or unknown";
|
||||
if (!sdReadCid(cid)) return (void)console.println("sd card: the card didn't answer");
|
||||
@@ -896,6 +1091,7 @@ static void runCommand(String line, bool fromSerial = false) {
|
||||
if (line == "sd list") {
|
||||
storageService->requestListing();
|
||||
listingWanted = true;
|
||||
listingFrom = console.origin();
|
||||
}
|
||||
if (line.startsWith("gemini trust ")) { // gemini trust <host> <port> <sha256>: accept a changed certificate
|
||||
char host[96] = "", fp[80] = "";
|
||||
@@ -1009,6 +1205,81 @@ static void runCommand(String line, bool fromSerial = false) {
|
||||
}
|
||||
}
|
||||
|
||||
// What the Shell App runs (issue #67): trusted like USB serial (Q205), and echoed into the console so
|
||||
// that a session reads the same from afar, except a token being set (Q210).
|
||||
//
|
||||
// The App only hands the line over: it is run from the main loop, like the consoles' commands, not
|
||||
// from inside the key handler. Run from there, `rm` on a folder overflowed the main loop's stack
|
||||
// (a key from the Debug Console, the App manager, the Shell, then runCommand a second time and
|
||||
// printf under all of it): the loop has under 2 KB of stack to spare.
|
||||
static std::deque<std::string> shellLines;
|
||||
|
||||
static void shellRun(const std::string& line) {
|
||||
if (shellLines.size() < 8) shellLines.push_back(line);
|
||||
}
|
||||
|
||||
static void shellCommands() {
|
||||
while (!shellLines.empty()) {
|
||||
std::string line = std::move(shellLines.front());
|
||||
shellLines.pop_front();
|
||||
Console::As as(Console::Origin::Shell); // what it prints, and what answers it later, is the Shell's
|
||||
String l = line.c_str();
|
||||
if (!l.startsWith("debug token ") || l == "debug token new") console.printf("> %s\n", line.c_str());
|
||||
runCommand(l, true);
|
||||
}
|
||||
}
|
||||
|
||||
// What a path is, for the Shell to know whether `rm` deserves a question (Q209).
|
||||
static ShellApp::Target shellProbe(const std::string& path) {
|
||||
bool exists = false;
|
||||
bool folder = fileOps->isFolder(path, exists);
|
||||
if (!exists) return ShellApp::Target::Missing;
|
||||
if (!folder) return ShellApp::Target::File;
|
||||
bool empty = true;
|
||||
storageService->runAndWait([&]() {
|
||||
File dir = SD.open(path.c_str());
|
||||
if (dir) {
|
||||
File first = dir.openNextFile();
|
||||
empty = !first;
|
||||
}
|
||||
});
|
||||
return empty ? ShellApp::Target::EmptyFolder : ShellApp::Target::FullFolder;
|
||||
}
|
||||
|
||||
// The entries of a folder that Tab could complete to, for the Shell (issue #67). Read on the storage
|
||||
// task while the main loop waits, so it is held to what a crowded folder allows: 400 entries looked
|
||||
// at, 24 given back.
|
||||
static std::vector<std::string> shellList(const std::string& folder, const std::string& prefix, bool& more) {
|
||||
std::vector<std::string> names;
|
||||
more = false;
|
||||
if (!storageService->state().present) return names;
|
||||
storageService->runAndWait([&]() {
|
||||
std::string path = folder.size() > 1 ? folder.substr(0, folder.size() - 1) : folder; // without the slash at its end
|
||||
File dir = SD.open(path.c_str());
|
||||
if (!dir || !dir.isDirectory()) return;
|
||||
int seen = 0;
|
||||
for (File f = dir.openNextFile(); f; f = dir.openNextFile()) {
|
||||
if (++seen > 400) {
|
||||
more = true;
|
||||
break;
|
||||
}
|
||||
std::string name = f.name();
|
||||
bool fits = name.size() >= prefix.size();
|
||||
for (size_t i = 0; fits && i < prefix.size(); i++) fits = tolower(static_cast<unsigned char>(name[i])) == tolower(static_cast<unsigned char>(prefix[i]));
|
||||
if (!fits) continue;
|
||||
if (names.size() >= 24) {
|
||||
more = true;
|
||||
break;
|
||||
}
|
||||
names.push_back(name + (f.isDirectory() ? "/" : ""));
|
||||
}
|
||||
});
|
||||
std::sort(names.begin(), names.end());
|
||||
return names;
|
||||
}
|
||||
|
||||
static int shellCount(const std::string& pattern, bool& more) { return static_cast<int>(expandGlob(pattern, more).size()); }
|
||||
|
||||
static void serialCommands() {
|
||||
if (upload.active()) return readUploadBytes(); // raw file bytes, not commands
|
||||
static String line;
|
||||
@@ -1097,12 +1368,15 @@ static void loopPass() {
|
||||
|
||||
serialCommands();
|
||||
remoteCommands();
|
||||
shellCommands();
|
||||
tasksStep();
|
||||
screenshotStep();
|
||||
ipTrialStep();
|
||||
if (noiseTest) noiseTest->step(now);
|
||||
noteStableOnce(now);
|
||||
updateStep();
|
||||
fileOpsStep();
|
||||
fileQueueStep();
|
||||
uploadStep();
|
||||
printListingWhenReady();
|
||||
M5Cardputer.update();
|
||||
|
||||
+103
-30
@@ -4,6 +4,7 @@
|
||||
|
||||
#include <esp_log.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
@@ -35,41 +36,124 @@ void Console::captureEspLogs() {
|
||||
if (!espLogNext) espLogNext = esp_log_set_vprintf(teeEspLog); // once: it stays, and costs nothing with the ring closed
|
||||
}
|
||||
|
||||
bool Console::openRing() {
|
||||
if (ring_) return true;
|
||||
auto* fresh = static_cast<uint8_t*>(malloc(kRingBytes));
|
||||
namespace {
|
||||
|
||||
// The tasks printing As the Shell right now, and how deep each is in it. Under ringLock. Two at
|
||||
// once is the most there is (the main loop, and the storage task finishing a listing).
|
||||
struct ShellTask {
|
||||
TaskHandle_t task;
|
||||
uint8_t depth;
|
||||
};
|
||||
ShellTask shellTasks[4] = {};
|
||||
|
||||
bool fromShellLocked(TaskHandle_t task) {
|
||||
for (auto& t : shellTasks)
|
||||
if (t.depth && t.task == task) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// One ring: the last kRingBytes written, and how many were written in all. Under ringLock.
|
||||
bool openOne(uint8_t*& ring, uint32_t& head) {
|
||||
if (ring) return true;
|
||||
auto* fresh = static_cast<uint8_t*>(malloc(Console::kRingBytes));
|
||||
if (!fresh) return false;
|
||||
portENTER_CRITICAL(&ringLock);
|
||||
head_ = 0;
|
||||
ring_ = fresh;
|
||||
head = 0;
|
||||
ring = fresh;
|
||||
portEXIT_CRITICAL(&ringLock);
|
||||
return true;
|
||||
}
|
||||
|
||||
void Console::closeRing() {
|
||||
void closeOne(uint8_t*& ring) {
|
||||
portENTER_CRITICAL(&ringLock);
|
||||
uint8_t* old = ring_;
|
||||
ring_ = nullptr;
|
||||
uint8_t* old = ring;
|
||||
ring = nullptr;
|
||||
portEXIT_CRITICAL(&ringLock);
|
||||
free(old);
|
||||
}
|
||||
|
||||
void writeOne(uint8_t* ring, uint32_t& head, const uint8_t* data, size_t len) { // inside the lock
|
||||
if (!ring) return;
|
||||
size_t at = head % Console::kRingBytes;
|
||||
size_t first = std::min(len, Console::kRingBytes - at);
|
||||
memcpy(ring + at, data, first);
|
||||
memcpy(ring, data + first, len - first);
|
||||
head += len;
|
||||
}
|
||||
|
||||
size_t readOne(const uint8_t* ring, uint32_t head, uint32_t& pos, uint8_t* out, size_t max, uint32_t& skipped) {
|
||||
portENTER_CRITICAL(&ringLock);
|
||||
size_t n = 0;
|
||||
skipped = 0;
|
||||
if (ring) {
|
||||
uint32_t from = head > Console::kRingBytes ? head - Console::kRingBytes : 0;
|
||||
skipped = pos < from ? from - pos : 0;
|
||||
if (pos < from) pos = from;
|
||||
n = std::min<size_t>(max, head - pos);
|
||||
size_t at = pos % Console::kRingBytes;
|
||||
size_t first = std::min(n, Console::kRingBytes - at);
|
||||
memcpy(out, ring + at, first);
|
||||
memcpy(out + first, ring, n - first);
|
||||
pos += n;
|
||||
}
|
||||
portEXIT_CRITICAL(&ringLock);
|
||||
return n;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool Console::openRing() { return openOne(ring_, head_); }
|
||||
void Console::closeRing() { closeOne(ring_); }
|
||||
bool Console::openShellRing() { return openOne(shellRing_, shellHead_); }
|
||||
void Console::closeShellRing() { closeOne(shellRing_); }
|
||||
|
||||
void Console::toRing(const uint8_t* data, size_t len) {
|
||||
if (len > kRingBytes) {
|
||||
data += len - kRingBytes; // only the tail can fit
|
||||
len = kRingBytes;
|
||||
}
|
||||
TaskHandle_t task = xTaskGetCurrentTaskHandle();
|
||||
portENTER_CRITICAL(&ringLock);
|
||||
if (ring_) {
|
||||
size_t at = head_ % kRingBytes;
|
||||
size_t first = std::min(len, kRingBytes - at);
|
||||
memcpy(ring_ + at, data, first);
|
||||
memcpy(ring_, data + first, len - first);
|
||||
head_ += len;
|
||||
writeOne(ring_, head_, data, len);
|
||||
if (shellRing_ && (shellAll_ || fromShellLocked(task))) writeOne(shellRing_, shellHead_, data, len);
|
||||
portEXIT_CRITICAL(&ringLock);
|
||||
}
|
||||
|
||||
Console::Origin Console::origin() const {
|
||||
TaskHandle_t task = xTaskGetCurrentTaskHandle();
|
||||
portENTER_CRITICAL(&ringLock);
|
||||
bool shell = fromShellLocked(task);
|
||||
portEXIT_CRITICAL(&ringLock);
|
||||
return shell ? Origin::Shell : Origin::System;
|
||||
}
|
||||
|
||||
Console::As::As(Origin origin) : entered_(false) {
|
||||
if (origin != Origin::Shell) return;
|
||||
TaskHandle_t task = xTaskGetCurrentTaskHandle();
|
||||
portENTER_CRITICAL(&ringLock);
|
||||
ShellTask* slot = nullptr;
|
||||
for (auto& t : shellTasks)
|
||||
if (t.depth && t.task == task) slot = &t;
|
||||
if (!slot)
|
||||
for (auto& t : shellTasks)
|
||||
if (!t.depth && !slot) slot = &t;
|
||||
if (slot) {
|
||||
slot->task = task;
|
||||
slot->depth++;
|
||||
entered_ = true;
|
||||
}
|
||||
portEXIT_CRITICAL(&ringLock);
|
||||
}
|
||||
|
||||
Console::As::~As() {
|
||||
if (!entered_) return;
|
||||
TaskHandle_t task = xTaskGetCurrentTaskHandle();
|
||||
portENTER_CRITICAL(&ringLock);
|
||||
for (auto& t : shellTasks)
|
||||
if (t.depth && t.task == task) t.depth--;
|
||||
portEXIT_CRITICAL(&ringLock);
|
||||
}
|
||||
|
||||
uint32_t Console::oldest() const {
|
||||
portENTER_CRITICAL(&ringLock);
|
||||
uint32_t pos = head_ > kRingBytes ? head_ - kRingBytes : 0;
|
||||
@@ -78,22 +162,11 @@ uint32_t Console::oldest() const {
|
||||
}
|
||||
|
||||
size_t Console::readSince(uint32_t& pos, uint8_t* out, size_t max, uint32_t& skipped) {
|
||||
portENTER_CRITICAL(&ringLock);
|
||||
size_t n = 0;
|
||||
skipped = 0;
|
||||
if (ring_) {
|
||||
uint32_t from = head_ > kRingBytes ? head_ - kRingBytes : 0;
|
||||
skipped = pos < from ? from - pos : 0;
|
||||
if (pos < from) pos = from;
|
||||
n = std::min<size_t>(max, head_ - pos);
|
||||
size_t at = pos % kRingBytes;
|
||||
size_t first = std::min(n, kRingBytes - at);
|
||||
memcpy(out, ring_ + at, first);
|
||||
memcpy(out + first, ring_, n - first);
|
||||
pos += n;
|
||||
}
|
||||
portEXIT_CRITICAL(&ringLock);
|
||||
return n;
|
||||
return readOne(ring_, head_, pos, out, max, skipped);
|
||||
}
|
||||
|
||||
size_t Console::readShellSince(uint32_t& pos, uint8_t* out, size_t max, uint32_t& skipped) {
|
||||
return readOne(shellRing_, shellHead_, pos, out, max, skipped);
|
||||
}
|
||||
|
||||
size_t Console::write(const uint8_t* data, size_t len) {
|
||||
|
||||
@@ -34,9 +34,38 @@ class Console : public Print {
|
||||
// The ring only, for output that already reaches the serial port another way.
|
||||
void toRing(const uint8_t* data, size_t len);
|
||||
|
||||
// Who asked for what is being printed. A command typed in the Shell App prints its reply `As`
|
||||
// the Shell, and so does whatever answers it later from another task (a listing read by the
|
||||
// storage task, `tasks` a second on): the one that starts the work notes origin() and the one that
|
||||
// prints takes it back. Everything else is the System's: logs, and the consoles' own commands.
|
||||
enum class Origin : uint8_t { System, Shell };
|
||||
Origin origin() const; // of the task that asks, now
|
||||
class As {
|
||||
public:
|
||||
explicit As(Origin origin);
|
||||
~As();
|
||||
As(const As&) = delete;
|
||||
As& operator=(const As&) = delete;
|
||||
|
||||
private:
|
||||
bool entered_;
|
||||
};
|
||||
|
||||
// A second ring of the same kind, for the Shell App (issue #67): open while the App is. It gets
|
||||
// what is printed As the Shell, and nothing else, so that the Shell shows the replies to its own
|
||||
// commands and not the rest (Q206); or everything, when asked.
|
||||
bool openShellRing();
|
||||
void closeShellRing();
|
||||
void shellShowsAll(bool all) { shellAll_ = all; }
|
||||
bool shellShowsAll() const { return shellAll_; }
|
||||
size_t readShellSince(uint32_t& pos, uint8_t* out, size_t max, uint32_t& skipped);
|
||||
|
||||
private:
|
||||
uint8_t* ring_ = nullptr;
|
||||
uint32_t head_ = 0; // total bytes written since the ring opened; it holds the last kRingBytes of them
|
||||
uint8_t* shellRing_ = nullptr;
|
||||
uint32_t shellHead_ = 0;
|
||||
volatile bool shellAll_ = false;
|
||||
};
|
||||
|
||||
extern Console console;
|
||||
|
||||
@@ -140,6 +140,7 @@ bool GeminiService::fetch(const std::string& url, bool preferSaved) {
|
||||
bool GeminiService::fetchToConsole(const std::string& url) {
|
||||
if (busy_) return false;
|
||||
toConsole_ = true;
|
||||
consoleFrom_ = console.origin();
|
||||
if (!fetch(url)) {
|
||||
toConsole_ = false;
|
||||
return false;
|
||||
@@ -380,7 +381,10 @@ void GeminiService::runFetch(GeminiPage& page) {
|
||||
}
|
||||
}
|
||||
page.ms = millis() - started;
|
||||
if (toConsole_) report(page, before, lowest_);
|
||||
if (toConsole_) {
|
||||
Console::As as(consoleFrom_);
|
||||
report(page, before, lowest_);
|
||||
}
|
||||
}
|
||||
|
||||
// One request, following up to kMaxRedirects redirects.
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "platform/console.h"
|
||||
#include "event_bus.h"
|
||||
#include "gemini_response.h"
|
||||
#include "key_value_store.h"
|
||||
@@ -95,6 +96,7 @@ class GeminiService {
|
||||
|
||||
// `gemini get <url>`: the same fetch, reported on the console instead of to the App.
|
||||
bool fetchToConsole(const std::string& url);
|
||||
Console::Origin consoleFrom_ = Console::Origin::System; // who asked for that fetch: its report is theirs
|
||||
|
||||
private:
|
||||
enum class Job { Fetch, Save, SaveWithLinks, Refresh, Delete, Bookmark, Download, Window };
|
||||
|
||||
@@ -42,6 +42,7 @@ void PowerService::applyScreen(ScreenState state) {
|
||||
}
|
||||
|
||||
void PowerService::powerOff() {
|
||||
poweringOff_ = true;
|
||||
if (beforePowerOff) beforePowerOff();
|
||||
auto& display = M5Cardputer.Display;
|
||||
display.wakeup();
|
||||
|
||||
@@ -28,6 +28,7 @@ class PowerService : public Service {
|
||||
|
||||
// Run before the device powers off: the last chance to put on the card what's only in memory.
|
||||
std::function<void()> beforePowerOff;
|
||||
bool poweringOff() const { return poweringOff_; } // from beforePowerOff on: no time for long work
|
||||
|
||||
private:
|
||||
void applyScreen(ScreenState state);
|
||||
@@ -38,6 +39,7 @@ class PowerService : public Service {
|
||||
PowerButton button_{2000};
|
||||
ScreenState applied_ = ScreenState::On;
|
||||
int appliedBrightness_ = -1;
|
||||
bool poweringOff_ = false;
|
||||
};
|
||||
|
||||
} // namespace roro
|
||||
|
||||
@@ -260,6 +260,19 @@ void test_the_help_panel_scrolls_within_its_rows() {
|
||||
TEST_ASSERT_EQUAL(0, m.rows().size());
|
||||
}
|
||||
|
||||
// Issue #67: an App is opened by its name with a capital letter.
|
||||
void test_an_app_command_is_its_id_with_a_capital() {
|
||||
TEST_ASSERT_EQUAL_STRING("Notes", AppManager::commandFor("notes").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("Wifi", AppManager::commandFor("wifi-tools").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("Irc", AppManager::commandFor("irc").c_str());
|
||||
Fixture f;
|
||||
TEST_ASSERT_NOT_NULL(f.manager.byCommand("Notes"));
|
||||
TEST_ASSERT_EQUAL_STRING("notes", f.manager.byCommand("Notes")->id);
|
||||
TEST_ASSERT_NULL(f.manager.byCommand("notes")); // small letters are commands, not Apps
|
||||
TEST_ASSERT_NULL(f.manager.byCommand("Demo")); // hidden: not in the Launcher, not here
|
||||
TEST_ASSERT_NULL(f.manager.byCommand("Nope"));
|
||||
}
|
||||
|
||||
int main() {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_launcher_is_in_foreground_after_begin);
|
||||
@@ -283,5 +296,6 @@ int main() {
|
||||
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);
|
||||
RUN_TEST(test_an_app_command_is_its_id_with_a_capital);
|
||||
return UNITY_END();
|
||||
}
|
||||
|
||||
@@ -104,6 +104,67 @@ void test_looks_like_text() {
|
||||
TEST_ASSERT_TRUE(looksLikeText(nullptr, 0)); // an empty file reads as text
|
||||
}
|
||||
|
||||
void test_rm_takes_its_switches_like_unix() {
|
||||
auto a = parseRm("/notes/a.txt");
|
||||
TEST_ASSERT_FALSE(a.recursive);
|
||||
TEST_ASSERT_FALSE(a.force);
|
||||
TEST_ASSERT_EQUAL_STRING("/notes/a.txt", a.path.c_str());
|
||||
a = parseRm("-r /gemini/saved");
|
||||
TEST_ASSERT_TRUE(a.recursive);
|
||||
TEST_ASSERT_FALSE(a.force);
|
||||
TEST_ASSERT_EQUAL_STRING("/gemini/saved", a.path.c_str());
|
||||
for (const char* both : {"-rf /x", "-fr /x", "-r -f /x", "-f -r /x", "-R -f /x"}) {
|
||||
a = parseRm(both);
|
||||
TEST_ASSERT_TRUE(a.recursive);
|
||||
TEST_ASSERT_TRUE(a.force);
|
||||
TEST_ASSERT_EQUAL_STRING("/x", a.path.c_str());
|
||||
}
|
||||
a = parseRm("-f /a file with spaces.txt ");
|
||||
TEST_ASSERT_TRUE(a.force);
|
||||
TEST_ASSERT_EQUAL_STRING("/a file with spaces.txt", a.path.c_str());
|
||||
a = parseRm("-x /odd"); // not a switch of rm: taken as the name
|
||||
TEST_ASSERT_FALSE(a.force);
|
||||
TEST_ASSERT_EQUAL_STRING("-x /odd", a.path.c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("", parseRm("-rf").path.c_str());
|
||||
}
|
||||
|
||||
void test_a_pattern_matches_names() {
|
||||
TEST_ASSERT_TRUE(globMatch("*.txt", "notes.txt"));
|
||||
TEST_ASSERT_TRUE(globMatch("*.TXT", "notes.txt")); // the card doesn't tell cases apart
|
||||
TEST_ASSERT_FALSE(globMatch("*.txt", "notes.txt.bak"));
|
||||
TEST_ASSERT_TRUE(globMatch("*", "anything"));
|
||||
TEST_ASSERT_TRUE(globMatch("*", ""));
|
||||
TEST_ASSERT_TRUE(globMatch("a?c", "abc"));
|
||||
TEST_ASSERT_FALSE(globMatch("a?c", "ac"));
|
||||
TEST_ASSERT_FALSE(globMatch("a?c", "abbc"));
|
||||
TEST_ASSERT_TRUE(globMatch("2026-10-0?.log", "2026-10-07.log"));
|
||||
TEST_ASSERT_TRUE(globMatch("a*b*c", "a-then-b-then-c"));
|
||||
TEST_ASSERT_TRUE(globMatch("a*b*c", "abbbc"));
|
||||
TEST_ASSERT_FALSE(globMatch("a*b*c", "a-then-c"));
|
||||
TEST_ASSERT_TRUE(globMatch("*a", "banana")); // the star has to give back and try again
|
||||
TEST_ASSERT_FALSE(globMatch("*ab", "banana"));
|
||||
TEST_ASSERT_TRUE(globMatch("file-000?.txt", "file-0001.txt"));
|
||||
TEST_ASSERT_TRUE(globMatch("exact", "EXACT"));
|
||||
TEST_ASSERT_FALSE(globMatch("", "x"));
|
||||
TEST_ASSERT_TRUE(globMatch("**", "x"));
|
||||
}
|
||||
|
||||
void test_a_path_with_a_pattern_is_taken_apart() {
|
||||
std::string folder, pattern;
|
||||
TEST_ASSERT_TRUE(splitGlob("/notes/*.txt", folder, pattern));
|
||||
TEST_ASSERT_EQUAL_STRING("/notes", folder.c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("*.txt", pattern.c_str());
|
||||
TEST_ASSERT_TRUE(splitGlob("/*", folder, pattern));
|
||||
TEST_ASSERT_EQUAL_STRING("/", folder.c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("*", pattern.c_str());
|
||||
TEST_ASSERT_FALSE(splitGlob("/notes/a.txt", folder, pattern)); // no pattern
|
||||
TEST_ASSERT_FALSE(splitGlob("/*/a.txt", folder, pattern)); // not in a folder's name
|
||||
TEST_ASSERT_FALSE(splitGlob("/n*/a?.txt", folder, pattern));
|
||||
TEST_ASSERT_FALSE(splitGlob("*.txt", folder, pattern)); // a path starts at the top
|
||||
TEST_ASSERT_TRUE(hasGlob("a?"));
|
||||
TEST_ASSERT_FALSE(hasGlob("/plain/path"));
|
||||
}
|
||||
|
||||
int main() {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_path_parts);
|
||||
@@ -114,5 +175,8 @@ int main() {
|
||||
RUN_TEST(test_copy_names);
|
||||
RUN_TEST(test_kinds);
|
||||
RUN_TEST(test_looks_like_text);
|
||||
RUN_TEST(test_rm_takes_its_switches_like_unix);
|
||||
RUN_TEST(test_a_pattern_matches_names);
|
||||
RUN_TEST(test_a_path_with_a_pattern_is_taken_apart);
|
||||
return UNITY_END();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,521 @@
|
||||
#include <unity.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <map>
|
||||
#include <random>
|
||||
#include <string>
|
||||
|
||||
#include "note_document.h"
|
||||
|
||||
using namespace roro::notes;
|
||||
|
||||
void setUp() {}
|
||||
void tearDown() {}
|
||||
|
||||
namespace {
|
||||
// A card in memory. `budget`: how many more bytes it takes before the power is cut: the write
|
||||
// that crosses it is left half done, and nothing works after it.
|
||||
struct FakeCard : NoteCard {
|
||||
std::map<std::string, std::string> files;
|
||||
int64_t budget = -1;
|
||||
bool dead = false;
|
||||
uint64_t free = 1ull << 30;
|
||||
int reads = 0;
|
||||
|
||||
bool spend(size_t n) {
|
||||
if (dead) return false;
|
||||
if (budget < 0) return true;
|
||||
if (static_cast<int64_t>(n) <= budget) return budget -= static_cast<int64_t>(n), true;
|
||||
return false;
|
||||
}
|
||||
bool size(const std::string& path, uint32_t& size) override {
|
||||
auto it = files.find(path);
|
||||
if (dead || it == files.end()) return false;
|
||||
size = static_cast<uint32_t>(it->second.size());
|
||||
return true;
|
||||
}
|
||||
size_t read(const std::string& path, uint32_t at, uint8_t* into, size_t len) override {
|
||||
auto it = files.find(path);
|
||||
if (dead || it == files.end() || at > it->second.size()) return 0;
|
||||
reads++;
|
||||
size_t n = std::min(len, it->second.size() - at);
|
||||
std::copy(it->second.begin() + at, it->second.begin() + at + static_cast<long>(n), into);
|
||||
return n;
|
||||
}
|
||||
bool create(const std::string& path) override {
|
||||
if (!spend(1)) return dead = true, false;
|
||||
files[path].clear();
|
||||
return true;
|
||||
}
|
||||
bool append(const std::string& path, const uint8_t* data, size_t len) override {
|
||||
if (dead) return false;
|
||||
if (!spend(len)) {
|
||||
files[path].append(reinterpret_cast<const char*>(data), static_cast<size_t>(std::max<int64_t>(0, budget)));
|
||||
return dead = true, false;
|
||||
}
|
||||
files[path].append(reinterpret_cast<const char*>(data), len);
|
||||
return true;
|
||||
}
|
||||
bool remove(const std::string& path) override {
|
||||
if (!spend(1)) return dead = true, false;
|
||||
return files.erase(path) > 0;
|
||||
}
|
||||
bool rename(const std::string& from, const std::string& to) override {
|
||||
if (!spend(1)) return dead = true, false;
|
||||
if (!files.count(from) || files.count(to)) return false;
|
||||
files[to] = files[from];
|
||||
files.erase(from);
|
||||
return true;
|
||||
}
|
||||
uint64_t freeBytes() override { return free; }
|
||||
void powerBack() {
|
||||
dead = false;
|
||||
budget = -1;
|
||||
}
|
||||
};
|
||||
|
||||
constexpr int kCols = 38, kRows = 8;
|
||||
const char* const kNote = "/notes/big.txt";
|
||||
|
||||
std::string lines(int from, int count) {
|
||||
std::string s;
|
||||
char b[32];
|
||||
for (int i = 0; i < count; i++) {
|
||||
std::snprintf(b, sizeof b, "line %06d\n", from + i);
|
||||
s += b;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// Each line is 12 bytes: "line 000000\n".
|
||||
constexpr uint32_t kLine = 12;
|
||||
|
||||
bool rewrite(NoteDocument& d) {
|
||||
std::string why;
|
||||
if (!d.rewriteStart(why)) return false;
|
||||
for (int guard = 0; guard < 100000; guard++) {
|
||||
int p = d.rewriteStep(why);
|
||||
if (p == 100) return true;
|
||||
if (p < 0) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void settle(NoteDocument& d) {
|
||||
std::string why;
|
||||
if (d.wantsMove()) TEST_ASSERT_TRUE_MESSAGE(d.move(why), why.c_str());
|
||||
}
|
||||
|
||||
void type(NoteDocument& d, const std::string& s) {
|
||||
for (char c : s) {
|
||||
TEST_ASSERT_TRUE(d.text().insert(static_cast<uint8_t>(c)));
|
||||
settle(d);
|
||||
}
|
||||
}
|
||||
|
||||
std::string currentRow(NoteDocument& d) { return d.text().rows()[static_cast<size_t>(d.text().cursorRow())]; }
|
||||
} // namespace
|
||||
|
||||
void test_a_small_note_is_all_window() {
|
||||
FakeCard card;
|
||||
card.files[kNote] = "Hello\nworld\n";
|
||||
NoteDocument d(card, kCols, kRows);
|
||||
TEST_ASSERT_EQUAL_STRING("", d.open(kNote).c_str());
|
||||
TEST_ASSERT_FALSE(d.windowed());
|
||||
TEST_ASSERT_FALSE(d.dirty());
|
||||
TEST_ASSERT_TRUE(d.wantsRewrite());
|
||||
type(d, "Oh! ");
|
||||
TEST_ASSERT_TRUE(d.dirty());
|
||||
TEST_ASSERT_TRUE(rewrite(d));
|
||||
TEST_ASSERT_FALSE(d.dirty());
|
||||
TEST_ASSERT_EQUAL_STRING("Oh! Hello\nworld\n", card.files[kNote].c_str());
|
||||
TEST_ASSERT_EQUAL_size_t(1, card.files.size()); // no side file, no temporary file left
|
||||
}
|
||||
|
||||
void test_a_new_note_has_no_file_until_it_is_rewritten() {
|
||||
FakeCard card;
|
||||
NoteDocument d(card, kCols, kRows);
|
||||
type(d, "A list");
|
||||
TEST_ASSERT_TRUE(d.dirty());
|
||||
std::string why;
|
||||
TEST_ASSERT_FALSE(d.rewriteStart(why));
|
||||
TEST_ASSERT_FALSE(d.journal(why));
|
||||
TEST_ASSERT_EQUAL_size_t(0, card.files.size());
|
||||
d.setPath("/notes/a-list.txt");
|
||||
TEST_ASSERT_TRUE(rewrite(d));
|
||||
TEST_ASSERT_EQUAL_STRING("A list", card.files["/notes/a-list.txt"].c_str());
|
||||
type(d, "!");
|
||||
TEST_ASSERT_TRUE(rewrite(d));
|
||||
TEST_ASSERT_EQUAL_STRING("A list!", card.files["/notes/a-list.txt"].c_str());
|
||||
}
|
||||
|
||||
void test_a_big_file_opens_as_a_window() {
|
||||
FakeCard card;
|
||||
card.files[kNote] = lines(0, 20000); // 240 KB
|
||||
NoteDocument d(card, kCols, kRows);
|
||||
TEST_ASSERT_EQUAL_STRING("", d.open(kNote).c_str());
|
||||
TEST_ASSERT_TRUE(d.windowed());
|
||||
TEST_ASSERT_EQUAL_UINT32(240000, d.size());
|
||||
TEST_ASSERT_TRUE(d.text().size() <= NoteText::kMaxBytes / 2 + 200);
|
||||
TEST_ASSERT_EQUAL_STRING("line 000000", d.text().rows()[0].c_str());
|
||||
TEST_ASSERT_FALSE(d.wantsRewrite());
|
||||
TEST_ASSERT_FALSE(d.dirty());
|
||||
}
|
||||
|
||||
void test_walking_down_and_back_up_through_windows() {
|
||||
FakeCard card;
|
||||
card.files[kNote] = lines(0, 5000);
|
||||
NoteDocument d(card, kCols, kRows);
|
||||
d.open(kNote);
|
||||
for (int i = 1; i <= 3000; i++) {
|
||||
d.text().down();
|
||||
settle(d);
|
||||
if (i % 500 == 0) {
|
||||
TEST_ASSERT_EQUAL_UINT32(static_cast<uint32_t>(i) * kLine, d.cursor());
|
||||
TEST_ASSERT_EQUAL_STRING(lines(i, 1).substr(0, 11).c_str(), currentRow(d).c_str());
|
||||
TEST_ASSERT_EQUAL_INT(kRows - 1, d.text().cursorRow()); // the screen didn't jump when the window moved
|
||||
}
|
||||
}
|
||||
for (int i = 2999; i >= 0; i--) {
|
||||
d.text().up();
|
||||
settle(d);
|
||||
if (i % 500 == 0) {
|
||||
TEST_ASSERT_EQUAL_UINT32(static_cast<uint32_t>(i) * kLine, d.cursor());
|
||||
TEST_ASSERT_EQUAL_STRING(lines(i, 1).substr(0, 11).c_str(), currentRow(d).c_str());
|
||||
}
|
||||
}
|
||||
TEST_ASSERT_FALSE(d.dirty()); // looking isn't editing
|
||||
TEST_ASSERT_EQUAL_size_t(1, card.files.size()); // and writes nothing
|
||||
}
|
||||
|
||||
void test_start_and_end() {
|
||||
FakeCard card;
|
||||
card.files[kNote] = lines(0, 5000) + "the end";
|
||||
NoteDocument d(card, kCols, kRows);
|
||||
d.open(kNote);
|
||||
std::string why;
|
||||
TEST_ASSERT_TRUE(d.jump(d.size(), why));
|
||||
TEST_ASSERT_EQUAL_UINT32(d.size(), d.cursor());
|
||||
TEST_ASSERT_EQUAL_STRING("the end", currentRow(d).c_str());
|
||||
TEST_ASSERT_EQUAL_INT(kRows - 1, d.text().cursorRow());
|
||||
TEST_ASSERT_EQUAL_INT(99, d.percent());
|
||||
TEST_ASSERT_TRUE(d.jump(0, why));
|
||||
TEST_ASSERT_EQUAL_UINT32(0, d.cursor());
|
||||
TEST_ASSERT_EQUAL_STRING("line 000000", currentRow(d).c_str());
|
||||
}
|
||||
|
||||
void test_an_edit_in_the_middle_is_rewritten_into_the_file() {
|
||||
FakeCard card;
|
||||
std::string was = lines(0, 20000);
|
||||
card.files[kNote] = was;
|
||||
NoteDocument d(card, kCols, kRows);
|
||||
d.open(kNote);
|
||||
std::string why;
|
||||
TEST_ASSERT_TRUE(d.jump(120000, why));
|
||||
type(d, "HERE ");
|
||||
TEST_ASSERT_TRUE(d.jump(0, why)); // the changed window goes to the side file
|
||||
TEST_ASSERT_TRUE(card.files.count(std::string(kNote) + ".edit"));
|
||||
TEST_ASSERT_EQUAL_STRING(was.c_str(), card.files[kNote].c_str()); // the file isn't touched until the rewrite
|
||||
type(d, "TOP ");
|
||||
TEST_ASSERT_TRUE(d.filePending());
|
||||
TEST_ASSERT_TRUE(rewrite(d));
|
||||
std::string want = "TOP " + was.substr(0, 120000) + "HERE " + was.substr(120000);
|
||||
TEST_ASSERT_TRUE(want == card.files[kNote]);
|
||||
TEST_ASSERT_EQUAL_size_t(1, card.files.size());
|
||||
TEST_ASSERT_FALSE(d.filePending());
|
||||
TEST_ASSERT_FALSE(d.dirty());
|
||||
// And it goes on from there.
|
||||
TEST_ASSERT_TRUE(d.jump(d.size(), why));
|
||||
type(d, "END");
|
||||
TEST_ASSERT_TRUE(rewrite(d));
|
||||
TEST_ASSERT_TRUE(want + "END" == card.files[kNote]);
|
||||
}
|
||||
|
||||
void test_typing_never_fills_the_note() {
|
||||
FakeCard card;
|
||||
NoteDocument d(card, kCols, kRows);
|
||||
d.setPath("/notes/long.txt");
|
||||
std::string want;
|
||||
for (int i = 0; i < 4000; i++) { // 48 KB, three windows' worth
|
||||
std::string l = lines(i, 1);
|
||||
for (char c : l) {
|
||||
if (!d.text().insert(static_cast<uint8_t>(c))) TEST_FAIL_MESSAGE("the window was full");
|
||||
if (d.wantsMove()) {
|
||||
std::string why;
|
||||
if (d.path().empty() || !d.windowed()) TEST_ASSERT_TRUE(rewrite(d)); // what the editor does: a file first
|
||||
TEST_ASSERT_TRUE_MESSAGE(d.move(why), why.c_str());
|
||||
}
|
||||
}
|
||||
want += l;
|
||||
}
|
||||
TEST_ASSERT_TRUE(d.windowed());
|
||||
TEST_ASSERT_EQUAL_UINT32(48000, d.size());
|
||||
TEST_ASSERT_TRUE(rewrite(d));
|
||||
TEST_ASSERT_TRUE(want == card.files["/notes/long.txt"]);
|
||||
}
|
||||
|
||||
void test_a_journal_is_picked_up_after_a_power_cut() {
|
||||
FakeCard card;
|
||||
std::string was = lines(0, 20000);
|
||||
card.files[kNote] = was;
|
||||
std::string why;
|
||||
{
|
||||
NoteDocument d(card, kCols, kRows);
|
||||
d.open(kNote);
|
||||
d.jump(60000, why);
|
||||
type(d, "one ");
|
||||
TEST_ASSERT_TRUE(d.journal(why));
|
||||
TEST_ASSERT_FALSE(d.dirty());
|
||||
TEST_ASSERT_TRUE(d.filePending());
|
||||
d.jump(180000, why);
|
||||
type(d, "two ");
|
||||
TEST_ASSERT_TRUE(d.journal(why));
|
||||
type(d, "never saved");
|
||||
} // the power goes
|
||||
TEST_ASSERT_EQUAL_STRING(was.c_str(), card.files[kNote].c_str());
|
||||
NoteDocument d(card, kCols, kRows);
|
||||
std::string told;
|
||||
TEST_ASSERT_EQUAL_STRING("", d.open(kNote, &told).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("Your unsaved changes are back", told.c_str());
|
||||
TEST_ASSERT_EQUAL_UINT32(240008, d.size());
|
||||
TEST_ASSERT_EQUAL_UINT32(180004, d.cursor()); // where it was
|
||||
TEST_ASSERT_TRUE(d.filePending());
|
||||
TEST_ASSERT_TRUE(rewrite(d));
|
||||
TEST_ASSERT_TRUE(was.substr(0, 60000) + "one " + was.substr(60000, 119996) + "two " + was.substr(179996) == card.files[kNote]);
|
||||
TEST_ASSERT_EQUAL_size_t(1, card.files.size());
|
||||
}
|
||||
|
||||
void test_a_cut_at_any_byte_keeps_the_note_or_the_last_save() {
|
||||
std::string was = lines(0, 9000); // 108 KB
|
||||
std::string saved1 = was.substr(0, 50000) + "first " + was.substr(50000);
|
||||
std::string saved2 = "second " + saved1;
|
||||
int outcomes[3] = {0, 0, 0};
|
||||
for (int64_t budget = 0; budget < 240000; budget += 997) {
|
||||
FakeCard card;
|
||||
card.files[kNote] = was;
|
||||
std::string why;
|
||||
bool journaled1 = false, journaled2 = false, rewritten = false;
|
||||
{
|
||||
NoteDocument d(card, kCols, kRows);
|
||||
d.open(kNote);
|
||||
card.budget = budget;
|
||||
d.jump(50000, why);
|
||||
for (char c : std::string("first ")) d.text().insert(static_cast<uint8_t>(c));
|
||||
journaled1 = d.journal(why);
|
||||
if (journaled1 && d.jump(0, why)) {
|
||||
for (char c : std::string("second ")) d.text().insert(static_cast<uint8_t>(c));
|
||||
journaled2 = d.journal(why);
|
||||
if (journaled2) rewritten = rewrite(d);
|
||||
}
|
||||
}
|
||||
card.powerBack();
|
||||
// What the Notes list does with a temporary file that has lost its note.
|
||||
if (!card.files.count(kNote) && card.files.count(std::string(kNote) + ".tmp")) card.rename(std::string(kNote) + ".tmp", kNote);
|
||||
NoteDocument d(card, kCols, kRows);
|
||||
TEST_ASSERT_EQUAL_STRING("", d.open(kNote).c_str());
|
||||
TEST_ASSERT_TRUE(rewrite(d) || !d.dirty());
|
||||
if (d.filePending()) TEST_ASSERT_TRUE(rewrite(d));
|
||||
const std::string& now = card.files[kNote];
|
||||
int which = now == was ? 0 : now == saved1 ? 1 : now == saved2 ? 2 : -1;
|
||||
char msg[64];
|
||||
std::snprintf(msg, sizeof msg, "budget %ld", static_cast<long>(budget));
|
||||
TEST_ASSERT_TRUE_MESSAGE(which >= 0, msg); // never anything else
|
||||
if (rewritten) TEST_ASSERT_EQUAL_INT_MESSAGE(2, which, msg); // what was said to be saved is there
|
||||
else if (journaled2) TEST_ASSERT_EQUAL_INT_MESSAGE(2, which, msg);
|
||||
else if (journaled1) TEST_ASSERT_TRUE_MESSAGE(which >= 1, msg);
|
||||
TEST_ASSERT_EQUAL_size_t_MESSAGE(1, card.files.size(), msg); // and nothing is left lying about
|
||||
outcomes[which]++;
|
||||
}
|
||||
TEST_ASSERT_TRUE(outcomes[0] > 0 && outcomes[1] > 0 && outcomes[2] > 0);
|
||||
}
|
||||
|
||||
void test_a_file_changed_elsewhere_sets_the_edits_aside() {
|
||||
FakeCard card;
|
||||
card.files[kNote] = lines(0, 20000);
|
||||
std::string why;
|
||||
{
|
||||
NoteDocument d(card, kCols, kRows);
|
||||
d.open(kNote);
|
||||
d.jump(60000, why);
|
||||
type(d, "typed ");
|
||||
TEST_ASSERT_TRUE(d.journal(why));
|
||||
}
|
||||
card.files[kNote] = lines(7, 100); // someone put another file there
|
||||
NoteDocument d(card, kCols, kRows);
|
||||
std::string told;
|
||||
TEST_ASSERT_EQUAL_STRING("", d.open(kNote, &told).c_str());
|
||||
TEST_ASSERT_TRUE(told.find(".edit.lost") != std::string::npos);
|
||||
TEST_ASSERT_TRUE(card.files.count(std::string(kNote) + ".edit.lost"));
|
||||
TEST_ASSERT_FALSE(card.files.count(std::string(kNote) + ".edit"));
|
||||
TEST_ASSERT_TRUE(card.files[std::string(kNote) + ".edit.lost"].find("typed ") != std::string::npos); // it can be read
|
||||
TEST_ASSERT_EQUAL_UINT32(1200, d.size());
|
||||
TEST_ASSERT_FALSE(d.filePending());
|
||||
}
|
||||
|
||||
void test_crlf_becomes_lf_everywhere() {
|
||||
FakeCard card;
|
||||
std::string crlf, lf;
|
||||
for (int i = 0; i < 6000; i++) {
|
||||
std::string l = lines(i, 1);
|
||||
lf += l;
|
||||
crlf += l.substr(0, 11) + "\r\n";
|
||||
}
|
||||
card.files[kNote] = crlf;
|
||||
NoteDocument d(card, kCols, kRows);
|
||||
d.open(kNote);
|
||||
for (int i = 1; i <= 1500; i++) { // through several windows, looking only
|
||||
d.text().down();
|
||||
settle(d);
|
||||
}
|
||||
TEST_ASSERT_EQUAL_STRING("line 001500", currentRow(d).c_str());
|
||||
TEST_ASSERT_EQUAL_INT(0, d.text().cursorCol());
|
||||
type(d, "x");
|
||||
TEST_ASSERT_TRUE(rewrite(d));
|
||||
std::string want = lf;
|
||||
want.insert(1500 * kLine, "x");
|
||||
TEST_ASSERT_TRUE(want == card.files[kNote]);
|
||||
}
|
||||
|
||||
void test_windows_never_cut_a_character() {
|
||||
FakeCard card;
|
||||
std::string text;
|
||||
while (text.size() < 80000) text += "\xC3\xA9\xE2\x82\xAC\xF0\x9F\x98\x80"; // é, €, a face: no space, no newline
|
||||
card.files[kNote] = text;
|
||||
NoteDocument d(card, kCols, kRows);
|
||||
d.open(kNote);
|
||||
std::string why;
|
||||
for (uint32_t to : {30001u, 12345u, 79990u, 40003u, 5u}) {
|
||||
TEST_ASSERT_TRUE(d.jump(to, why));
|
||||
const std::string& w = d.text().text();
|
||||
TEST_ASSERT_TRUE((static_cast<uint8_t>(w[0]) & 0xC0) != 0x80);
|
||||
for (size_t i = 0; i < w.size();) { // whole characters, to the last
|
||||
uint8_t c = static_cast<uint8_t>(w[i]);
|
||||
size_t n = c >= 0xF0 ? 4 : c >= 0xE0 ? 3 : c >= 0xC0 ? 2 : 1;
|
||||
TEST_ASSERT_TRUE(c < 0x80 || c >= 0xC0);
|
||||
TEST_ASSERT_TRUE(i + n <= w.size());
|
||||
i += n;
|
||||
}
|
||||
TEST_ASSERT_TRUE((static_cast<uint8_t>(w[d.text().cursor()]) & 0xC0) != 0x80 || d.text().cursor() == w.size());
|
||||
type(d, "a");
|
||||
}
|
||||
TEST_ASSERT_TRUE(rewrite(d));
|
||||
TEST_ASSERT_EQUAL_size_t(text.size() + 5, card.files[kNote].size());
|
||||
}
|
||||
|
||||
void test_random_edits_match_a_plain_string() {
|
||||
std::mt19937 rng(47);
|
||||
for (int round = 0; round < 6; round++) {
|
||||
FakeCard card;
|
||||
std::string model;
|
||||
while (model.size() < 30000u + 20000u * static_cast<unsigned>(round)) {
|
||||
int words = 1 + static_cast<int>(rng() % 30);
|
||||
for (int w = 0; w < words; w++) model += std::string(1 + rng() % 9, static_cast<char>('a' + rng() % 26)) + " ";
|
||||
model += rng() % 4 ? "\n" : "\n\n";
|
||||
}
|
||||
card.files[kNote] = model;
|
||||
std::string saved = model; // what a power cut would leave
|
||||
NoteDocument* d = new NoteDocument(card, kCols, kRows);
|
||||
d->open(kNote);
|
||||
std::string why;
|
||||
for (int step = 0; step < 6000; step++) {
|
||||
unsigned op = rng() % 100;
|
||||
NoteText& t = d->text();
|
||||
if (op < 40) {
|
||||
char c = rng() % 8 ? static_cast<char>('A' + rng() % 26) : '\n';
|
||||
uint32_t at = d->cursor();
|
||||
TEST_ASSERT_TRUE(t.insert(static_cast<uint8_t>(c)));
|
||||
model.insert(at, 1, c);
|
||||
} else if (op < 50) {
|
||||
uint32_t at = d->cursor();
|
||||
t.backspace();
|
||||
if (at > 0 && d->cursor() == at - 1) model.erase(at - 1, 1);
|
||||
else TEST_ASSERT_EQUAL_UINT32(at, d->cursor());
|
||||
} else if (op < 60) t.left();
|
||||
else if (op < 70) t.right();
|
||||
else if (op < 78) t.up();
|
||||
else if (op < 86) t.down();
|
||||
else if (op < 90) t.pageUp();
|
||||
else if (op < 94) t.pageDown();
|
||||
else if (op < 96) TEST_ASSERT_TRUE(d->jump(rng() % (d->size() + 1), why));
|
||||
else if (op < 98) {
|
||||
if (d->dirty()) {
|
||||
TEST_ASSERT_TRUE(d->wantsRewrite() ? rewrite(*d) : d->journal(why));
|
||||
saved = model;
|
||||
}
|
||||
} else if (op == 98) {
|
||||
TEST_ASSERT_TRUE(rewrite(*d));
|
||||
saved = model;
|
||||
TEST_ASSERT_TRUE(model == card.files[kNote]);
|
||||
} else { // the power goes, and comes back
|
||||
delete d;
|
||||
d = new NoteDocument(card, kCols, kRows);
|
||||
TEST_ASSERT_EQUAL_STRING("", d->open(kNote).c_str());
|
||||
model = saved;
|
||||
}
|
||||
settle(*d);
|
||||
TEST_ASSERT_EQUAL_UINT32(static_cast<uint32_t>(model.size()), d->size());
|
||||
// The window is the note's text at its place.
|
||||
TEST_ASSERT_TRUE(d->cursor() <= model.size());
|
||||
uint32_t start = d->cursor() - static_cast<uint32_t>(d->text().cursor());
|
||||
TEST_ASSERT_TRUE(model.compare(start, d->text().size(), d->text().text()) == 0);
|
||||
}
|
||||
TEST_ASSERT_TRUE(rewrite(*d));
|
||||
TEST_ASSERT_TRUE(model == card.files[kNote]);
|
||||
TEST_ASSERT_EQUAL_size_t(1, card.files.size());
|
||||
delete d;
|
||||
}
|
||||
}
|
||||
|
||||
void test_a_full_card_and_a_missing_file() {
|
||||
FakeCard card;
|
||||
card.files[kNote] = lines(0, 20000);
|
||||
card.free = 1000;
|
||||
NoteDocument d(card, kCols, kRows);
|
||||
TEST_ASSERT_TRUE(d.open(kNote).find("Not enough room") != std::string::npos);
|
||||
TEST_ASSERT_TRUE(d.open("/notes/none.txt").find("refused") != std::string::npos);
|
||||
card.files["/notes/small.txt"] = "small";
|
||||
TEST_ASSERT_EQUAL_STRING("", d.open("/notes/small.txt").c_str()); // a small one still opens
|
||||
type(d, "x");
|
||||
std::string why;
|
||||
TEST_ASSERT_FALSE(d.rewriteStart(why));
|
||||
TEST_ASSERT_EQUAL_STRING("the card is full", why.c_str());
|
||||
TEST_ASSERT_TRUE(d.dirty());
|
||||
}
|
||||
|
||||
void test_the_side_file_and_the_piece_list_ask_for_a_rewrite() {
|
||||
FakeCard card;
|
||||
card.files[kNote] = lines(0, 20000);
|
||||
NoteDocument d(card, kCols, kRows);
|
||||
d.open(kNote);
|
||||
std::string why;
|
||||
int saves = 0;
|
||||
while (!d.wantsRewrite() && saves < 1000) {
|
||||
type(d, "x");
|
||||
TEST_ASSERT_TRUE(d.journal(why));
|
||||
saves++;
|
||||
}
|
||||
TEST_ASSERT_TRUE(saves > 50 && saves < 300); // about 1 MB of 8 KB windows
|
||||
TEST_ASSERT_TRUE(rewrite(d));
|
||||
TEST_ASSERT_FALSE(d.wantsRewrite());
|
||||
TEST_ASSERT_EQUAL_size_t(240000 + static_cast<size_t>(saves), card.files[kNote].size());
|
||||
}
|
||||
|
||||
int main() {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_a_small_note_is_all_window);
|
||||
RUN_TEST(test_a_new_note_has_no_file_until_it_is_rewritten);
|
||||
RUN_TEST(test_a_big_file_opens_as_a_window);
|
||||
RUN_TEST(test_walking_down_and_back_up_through_windows);
|
||||
RUN_TEST(test_start_and_end);
|
||||
RUN_TEST(test_an_edit_in_the_middle_is_rewritten_into_the_file);
|
||||
RUN_TEST(test_typing_never_fills_the_note);
|
||||
RUN_TEST(test_a_journal_is_picked_up_after_a_power_cut);
|
||||
RUN_TEST(test_a_cut_at_any_byte_keeps_the_note_or_the_last_save);
|
||||
RUN_TEST(test_a_file_changed_elsewhere_sets_the_edits_aside);
|
||||
RUN_TEST(test_crlf_becomes_lf_everywhere);
|
||||
RUN_TEST(test_windows_never_cut_a_character);
|
||||
RUN_TEST(test_random_edits_match_a_plain_string);
|
||||
RUN_TEST(test_a_full_card_and_a_missing_file);
|
||||
RUN_TEST(test_the_side_file_and_the_piece_list_ask_for_a_rewrite);
|
||||
return UNITY_END();
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
#include <unity.h>
|
||||
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "png_rgb332.h"
|
||||
|
||||
using namespace roro::png;
|
||||
|
||||
void setUp() {}
|
||||
void tearDown() {}
|
||||
|
||||
static uint32_t be(const std::vector<uint8_t>& d, size_t at) {
|
||||
return (uint32_t(d[at]) << 24) | (uint32_t(d[at + 1]) << 16) | (uint32_t(d[at + 2]) << 8) | d[at + 3];
|
||||
}
|
||||
|
||||
void test_the_checksums_match_their_reference_values() {
|
||||
const char* nine = "123456789";
|
||||
TEST_ASSERT_EQUAL_HEX32(0xCBF43926, crc32(0, reinterpret_cast<const uint8_t*>(nine), 9));
|
||||
TEST_ASSERT_EQUAL_HEX32(0x11E60398, adler32(1, reinterpret_cast<const uint8_t*>("Wikipedia"), 9));
|
||||
// Running: in two pieces, the same.
|
||||
uint32_t c = crc32(0, reinterpret_cast<const uint8_t*>(nine), 4);
|
||||
TEST_ASSERT_EQUAL_HEX32(0xCBF43926, crc32(c, reinterpret_cast<const uint8_t*>(nine + 4), 5));
|
||||
}
|
||||
|
||||
// Walks the chunks of what was written: every length adds up and every CRC is right.
|
||||
void test_a_small_image_is_a_well_formed_png() {
|
||||
std::vector<uint8_t> out;
|
||||
const int w = 4, h = 3;
|
||||
Rgb332Writer writer(w, h, [&](const uint8_t* d, size_t n) {
|
||||
out.insert(out.end(), d, d + n);
|
||||
return true;
|
||||
});
|
||||
TEST_ASSERT_TRUE(writer.begin());
|
||||
const uint8_t rows[3][4] = {{0x00, 0xE0, 0x1C, 0x03}, {0xFF, 0x80, 0x10, 0x02}, {1, 2, 3, 4}};
|
||||
for (auto& r : rows) TEST_ASSERT_TRUE(writer.row(r));
|
||||
TEST_ASSERT_FALSE(writer.row(rows[0])); // no more rows than the height
|
||||
TEST_ASSERT_TRUE(writer.end());
|
||||
TEST_ASSERT_EQUAL(Rgb332Writer::fileSize(w, h), out.size());
|
||||
|
||||
const uint8_t signature[] = {0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n'};
|
||||
TEST_ASSERT_EQUAL_MEMORY(signature, out.data(), 8);
|
||||
std::vector<std::string> kinds;
|
||||
std::vector<uint8_t> idat;
|
||||
for (size_t at = 8; at < out.size();) {
|
||||
uint32_t len = be(out, at);
|
||||
std::string kind(out.begin() + at + 4, out.begin() + at + 8);
|
||||
kinds.push_back(kind);
|
||||
TEST_ASSERT_EQUAL_HEX32(crc32(0, out.data() + at + 4, len + 4), be(out, at + 8 + len));
|
||||
if (kind == "IDAT") idat.assign(out.begin() + at + 8, out.begin() + at + 8 + len);
|
||||
if (kind == "IHDR") {
|
||||
TEST_ASSERT_EQUAL(w, be(out, at + 8));
|
||||
TEST_ASSERT_EQUAL(h, be(out, at + 12));
|
||||
TEST_ASSERT_EQUAL(8, out[at + 16]);
|
||||
TEST_ASSERT_EQUAL(3, out[at + 17]); // indexed
|
||||
}
|
||||
if (kind == "PLTE") {
|
||||
TEST_ASSERT_EQUAL(768, len);
|
||||
const uint8_t* white = out.data() + at + 8 + 255 * 3;
|
||||
TEST_ASSERT_EQUAL(255, white[0] + 0);
|
||||
TEST_ASSERT_EQUAL(255, white[1] + 0);
|
||||
TEST_ASSERT_EQUAL(255, white[2] + 0);
|
||||
const uint8_t* red = out.data() + at + 8 + 0xE0 * 3;
|
||||
TEST_ASSERT_EQUAL(255, red[0] + 0);
|
||||
TEST_ASSERT_EQUAL(0, red[1] + 0);
|
||||
}
|
||||
at += 12 + len;
|
||||
}
|
||||
TEST_ASSERT_EQUAL(4, kinds.size());
|
||||
TEST_ASSERT_EQUAL_STRING("IHDR", kinds[0].c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("PLTE", kinds[1].c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("IDAT", kinds[2].c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("IEND", kinds[3].c_str());
|
||||
|
||||
// The zlib stream: one stored, final block of (w + 1) * h bytes, then their Adler-32.
|
||||
const size_t raw = (w + 1) * h;
|
||||
TEST_ASSERT_EQUAL(2 + 5 + raw + 4, idat.size());
|
||||
TEST_ASSERT_EQUAL_HEX8(0x78, idat[0]);
|
||||
TEST_ASSERT_EQUAL(0, ((idat[0] << 8) | idat[1]) % 31); // the header's own check
|
||||
TEST_ASSERT_EQUAL_HEX8(0x01, idat[2]); // final, stored
|
||||
TEST_ASSERT_EQUAL(raw, idat[3] | (idat[4] << 8));
|
||||
TEST_ASSERT_EQUAL(static_cast<uint16_t>(~raw), idat[5] | (idat[6] << 8));
|
||||
TEST_ASSERT_EQUAL(0, idat[7]); // the first row's filter byte: none
|
||||
TEST_ASSERT_EQUAL_HEX8(0xE0, idat[9]); // its second pixel
|
||||
TEST_ASSERT_EQUAL_HEX32(adler32(1, idat.data() + 7, raw), be(idat, 7 + raw));
|
||||
}
|
||||
|
||||
void test_the_screen_fits_and_a_bigger_image_is_refused() {
|
||||
TEST_ASSERT_EQUAL(8 + 25 + 780 + 12 + (2 + 5 + 241 * 135 + 4) + 12, Rgb332Writer::fileSize(240, 135));
|
||||
int calls = 0;
|
||||
Rgb332Writer screen(240, 135, [&](const uint8_t*, size_t) { return ++calls > 0; });
|
||||
TEST_ASSERT_TRUE(screen.begin());
|
||||
Rgb332Writer big(320, 240, [](const uint8_t*, size_t) { return true; }); // 77,040 bytes: more than one block holds
|
||||
TEST_ASSERT_FALSE(big.begin());
|
||||
}
|
||||
|
||||
void test_a_sink_that_fails_stops_it_and_too_few_rows_dont_end() {
|
||||
Rgb332Writer failing(4, 3, [](const uint8_t*, size_t) { return false; });
|
||||
TEST_ASSERT_FALSE(failing.begin());
|
||||
Rgb332Writer shortOne(4, 3, [](const uint8_t*, size_t) { return true; });
|
||||
TEST_ASSERT_TRUE(shortOne.begin());
|
||||
const uint8_t row[4] = {};
|
||||
TEST_ASSERT_TRUE(shortOne.row(row));
|
||||
TEST_ASSERT_FALSE(shortOne.end());
|
||||
}
|
||||
|
||||
int main(int, char**) {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_the_checksums_match_their_reference_values);
|
||||
RUN_TEST(test_a_small_image_is_a_well_formed_png);
|
||||
RUN_TEST(test_the_screen_fits_and_a_bigger_image_is_refused);
|
||||
RUN_TEST(test_a_sink_that_fails_stops_it_and_too_few_rows_dont_end);
|
||||
return UNITY_END();
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
#include <unity.h>
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "shell_log.h"
|
||||
|
||||
using namespace roro;
|
||||
|
||||
void setUp() {}
|
||||
void tearDown() {}
|
||||
|
||||
static void feed(ShellLog& log, const std::string& text) { log.feed(text.data(), text.size()); }
|
||||
|
||||
void test_lines_arrive_in_pieces() {
|
||||
ShellLog log;
|
||||
feed(log, "firmware: roro");
|
||||
TEST_ASSERT_EQUAL(0, log.lines().size());
|
||||
feed(log, "9stack\r\nuptime: 1s\n");
|
||||
TEST_ASSERT_EQUAL(2, log.lines().size());
|
||||
TEST_ASSERT_EQUAL_STRING("firmware: roro9stack", log.lines()[0].c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("uptime: 1s", log.lines()[1].c_str());
|
||||
}
|
||||
|
||||
void test_the_heap_line_is_never_kept() {
|
||||
ShellLog log;
|
||||
feed(log, "status: heap 105000 min 90000\nirc: connected\n");
|
||||
TEST_ASSERT_EQUAL(1, log.lines().size());
|
||||
TEST_ASSERT_EQUAL_STRING("irc: connected", log.lines()[0].c_str());
|
||||
}
|
||||
|
||||
void test_the_oldest_lines_go_past_4_kb() {
|
||||
ShellLog log;
|
||||
for (int i = 0; i < 200; i++) feed(log, std::string(49, 'x') + "\n"); // 10 KB in all
|
||||
size_t bytes = 0;
|
||||
for (auto& l : log.lines()) bytes += l.size() + 1;
|
||||
TEST_ASSERT_TRUE(bytes <= ShellLog::kMaxBytes);
|
||||
TEST_ASSERT_TRUE(log.lines().size() >= 80);
|
||||
uint32_t before = log.revision();
|
||||
log.add("> info");
|
||||
TEST_ASSERT_EQUAL_STRING("> info", log.lines().back().c_str());
|
||||
TEST_ASSERT_TRUE(log.revision() != before);
|
||||
log.clear();
|
||||
TEST_ASSERT_EQUAL(0, log.lines().size());
|
||||
}
|
||||
|
||||
static const char* kHelp =
|
||||
"info firmware, uptime, memory\n"
|
||||
"ls [folder] | du <path> | mkdir <path> | rm [-r] [-f] <path> the SD card\n"
|
||||
"lora probe | lora status | lora rx on|off | lora preset <name> the LoRa radio\n"
|
||||
"lora sweep on [from MHz] [to MHz] | lora sweep off | lora sweep dump RSSI across a band\n"
|
||||
"gnss track start|stop | gnss nmea on|off | gnss send <sentence without $ and checksum>\n"
|
||||
"log level <0-5> ESP-IDF log level\n"
|
||||
"wifi ip <ssid> dhcp | wifi ip ... try <seconds> | wifi ip keep a Saved Network's IP setting\n"
|
||||
"sd card | sd list | cat <path> | log <text> | burst\n"
|
||||
"debug on | debug token <16 to 64 characters> | debug token new (USB serial only)\n"
|
||||
"get <path> | put <path> <size> <sha256> | screenshot (Debug Console only) binary\n"
|
||||
"Irc | Notes | Storage open that App\n";
|
||||
|
||||
static std::string tab(const std::string& typed, std::vector<std::string>* candidates = nullptr) {
|
||||
std::vector<std::string> matches;
|
||||
std::string out = completeWords(typed, kHelp, matches);
|
||||
if (candidates) *candidates = matches;
|
||||
return out;
|
||||
}
|
||||
|
||||
void test_tab_completes_the_first_word() {
|
||||
std::vector<std::string> m;
|
||||
TEST_ASSERT_EQUAL_STRING("info ", tab("in", &m).c_str()); // the only one: with its space
|
||||
TEST_ASSERT_EQUAL(0, m.size());
|
||||
TEST_ASSERT_EQUAL_STRING("l", tab("l", &m).c_str()); // ls, lora, log: they agree on no more
|
||||
TEST_ASSERT_EQUAL(3, m.size());
|
||||
TEST_ASSERT_EQUAL_STRING("lo", tab("lo", &m).c_str()); // lora, log
|
||||
TEST_ASSERT_EQUAL(2, m.size());
|
||||
TEST_ASSERT_EQUAL_STRING("mkdir ", tab("m").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("zz", tab("zz", &m).c_str()); // nothing: as it was
|
||||
TEST_ASSERT_EQUAL(0, m.size());
|
||||
TEST_ASSERT_EQUAL_STRING("", tab("").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("Notes ", tab("N").c_str()); // an App: a capital
|
||||
TEST_ASSERT_EQUAL_STRING("n", tab("n").c_str()); // and no command starts with n here
|
||||
TEST_ASSERT_EQUAL_STRING("screenshot ", tab("sc").c_str());
|
||||
}
|
||||
|
||||
// Every word of a command, not only the first.
|
||||
void test_tab_completes_the_words_after_the_first() {
|
||||
std::vector<std::string> m;
|
||||
TEST_ASSERT_EQUAL_STRING("lora status ", tab("lora st").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("lora s", tab("lora s", &m).c_str()); // status, sweep
|
||||
TEST_ASSERT_EQUAL(2, m.size());
|
||||
TEST_ASSERT_EQUAL_STRING("lora ", tab("lora ", &m).c_str()); // everything lora can be followed by
|
||||
const char* afterLora[] = {"probe", "status", "rx", "preset", "sweep"};
|
||||
TEST_ASSERT_EQUAL(5, m.size());
|
||||
for (size_t i = 0; i < m.size(); i++) TEST_ASSERT_EQUAL_STRING(afterLora[i], m[i].c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("lora rx o", tab("lora rx o", &m).c_str()); // on|off: either
|
||||
TEST_ASSERT_EQUAL(2, m.size());
|
||||
TEST_ASSERT_EQUAL_STRING("lora rx off ", tab("lora rx of").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("lora sweep dump ", tab("lora sweep d").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("gnss track st", tab("gnss track st", &m).c_str()); // start, stop
|
||||
TEST_ASSERT_EQUAL(2, m.size());
|
||||
TEST_ASSERT_EQUAL_STRING("gnss track stop ", tab("gnss track sto").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("log level ", tab("log l").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("wifi ip keep ", tab("wifi ip k").c_str()); // past the <ssid> of its neighbours
|
||||
TEST_ASSERT_EQUAL_STRING("debug token new ", tab("debug token n").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("sd ", tab("sd ", &m).c_str());
|
||||
TEST_ASSERT_EQUAL(2, m.size()); // card, list
|
||||
}
|
||||
|
||||
// Where a command's words end, Tab has nothing to say: the Shell then tries a path.
|
||||
void test_tab_stops_where_the_arguments_start() {
|
||||
std::vector<std::string> m;
|
||||
TEST_ASSERT_EQUAL_STRING("ls /no", tab("ls /no", &m).c_str());
|
||||
TEST_ASSERT_EQUAL(0, m.size());
|
||||
TEST_ASSERT_EQUAL_STRING("rm ", tab("rm ", &m).c_str());
|
||||
TEST_ASSERT_EQUAL(0, m.size());
|
||||
TEST_ASSERT_EQUAL_STRING("lora preset Lo", tab("lora preset Lo").c_str()); // a <name>: not a word of the command
|
||||
TEST_ASSERT_EQUAL_STRING("lora status x", tab("lora status x").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("nope st", tab("nope st").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("lora st", tab("lora st").c_str()); // two spaces
|
||||
}
|
||||
|
||||
void test_a_later_word_is_taken_apart_as_a_path() {
|
||||
PathToComplete p;
|
||||
TEST_ASSERT_FALSE(splitForPath("ls", p)); // still the command
|
||||
TEST_ASSERT_FALSE(splitForPath("rm -r", p)); // a switch
|
||||
TEST_ASSERT_FALSE(splitForPath("irc say he", p)); // not a path, and irc doesn't take one
|
||||
TEST_ASSERT_TRUE(splitForPath("rm -r /notes/sh", p));
|
||||
TEST_ASSERT_EQUAL_STRING("rm -r ", p.head.c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("/notes/", p.folder.c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("sh", p.prefix.c_str());
|
||||
TEST_ASSERT_TRUE(splitForPath("ls /", p));
|
||||
TEST_ASSERT_EQUAL_STRING("/", p.folder.c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("", p.prefix.c_str());
|
||||
TEST_ASSERT_TRUE(splitForPath("cat no", p)); // a file command: the slash is understood
|
||||
TEST_ASSERT_EQUAL_STRING("cat ", p.head.c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("/", p.folder.c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("no", p.prefix.c_str());
|
||||
TEST_ASSERT_TRUE(splitForPath("ls ", p));
|
||||
TEST_ASSERT_EQUAL_STRING("/", p.folder.c_str());
|
||||
TEST_ASSERT_TRUE(splitForPath("gemini get /x", p)); // any word that starts with a slash
|
||||
TEST_ASSERT_TRUE(splitForPath("cp /notes/a.txt /gem", p)); // the second path of two
|
||||
TEST_ASSERT_EQUAL_STRING("cp /notes/a.txt ", p.head.c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("gem", p.prefix.c_str());
|
||||
}
|
||||
|
||||
void test_tab_completes_a_path() {
|
||||
std::vector<std::string> root = {"captures/", "gemini/", "gnss/", "irc/", "notes/", "updates/", "wifi/", "readme.txt"};
|
||||
std::vector<std::string> matches;
|
||||
PathToComplete p;
|
||||
splitForPath("ls /no", p);
|
||||
TEST_ASSERT_EQUAL_STRING("ls /notes/", completePath(p, root, matches).c_str()); // a folder: its slash, to go on
|
||||
TEST_ASSERT_EQUAL(0, matches.size());
|
||||
splitForPath("cat /re", p);
|
||||
TEST_ASSERT_EQUAL_STRING("cat /readme.txt ", completePath(p, root, matches).c_str()); // a file: done, a space
|
||||
splitForPath("ls /g", p);
|
||||
TEST_ASSERT_EQUAL_STRING("ls /g", completePath(p, root, matches).c_str()); // gemini, gnss: they agree on no more
|
||||
TEST_ASSERT_EQUAL(2, matches.size());
|
||||
TEST_ASSERT_EQUAL_STRING("gemini/", matches[0].c_str());
|
||||
splitForPath("ls /zz", p);
|
||||
TEST_ASSERT_EQUAL_STRING("ls /zz", completePath(p, root, matches).c_str()); // nothing: as it was
|
||||
TEST_ASSERT_EQUAL(0, matches.size());
|
||||
splitForPath("rm -r /NO", p); // the card doesn't tell cases apart: the name's own case is taken
|
||||
TEST_ASSERT_EQUAL_STRING("rm -r /notes/", completePath(p, root, matches).c_str());
|
||||
std::vector<std::string> notes = {"shopping-list.txt", "Shed.txt", "todo.txt"};
|
||||
splitForPath("cat /notes/sh", p);
|
||||
TEST_ASSERT_EQUAL_STRING("cat /notes/sh", completePath(p, notes, matches).c_str()); // "sh" and "Sh": as far as they agree
|
||||
TEST_ASSERT_EQUAL(2, matches.size());
|
||||
splitForPath("cat /notes/", p);
|
||||
TEST_ASSERT_EQUAL_STRING("cat /notes/", completePath(p, notes, matches).c_str()); // everything fits: the list
|
||||
TEST_ASSERT_EQUAL(3, matches.size());
|
||||
}
|
||||
|
||||
int main(int, char**) {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_lines_arrive_in_pieces);
|
||||
RUN_TEST(test_the_heap_line_is_never_kept);
|
||||
RUN_TEST(test_the_oldest_lines_go_past_4_kb);
|
||||
RUN_TEST(test_tab_completes_the_first_word);
|
||||
RUN_TEST(test_tab_completes_the_words_after_the_first);
|
||||
RUN_TEST(test_tab_stops_where_the_arguments_start);
|
||||
RUN_TEST(test_a_later_word_is_taken_apart_as_a_path);
|
||||
RUN_TEST(test_tab_completes_a_path);
|
||||
return UNITY_END();
|
||||
}
|
||||
Reference in New Issue
Block a user