Seven posts imported into site/content/devlog/ with their text unchanged, links between them pointing to /devlog/, and shortcodes (sign, cast, steps, asides, folded sections, diagrams, captions) restyled in the site's palette. The 17 inline SVG diagrams carried <style> blocks and style attributes the site's Content-Security-Policy refuses: their rules moved to devlog-diagrams.css, and a diagram's minimum width is a class. An Atom feed at /devlog/atom.xml. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
18 KiB
+++ title = '''roro9stack''' description = '''A pocket computer with a keyboard and a LoRa radio, and my first firmware written from scratch. In three days it learned to join Wi-Fi by itself, stay on IRC in the background, keep its logs on an SD card, map the Wi-Fi channels around it and find an access point by ear. The mesh messenger it's meant to become comes later.''' date = 2026-10-03T21:30:00+02:00
[extra] topics = '''ESP32-S3 · LoRa · IRC''' read_label = '''Read how I built it →''' uid = '''roro_2fa4 JOIN #roro9stack-test''' dek = "An M5Stack Cardputer ADV with a LoRa cap is a tiny keyboard computer with a mesh radio on its back. Meshtastic already runs on it. I wanted my own: a small multi-app firmware, a messenger first, with Wi-Fi tools and IRC beside it. This post covers the first three days, from an empty repository to v0.2.1: the skeleton, Wi-Fi, IRC and Wi-Fi Tools, dead ends included." byline = '''designed by interrogation again: a bot asked me about 50 questions, then wrote 260 tests before the Cardputer ran any of it'''
extra.cast name = "The Cardputer ADV" role = "ESP32-S3, 8 MB flash, no PSRAM" text = "A 240×135 screen, a 56-key keyboard read by a TCA8418 chip, a speaker, an RGB LED, a microSD slot and a 1750 mAh battery. About 340 KB of RAM to share between everything, which turned out to be the real design constraint."
extra.cast name = "The Cap LoRa-1262" role = "SX1262 + GNSS" text = "Clips onto the back: a LoRa radio for the 868 MHz band and a GNSS receiver. It shares its SPI bus with the SD card, so its chip-select is held high until the radio gets its own milestone."
extra.cast name = "The dev box" role = "a VM with Docker" text = "Nothing but Docker is installed. PlatformIO, the ESP32 compiler and every library live in a container, which builds, runs the tests and flashes over USB passthrough. Mostly."
extra.cast name = "Libera.Chat" role = "irc.libera.chat:6697" text = "The first server the Cardputer ever talked to over TLS, in an empty channel made for the occasion." +++
TL;DR
- roro9stack is my own firmware for the Cardputer ADV: apps on a launcher, services running underneath, and a Meshtastic-compatible messenger as its first real job.
- Before any code, about 50 design questions, a glossary and two decision records. The big one: write my own firmware that speaks Meshtastic, instead of forking Meshtastic.
- Docker is the only tool installed. Every piece of logic is tested on the PC first: 260 tests by the end of this post.
- M0 (v0.1.0): launcher, status bar, settings, a first-boot wizard, toasts with beep and LED, accented letters on a US keyboard, and SD card handling.
- M1 (v0.2.0): the Cardputer joins Wi-Fi by itself, sets its clock over NTP, writes daily logs to the SD card, and stays on IRC over TLS in the background, with notifications when someone mentions me.
- Wi-Fi Tools, finished in v0.2.1: a sortable, filterable list of the networks nearby that can log every scan to CSV, a chart of how crowded each 2.4 GHz channel is, and a tracker that follows one access point with clicks that speed up as you get closer.
- Memory decided one thing: with IRC on TLS, the lowest free RAM fell to 51 KB, so the screen buffer went from 16-bit to 8-bit colour. That bought 28 KB back.
- What came next, updating and debugging it all without a cable, has its own post.
What it is
The Cardputer ADV is a credit-card-sized computer with a real keyboard. With the LoRa cap, it's also the obvious shape for an off-grid messenger. Meshtastic already supports this exact combination, and M5Stack even sells it as a kit.
What I wanted is different: a small operating environment where the messenger is one app among several, with my own interface. Wi-Fi diagnostics, an IRC client, GNSS, a LoRa scanner, notes. The radio keeps relaying for the mesh whatever app is on screen.
It's also my first embedded project. I'd never used PlatformIO, ESP-IDF or Arduino before. The bot writes the code; I decide what it should do, test it on the device, and say when something feels wrong.
The cast
{{ cast() }}
Designed by interrogation
Like my Music Remote, this started with questions, not code. Rounds of numbered questions, each with a recommended answer to accept or overrule: is the mesh a background service or an app? Where do messages live when there's no SD card? What happens when the card is 90 % full? About 50 decisions later, two things went into the repository.
A glossary, so every word means one thing. A Service runs in the background; an App is what's on screen. A Log is recorded on its own; a Capture is something you start. "Channel" turned out to have three meanings (mesh, IRC, Wi-Fi), so the glossary now says which one is meant when.
And two decision records, for the choices that are expensive to undo:
- My own firmware that speaks Meshtastic, not a fork. A fork gives full compatibility on day one, but Meshtastic is built to be a single-purpose node, and a multi-app design would fight it everywhere. The price: re-implementing the protocol, and only partial compatibility at first.
- A small widget kit of my own, not LVGL. With no PSRAM, the 40 to 60 KB LVGL would cost is memory the Wi-Fi and radio stacks need. The screens are mostly lists and text anyway.
Docker, and tests first
Two scripts do everything. scripts/ci.sh runs the tests on the PC, then builds the firmware. scripts/flash.sh uploads it and opens the serial console. Versions are pinned (Arduino-ESP32 3.3 on ESP-IDF 5.5, M5Cardputer 1.1.1), so a build next year should still produce the same firmware.
The rule that paid off most: all logic lives in libraries that don't know about the hardware. Settings validation, the battery curve, the Wi-Fi state machine, the IRC protocol, the channel arithmetic: each one has tests written before the code, and runs on the PC in seconds. The Cardputer only ever runs logic that has already passed.
The last piece is a handful of serial commands. burst fires five notifications at once, key select presses a key, irc dump prints every IRC buffer. With those, the bot can drive the device and read its state without me touching the keyboard, which mattered more than I expected (see below).
M0: a skeleton you can hold
The first milestone, tagged v0.1.0, proves the architecture every later feature plugs into:
{{ diagram(src="architecture.svg", min_width=580, caption="Services keep running underneath; one App has the screen. Events come up through a single queue, so drawing never happens on two tasks at once. Storage and IRC run on their own tasks, because an SD mount or a TLS handshake can block for seconds.") }}
On top of that:
- A widget kit: list, text view, line editor, dialog, status bar and toast. Each frame is drawn off-screen and sent to the display in one transfer, about 15 ms.
- A keyboard layer. Outside text fields,
; . , /are arrows on their own; while typing, they need Fn. Theoptkey is a dead key:opt'etypes é, which matters when you live in Belgium. - Notifications as toasts, with a beep and an LED flash. If the screen is off, it lights up dimmed while the toast shows, so you can see what beeped.
- Settings, an About page and a first-boot wizard that asks for your names and your radio region. The radio isn't allowed to transmit until the region is confirmed.
- SD card rules: a warning at 80 %, logs paused at 90 % to keep room for captures, and a format feature. It turned an old Raspberry Pi card, seen as 512 MB, back into 8 GB of FAT32.
{{ figure(src="apps.png", alt="Four screens of the Cardputer at 2x, in a grid. Top left: the Launcher, with IRC highlighted, Wi-Fi Tools and Settings, and a blue toast reading Burst 1 at the bottom; the status bar shows roro9stack, a W with Wi-Fi bars, SD, 97 percent battery and the time. Top right: the IRC App on the buffer #roro9stack-test 2/2, with the line 21:20 Joined #roro9stack-test and an empty input box at the bottom. Bottom left: Settings, with Long name roro9stack proto, Short name roro, Region EU868, Timezone Brussels, Brightness 100 percent, Dim after 30 s, Screen off after 1 min, Sound and LED On. Bottom right: the SD card page, with SD card 7.3 GB 0 percent used, Logs recording, Captures allowed, Clean up and Erase SD card", width=976, height=556, landscape=true, full=true, caption=The Launcher with a toast from burst, the IRC App, the first page of Settings, and the SD card page. These were captured later, over Wi-Fi, with a tool from [the next post](/devlog/roro9stack-ota/); the screens themselves are the ones from this one.) }}
M1: Wi-Fi and IRC
The Cardputer now stays online and on IRC in the background.
- Wi-Fi keeps up to 8 saved networks and joins the strongest one in range. It backs off when none is around and turns the radio off when nothing is saved. Once connected, it sets the clock over NTP.
- Logs go to daily files such as
/irc/irc.libera.chat/#roro9stack-test/2026-10-02.log. Settings has a clean-up screen that shows how much each age cutoff ("older than 3 months") would free before deleting anything. - IRC runs as a service, connected over TLS with the server's certificate checked against the ESP-IDF bundle. It logs in with SASL or NickServ, rejoins after a drop, and raises a notification when someone mentions me or sends a private message, wherever I am. Its server settings are edited as a draft and applied in one go, under the service's lock, so the connection never sees a half-edited configuration.
- The IRC App shows one buffer at a time: Tab switches between the server, channels and private chats, and each one counts its unread lines.
v0.2.1 polished the IRC side after a day of use:
- Registered channels. It falls back to NickServ when SASL fails, waits for the login to be confirmed before joining channels that only accept registered users, takes channel keys (
#private key) in the auto-join list, and takes its nick back from a stale session. - The input line behaves like a shell: Up and Down recall what I sent, Alt with the arrow keys scrolls back through the buffer, and
/jworks as/join, because nobody types/join.
Memory decided one design change. The first thing M1 did was measure: Wi-Fi costs about 60 KB of RAM, and one TLS connection another 46 KB. With IRC actually running, the lowest free memory fell to 51 KB, too close to the 40 KB floor I'd set. The fallback picked during the design round was to halve the screen buffer:
{% table() %}
| Screen buffer | Free RAM, IRC online | Lowest seen |
|---|---|---|
| 16-bit colour, 64 KB | 61 KB | 51 KB |
| 8-bit colour, 32 KB | 93 KB | 79 KB |
| {% end %} |
Wi-Fi Tools
Three tools on one menu, all built from ordinary scans. They ask the Wi-Fi Service for scans of their own: active scans of every channel, hidden networks included, which the ESP32 runs without dropping its connection. So IRC stays online while you look around. If a scan is already running for the Wi-Fi Service, its results serve both. With Wi-Fi switched off in Settings, the radio comes on for the scans and goes off again when you leave.
{{ figure(src="wifi-tools.png", alt="Four screens of Wi-Fi Tools at 2x, in a grid. Top left: the menu, with Networks nearby highlighted, Channel occupancy and Signal tracker. Top right: the networks list, sorted by channel, with LOG 6 in orange at the top right; rows read knbg-guests ch1 -48 WPA2, a blacked-out name ch1 -48 WPA2, (hidden) ch1 -49 WPA2, a blacked-out name ch6 -68 WPA2, knbg-guests ch6 -68 WPA2, (hidden) ch6 -68 WPA2. Bottom left: channel occupancy, reading 6 networks, quietest of 1/6/11: 11, with bars over channels 1 to 13: tall on 1, medium on 2 and 6, small on 3, 4, 5, 7 and 8, nothing from 9 to 13, and the 11 label in green. Bottom right: the signal tracker on knbg-guests, with its BSSID, ch1 and m: clicks on, a large -48 dBm, a green bar about three quarters full, and a flat block of history bars", width=976, height=556, landscape=true, full=true, caption=The menu, Networks nearby sorted by channel while logging, Channel occupancy, and the Signal tracker. The neighbours' network names are blacked out; mine stays. The tracker's history is flat because the Cardputer was sitting on a shelf, which is exactly when a signal tracker is least interesting.) }}
Networks nearby lists every access point the last scan saw, with its channel, signal and security, and rescans every 4 seconds. A few letters change what's shown:
{% table() %}
| Key | Does |
|---|---|
s |
Cycles the sort: by signal, by channel, by name |
o |
Only open networks |
h |
Hides hidden networks |
w |
Only strong ones, −75 dBm or better |
l |
Logs every scan to the SD card |
| Enter | Opens the Signal tracker on that network |
| {% end %} |
The line above the list says which sort and filters are on, and LOG 6 in the corner counts the rows written so far. A log is a CSV file per day, /wifi/scans/2026-10-03.csv: a header, then one row per access point per logged scan, with the time, BSSID, channel, RSSI, security and SSID, quoted when a field contains a comma or a quote. It logs at most every 30 seconds, only while Wi-Fi Tools is on screen, and only once the clock is set, because a row without a time is useless. Storage Clean-up has a "Wi-Fi scan logs" category to delete old ones by age. The CSV format, the throttle, and the sorting and filtering are all host-tested, down to an SSID with a comma in it.
Channel occupancy shows how crowded each 2.4 GHz channel from 1 to 13 is. A 20 MHz network doesn't stay on its channel: it spills onto the channels up to two away, less the further it goes. So each network adds to five bars, and a strong signal counts more than a faint one. Channels 1, 6 and 11, the only three that don't overlap, are drawn in blue, and the quietest of those three gets a green label: that's where to put your own access point. Here it's 11, which nobody nearby uses at all.
Signal tracker follows one access point, picked from the list. It rescans only that network's channel, every 0.4 seconds, and shows the signal in dBm, as a bar, and as a graph of the last 110 readings, about 45 seconds. It also clicks, like a Geiger counter: once every 1.2 seconds at −95 dBm, speeding up to every 60 ms at −35 dBm. Walk around with it and listen; the clicks get frantic near the access point. m mutes them, and after 5 seconds without a sighting the readout says "lost".
Everything here comes from ordinary scans. Tools that would need more than that were on the plan, and are on hold.
Where it hurt
The toast that expired before it appeared. Toasts showed late, and a burst of five showed only some of them. The main loop read the clock once per pass, but each toast was stamped a few milliseconds later. In unsigned arithmetic, "3 ms in the future" is "49 days ago", so the toast was already expired when it arrived. A failing test reproduced it; a signed comparison fixed it.
The bug that wasn't. After that fix, I still saw only "Burst 3" and "Burst 5". Instead of fixing it a second time, the bot added logs, then fired the burst itself over serial: all five drawn, exactly 3 s apart. The real culprit was an earlier test key that had saved a 10-second screen timeout, so the screen went dark mid-burst. Measure before fixing twice.
The SD card that froze everything. With no card inserted, each mount attempt blocked for 2.5 seconds, every 15 seconds. All card access moved to its own task, which later also became the only writer of logs, since the SD driver doesn't like two tasks at once.
Errno 71. My dev box is a VM with USB passthrough. Every so often, flashing fails with OSError: [Errno 71] Protocol error, and retrying never helps; resetting the Cardputer does. Separately, stopping a background serial logger didn't stop its Docker container, which kept the port busy and made flashes fail quietly. The logger now runs in a named container that the flash script removes first. This one eventually got a whole post of its own: Look, no cables.
The channel that wouldn't let me in. My registered IRC channel refused every join. The obvious fix was a delay: join only after NickServ confirms the login. It still failed, so the bot dumped the whole server buffer instead of guessing again. Three problems were stacked. SASL had failed, and because SASL was configured, NickServ was never tried. Then the device's previous connection, cut short by a reflash, still held my nick, so it logged in as roro9_ and identified the wrong name. Now SASL falls back to NickServ, the identify names the account explicitly, and the device takes its nick back from a stale session. The last layer was the password I'd typed, which is mine to fix. Still. The screenshot above is in a channel that doesn't ask.
By the numbers
{% table() %}
| Commits from the first, on 1 October, to v0.2.1 | 30 |
| Firmware code, without font data | about 7,000 lines of C++ |
| Tests | 260, in 28 suites, about 3,400 lines |
| Flash used by the app | 1.49 MB of 3.3 MB (45 %) |
| Lowest free RAM, Wi-Fi and IRC on TLS | about 80 KB |
| {% end %} |
Where it ended up
{% steps() %}
-
M0: launcher, status bar, settings, first-boot wizard, toasts, Compose key, SD card rules.Done, tagged v0.1.0. -
M1: memory baseline, Wi-Fi service, logs and clean-up, IRC service, IRC app, Wi-Fi Tools.Done, tagged v0.2.0. -
IRC polish, and Wi-Fi Tools' sorting, filters and scan logs.Done, tagged v0.2.1. -
Firmware updates without a cable, and remote debugging.Not on the original plan, and done since, in v0.3.0: Look, no cables. -
Next: M2, GNSS, with position, fix status and a radar view. Then M3, the LoRa radio, with a scanner, plus notes and a file browser. Then M4 and M5, the mesh: receiving and decoding Meshtastic packets first, then sending, direct messages, relaying and EU868 duty-cycle limits. That part waits for a second Meshtastic node to test against. {% end %}
{% signoff() %} Three days, one skeleton, two networks to talk on, and a device that clicks when you walk towards your router. The next part cut the USB cable. {% end %}