+++ title = '''836 bytes''' description = '''roro9stack learns to browse its SD card and keep notes that save themselves, CI starts building and signing every release, and the device installs the project's releases on its own, which nearly took all of its memory while IRC was connected.''' date = 2026-10-06T17:15:00+02:00 [extra] topics = '''ESP32-S3 · CI · Memory''' read_label = '''Read where the memory went →''' uid = '''notification: v0.11.0 is out: see Settings > Firmware''' dek = "Two more milestones for [roro9stack](/devlog/roro9stack/), my firmware for the M5Stack Cardputer: a Storage App and Notes, then releases that build and sign themselves on a CI runner, and a device that fetches them from my Gitea. Each of the three ran into the same wall, which is how much RAM is left once IRC is connected. The last time it was a download that left **836 bytes**." byline = '''designed by interrogation, rounds eight to eleven: 47 questions, and a runner label registered three times''' [extra.sign] label = "Lowest free heap during a download, IRC connected" note = "Before IRC was asked to step aside. Afterwards: 38,316." count = "836" tone = "red" [[extra.cast]] name = "IRC's connection" role = "one TLS session, always on" text = "Holds about 41 KB of the 107 KB the chip has free after boot, just by being connected. Perfectly well behaved. Doesn't know anyone else wants to talk." [[extra.cast]] name = "The largest free block" role = "31.7 KB, with IRC connected" text = "The number that decides things, not the total. Out of memory on this chip isn't an error to handle: it's an abort." [[extra.cast]] name = "The runner" role = "runner0, Ubuntu 26.04, 4 cores, 7 GB" text = "Mine, on my network, running whatever a workflow tells it to. Spent its first hours running jobs on its own host because of how its label had been written." [[extra.cast]] name = "The signing key" role = "ECDSA P-256, now in two places" text = "Used to live on one machine. Now also in a repository secret, on purpose, so that a tag is a release without anyone at a keyboard." [[extra.cast]] name = "The server's certificate" role = "Let's Encrypt, replaced every few months" text = "Next expiry: 2026-12-14. Too short-lived to pin, which is why the device carries the roots it chains to instead." +++ ## TL;DR - **The Storage App** (v0.9.0) browses the SD card: sizes, dates, copy, move, rename, delete, new folder, with a viewer for each kind of file the firmware writes. A copy runs in slices so the Logs keep being written, and says what it's about to delete before it does. - **Notes** (v0.10.0): plain text files in `/notes`, no save key. The note is written five seconds after the last key, on Back, and when the screen turns off. A power cut costs a few seconds, never the note. - **CI** on my own Gitea runner: the host tests on every push to `main`, both firmwares on every pull request, and every tag `v*` becomes a signed release. All fourteen tags from v0.1.0 to v0.11.0 now have one, each downloaded again and checked. - **The device installs releases itself,** from Settings, with no PC and no card. That's **v0.11.0**, the first release that can look for the next one. Getting there turned up the number in the title. - Three bugs with one shape: a subtraction of unsigned times that goes wrong when a stamp comes from the future. - 456 host tests, 60 more than last time. The code is [on my Gitea](https://git.twis.la/twisla/roro9stack/src/branch/main), and the plans with every measurement are [F1](https://git.twis.la/twisla/roro9stack/src/branch/main/docs/milestones/F1.md) and [R1](https://git.twis.la/twisla/roro9stack/src/branch/main/docs/milestones/R1.md). ## Two milestones, and one wall After the [housekeeping milestone](/devlog/roro9stack-s1/) the backlog still said two things. The card holds everything the firmware writes, and the only way to look at it was a debug console command. And every release had been built and signed on my laptop and pushed to one device, with nothing published anywhere. So **F1** is files and notes, and **R1** is releases. Neither is glamorous. Both are the sort of thing a device needs before it's something other than mine. What I didn't plan for is that the story of both is memory. The Cardputer's ESP32-S3 has no external RAM: about 107 KB free after boot, and IRC's TLS session takes 41 of it the moment it connects. Every feature below was fine until I tested it with IRC up. ## The cast {{ cast() }} ## A card you can look at The Storage App is a file browser, with the decisions I'd normally take for granted made out loud in a design round: - **One item at a time, with a clipboard.** `c` copies, `x` cuts, `v` pastes, `r` renames, `d` deletes, `n` makes a folder, `i` says what it is. Back goes up a level. No multiple selection: that's [an issue](https://git.twis.la/twisla/roro9stack/issues/41) of its own. - **Folders first, then by name**, with `s` to sort by date or size. A folder holds at most 256 entries on screen and says "first 256 of 329" when it's bigger. - **Settings > Storage is gone.** Its usage figures, Clean-up and "Erase SD card" are the last row at the top of the card, behind a warning that they delete things for good. {{ figure(src="storage.png", alt="Four Cardputer screens at 2x in a grid. Top left: the Storage App at the top level of the card: captures (selected), gemini, gnss, irc, updates and wifi, each marked folder, then Maintenance, with 7.3 GB free in the corner and the key hints c x v:paste r:rename d:del n:new i s:sort. Top right: a box reading Copying f1test (2) with a progress bar at 1.8 MB of 8.0 MB and Back: cancel. Bottom left: the same list with an orange line at the bottom reading The firmware keeps its files in /captures. Bottom right: a box reading Delete f1test and its 300 files (2.5 KB)? It can't be undone, with Cancel selected beside Delete", width=976, height=556, landscape=true, full=true, caption=`The top of the card, an 8 MB folder being copied, a refusal with its reason, and the question before a delete. The folders named f1test are my scratch space for the checks.`) }} ### Copying without starving the Logs Everything that touches the card runs on one task, because the SD driver isn't safe across tasks. The IRC Logs, a Track being recorded and a LoRa Capture write through that same task. A copy that holds it for twenty seconds makes all three wait. So an operation works in slices of about 150 milliseconds and puts itself back at the end of the queue, and the Logs get their turn in between. I checked it the obvious way: queued two Log lines in the middle of an 8.4 MB folder copy, which took 19.4 seconds (435 KB/s), and both were on the card afterwards. Cancelling a copy at 1.8 MB deletes what it had written and says "nothing was copied". Afterwards every file's size is compared with the original; not its contents, since the driver has earned back that much trust since [last time](/devlog/roro9stack-s1/). ### The listing that took two seconds My first version listed a folder by asking the Arduino `File` object for each entry, then its size, then its date. Each of those looks the entry up by name again. A folder of 329 files took over two seconds to appear. The listing now reads the folder from FatFs in one pass, and it's on screen before the display has redrawn. ### What can't be deleted Nothing is hidden, and almost nothing is protected. The exceptions are the six folders the firmware keeps its files in (what's in them is yours), `/gemini/cache`, and any file the firmware has open for writing right now: today's IRC Log, a Track, a Capture. I tried each from the console: `rm /irc`, `rm` on a Capture while it was recording, a folder into itself. Each came back refused with a reason. A Capture being recorded could still be *copied*, which is the point of having separate rules for "change" and "read". ### Looking inside Enter on a file opens it by what it is. {{ figure(src="viewers.png", alt="Four Cardputer screens at 2x in a grid. Top left: big.log, 1.0 MB, opened at its end, the last line reading 20:59:59 THE LAST LINE, with the hint Tab: hex t: top b: end. Top right: the same file as a hex dump, rows of eight bytes with their offsets and the text beside them, starting 00000 3230 3a30 303a 3030 20:00:00. Bottom left: a file called readme with no extension, 102 B, shown as text: A file without an extension. It looks like text, so it opens as text, and a line with accents, été, ça. Bottom right: test1.ota, 1.6 MB: Version v0.2.1-13-g14ff13f-dirty+debug, Running v0.8.1-2-g7ab8f04-dirty+debug, Signed with the project's key, intact, Older than what's running, and the hint Enter: install, Tab: hex", width=976, height=556, landscape=true, full=true, caption=`A megabyte of log, open at its last line; the same file as bytes; a file with no extension that looks like text; and an Update File.`) }} **Text** is read from the card as you scroll, a kilobyte around the screen at a time, so a 1 MB log opens at once, at its end. Scrolling back wraps the paragraph before again, so a file reads the same in both directions; that's a [small class](https://git.twis.la/twisla/roro9stack/src/tag/v0.10.0/lib/files/src/text_pager.h) with its own tests. **Hex** is what anything else gets. A **`.pcap`** shows the packets the way the LoRa Scanner lists them, a **`.gpx`** its point count, start, duration and distance, and an **`.ota`** is checked the way an install would check it: the same parser, with a sink that writes nothing. "Signed with the project's key, intact" is that parser's answer. ### Two things I found on the way **The Storage Warning had never opened anything.** The project's own glossary said "selecting it opens Storage Clean-up". It had only ever been a Toast, and a Toast can't be selected. It now says "see Storage" and promises nothing more. **The Clock now sets the system time**, whatever its source. Files used to be dated only after NTP. I pointed NTP at an address that doesn't answer, restarted, and made a folder: it was dated 08:39, from the GNSS fix. A Track written before the clock was set still shows "-" for its date, which is honest. And a confession: a key script I ran against the device was one step off and renamed `/gemini/saved` to `saved2`, then copied it to the top of the card. I noticed within a minute and put it back (same 7 files, 53,798 bytes). The rule I took from it is to look at a screenshot before any key that renames, moves or deletes. ## Notes that save themselves {{ figure(src="notes.png", alt="Four Cardputer screens at 2x in a grid. Top left: the Notes list for /notes, 6 notes, each under its first line with the date 2026-10-06: The note as it was saved (selected), Only the temporary file is, This is line 0000 of a not twice, Ideas and abcdefghijklmnopqrstuvwxyz. Top right: the editor on shopping-list.txt, 66 B, saved: Shopping list, milk, and bread ad a longer line that wraps on the screen, with the cursor at the end. Bottom left: a box titled Unsaved copy reading A save of this note was cut short. Its copy has 79 B, the note 26 B, with the buttons Keep the note (selected) and Use the copy. Bottom right: full.txt, 16 KB, saved, lines reading This is line 0000 of a note made to be exactly as big as a note may be, and an orange line at the bottom reading This note is full: 16 KB", width=976, height=556, landscape=true, full=true, caption=`The list, under each note's first line; the editor; a note whose last save was cut short; and a note at its 16 KB limit.`) }} Notes are plain text files in `/notes`, listed by their first line. There is no save key, which was a decision, not an omission: **five seconds after the last key, on Back, on leaving the App, when the screen turns off, and before the device powers off.** A new note has no file until something is typed, and then it takes its name from its first line: `shopping-list.txt`. ### Never half a note A save writes `shopping-list.txt.tmp`, checks its size, and puts it in the note's place. FAT can't rename one file onto another, so between the delete and the rename there's a moment when only the temporary file exists. If the power goes then, the next time the Notes list opens it finds the lone `.tmp` and puts it back under its name. If the note and a `.tmp` both exist, the save was cut short earlier, and opening the note offers the copy back (third screen above). I tried the power cut, as well as a device can fake one: typed, waited past the five seconds, typed more, and restarted it at once. The note had what had been saved, whole, and not the last few keys. The first runs looked as if the editor were dropping keys. It wasn't. The tool I use to send keys held lines back until the next one arrived, a bug in the tool, now fixed. ### The 16 KB buffer A note is held in memory while it's edited, up to 16 KB, and a bigger text file opens read-only in the Storage App. I'd have liked to say "any size". Editing in place needs a structure that keeps the changes apart from a file you can't load, and I'm not starting that inside a first version. It's [issue #47](https://git.twis.la/twisla/roro9stack/issues/47), it's marked high, and the plan says it has to come in a later release. The first version of the 16 KB limit did something that looks harmless. It read the file into one string and copied it into the editor's buffer. Opening a full note with IRC connected **restarted the device.** The backtrace ended in `operator new`: with IRC up, the largest free block is 31.7 KB, two 16 KB blocks weren't going to fit in it, and on this chip a failed allocation isn't an error to handle. It's an abort. Now the buffer is reserved once when the note is opened, the file is read straight into it, and nothing typed afterwards ever makes it grow. The editor also refuses to open without a free block of 24 KB, and says so. With IRC connected and a full note open there are 55 KB free. ## The same bug, three times In one day, the same mistake found me three times, and I'd like you to be spared it. **1. A panic nine seconds after boot.** The first boot of a new build crashed with `Operating mode must not be set while SNTP client is running`. The device was on Probation, so it rolled back to the previous build by itself, which is the safety net from [the OTA post](/devlog/roro9stack-ota/) catching a real bug. The cause: when Wi-Fi joins, the code stamps the time it last set up DNS and NTP, from `millis()`, and a few lines later compares *now* with that stamp, where "now" was read earlier in the same pass. The stamp is a few milliseconds in the future, an unsigned subtraction wraps to forty-nine days, and the setup ran twice. Starting SNTP is only queued for the network task, so about once in a dozen boots the second request landed before the first had run. It had been there since v0.7.0. [Issue #46](https://git.twis.la/twisla/roro9stack/issues/46). **2. Keys that went missing.** About one key in twenty-five sent through the Debug Console never arrived. The `key` command stamps the screen's idle timer from `millis()`; the power tick compares with its own, older time; the screen "turned off" for one tick, and the next key was swallowed as a wake-up. Keys from the real keyboard pass the loop's own time, so they were never affected. This also explains some "lost" keys from earlier sessions I'd put down to the screen waking. **3. A message that never showed.** In the Storage App, a footer message set by a key press in a pass looked 49 days old to the code that decides when to hide it. Same shape each time, and each fixed by comparing signed. A test now covers the one in the power policy. I haven't hunted down the rest of the pattern; there are more comparisons like it in the code, and the ones in the Apps only cost an extra redraw. ## Releases that build themselves Until now a release was a tag on my laptop and an `.ota` pushed to one device. [Issue #5](https://git.twis.la/twisla/roro9stack/issues/5) asked for the real thing: push a tag, get a signed release on Gitea. I'd registered a runner on my own server, so the first question was what a job on it can do. A probe workflow answered: Ubuntu 26.04, Docker, git, no Node, no compiler. That ruled out the usual JavaScript actions, so the checkout is four git commands and the build is the same `scripts/ci.sh` that runs on a laptop. ### The label that took three tries The runner's label was registered as `ubuntu://docker:ubuntu:resolute`, and Gitea took the whole string for the label's *name*. The runner wasn't running jobs in containers at all: it ran them on its own host, as a user in the Docker group. My first workflow, and the release of v0.10.0, were written for that. I wanted containers. The label became `ubuntu::docker://…`, which was also wrong, and then `ubuntu` with the image given properly. With a container per job and a named volume for the toolchains, the workflow installs PlatformIO into a plain Python image and mounts the volume as the cache. The first full run, on an empty cache, took 11.4 minutes: the framework is rebuilt with smaller TLS buffers. A pull request run now takes about 8.7 minutes, a release build alone 5.5. Gitea 1.27's API can't cancel a run that isn't finished. Twelve release runs, queued for the old label, stayed queued until I cancelled them in the web UI. ### Putting the key in a secret CI has to sign, or a tag isn't a release. I decided to automate it: the signing key is now a repository secret, written to a file for the length of one step and removed after. [ADR 0008](https://git.twis.la/twisla/roro9stack/src/branch/main/docs/adr/0008-ci-signs-releases.md) is the part where I wrote down what that costs: **anyone who can run a workflow here can sign firmware every device accepts**, and the runner executes jobs on its own host's Docker. What limits it: the release step checks the signed file against the public key in the sources before publishing, so a wrong or swapped secret stops the release instead of producing one nobody can install. And a device only installs what it's told to, keeps it on Probation, and rolls back what doesn't hold. ### The old tags Releases for the thirteen tags that existed went through the same workflow, each built from its own sources. The first attempt, on the old label, failed on v0.1.0: it predates the rebuilt framework and wouldn't link against the rebuilt one the cache held. The release script now restores the stock libraries for tags from before that, and the whole backfill passed on its second go. Then I downloaded all thirteen `.ota` files from the published releases and checked each checksum and signature from here: all good. Two things I measured and left alone. A CI build isn't byte-identical to one on my laptop for the same tag: same size, different bytes. Signing no longer depends on it. And the Debug Build is built by CI to prove it compiles but never published, because each one carries its builder's console token. ### What runs when My first workflow rebuilt both firmwares on every push, and I said so: that's overkill. A push now runs only the host tests, about 45 seconds of them. A pull request adds both firmware builds, and the repository only merges a pull request one way, as "rebase, then a merge commit", so the commits in a branch keep their messages. And then a branch with an open pull request ran twice per push, once for the push and once for the pull request, so branch pushes now run nothing and the pull request is what runs. The README has badges now: the workflow's status, the latest release, and a coverage figure. **94.6%** is the share of `lib/` the host tests run, 3,193 lines. It leaves out `lib/SD` and all of `src/`, about 10,000 lines of Apps and Services that need the device and have no host tests. The badge says "lib coverage", and the README says what it doesn't cover, because a bare percentage there would be a small lie. ## 836 bytes With releases published, [issue #6](https://git.twis.la/twisla/roro9stack/issues/6) was unblocked: **the device checks Gitea for a newer release and installs it.** It's in Settings > Firmware. *Latest release* checks the server and shows `v0.11.0 (new)` or `(current)`; Enter opens the release, with its version, date, size and the tag's message, and an Install button when it's newer. *Older releases* lists the last ten, and going back asks a different question. A setting, *Check for updates*, looks once a day, with Wi-Fi up and the clock set, and says `v0.11.0 is out: see Settings > Firmware`. It installs nothing by itself. {{ figure(src="updates.png", alt="Four Cardputer screens at 2x in a grid. Top left: Settings, Firmware: Version, Status confirmed, Push to 10.39.39.12:3232, Latest release v0.10.0 (new) selected, Older releases, then On the SD card with two .ota files. Top right: the release page, v0.10.0, Published 2026-10-06, 1.8 MB, then the start of the tag's message about the Notes App, with the hint Enter: install, c: check again. Bottom left: a box titled Install update? reading v0.10.0 replaces v0.9.0. The device restarts once it's written, with Cancel selected beside Install. Bottom right: the full-screen progress: Firmware update, Receiving v0.10.0, a bar at 28 percent", width=976, height=556, landscape=true, full=true, caption=`The page, a release, the question, and the download. The device was pretending to run v0.9.0, so that the release of v0.10.0 counted as an update.`) }} ### What's trusted The server's certificate is a Let's Encrypt chain, all ECDSA, and it changes every few months. Pinning it, as the Gemini App does for capsules, would ask a question at every renewal. The framework's bundle of well over a hundred authorities would let any of them vouch for my server. So the firmware carries the two roots the chain ends in, ISRG Root X1 and X2, 2.7 KB, and checks the chain and the name against them ([ADR 0009](https://git.twis.la/twisla/roro9stack/src/branch/main/docs/adr/0009-the-device-trusts-the-isrg-roots.md)). If the server ever moves to another authority, the next firmware has to come from the PC. That's the transport. What decides whether a download installs is still the Update File's own signature, checked after the first 160 bytes, before a byte is written to the slot. I tried the refusals against the real server rather than trusting that: a download cut at 800,000 bytes ("update file too short"), a byte flipped in the signature ("bad signature"), a byte flipped in the image, which downloads in full and is refused at the end ("image corrupted"). Each time the running firmware was untouched. And connections to github.com, example.com and four deliberately broken badssl.com hosts (expired, self-signed, wrong host, untrusted root) were refused. ### The install The real thing, twice: once from the console and once from the screen. The device downloaded v0.10.0 from `git.twis.la`, restarted into it, and confirmed itself on Probation. Then I pushed my Debug Build back from the PC. The slot table afterwards read `v0.10.0, valid`. No card, no PC in the install. The full download took 46 seconds, about 40 KB/s, over a guest Wi-Fi at −65 dBm. I haven't looked into why it isn't faster. ### Where the memory went Then I did what I'd done with every feature that day, and connected IRC first. {{ diagram(src="memory.svg", min_width=620, caption=`The lowest the free heap got, from a fresh boot each time, with Gemini's 20 KB floor marked. IRC's connection holds 41 KB of the 107 KB; a TLS connection to this server needs about 52 on top.`) }} A TLS connection to the server peaks at about **52 KB** of heap. I wondered if checking the certificate was the expensive part, and tried three modes: both roots, only the small ECDSA one, no verification at all. The lowest free heap was 56.3, 55.0 and 56.0 KB. Skipping the check would have saved nothing. The cost is the connection: record buffers and the handshake. With no IRC that leaves a comfortable margin. With IRC connected, 66 KB free, a plain check bottomed out at **3 KB**, and a list at 2.9 KB. And a full download, twice: the lowest free heap was 6,140 bytes the first time and **836 bytes** the second. The device didn't crash. It was entirely luck. My start floor was 55 KB, which IRC leaves you at 66 and passes; the floor was measuring the wrong thing. ### Making room IRC steps aside. A check, a list or an install you asked for makes IRC say QUIT, free its TLS session, and reconnect when the Update Service is done. A TLS connection now needs 80 KB free to start, the 52 plus the 20 KB spare the Gemini milestone settled on, with some margin. The same worst case, IRC connected and a full download, now bottoms out at **38,316 bytes**, and IRC comes back afterwards: its byte counters kept growing. The daily check never does that. It's not worth taking IRC down for a look nobody asked for. So with IRC connected it waits for a moment when it isn't. I tried both: with IRC connected, 68 KB free, the daily check didn't run. Without it, the check ran by itself and announced the update. Its first message was `Update v0.10.0 available: see Settings > Firmware`, and the console printed it cut off at "Firmwa", because a notification holds 48 bytes. It reads `v0.10.0 is out: see Settings > Firmware` now. That's a real limit, and I'd rather state it than hide it: **with IRC connected for days, the daily check doesn't run.** Opening *Latest release* still does it. And a Debug Build shows the latest release but won't install it, since releases carry no debug console and installing one would take mine away. ### The first release it could see v0.11.0 is the release that carries all this, tagged on `main` after the merge. CI took 14.1 minutes (the tests, both firmwares, then the signed release build and the upload) and published the usual four files. I downloaded the `.ota` again and checked the checksum and the signature from here: both good. The device was still on a v0.10.0-based build, so this was the first time it could see a release newer than itself without my pretending. I ran the daily check, with none of the knobs, and it announced `v0.11.0 is out: see Settings > Firmware` by itself. Settings > Firmware says `v0.11.0 (new)`. The release page, on this Debug Build, says it won't install it. Then I pushed the tagged Debug Build from the PC, and the same check now says the latest release isn't newer. {{ figure(src="release.png", alt="Two Cardputer screens at 2x side by side. Left: Settings, Firmware: Version v0.10.0-19-g4ef4134-dirty+debug, Status confirmed, Push to 10.39.39.12:3232, Latest release v0.11.0 (new) selected, Older releases, then On the SD card with two .ota files. Right: the release page, v0.11.0, Published 2026-10-06, 1.8 MB, then the start of the tag's message, v0.11.0: updates from Gitea (Settings > Firmware checks the project's server, lists the last ten releases and installs one straight into the inactive slot; a daily check announces, and at the bottom, cut off at the screen's edge, A Debug Build keeps its console: update it from", width=976, height=270, landscape=true, full=true, caption=`The real thing: a build older than v0.11.0 sees it as new, and a Debug Build says it won't install it. The sentence at the bottom is cut off by the screen, which is a bug.`) }} Two flaws showed up. That message overruns the screen's edge, and it needs to be shorter. And the README's release badge still said v0.10.0 afterwards: CI draws it when `main` is pushed, not when a tag is, so it catches up at the next push. Neither is fixed yet. ### What I didn't verify - **The certificate's name, on its own.** I tried connecting by IP address, hoping for a valid chain with the wrong name. The server ended the handshake before showing its certificate, so that proved nothing. The library does check the name it connects to, and OpenSSL on the PC refused the wrong name against the same chain, but I haven't seen the device do it. - **A failed daily check retrying**, and **not announcing a version that already failed here**. Both have host tests. Neither was on the device. ## By the numbers {% table() %} | | | | --- | --- | | Releases cut | 3 (v0.9.0, v0.10.0, v0.11.0), and 14 published on Gitea, v0.1.0 to v0.11.0 | | Design questions | 47 (Q128 to Q174) | | Host tests | 456, 60 of them new | | Lines of `lib/` the tests run | 94.6% of 3,193 | | The signed image | 1.92 MB, against 1.80 for v0.8.0 | | Releases whose `.ota` I downloaded again and verified | 14 of 14 | | CI: tests only (a push to `main`), a pull request, a tag with its release | 1.1, 8.7 and 14.1 min | | Times the runner's label was registered | 3 | | Queued runs the API wouldn't cancel | 12 | | Times the same unsigned subtraction found me | 3 | | A full 1.9 MB download | 46 s | | Lowest free heap during it, IRC connected: before, after | 836 B, 38,316 B | | Bytes a notification holds | 48 | {% end %} ## Where it stands {% steps() %} 1. ~~M0 and M1: the skeleton, Wi-Fi, IRC, Wi-Fi Tools.~~ v0.1.0 to v0.2.1, [the first post](/devlog/roro9stack/). 2. ~~Updates and debugging over the air.~~ v0.3.0, [Look, no cables](/devlog/roro9stack-ota/). 3. ~~M2: GNSS.~~ v0.4.0, [Seventeen satellites](/devlog/roro9stack-gnss/). 4. ~~G1: Gemini.~~ v0.5.0, [A browser in the RAM IRC left over](/devlog/roro9stack-gemini/). 5. ~~M3: the LoRa radio, listening.~~ v0.6.0, [The loudest thing it hears is itself](/devlog/roro9stack-lora/). 6. ~~S1: the card, fixed addresses, the System App.~~ v0.6.1 to v0.8.1, [One byte too early](/devlog/roro9stack-s1/). 7. ~~F1: the Storage App and Notes.~~ v0.9.0 and v0.10.0, this post. 8. R1, so far: CI and signed releases, and updates from Gitea. v0.11.0, this post. Next in R1 is an Issues App, to list and file issues from the device. 9. Still waiting: the card as a USB drive, notes of any size, and M4, the mesh, which wants a second node I still haven't got. {% end %} {% signoff() %} Out of memory on this chip isn't a message, it's an abort, and my check against it was a number I'd chosen without measuring. The measuring took one afternoon and a device that, twice, survived on luck. {% end %}