Files
roro9stack/docs/milestones/F1.md
T
twislaandClaude Opus 5.5 2c18762614
CI / build (pull_request) Successful in 1m56s
Site / build (pull_request) Successful in 10s
Storage: view pictures, PNG, JPEG, BMP and GIF (#45)
Enter on a picture shows it: shrunk to fit the screen, or at its own size
with Enter again and the arrows to move. Dithered to the screen's 256
colours; a colour the screen has exactly is left alone, so screenshots are
shown as they are.

The picture is decoded once, straight into the screen's buffer, and kept
there (App::retainsContent): no copy in memory. Decoding runs on the
storage task, so the keys keep working and a 12 megapixel photograph
appears as it comes instead of tripping the watchdog.

PNG, BMP and GIF are read by decoders of our own, host-tested against files
made by Pillow; the PNG one needs 32 KB where the display library's needed
44 KB in one block, which the device often doesn't have. JPEG uses the
library's TJpgDec.

Also corrects two sentences that still gave 16 KB as the editing limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-07 21:04:39 +02:00

31 KiB

F1 — Files and Notes

Status: in progress. The Storage App (issue #3) shipped as v0.9.0 on 2026-10-06. Notes (#19) shipped as v0.10.0 the same day. The card as a USB drive (#1) comes after.

Goal: get at what's on the SD card from the device itself: browse it, look inside the files the firmware writes, copy, move, rename and delete, and keep notes. A side milestone, like G1 and S1; Files and Notes were M3's original second half (Q30, Q89).

The Storage App (issue #3)

Until now the card could be looked at only through the Debug Console (ls, get, put), and Settings > Storage could only delete whole categories by age.

On the card today: six top-level folders, irc, wifi, updates, gnss, gemini and captures. No notes yet; settings are in flash, not on the card.

Decisions (design round 2026-10-06)

# Decision
Q128 An App of its own, Storage, in the Launcher. Settings > Storage goes away: its usage figures, Storage Clean-up and "Erase SD card" move into the App, under Maintenance, behind a warning that these delete things for good.
Q129 A row shows the name, then the size or "folder", then the date modified. Folders first, then by name; s cycles the sort (name, date, size). The top line shows the path and the card's free space.
Q130 Nothing is hidden. Read-only: /gemini/cache; any file the firmware has open right now (today's IRC log, a Track or Capture being recorded, a file being received); and the top-level folders themselves, which can't be renamed or deleted though their contents can. Everything else, the user's own data included, can be renamed, moved or deleted, always after a confirmation.
Q131 One item at a time, with a clipboard: Enter opens; Back goes up, and leaves the App at the top; c copy, x cut, v paste into the current folder; r rename; d delete; n new folder; i details.
Q132 Copy, move and delete work on folders too, recursively. The confirmation says what's inside: "Delete saved and its 42 files?".
Q133 A copy is a job on the storage task in 4 KB pieces, with a progress Toast; Back cancels it. It checks free space first and asks before replacing anything. Afterwards the sizes are compared, not the contents: the driver is trusted since v0.6.1 (ADR 0007). A move within the card is a rename.
Q134 Viewers by type. Text (.txt, .log, .gmi, .csv, .gpx, and anything that looks like text): read from the card as you scroll, so size doesn't matter; logs open at the end. .pcap: the LoRa Scanner's packet list. .gpx: a summary (start, duration, points, distance), Tab for the text. .ota: version, size, whether the signature is valid; Enter installs through Update from SD. Anything else: a hex dump.
Q135 No editing: that comes with Notes (#19).
Q136 A listing holds up to 256 entries, packed, about 10 KB; a bigger folder shows the first 256 by name and says how many more there are. The App refuses to open below the memory floors (Q86).
Q137 The Clock also sets the system time, so files are dated correctly with GNSS alone and not only after NTP. A file dated before 2020 shows "-".
Q138 Console: cp, mv and mkdir, next to ls and rm.
Q139 Left out, each with its issue: selecting several items (#41), finding files by name (#42), opening a .gmi in the Gemini App (#43), a table view for .csv (#44), images (#45).
Q140 Ships as v0.9.0 when done and checked.

Done when

  • The Storage App lists any folder of the card with sizes and dates, sorted three ways, and says so when a folder has more than 256 entries.
  • A file can be copied, moved, renamed and deleted, and a folder too; a new folder can be made. Each destructive action asks first; a copy shows progress and can be cancelled.
  • The read-only rules of Q130 hold, with a reason given when something is refused.
  • Each viewer of Q134 opens its type, and a 1 MB text file scrolls without loading whole.
  • Maintenance shows the card's usage and does what Settings > Storage did, behind its warning; Settings no longer has a Storage row; the Storage Warning points at the Storage App.
  • A file written with only a GNSS Fix (no Wi-Fi) is dated correctly.
  • Free heap stays above the floors with the App open, Wi-Fi and IRC on TLS.

Work breakdown

  1. Model (host-tested): paths and names, the read-only rules, the packed listing and its sorts, file types, sizes and dates for display, the GPX summary.
  2. Card operations: listing a folder, copy, move, delete (recursive, counted), new folder, as storage jobs with progress and cancel; cp, mv, mkdir; the Clock sets the system time.
  3. The App: browsing, the clipboard, dialogs, details.
  4. Maintenance: usage, Clean-up and Erase moved in from Settings, with the warning.
  5. Viewers: text, hex, .pcap, .gpx, .ota.
  6. Checks on the device, recorded here.

As built

  • FileOps (src/services/file_ops) does the card's work for the App and for the console alike: list, count, copy, move, delete, new folder. One operation at a time on the storage task, in turns of about 150 ms that queue themselves again, so Log lines and a Capture are written in between. The rules of Q130 are checked there, whoever asks.
  • A listing reads the folder straight from FatFs. Through the Arduino File, every entry was looked up by name again for its size and again for its date: 329 entries took over two seconds. One pass now, and it's there before the screen has redrawn. Counting, copying and deleting still walk with File; they show progress and can be stopped.
  • A copy shows its progress in a box in the App, not a Toast (Q133): it has a bar and says Back cancels. A cancelled or failed copy deletes what it had written. The copy gets today's date, like cp.
  • The viewers (src/apps/file_viewer, models in lib/files): text through TextPager, which reads about a kilobyte around the screen and wraps at spaces, 38 columns; going back a line wraps the paragraph before again, so a file reads the same in both directions. A .pcap, a .gpx and an .ota are read through once by a storage job, in the same 150 ms turns. An Update File is fed to the installer's own parser with a sink that writes nothing, so "would it install" is the same answer an install gives.
  • Tab in a viewer shows the same file as hex, or as text (not in Q134).
  • Maintenance is the last row at the top of the card, and m anywhere in the App. It's the old Settings > Storage page behind a dialog.
  • The Storage Warning was only ever a Toast; "selecting it opens Storage Clean-up" (CONTEXT.md) was never built. It now reads "SD card over 80% full: see Storage".
  • Console: cp, mv, mkdir (Q138), and rm and du through the same code, so rm now takes folders and follows the rules; ls shows dates. Debug Builds: sd fill <folder> <count> makes test files.

Checks on the device (2026-10-06, v0.8.1-2 Debug Build)

All in a scratch folder, /f1test, removed afterwards.

Check Result
Host tests 424 pass (411 before the viewers' models)
Browsing Folders first, sizes and dates, the three sorts; a 300-file and a 329-file folder show "first 256 of 300" and "of 329"
New folder, rename, copy, cut and paste, delete Each works on a file and on a folder; a copy next to its original is named (2); a name in the way asks "Replace it?"
A folder of 11 files, 8.4 MB, copied 19.4 s, 435 KB/s, the bar moving; two Log lines queued meanwhile were written
The same copy cancelled at 1.8 MB "Cancelled: nothing was copied", and nothing was left behind
Delete 341 files in 9.7 s; the dialog had counted them first
Read-only rules /irc, /gnss (top-level folders), /, /gemini/cache and a folder made inside it, a folder into itself, a name with :; a Capture being recorded and the folder holding it; a folder under /irc while IRC runs. Each refused with its reason; the Capture could still be copied
Text A 1 MB log opens at its last line at once; top, pages, lines; a file without an extension that looks like text opens as text
Hex A 5 KB binary file; Tab from any other viewer
.pcap A LoRa Capture: 3 packets as the Scanner lists them, Enter shows the Meshtastic header and bytes
.gpx 400 points: start, 33 min 15 s, 4.68 km; Tab shows the text
.ota A signed file: version, "intact", "older than what's running", Enter asks to install (not confirmed). A tampered one: "image corrupted (hash mismatch)"
Maintenance The warning, then usage, Clean-up's categories and Erase (not run)
Date with GNSS only NTP pointed at an address that doesn't answer, restart: the Clock came from the Fix, and a folder made then is dated 2026-10-06 08:39. A Track from the day before, written the same way by v0.8.1, shows "-"
Memory IRC connected, the App open on 256 entries: 61 KB free (70 KB before opening). Lowest since boot 29.7 KB, during IRC's TLS handshake
Stacks storage 3.1 KB free of 6 KB at worst, loopTask 1.5 KB

Not checked by hand: how the keys feel on the device itself; everything above was driven through the Debug Console's key command and screenshots.

One slip during the checks: a scripted key sequence ran in the wrong folder and renamed /gemini/saved to saved2, then copied it to the top of the card. Both were put right at once (renamed back, the copy deleted; 7 files, 53,798 bytes, as before).

Found on the way: a panic at Wi-Fi join, there since v0.7.0 (SNTP started twice, issue #46). Fixed in v0.9.0.

Notes (issue #19)

Plain text notes on the SD card, written on the device. Q30 settled the base: .txt files in /notes, created, edited and deleted from the device, never offered by Storage Clean-up.

Decisions (design round 2026-10-06)

# Decision
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). (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.
Q148 UTF-8, LF line ends; a file with CRLF is saved back with LF. Characters the font lacks are kept on save.
Q149 Left out, each with its issue: editing files of any size (#47), searching inside notes (#48), undo (#49), selecting and copying text (#50).
Q150 Ships as v0.10.0 when built and checked on the device.

Done when

  • A note can be started, typed with accents, left and found again in the list under its first line; renamed; deleted after a confirmation.
  • What's typed is on the card five seconds after the last key, and after Back, Home, or the screen turning off, without a prompt.
  • Pulling the power while typing loses a few seconds at most, and the note is never left empty or half-written.
  • The cursor moves by character and by line through wrapped text, and the screen follows it; a 16 KB note edits without lag.
  • A note at 16 KB refuses more text and says so; a bigger file opens read-only.
  • e in the Storage App's text viewer edits a file; a read-only one is refused with its reason.
  • Free heap stays above the floors with a 16 KB note open and IRC connected.

Work breakdown

  1. Model (host-tested): the text buffer with its cursor, wrapping and scrolling; file names from first lines.
  2. The editor on the device: loading, drawing, keys, autosave through a temporary file, recovery.
  3. The Notes App: the list with titles, new, rename, delete.
  4. e in the Storage App.
  5. Checks on the device, recorded here.

As built

  • NoteText (lib/notes, host-tested) is the text, its cursor and the screen around it. A line owns the space or the newline it ends with, so every byte is on exactly one line and the cursor has one place for each. No index of lines is kept: a note of newlines alone would need twice its own size for one. Where a line starts is worked out from the start of its paragraph.
  • One buffer, 16 KB, for as long as the editor is open. It's reserved when the note is opened, the file is read straight into it, and typing never makes it grow. On this device a failed allocation is an abort, and with IRC connected the largest free block is about 31 KB whatever the total says: the first version read the file into one string and copied it into another, and opening a full note with IRC connected restarted the device. The editor now also refuses to open without a free block of 24 KB.
  • NoteEditor (src/apps/note_editor) is shared by the Notes App and the Storage App's e. A save runs on the storage task while the main loop waits for it: no second copy of the note, and at 16 KB the wait is a fraction of a second at a moment when nobody has typed for five.
  • A save writes <note>.tmp, checks its size, deletes the note and renames the temporary file (FAT can't rename onto a file). A cut between the last two steps leaves only the .tmp: the Notes list puts such a file back under its name. A .tmp next to its note is an unfinished save: opening the note offers it.
  • Titles in the list are read from the card for the eight rows on screen, when the list moves.
  • Before powering off, the firmware now leaves the foreground App (PowerService::beforePowerOff), which makes the editor save.
  • Shift or Alt with Fn+Up and Fn+Down moves a page (not in Q145).

Found on the way

  • The screen could go "off" for one tick after a key sent through the Debug Console, and the next key was then swallowed as a wake-up: the key command stamps the power timer from millis(), the power tick compares with its pass's older time, and the unsigned difference read as 49 days idle. The same shape as #46. Fixed in PowerPolicy::update with a test. Keys from the keyboard were never affected. It explains remote keys "lost" in earlier sessions.
  • scripts/rdbg.py held back piped lines written while it was still connecting, until the next line came (a buffered readline() behind select()). Fixed.

Checks on the device (2026-10-06, Debug Build of branch notes)

Test notes were made in /notes and removed afterwards; the folder is left, empty.

Check Result
Host tests 439 pass
A first note "No notes yet", n, typed three lines: the top line says "typing", then "saved" five seconds after the last key, under shopping-list.txt. 63 keys in a row all arrived
Leaving Back saves and returns to the list, which shows the note under its first line. Home in the middle of a new note saved it as ideas.txt
The cursor Down, Right, an insertion in the middle of a line; the screen scrolls through a note of about 230 lines
A power cut Typed, waited seven seconds, typed more and restarted the device at once (reset): the note has what was saved, whole, and not the last keys
An unfinished save A .tmp next to its note: "Unsaved copy... Keep the note / Use the copy"; using it brings its text back and saves it. A .tmp alone was put back under its name when the list opened
16 KB A note of exactly 16,384 bytes opens and scrolls; one more character: "This note is full: 16 KB". A file of 16,398 bytes: "Too big to edit: 16 KB at most"
Rename, delete, sort r renamed orphan.txt to orphan2.txt; d asked, then deleted; s switched between newest first and by file name
e in the Storage App A note opened from the text viewer, edited, saved on Back; the listing shows its new size
Memory IRC connected, the full 16 KB note open: 55 KB free, largest block 31.7 KB (72 KB free before opening)

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.

Pictures in the Storage App (issue #45)

Q139 left images out: the firmware wrote none. Since the Shell's screenshot it does.

Decisions (design round 2026-10-07)

# Decision
Q233 PNG, JPEG, BMP and GIF. A GIF shows its first picture; it doesn't move.
Q234 Revised while building. Our own PNG decoder, a row at a time, with the window the file's compression asks for: 32 KB at most. The plan was the display library's, which takes 44 KB in one block: after one picture the largest free block was 43 to 47 KB, and every second PNG was refused. The firmware's own screenshots are read with no decoding at all: they are stored uncompressed, each byte already a colour of the screen.
Q235 The picture is decoded once, straight into the screen's buffer, and left there. No copy in memory (it would be up to 30 KB). App::retainsContent() tells the screen not to clear the App's part; contentLost() tells the App that it was cleared after all, or that a Toast or the help panel drawn over it has gone: then it is decoded again.
Q236 Shrunk to fit; Enter shows it at its own size, the arrows then moving half a screen. A picture smaller than the screen sits in the middle at its size.
Q237 Ordered dithering to the screen's 256 colours (a 4 x 4 pattern). A colour the screen has exactly comes out as itself wherever it lands, so a screenshot isn't touched.
Q238 Shrinking takes, for each pixel of the screen, the first of the picture's that falls on it. No averaging: there is nowhere to keep the sums. Thin lines break up; a JPEG looks better, its decoder halving it up to three times first.
Q239 What can't be shown opens as hex, with the reason: a progressive JPEG, an interlaced PNG, a BMP that is compressed or has 16 bits. The rotation a camera stores in the file is ignored.
Q240 Revised while building. Decoding runs on the storage task while the main loop goes on. The picture appears as it comes, and anything that needs the screen back stops the decoding first. The plan was to wait for it, behind a "Decoding..." line: 12 megapixels took longer than the watchdog allows the main loop to stand still, and the device restarted.
Q241 The Storage App: Enter on .png, .jpg, .jpeg, .bmp, .gif, or on a file with no known extension whose first bytes say what it is. Tab gives the hex. i, and opening, show the size in pixels on the last line for three seconds.
Q242 Not in this one: animation, opening a picture from the Gemini App, a slideshow.

As built

  • lib/files/src/image_file.h (host-tested): what a file is and how big, where each pixel lands (ImageFrame, ImageMap), the dithering, and readers for BMP and GIF that hand their pixels on as they get them. png_reader.h: the PNG decoder, with its own inflate: every colour type and bit depth, palettes with transparency. Transparent pixels are left as the background.
  • ImagePane (src/apps/image_pane) is the view. One decoding is a Job shared with the storage task; cancel() flags it and waits behind it in the task's queue, which is how the screen is known to be free again.
  • JPEG is the one decoder that isn't ours: the display library's TJpgDec, with its 3.9 KB pool. It shrinks by 2, 4 or 8 while decoding, which is why a photograph is possible at all.
  • A decoder stops early once the rest of the file is below the screen (a picture at its own size), and a BMP's rows that aren't shown aren't read.
  • The note on the last line is written over the picture; the strip under it (2.6 KB) is kept and put back, so showing it costs no decoding.
  • A BMP is read in the order its rows are stored, last row first: reading it top to bottom meant going back through the file for every row, a second for 135 rows.
  • Cost: 21 KB of flash. Nothing while no picture is shown.

Measured on the device

Picture Fitted Its own size
A screenshot of ours, 240 x 135 80 ms 78 ms
PNG, 800 x 600 855 ms 575 ms
PNG with transparency, 800 x 600 1,098 ms
JPEG, 800 x 600 305 ms
JPEG, 4000 x 3000 (2.6 MB) 6.9 s 7.7 s (the middle of it)
GIF, 800 x 600 642 ms
BMP, 800 x 600 (1.4 MB) 991 ms
BMP, 240 x 135 124 ms

Free memory fell to 48.8 KB at the lowest while a PNG was decoded, from 104 KB. The storage task's stack: 3.2 KB never used, of 6.

Host tests (20, test/test_image_file and test/test_png_reader)

The pictures in them were made with Pillow, so the readers are checked against an encoder that isn't ours: GIFs plain, interlaced, transparent and long enough for the codes to reach 12 bits; BMPs of 8 and 24 bits; PNGs of every kind (colour, with alpha, palette of 8 and 4 bits with a transparent entry, greys of 1, 8 and 16 bits, grey with alpha, not compressed, and one that refers 21,600 bytes back). Every reader is also fed its file with a byte changed, at every few bytes: an answer each time, and no pixel outside the picture.

Checks on the device (2026-10-07, driven over the Debug Console)

Test pictures were copied to a scratch folder and removed afterwards, with the test screenshot.

Check Result
Colour bars as PNG, JPEG, BMP and GIF, 240 x 135 and 800 x 600 The same picture each time, the colours in the right order
A PNG with a transparent square The square is the background
A screenshot taken in the Shell Shown; at its own size it is the screen, pixel for pixel
A progressive JPEG Hex, with "A progressive JPEG can't be shown"
An animated GIF Its first picture
12 megapixels Arrives from the top down in 6.9 s; the size is noted when it is whole
Enter, then the arrows Its own size from the middle, then half a screen at a time
Back in the middle of a decoding The folder's listing at once
Tab to hex and back, three times; the help panel, then closed The picture again each time

Not checked: a photograph from a real camera (the test JPEGs were made by Pillow); a Toast over a picture; a PNG with IRC connected, when there may not be the memory; the card pulled while decoding; the real keyboard.

What went wrong while building it

The watchdog. The first version waited for the decoder. The main loop is watched: five seconds without a pass and the device restarts, which is what it did on the 12-megapixel test. The crash report named the decoder's line. The decoding moved to the background, which also made the picture appear as it comes.

A decoder that worked once. The library's PNG decoder showed the first picture and refused the next five: "no memory". It wants 44 KB in one piece, and after some use the largest piece is 43 to 47 KB. Writing a decoder that needs 32 KB was less work than it sounds, and unlike the library's it has tests.

Two sentences still said "up to 16 KB" about editing, in the README and the Storage guide, after issue #47 lifted that. Corrected here.